acme协议
All checks were successful
Release / deploy (push) Successful in 2m13s

This commit is contained in:
2024-03-30 00:33:36 +08:00
parent 359881a17c
commit 460c6cdc87
8 changed files with 476 additions and 16 deletions

View File

@ -0,0 +1,469 @@
# ACME 协议流程详解与实现
ACME 协议全名为 “Automatic Certificate Management Environment”[RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555)
意为自动证书管理环境。它是一个由 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](https://letsencrypt.org/)
- [ZeroSSL](https://zerossl.com/)
大多数系统都是支持的 Let's Encrypt因此我们这里通过 Let's Encrypt 来进行介绍。
Lets Encrypt 提供域名验证型DV证书。不提供组织验证OV或扩展验证EVOV 和 EV 证书一般需要验证真实实体才能颁发,不能做到自动化的颁发,自动化也会削弱这种信任。[Introduction](https://datatracker.ietf.org/doc/html/rfc8555#section-1)
## ACME 协议流程
ACME 协议的流程如下:
### 注册账户
> [Account Management](https://datatracker.ietf.org/doc/html/rfc8555#autoid-28)
通过`new-account`向 ACME 服务商注册账户,获取账户的唯一标识符(在发送请求时的 kid 字段)。有的 ACME 提供商可能还需要验证邮箱等信息,需要先注册 CA 账户,然后在请求 ACME 注册账户时携带上指定信息。
### 创建订单
> [Applying for Certificate Issuance](https://datatracker.ietf.org/doc/html/rfc8555#autoid-35)
通过`new-order`向 ACME 服务商创建一个新的订单,指定需要颁发证书的域名。如果通过,此时处于`pending`状态ACME 服务商会返回一个订单标识符finalize和授权authorizations用于后续的操作。
### 验证域名
> [Identifier Authorization](https://datatracker.ietf.org/doc/html/rfc8555#section-7.5)
> [DNS Challenge](https://datatracker.ietf.org/doc/html/rfc8555#section-8.4)
在获取到 ACME 服务商返回的授权信息后,此时还只是处于`pending`,后续需要验证域名的所有权。此时我们请求上一步返回的`authorizations`URLACME 服务商会返回一个验证信息challenges客户端需要根据这个信息来验证域名的所有权。一般情况下会有`http-01``dns-01`两种验证方式。
我们只介绍`dns-01`的方式。
#### DNS-01 验证
> [Key Authorizations](https://datatracker.ietf.org/doc/html/rfc8555#section-8.1)
我们需要在 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 编码。
```go
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](https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.1)
当我们配置好`challenge`后,我们需要先请求一次`challenges`URL发送一个`{}`空对象来通知 ACME 服务器已经准备好挑战了,我们可以轮训`authorizations`URL 来查看验证状态,也可以轮询`challenges`URL更推荐`authorizations`URL如果验证成功那么我们的订单就会变成`valid`状态,这时候我们也可以将 dns 等记录删除了。
当变为`valid`状态后,我们就可以请求证书了。
### 请求颁发证书
> [Applying for Certificate Issuance](https://datatracker.ietf.org/doc/html/rfc8555#section-7.4)
当我们的订单变成`valid`状态后,我们就可以请求颁发证书了,通过`finalize`URL 来请求颁发证书ACME 服务商会返回我们的证书。其中必须要携带上我们的 CSR 信息。
申请成功后,会返回一个`certificate`URL
### 下载证书
> [Downloading the Certificate](https://datatracker.ietf.org/doc/html/rfc8555#section-7.4.2)
当我们请求颁发证书成功后,我们就可以通过`certificate`URL 来下载我们的证书了。然后就可以部署到我们的服务器上了。
## ACME 协议的实现
> [Request Authentication](https://datatracker.ietf.org/doc/html/rfc8555#section-6.2)
> [JWS RFC7515](https://datatracker.ietf.org/doc/html/rfc7515)
ACME 使用 jwsJSON Web Signature来进行签名但是 ACME 依赖非对称算法,所以请注意不能使用`HS256`RFC 中推荐使用`ES256`,我们可以先实现一个 JWS 的签名和验证的工具类。
在这里我就不详细展开了,可以看看我的 acme 包中的 jws 实现: [algorithm_test.go](https://github.com/CodFrm/dns-kit/blob/6dcac9b084a8487188af9eb58c5e411b489cedbe/pkg/jws/algorithm_test.go)
这里为 acme 的请求做了一层封装,方便使用:
```go
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`
```go
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`中有对应实现
```go
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
```go
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然后我们需要根据这个信息来验证域名的所有权。
```go
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`
```go
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 等记录删除了。
```go
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`URLACME 服务商会返回我们的证书。其中必须要携带上我们的 CSR 信息。
下载下来的证书是一个 PEM 格式的证书,我们可以通过`x509.ParseCertificate`来解析证书,然后就可以使用了。
```go
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](https://github.com/CodFrm/dns-kit/blob/6dcac9b084a8487188af9eb58c5e411b489cedbe/pkg/acme/acme_test.go)
## 结尾
其中也参考了官方写的`acme`golang.org/x/crypto/acme虽然已经有很多现成的包可以使用也有很多文档但是理论和实践差距还是挺大的其中很多坑还是得自己趟一遍才能理解。
这次实现又接触到了不少东西CSR、JWS、JWK、ES256、x509 等等,大家有兴趣也可以再深入一下,有空我也再写几篇,整理一下这些内容。