读一遍 ACME 协议

log
·

读一遍 ACME 协议

最近用 typescript 自己实现了一个 ACME 客户端:https://github.com/shiny/HandyAcme 读了一遍协议,一点点做了实现,然后把理解的内容记录到下文中。

ACME 协议全名为 “Automatic Certificate Management Environment”,为 RFC 8555,地址位于:

RFC 8555 - Automatic Certificate Management Environment (ACME)

常见支持 ACME 协议的 Ca 有

消息传输

ACME 客户端和服务端通过 HTTPS 通信,签名格式为 JWS:JSON Web Signature(RFC 7515)。JWS 提供了客户端请求的认证,并能防止重放攻击。

必须使用 HTTPS 连接。

ACME 客户端必须包含 User-Agent 头。http header 中必须包含依赖的 http 客户端名称和版本号。

ACME 客户端必须发送 Accept-Language header(RFC 7231),以便实现本地化错误信息。

JSON 对象二进制字段必须用 URL安全的 base64 协议。必须过滤末尾的= 符号。如果末尾包含了= 则必须视为错误编码而拒绝。

ACME 资源关系图

                                  directory
                                      |
                                      +--> newNonce
                                      |
          +----------+----------+-----+-----+------------+
          |          |          |           |            |
          |          |          |           |            |
          V          V          V           V            V
     newAccount   newAuthz   newOrder   revokeCert   keyChange
          |          |          |
          |          |          |
          V          |          V
       account       |        order --+--> finalize
                     |          |     |
                     |          |     +--> cert
                     |          V
                     +---> authorization
                               | ^
                               | | "up"
                               V |
                             challenge

目录 Directory

它是客户端唯一必须的配置,提供 ACME 服务器操作的正确 URL 地址。

提供了以下字段

字段 URL 含义
newNonce 新的 nonce
newAccount 新的 account
newOrder 新的订单
newAuthz 新的 authorization
revokeCert 吊销证书
keyChange key change

除此之外还有一个可选的 meta 字段

meta 里包含如下字段

  • termsOfService: string 服务协议 URL
  • website: string 网址
  • caaIdentities: string[] 我不理解
  • externalAccountRequired : boolean 重要,CA 是否需要 externalAccountBinding 字段绑定账号。ZeroSSL 需要,Let's Encrypt 不需要。

请求方式

  1. 获取目录为 GET

  2. 获取 nonce 为 HEAD

  3. 其他均为 POST

  4. POST-as-GET 是什么:https://datatracker.ietf.org/doc/html/rfc8555#section-6.3

    即 http Method 为 POST,但 payload 字段为空字符串。注意:空字符串和空对象 {} 是有区别的,用错会导致请求失败。

1. 新建账户 new Account

POST /acme/new-account HTTP/1.1
Host: example.com
Content-Type: application/jose+json

{
 "protected": base64url({
   "alg": "ES256",
   "jwk": {...},
   "nonce": "6S8IqOGY7eL2lsGoTZYifg",
   "url": "https://example.com/acme/new-account"
 }),
 "payload": base64url({
   "termsOfServiceAgreed": true,
   "contact": [
     "mailto:[email protected]",
     "mailto:[email protected]"
   ]
 }),
 "signature": "RZPOnYoPs1PhjszF...-nh6X1qtOFPB519I"
}

alg: 算法

jwk:JSON Web Key,包含私钥公钥

nonce:从 newNonce 获取到的随机字符串

url:服务的地址

externalAccountBinding

  • 如果像 ZeroSSL 这样的 CA 需要 externalAccountBinding,则在 Payload 中需要提供该字段。

  • 你可以在后台直接复制

  • 分析 acme.sh 代码可以得知,它通过 ZeroSSL 的 API,获取了邮箱对应的 EAB credentials。

    URL: api.zerossl.com/acme/eab-credentials-email

    Method: Post

    字段: email

2. 创建订单 newOrder

有了账号之后需要创建订单

订单结构如下:

{
  status: 'pending',
  expires: '2022-08-05T10:00:13Z',
  identifiers: [
    { type: 'dns', value: 'example.com' },
    { type: 'dns', value: 'test1.example.com' }
  ],
  authorizations: [
    'https://acme-staging-v02.api.letsencrypt.org/acme/authz-v3/3166415814',
    'https://acme-staging-v02.api.letsencrypt.org/acme/authz-v3/3166415824'
  ],
  finalize: 'https://acme-staging-v02.api.letsencrypt.org/acme/finalize/62584554/3411423254'
}

关于 identifiers 的定义:https://datatracker.ietf.org/doc/html/rfc8555#section-9.7.8

根据这里的 authorizations 可以进入下一步:获取验证方式 challenges

2.1 获取验证方式 challenges

往上一步的 authorizations url 发送 POST-as-GET 的请求(payload 是空字符串)。

POST /acme/authz-v3/3188690644 HTTP/1.1
Host: example.com
Content-Type: application/jose+json

{
 "protected": base64url({
   "alg": "ES256",
   "jwk": {...},
   "nonce": "6S8IqOGY7eL2lsGoTZYifg",
   "url": "https://example.com/acme/authz-v3/3188690644"
 }),
 "payload": '',
 "signature": "RZPOnYoPs1PhjszF...-nh6X1qtOFPB519I"
}

返回验证方式,示例如下:

{
  "identifier": {
    "type": "dns",
    "value": "example.com"
  },
  "status": "pending",
  "expires": "2022-08-05T10:00:13Z",
  "challenges": [
    {
      "type": "http-01",
      "status": "pending",
      "url": "https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/3166415814/NPuHTg",
      "token": "Jyq2Kxs8rbwGOPAPMOiHMhj3X_Y9cjqYIDcuKss0tTk"
    },
    {
      "type": "dns-01",
      "status": "pending",
      "url": "https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/3166415814/-NH__g",
      "token": "Jyq2Kxs8rbwGOPAPMOiHMhj3X_Y9cjqYIDcuKss0tTk"
    },
    {
      "type": "tls-alpn-01",
      "status": "pending",
      "url": "https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/3166415814/zlVqNQ",
      "token": "Jyq2Kxs8rbwGOPAPMOiHMhj3X_Y9cjqYIDcuKss0tTk"
    }
  ]
}

dns-01 验证方式

dns 的 key 是 _acme-challenge,value 是 token + jwk指纹 sha256 摘要

然后再次 sha256,并以 url 安全的 base64 编码。

urlsafebase64(sha256({token}.{key}))

参考实现方式:https://github.com/publishlab/node-acme-client/blob/4437c8377c40558a869a23f55471dff1cc81f6c7/src/client.js#L465

http-01 验证方式

相对于 dns-01,http-01 验证方式更简单一些;缺点是不能验证 wildcard 证书(*.example.com)。

要求在域名解析出来的 IPv4 或 IPv6 地址上监听 80 端口(且必须是 80 端口),请求的地址为:http://{domain}/.well-known/acme-challenge/{token}

返回的数据为:

{token}.{key}

这次只需明文文本,无需 sha256 也无需 urlsafebase64。

tls-alpn-01 验证方式

它的好处是可以通过 443 端口来验证。不了解更详细的验证细节。

2.2 通知验证已完成

完成验证操作后,请求上述数据包中的 challenges.url 通知验证已经完成。

状态变化需要一点时间,可以轮询状态,等到所有 challengestatusvalid 时即可完成订单。

{
  type: 'http-01',
  status: 'valid',
  url: 'https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/3188690644/15PaJg',
  token: 'AXDybThqwiYOKqimUQmN7g4o4zlv2ZkpQMgEEC7_XMo',
  validationRecord: [
    {
      url: 'http://test.example.com/.well-known/acme-challenge/AXDybThqwiYOKqimUQmN7g4o4zlv2ZkpQMgEEC7_XMo',
      hostname: 'test.example.com',
      port: '80',
      addressesResolved: ['1.1.1.1'],
      addressUsed: '1.1.1.1'
    }
  ],
  validated: '2022-07-31T14:29:08Z'
}

2.3 检测订单状态

当订单状态为 ready 时可以 finalize 订单(传入 csr)

当订单状态为 valid 时可以下载证书

2.4 完成订单 finalizeOrder

第一步创建订单中的 finalize 字段即是通知订单完成的 URL。当所有验证均已完成后即可完成订单。

3. 获取证书

快速验证申请的证书

const https = require('https');
const fs = require('fs');

const options = {
  key: fs.readFileSync('<YOUR-SSL-KEY>.key'),
  cert: fs.readFileSync('<YOUR-SSL-PEM>.crt')
};

https.createServer(options, function (req, res) {
  res.writeHead(200);
  res.end("hello world\n");
}).listen(8000);

console.log(`Open https://127.0.0.1:8000`);

替换代码中的 key 和 crt 文件位置,在浏览器中打开并查看证书是否和申请的一致。

错误

  1. 当 http 状态码为 4xx、5xx 即为出错

    出错时的数据结构:

    interface Error {
    	  // 清单:[https://datatracker.ietf.org/doc/html/rfc8555#section-6.7](https://datatracker.ietf.org/doc/html/rfc8555#section-6.7)
    		// 例如:"urn:ietf:params:acme:error:malformed"
        type: string
    		// 人类友好的描述,例如:"Request payload did not parse as JSON"
    		detail: string
        // 例如:400
    		status: number
    }
    
  2. 频率限制

    错误类型为:urn:ietf:params:acme:error:rateLimited

    detail 字段为

    Header 中会带上 Retry-After 字段,提示延迟的时间

【未完待续】

参考资源 Reference

  1. ACME 协议 https://datatracker.ietf.org/doc/html/rfc8555
  2. 一个 node 实现的 acme client https://github.com/publishlab/node-acme-client
  3. WebCrypto 加解密 https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
  4. x.509 证书生成的 node 实现 https://github.com/PeculiarVentures/x509
评论

起因是想做一个 SSL 证书自动签发、部署的面板。一开始也是满头雾水,啃了几天慢慢觉得简单。

reply
回复
1 回复
arrow_right_alt

RFC 对一般人太难读了,确实需要一些精简的例子。👍

reply
回复
1 回复
arrow_right_alt
社区准则 博客 联系 反馈 状态
主题