blog/docs/dev/backend/acme协议.md
王一之 388012cf93
All checks were successful
Release / deploy (push) Successful in 5m48s
IAM?认证?授权?傻傻分不清楚
2024-04-01 17:49:12 +08:00

18 KiB
Raw Blame History

ACME 协议流程详解与实现

ACME 协议全名为 “Automatic Certificate Management Environment”RFC 8555 意为自动证书管理环境。它是一个由 IETF 制定的协议,用于自动化证书颁发和更新过程。

在了解 ACME 之前,我们先来了解一下一些相关的名词:

  • CACertificate Authority证书颁发机构负责颁发数字证书。
  • CSRCertificate Signing Request证书签名请求是由申请者生成的包含公钥的数据块用于向 CA 申请数字证书。
  • CRTCertificate证书文件通常使用.crt 扩展名,可以是 PEM 或 DER 格式。.crt 文件包含公钥并由证书颁发机构CA签发用于身份验证和数据加密。
  • PEMPrivacy-Enhanced Mail一种证书文件格式可用于存储证书.crt、.pem、证书请求.csr和私钥.key
  • DERDistinguished Encoding Rules一种证书文件格式可用于存储证书.cer、.der
  • DV 证书Domain Validation Certificate域名验证证书证书颁发机构验证申请者对域名的控制权后颁发的证书。
  • OV 证书Organization Validation Certificate组织验证证书除了验证域名所有权外CA 还验证申请组织的身份。
  • EV 证书Extended Validation Certificate扩展验证证书提供最高级别的验证包括域名所有权、组织信息以及组织的实体存在性。

ACME 协议通过 HTTPS 与 JSON 来进行通信,它定义了一套标准的 API用于客户端与证书颁发机构之间通信实现证书的自动化颁发和更新。

ACME 提供商

常见的免费的 ACME 提供商有:

大多数系统都是支持的 Let's Encrypt因此我们这里通过 Let's Encrypt 来进行介绍。

Lets Encrypt 提供域名验证型DV证书。不提供组织验证OV或扩展验证EVOV 和 EV 证书一般需要验证真实实体才能颁发,不能做到自动化的颁发,自动化也会削弱这种信任。Introduction

ACME 协议流程

ACME 协议的流程如下:

注册账户

Account Management

通过new-account向 ACME 服务商注册账户,获取账户的唯一标识符(在发送请求时的 kid 字段)。有的 ACME 提供商可能还需要验证邮箱等信息,需要先注册 CA 账户,然后在请求 ACME 注册账户时携带上指定信息。

创建订单

Applying for Certificate Issuance

通过new-order向 ACME 服务商创建一个新的订单,指定需要颁发证书的域名。如果通过,此时处于pending状态ACME 服务商会返回一个订单标识符finalize和授权authorizations用于后续的操作。

验证域名

Identifier Authorization

DNS Challenge

在获取到 ACME 服务商返回的授权信息后,此时还只是处于pending,后续需要验证域名的所有权。此时我们请求上一步返回的authorizationsURLACME 服务商会返回一个验证信息challenges客户端需要根据这个信息来验证域名的所有权。一般情况下会有http-01dns-01两种验证方式。

我们只介绍dns-01的方式。

DNS-01 验证

Key Authorizations

我们需要在 DNS 中添加一条 TXT 记录,记录名为 _acme-challenge加上域名,例如:docs.scriptcat.org那么记录名为 _acme-challenge.docs.scriptcat.org

但在 dns 管理平台中请注意,添加的 TXT 记录名为:_acme-challenge.docs,你可以使用命令:dig -t txt _acme-challenge.docs.scriptcat.org来查看是否添加成功。

记录值需要计算你的账户指纹,然后与返回的 token 一起组成一个字符串,然后对这个字符串进行 SHA256 哈希,然后 base64 编码,最后得到的值就是记录值,十分复杂。。。。

keyAuthorization = token || '.' || base64url(Thumbprint(accountKey))

这里我们是用的 ES256 算法,所以 Thumbprint 就是对公钥进行 SHA256 哈希,然后 base64 编码。

func ES256Jwk(publicKey ecdsa.PublicKey) string {
 return fmt.Sprintf(`{"crv":"%s","kty":"EC","x":"%s","y":"%s"}`,
  "P-256",
  base64.RawURLEncoding.EncodeToString(publicKey.X.Bytes()),
  base64.RawURLEncoding.EncodeToString(publicKey.Y.Bytes()),
 )
}

func (c *Client) thumbprint() string {
 sha256Bytes := sha256.Sum256([]byte(ES256Jwk(c.options.privateKey.PublicKey)))
 return base64.RawURLEncoding.EncodeToString(sha256Bytes[:])
}

func (c *Client) keyAuthorization(token string) string {
 return token + "." + c.thumbprint()
}

func (c *Client) DNS01ChallengeRecord(token string) string {
 hash := sha256.Sum256([]byte(c.keyAuthorization(token)))
 return base64.RawURLEncoding.EncodeToString(hash[:])
}

请求验证

Responding to Challenges

当我们配置好challenge后,我们需要先请求一次challengesURL发送一个{}空对象来通知 ACME 服务器已经准备好挑战了,我们可以轮训authorizationsURL 来查看验证状态,也可以轮询challengesURL更推荐authorizationsURL如果验证成功那么我们的订单就会变成valid状态,这时候我们也可以将 dns 等记录删除了。

当变为valid状态后,我们就可以请求证书了。

请求颁发证书

Applying for Certificate Issuance

当我们的订单变成valid状态后,我们就可以请求颁发证书了,通过finalizeURL 来请求颁发证书ACME 服务商会返回我们的证书链接。其中必须要携带上我们的 CSR 信息。

申请成功后,会返回一个certificateURL

下载证书

Downloading the Certificate

当我们请求颁发证书成功后,我们就可以通过certificateURL 来下载我们的证书了。然后就可以部署到我们的服务器上了。

ACME 协议的实现

Request Authentication

JWS RFC7515

ACME 使用 jwsJSON Web Signature来进行签名但是 ACME 依赖非对称算法,所以请注意不能使用HS256RFC 中推荐使用ES256,我们可以先实现一个 JWS 的签名和验证的工具类。

在这里我就不详细展开了,可以看看我的 acme 包中的 jws 实现: algorithm_test.go

这里为 acme 的请求做了一层封装,方便使用:

func (c *Client) newRequest(url string, payload any) (*http.Request, error) {
 nonce, err := c.NewNonce()
 if err != nil {
  return nil, err
 }
 // 注册账户需要签名
 if c.options.privateKey == nil {
  return nil, ErrPrivateKeyNotFound
 }
 var header *jws.Header
 // 如果有kid则使用kid签名
 if c.options.kid != "" {
  header = jws.NewHeader(newEs256(c.options.kid, c.options.privateKey))
 } else {
  header = jws.NewHeader(jws.ES256(c.options.privateKey))
 }
 data, err := jws.Encode(header.Set("nonce", nonce).Set("url", url),
  payload, jws.WithSerialization(jws.JSONSerialization))
 if err != nil {
  return nil, err
 }
 req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer([]byte(data)))
 if err != nil {
  return nil, err
 }
 req.Header.Set("Content-Type", "application/jose+json")
 return req, nil
}

func (c *Client) do(url string, payload any) ([]byte, *http.Response, error) {
 req, err := c.newRequest(url, payload)
 if err != nil {
  return nil, nil, err
 }
 resp, err := c.options.httpClient.Do(req)
 if err != nil {
  return nil, nil, err
 }
 defer resp.Body.Close()
 body, err := io.ReadAll(resp.Body)
 if err != nil {
  return nil, nil, err
 }
 return body, resp, nil
}

获取目录

首先我们需要通过GET请求来获取 ACME 服务商的目录信息,目录信息中包含了 ACME 服务商的一些信息,例如:new-accountnew-order等等。

我们以 Let's Encrypt 为例,其中 DirectoryUrl 为https://acme-v02.api.letsencrypt.org/directory

type Directory struct {
 NewNonce   string `json:"newNonce"`
 NewAccount string `json:"newAccount"`
 NewOrder   string `json:"newOrder"`
 NewAuthz   string `json:"newAuthz"`
 RevokeCert string `json:"revokeCert"`
 KeyChange  string `json:"keyChange"`
 Meta       struct {
  TermsOfService          string   `json:"termsOfService"`
  Website                 string   `json:"website"`
  CaaIdentities           []string `json:"caaIdentities"`
  ExternalAccountRequired bool     `json:"externalAccountRequired"`
 } `json:"meta"`
}

func (c *Client) GetDirectory() (*Directory, error) {
 // 请求目录
 req, err := http.NewRequest(http.MethodGet, c.options.directoryUrl, nil)
 if err != nil {
  return nil, err
 }
 resp, err := c.options.httpClient.Do(req)
 if err != nil {
  return nil, err
 }
 defer resp.Body.Close()
 directory := &Directory{}
 // 解析目录
 if err := json.NewDecoder(resp.Body).Decode(directory); err != nil {
  return nil, err
 }
 c.options.directory = directory
 return directory, nil
}

注册账户

此处对应的是注册账户我们需要在请求中携带上我们的邮箱信息ACME 服务商会返回一个账户的唯一标识符kid。我们需要保存这个 kid 和私钥,后续的请求都需要携带上这个 kid然后用私钥来签名。

注册账户请求需要带上 jws 信息,在jws.ES256.PreCompute中有对应实现

func (c *Client) NewAccount(contact []string) (string, error) {
 body, resp, err := c.do(c.options.directory.NewAccount, map[string]interface{}{
  "termsOfServiceAgreed": true,
  "contact":              contact,
 })
 if err != nil {
  return "", err
 }
 if resp.StatusCode != http.StatusCreated {
  return "", fmt.Errorf("NewAccount failed: %s", body)
 }
 if resp.Header.Get("Location") == "" {
  return "", fmt.Errorf("location not found: %s", body)
 }
 return resp.Header.Get("Location"), nil
}

创建订单

此步骤对应创建订单我们需要在请求中携带上我们的域名信息ACME 服务商会返回一个订单的标识符finalize和授权authorizations

type Identifiers struct {
 Type  string `json:"type"`
 Value string `json:"value"`
}

type NewOrderResponse struct {
 Status      string    `json:"status"`
 Expires     time.Time `json:"expires"`
 NotBefore   time.Time `json:"notBefore"`
 NotAfter    time.Time `json:"notAfter"`
 Identifiers []struct {
  Type  string `json:"type"`
  Value string `json:"value"`
 } `json:"identifiers"`
 Authorizations []string `json:"authorizations"`
 Finalize       string   `json:"finalize"`
}

func (c *Client) NewOrder(identifiers []*Identifiers) (*NewOrderResponse, error) {
 body, resp, err := c.do(c.options.directory.NewOrder, map[string]interface{}{
  "identifiers": identifiers,
 })
 if err != nil {
  return nil, err
 }
 if resp.StatusCode != http.StatusCreated {
  return nil, fmt.Errorf("NewOrder failed: %s", body)
 }
 order := &NewOrderResponse{}
 if err := json.Unmarshal(body, order); err != nil {
  return nil, err
 }
 return order, nil
}

获取授权

在上一步创建成功订单后ACME 会返回一个授权authorizationsURL我们需要请求这个 URL 来获取授权信息challenges然后我们需要根据这个信息来验证域名的所有权。

type AuthorizationResponse struct {
 Identifier struct {
  Type  string `json:"type"`
  Value string `json:"value"`
 } `json:"identifier"`
 Status     string    `json:"status"`
 Expires    time.Time `json:"expires"`
 Challenges []struct {
  Type   string `json:"type"`
  Status string `json:"status"`
  Url    string `json:"url"`
  Token  string `json:"token"`
 } `json:"challenges"`
}

func (c *Client) GetAuthorization(url string) (*AuthorizationResponse, error) {
 body, resp, err := c.do(url, nil)
 if err != nil {
  return nil, err
 }
 if resp.StatusCode != http.StatusOK {
  return nil, fmt.Errorf("GetAuthorization failed: %s", body)
 }
 auth := &AuthorizationResponse{}
 if err := json.Unmarshal(body, auth); err != nil {
  return nil, err
 }
 return auth, nil
}

应对挑战

在上一步获取到授权信息后,我们需要根据这个信息来验证域名的所有权,我们需要在 DNS 中添加一条 TXT 记录,记录名为 _acme-challenge加上域名。

例如:docs.scriptcat.org那么记录名为 _acme-challenge.docs.scriptcat.org

func (c *Client) thumbprint() string {
 sha256Bytes := sha256.Sum256([]byte(jws.ES256Jwk(c.options.privateKey.PublicKey)))
 return base64.RawURLEncoding.EncodeToString(sha256Bytes[:])
}

func (c *Client) keyAuthorization(token string) string {
 return token + "." + c.thumbprint()
}

func (c *Client) ChallengeRecord(token string) string {
 hash := sha256.Sum256([]byte(c.keyAuthorization(token)))
 return base64.RawURLEncoding.EncodeToString(hash[:])
}

dns 记录设置好了之后,我们需要请求一次challengesURL发送一个{}空对象来通知 ACME 服务器已经准备好挑战了,然后我们可以轮询authorizationsURL 来查看验证状态,如果验证成功,那么我们的订单就会变成valid状态,这时候我们也可以将 dns 等记录删除了。

type ChallengeResponse struct {
 Type             string `json:"type"`
 Status           string `json:"status"`
 Url              string `json:"url"`
 Token            string `json:"token"`
 ValidationRecord []struct {
  Hostname      string   `json:"hostname"`
  ResolverAddrs []string `json:"resolverAddrs"`
 } `json:"validationRecord"`
 Validated time.Time `json:"validated"`
}

// GetChallenge 获取挑战
func (c *Client) GetChallenge(url string) (*ChallengeResponse, error) {
 body, resp, err := c.do(url, nil)
 if err != nil {
  return nil, err
 }
 if resp.StatusCode != http.StatusOK {
  return nil, fmt.Errorf("GetChanllenge failed: %s", body)
 }
 challenge := &ChallengeResponse{}
 if err := json.Unmarshal(body, challenge); err != nil {
  return nil, err
 }
 return challenge, nil
}

// RequestChallenge 请求挑战
// 当你当http-01/dns-01记录准备好后调用此接口
// 然后使用GetChallenge或者GetAuthorization轮询查看状态
func (c *Client) RequestChallenge(url string) (*ChallengeResponse, error) {
 body, resp, err := c.do(url, "{}")
 if err != nil {
  return nil, err
 }
 if resp.StatusCode != http.StatusOK {
  return nil, fmt.Errorf("RequestChallenge failed: %s", body)
 }
 challenge := &ChallengeResponse{}
 if err := json.Unmarshal(body, challenge); err != nil {
  return nil, err
 }
 return challenge, nil
}

请求颁发证书

请求颁发证书需要先生成一个 CSR 信息,然后请求finalizeURLACME 服务商会返回我们的证书。其中必须要携带上我们的 CSR 信息。

下载下来的证书是一个 PEM 格式的证书,我们可以通过x509.ParseCertificate来解析证书,然后就可以使用了。


type FinalizeResponse struct {
 Status      string    `json:"status"`
 Expires     time.Time `json:"expires"`
 Identifiers []struct {
  Type  string `json:"type"`
  Value string `json:"value"`
 } `json:"identifiers"`
 Authorizations []string `json:"authorizations"`
 Finalize       string   `json:"finalize"`
 Certificate    string   `json:"certificate"`
}

func (c *Client) Finalize(url string, csr []byte) (*FinalizeResponse, error) {
 body, resp, err := c.do(url, map[string]interface{}{
  "csr": base64.RawURLEncoding.EncodeToString(csr),
 })
 if err != nil {
  return nil, err
 }
 if resp.StatusCode != http.StatusOK {
  return nil, fmt.Errorf("FinalizeOrder failed: %s", body)
 }
 finalize := &FinalizeResponse{}
 if err := json.Unmarshal(body, finalize); err != nil {
  return nil, err
 }
 return finalize, nil
}

func CreateCertificateRequest(auth []*Identifiers) ([]byte, *ecdsa.PrivateKey, error) {
 csr := x509.CertificateRequest{}
 for _, v := range auth {
  switch v.Type {
  case "dns":
   csr.DNSNames = append(csr.DNSNames, v.Value)
  }
 }
 k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
 if err != nil {
  return nil, nil, err
 }
 b, err := x509.CreateCertificateRequest(rand.Reader, &csr, k)
 if err != nil {
  return nil, nil, err
 }
 return b, k, nil
}

func (c *Client) GetCertificate(url string) ([]byte, error) {
 body, resp, err := c.do(url, nil)
 if err != nil {
  return nil, err
 }
 if resp.StatusCode != http.StatusOK {
  return nil, fmt.Errorf("GetCertificate failed: %s", body)
 }
 return body, nil
}

测试

我们可以写一个方法来测试一下上面的流程是否可行,代码我就不贴这里了,大家可以到这里查看:acme_test.go

这是最开始与本文写的一致的一个版本,后续会有所改动,可以回到主分支查看。

结尾

其中也参考了官方写的acmegolang.org/x/crypto/acme虽然已经有很多现成的包可以使用也有很多文档但是理论和实践差距还是挺大的其中很多坑还是得自己趟一遍才能理解。

这次实现又接触到了不少东西CSR、JWS、JWK、ES256、x509 等等,大家有兴趣也可以再深入一下,有空我也再写几篇,整理一下这些内容。