在我们之前关于 ACME 续订信息 (ARI) 的基础益处的文章 (previous) 之后,这篇文章提供了一个详细的技术指南,用于将 ARI 集成到现有的 ACME 客户端中。

自 2023 年 3 月推出以来,ARI 显著增强了越来越多订阅者的证书吊销和续订的弹性和可靠性。为了将这些优势扩展到更广泛的受众,将 ARI 集成到更多 ACME 客户端中至关重要。

为了促进更广泛的采用,我们很高兴地宣布一项引人注目的新激励措施:使用 ARI 的证书续订将不再受所有 速率限制 的约束。为了利用此优势,续订必须在 ARI 建议的续订窗口内完成,并且请求必须明确表明正在替换哪个现有证书。要了解如何请求建议的续订窗口、选择最佳续订时间以及指定证书替换,请继续阅读!

将 ARI 集成到现有 ACME 客户端

2023 年 5 月,我们为 Lego ACME 客户端贡献了一个 pull request,添加了对 draft-ietf-acme-ari-01 的支持。2023 年 12 月和 2024 年 2 月,我们贡献了两个后续的 pull request (20662114),为 draft-ietf-acme-ari-02 和 03 中的更改添加了支持。这些经验为将 ARI 集成到现有 ACME 客户端的过程中提供了宝贵的见解。我们已将这些见解提炼成六个步骤,我们希望它们对其他 ACME 客户端开发人员有所帮助。

注意:本帖中的代码片段是用 Golang 编写的。我们已对其结构和上下文进行了调整以确保清晰度,以便它们也可以轻松地适应其他编程语言。

步骤 1:检测对 ARI 的支持

虽然 Let’s Encrypt 在 2023 年 3 月首先在 Staging 和生产环境中启用了 ARI,但许多 ACME 客户端与各种 CA 一起使用,因此确定 CA 是否支持 ARI 至关重要。这很容易确定:如果 CA 的目录对象中包含“renewalInfo”端点,则该 CA 支持 ARI。

在几乎所有客户端中,您都会发现一个用于解析 ACME 目录对象的 JSON 的函数或方法。如果此代码正在将 JSON 反序列化为定义的类型,则需要修改此类型以包含新的“renewalInfo”端点。

在 Lego 中,我们在 Directory 结构中添加了一个“renewalInfo”字段,该字段可通过 GetDirectory 方法访问

type Directory struct {
    NewNonceURL    string `json:"newNonce"`
    NewAccountURL  string `json:"newAccount"`
    NewOrderURL    string `json:"newOrder"`
    NewAuthzURL    string `json:"newAuthz"`
    RevokeCertURL  string `json:"revokeCert"`
    KeyChangeURL   string `json:"keyChange"`
    Meta           Meta   `json:"meta"`
    RenewalInfo    string `json:"renewalInfo"`
}

如上所述,并非所有 ACME CA 目前都实现了 ARI,因此在尝试使用“renewalInfo”端点之前,我们应该确保在调用它之前实际填充了该端点

func (c *CertificateService) GetRenewalInfo(certID string) (*http.Response, error) {
  if c.core.GetDirectory().RenewalInfo == "" {
    return nil, ErrNoARI
  }
}

步骤 2:确定 ARI 在客户端续订生命周期中的位置

下一步涉及选择客户端工作流程中的最佳位置来集成 ARI 支持。ACME 客户端可以持续运行或按需执行。ARI 对持续运行的客户端或至少每天运行一次的按需客户端特别有利。

以 Lego 为例,它属于后一类。它的 renew 命令是按需执行的,通常通过 cron 之类的作业调度器执行。因此,将 ARI 支持集成到 renew 命令是合乎逻辑的选择。与许多 ACME 客户端一样,Lego 已经有一个机制来根据证书的剩余有效期和用户配置的续订时间范围来决定何时续订证书。引入对 ARI 的调用应该优先于此机制,从而导致对 renew 命令进行修改,以在求助于内置逻辑之前咨询 ARI。

步骤 3:构造 ARI CertID

ARI CertID 的组成是 ARI 规范中至关重要的部分。此标识符对每个证书都是唯一的,它是通过将证书的颁发机构密钥标识符 (AKI) 扩展的 base64url 编码字节与其序列号结合起来生成的,两者之间用句点分隔。将 AKI 和序列号结合起来的方法具有战略意义:AKI 特定于颁发中间证书,并且 CA 可能有多个中间证书。证书的序列号需要对每个颁发中间证书是唯一的,但序列号可以在中间证书之间重复使用。因此,AKI 和序列号的组合唯一地标识了证书。在弄清楚这一点后,让我们继续仅使用要替换的证书的内容来构造 ARI CertID。

假设证书的颁发机构密钥标识符 (AKI) 扩展的“keyIdentifier”字段的十六进制字节为 69:88:5B:6B:87:46:40:41:E1:B3:7B:84:7B:A0:AE:2C:DE:01:C8:D4,作为其 ASN.1 八位字节串值。这些字节的 base64url 编码为 aYhba4dGQEHhs3uEe6CuLN4ByNQ=。此外,证书的序列号在表示为其 DER 编码(不包括标记和长度字节)时,具有十六进制字节 00:87:65:43:21。这包括一个前导零字节,以确保序列号被解释为正整数,如 0x87 中的前导 1 位所要求的那样。这些字节的 base64url 编码为 AIdlQyE=。在从每个编码部分中剥离尾随填充字符(“=”)并将它们与句点作为分隔符连接在一起后,此证书的 ARI CertID 为 aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE

在 Lego 的情况下,我们在以下函数中实现了上述逻辑

// MakeARICertID constructs a certificate identifier as described in
// draft-ietf-acme-ari-03, section 4.1.

func MakeARICertID(leaf *x509.Certificate) (string, error) {
  if leaf == nil {
    return "", errors.New("leaf certificate is nil")
  }

  // Marshal the Serial Number into DER.
  der, err := asn1.Marshal(leaf.SerialNumber)
  if err != nil {
    return "", err
  }

  // Check if the DER encoded bytes are sufficient (at least 3 bytes: tag,
  // length, and value).
  if len(der) < 3 {
    return "", errors.New("invalid DER encoding of serial number")
  }

  // Extract only the integer bytes from the DER encoded Serial Number
  // Skipping the first 2 bytes (tag and length). The result is base64url
  // encoded without padding.
  serial := base64.RawURLEncoding.EncodeToString(der[2:])

  // Convert the Authority Key Identifier to base64url encoding without
  // padding.
  aki := base64.RawURLEncoding.EncodeToString(leaf.AuthorityKeyId)

  // Construct the final identifier by concatenating AKI and Serial Number.
  return fmt.Sprintf("%s.%s", aki, serial), nil
}

注意:在提供的代码中,我们使用了 RawURLEncoding,它是 RFC 4648 中定义的无填充 base64 编码。此编码类似于 URLEncoding,但它排除了填充字符,例如“=”。如果您的编程语言的 base64 包只支持 URLEncoding,则需要在将它们组合在一起之前从编码字符串中删除任何尾随填充字符。

步骤 4:请求建议的续订窗口

有了 ARI CertID,我们现在可以向 CA 请求续订信息。这可以通过向“renewalInfo”端点发送 GET 请求来完成,并将 ARI CertID 包含在 URL 路径中。

GET https://example.com/acme/renewal-info/aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE

ARI 响应是一个 JSON 对象,其中包含一个“suggestedWindow”,其“start”和“end”时间戳指示建议的续订期限,以及可选的“explanationURL”,提供有关续订建议的更多上下文。

{
  "suggestedWindow": {
    "start": "2021-01-03T00:00:00Z",
    "end": "2021-01-07T00:00:00Z"
  },
  "explanationURL": "https://example.com/docs/ari"
}

“explanationURL”是可选的。但是,如果提供它,建议将其显示给用户或记录它。例如,在 ARI 由于需要吊销的事件而建议立即续订的情况下,“explanationURL”可能链接到一个页面,解释该事件。

接下来,我们将介绍如何使用“suggestedWindow”来确定续订证书的最佳时间。

步骤 5:选择特定续订时间

draft-ietf-acme-ari 提供了一种建议的算法,用于确定何时续订证书。该算法不是强制性的,但建议使用。

  1. 在建议的窗口内选择一个均匀随机时间。

  2. 如果选择的时间已过去,则立即尝试续订。

  3. 否则,如果客户端可以调度自己在选择的时间准确尝试续订,则这样做。

  4. 否则,如果选择的时间在客户端正常唤醒的下一个时间之前,则立即尝试续订。

  5. 否则,睡眠到下一个正常唤醒时间,重新检查 ARI,然后返回到“1”。

对于 Lego,我们在以下函数中实现了上述逻辑

func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time {

  // Explicitly convert all times to UTC.
  now = now.UTC()
  start := r.SuggestedWindow.Start.UTC()
  end := r.SuggestedWindow.End.UTC()

  // Select a uniform random time within the suggested window.
  window := end.Sub(start)
  randomDuration := time.Duration(rand.Int63n(int64(window)))
  rt := start.Add(randomDuration)

  // If the selected time is in the past, attempt renewal immediately.
  if rt.Before(now) {
    return &now
  }

  // Otherwise, if the client can schedule itself to attempt renewal at exactly the selected time, do so.
  willingToSleepUntil := now.Add(willingToSleep)
  if willingToSleepUntil.After(rt) || willingToSleepUntil.Equal(rt) {
    return &rt
  }

  // TODO: Otherwise, if the selected time is before the next time that the client would wake up normally, attempt renewal immediately.

  // Otherwise, sleep until the next normal wake time.

  return nil

}

步骤 6:指示此新订单替换了哪个证书

为了表明续订是 ARI 建议的,一个新的“replaces”字段已添加到 ACME Order 对象中。ACME 客户端应在创建新订单时填充此字段,如以下示例所示

{
  "protected": base64url({
    "alg": "ES256",
    "kid": "https://example.com/acme/acct/evOfKhNU60wg",
    "nonce": "5XJ1L3lEkMG7tR6pA00clA",
    "url": "https://example.com/acme/new-order"
  }),
  "payload": base64url({
    "identifiers": [
      { "type": "dns", "value": "example.com" }
    ],
    "replaces": "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"
  }),
  "signature": "H6ZXtGjTZyUnPeKn...wEA4TklBdh3e454g"
}

许多客户端将有一个对象,客户端将其反序列化为用于订单请求的 JSON。在 Lego 客户端中,这是 Order 结构。它现在包含一个“replaces”字段,可通过 NewWithOptions 方法访问

// Order the ACME order Object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.3

type Order struct {
  ...
  // replaces (optional, string):
  // a string uniquely identifying a previously-issued
  // certificate which this order is intended to replace.
  // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
  Replaces string `json:"replaces,omitempty"`
}

...

// NewWithOptions Creates a new order.
func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acme.ExtendedOrder, error) {
  ...
  if o.core.GetDirectory().RenewalInfo != "" {
    orderReq.Replaces = opts.ReplacesCertID
  }
}

当 Let’s Encrypt 处理包含“replaces”字段的新订单请求时,将进行一些重要的检查。首先,它将验证此字段中指示的证书以前是否已被替换。接下来,我们确保证书与当前发出请求的同一个 ACME 帐户相关联。此外,现有证书和请求的证书之间必须至少共享一个域名。如果满足这些条件并且新订单请求在 ARI 建议的续订窗口内提交,则该请求有资格免除所有速率限制。恭喜!

展望未来

将 ARI 集成到更多 ACME 客户端中不仅仅是技术升级,它还是 ACME 协议演进的下一步;在这一步中,CA 和客户端协同工作以优化续订流程,确保证书有效期失效成为过去。其结果是为所有人、在任何地方创造了一个更加安全和尊重隐私的互联网。

与往常一样,我们很高兴在这段旅程中与我们的社区互动。您的见解、经验和 反馈 在我们不断突破 ACME 可能性的界限时至关重要。

我们感谢普林斯顿大学与我们合作进行 ACME 续订信息工作,感谢 开放技术基金 的慷慨支持。

互联网安全研究小组 (ISRG)Let’s EncryptProssimoDivvi Up 的母体组织。ISRG 是一个 501(c)(3) 非营利组织。如果您想支持我们的工作,请考虑 参与捐赠 或鼓励您的公司 成为赞助商