繼我們先前關於 ACME 更新資訊 (ARI) 的基礎優勢文章之後,本文將提供詳細的技術指南,說明如何將 ARI 整合至現有的 ACME 用戶端。

自 2023 年 3 月推出以來,ARI 已顯著提升了越來越多訂閱者的憑證撤銷和更新的彈性和可靠性。為了將這些優勢擴展到更廣泛的受眾,將 ARI 整合到更多 ACME 用戶端至關重要。

為了促進更廣泛的採用,我們很高興宣布一項新的誘人誘因:利用 ARI 進行的憑證更新,現在將免除所有速率限制。為了利用此優勢,更新必須在 ARI 建議的更新視窗內發生,並且請求必須清楚地指示正在替換哪個現有憑證。要了解如何請求建議的更新視窗、選擇最佳更新時間,以及指定憑證替換,請繼續閱讀!

將 ARI 整合至現有 ACME 用戶端

在 2023 年 5 月,我們向 Lego ACME 用戶端提交了提取請求,新增了對 draft-ietf-acme-ari-01 的支援。在 2023 年 12 月和 2024 年 2 月,我們提交了兩個後續提取請求 (20662114),新增了對 draft-ietf-acme-ari-02 和 03 中所做變更的支援。這些經驗為將 ARI 整合至現有 ACME 用戶端的過程提供了寶貴的見解。我們將這些見解濃縮為六個步驟,希望對其他 ACME 用戶端開發人員有所幫助。

注意:本文中的程式碼片段以 Golang 編寫。我們已針對清晰度進行結構化和背景化,以便它們也可以輕鬆地改編為其他程式設計語言。

步驟 1:偵測對 ARI 的支援

雖然 Let's Encrypt 最初在 2023 年 3 月於測試和生產環境中啟用了 ARI,但許多 ACME 用戶端都與各種 CA 一起使用,因此確定 CA 是否支援 ARI 至關重要。這可以很容易地確定:如果 CA 的目錄物件中包含「renewalInfo」端點,則 CA 支援 ARI。

在大多數用戶端中,您都會找到一個函數或方法,負責剖析 ACME 目錄物件的 JSON。如果此程式碼將 JSON 反序列化為定義的類型,則必須修改此類型以包含新的「renewalInfo」端點。

在 Lego 中,我們將「renewalInfo」欄位新增至 Directory 結構中,該結構由 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 請求更新資訊。這是透過將 GET 請求傳送到「renewalInfo」端點 (包括 URL 路徑中的 ARI CertID) 來完成的。

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」是選擇性的。但是,如果提供了此 URL,則建議將其顯示給使用者或記錄下來。例如,如果 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 建議的,ACME 訂單物件中新增了一個新的「replaces」欄位。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) 非營利組織。如果您想支持我們的工作,請考慮參與其中捐款,或鼓勵您的公司成為贊助商