18 KiB
ACME 协议流程详解与实现
ACME 协议全名为 “Automatic Certificate Management Environment”,RFC 8555, 意为自动证书管理环境。它是一个由 IETF 制定的协议,用于自动化证书颁发和更新过程。
在了解 ACME 之前,我们先来了解一下一些相关的名词:
- CA:Certificate Authority,证书颁发机构,负责颁发数字证书。
- CSR:Certificate Signing Request,证书签名请求,是由申请者生成的包含公钥的数据块,用于向 CA 申请数字证书。
- CRT:Certificate,证书文件,通常使用.crt 扩展名,可以是 PEM 或 DER 格式。.crt 文件包含公钥并由证书颁发机构(CA)签发,用于身份验证和数据加密。
- PEM:Privacy-Enhanced Mail,一种证书文件格式,可用于存储证书(.crt、.pem)、证书请求(.csr)和私钥(.key)。
- DER:Distinguished 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 来进行介绍。
Let’s Encrypt 提供域名验证型(DV)证书。不提供组织验证(OV)或扩展验证(EV),OV 和 EV 证书一般需要验证真实实体才能颁发,不能做到自动化的颁发,自动化也会削弱这种信任。Introduction
ACME 协议流程
ACME 协议的流程如下:
注册账户
通过new-account
向 ACME 服务商注册账户,获取账户的唯一标识符(在发送请求时的 kid 字段)。有的 ACME 提供商可能还需要验证邮箱等信息,需要先注册 CA 账户,然后在请求 ACME 注册账户时携带上指定信息。
创建订单
通过new-order
向 ACME 服务商创建一个新的订单,指定需要颁发证书的域名。如果通过,此时处于pending
状态,ACME 服务商会返回一个订单标识符(finalize)和授权(authorizations),用于后续的操作。
验证域名
在获取到 ACME 服务商返回的授权信息后,此时还只是处于pending
,后续需要验证域名的所有权。此时我们请求上一步返回的authorizations
URL,ACME 服务商会返回一个验证信息(challenges),客户端需要根据这个信息来验证域名的所有权。一般情况下会有http-01
和dns-01
两种验证方式。
我们只介绍dns-01
的方式。
DNS-01 验证
我们需要在 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[:])
}
请求验证
当我们配置好challenge
后,我们需要先请求一次challenges
URL,发送一个{}
空对象来通知 ACME 服务器已经准备好挑战了,我们可以轮训authorizations
URL 来查看验证状态,也可以轮询challenges
URL,更推荐authorizations
URL,如果验证成功,那么我们的订单就会变成valid
状态,这时候我们也可以将 dns 等记录删除了。
当变为valid
状态后,我们就可以请求证书了。
请求颁发证书
当我们的订单变成valid
状态后,我们就可以请求颁发证书了,通过finalize
URL 来请求颁发证书,ACME 服务商会返回我们的证书链接。其中必须要携带上我们的 CSR 信息。
申请成功后,会返回一个certificate
URL
下载证书
当我们请求颁发证书成功后,我们就可以通过certificate
URL 来下载我们的证书了。然后就可以部署到我们的服务器上了。
ACME 协议的实现
ACME 使用 jws(JSON Web Signature)来进行签名,但是 ACME 依赖非对称算法,所以请注意不能使用HS256
,RFC 中推荐使用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-account
、new-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 会返回一个授权(authorizations)URL,我们需要请求这个 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 记录设置好了之后,我们需要请求一次challenges
URL,发送一个{}
空对象来通知 ACME 服务器已经准备好挑战了,然后我们可以轮询authorizations
URL 来查看验证状态,如果验证成功,那么我们的订单就会变成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 信息,然后请求finalize
URL,ACME 服务商会返回我们的证书。其中必须要携带上我们的 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。
这是最开始与本文写的一致的一个版本,后续会有所改动,可以回到主分支查看。
结尾
其中也参考了官方写的acme
包:golang.org/x/crypto/acme,虽然已经有很多现成的包可以使用,也有很多文档,但是理论和实践差距还是挺大的,其中很多坑还是得自己趟一遍才能理解。
这次实现又接触到了不少东西:CSR、JWS、JWK、ES256、x509 等等,大家有兴趣也可以再深入一下,有空我也再写几篇,整理一下这些内容。