ACME 更新情報(ARI)の基本的な利点に関する以前の投稿に続き、今回は、ARI を既存の ACME クライアントに組み込むための詳細な技術ガイドを提供します。

2023年3月に導入されて以来、ARI は、ますます多くの加入者に対して、証明書の失効と更新の回復力と信頼性を大幅に向上させてきました。これらの利点をさらに幅広いユーザーに拡大するためには、より多くの ACME クライアントに ARI を組み込むことが不可欠です。

より幅広い採用を促進するために、私たちは新たな魅力的なインセンティブを発表できることを嬉しく思います。ARI を利用した証明書の更新は、すべてレート制限から除外されます。この特典を活用するには、更新は ARI が推奨する更新期間内に行う必要があり、リクエストはどの既存の証明書が置き換えられているかを明確に示す必要があります。推奨される更新期間をリクエストする方法、最適な更新時間を選択する方法、および証明書の置き換えを指定する方法については、読み進めてください!

既存の ACME クライアントに ARI を統合する

2023年5月、私たちはプルリクエストLego ACME クライアントに提出し、draft-ietf-acme-ari-01 のサポートを追加しました。2023年12月と2024年2月には、draft-ietf-acme-ari-02 と 03 で行われた変更のサポートを追加する 2 つのフォローアッププルリクエスト (20662114) を提出しました。これらの経験は、既存の ACME クライアントに ARI を統合するプロセスに関する貴重な洞察を提供しました。私たちはこれらの洞察を 6 つのステップにまとめました。これは、他の ACME クライアントの開発者にとっても役立つと願っています。

注: この投稿のコードスニペットは Golang で記述されています。明確にするために構造化および文脈化しており、他のプログラミング言語にも簡単に適応できるようにしています。

ステップ 1:ARI のサポートの検出

Let's Encrypt は 2023 年 3 月にステージング環境と本番環境で初めて ARI を有効にしましたが、多くの ACME クライアントはさまざまな CA で使用されているため、CA が ARI をサポートしているかどうかを確認することが重要です。これは簡単に判断できます。CA のディレクトリ オブジェクトに 'renewalInfo' エンドポイントが含まれている場合、CA は ARI をサポートしています。

ほとんどのクライアントには、ACME ディレクトリ オブジェクトの JSON を解析する関数またはメソッドがあります。このコードが JSON を定義済みの型にデシリアライズしている場合は、新しい 'renewalInfo' エンドポイントを含めるようにこの型を修正する必要があります。

Lego では、GetDirectory メソッドでアクセスされる Directory struct に 'renewalInfo' フィールドを追加しました。

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 のようなジョブスケジューラを介してオンデマンドで実行されます。したがって、renew コマンドに ARI サポートを組み込むことが論理的な選択でした。多くの ACME クライアントと同様に、Lego には、証明書の残り有効期間とユーザーが構成した更新期間に基づいて、証明書をいつ更新するかを決定するメカニズムが既にあります。ARI への呼び出しの導入は、このメカニズムよりも優先される必要があり、これにより、組み込みロジックに頼る前に ARI を参照するように renew コマンドが修正されます。

ステップ 3:ARI CertID の構築

ARI CertID の構成は、ARI 仕様の重要な部分です。各証明書に固有のこの識別子は、証明書の Authority Key Identifier (AKI) 拡張機能とシリアル番号の base64url エンコードされたバイトを、ピリオドで区切って結合することによって導出されます。AKI とシリアル番号を組み合わせるアプローチは戦略的です。AKI は発行する中間証明書に固有であり、CA は複数の中間証明書を持つ場合があります。証明書のシリアル番号は、発行する中間証明書ごとに一意である必要がありますが、中間証明書間でシリアルを再利用できます。したがって、AKI とシリアルの組み合わせで証明書が一意に識別されます。これでカバーできたので、置き換えられる証明書の内容のみを使用して ARI CertID を構築することに進みましょう。

証明書の Authority Key Identifier (AKI) 拡張機能の 'keyIdentifier' フィールドに、ASN.1 Octet String 値として 16 進数のバイト 69:88:5B:6B:87:46:40:41:E1:B3:7B:84:7B:A0:AE:2C:DE:01:C8:D4 が含まれているとします。これらのバイトの base64url エンコードは aYhba4dGQEHhs3uEe6CuLN4ByNQ= です。さらに、証明書のシリアル番号は、その DER エンコード (タグと長さのバイトを除く) で表すと、16 進数のバイト 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
}

注: 提供されたコードでは、RFC 4648 で定義されている、パディングされていない base64 エンコードである RawURLEncoding を利用しています。このエンコードは URLEncoding に似ていますが、"=" などのパディング文字は含まれません。プログラミング言語の base64 パッケージが URLEncoding のみをサポートしている場合は、エンコードされた文字列を結合する前に、末尾のパディング文字を削除する必要があります。

ステップ 4:推奨される更新期間のリクエスト

ARI CertID が手に入ったので、CA から更新情報をリクエストできるようになりました。これは、URL パスに ARI CertID を含めて、'renewalInfo' エンドポイントに GET リクエストを送信することによって行われます。

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

ARI レスポンスは、推奨される更新期間を示す 'start' および 'end' タイムスタンプを含む 'suggestedWindow' と、オプションで更新の提案に関する追加のコンテキストを提供する 'explanationURL' を含む JSON オブジェクトです。

{
  "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 struct です。NewWithOptions メソッドでアクセスできる 'replaces' フィールドが含まれるようになりました

// 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 アカウントにリンクされていることを確認します。さらに、既存の証明書とリクエストされている証明書の間で、少なくとも 1 つのドメイン名を共有する必要があります。これらの基準が満たされ、新しい注文リクエストが ARI が推奨する更新期間内に送信された場合、リクエストはすべてのレート制限からの免除の対象となります。おめでとうございます!

今後

より多くの ACME クライアントへの ARI の統合は、単なる技術的なアップグレードではなく、ACME プロトコルの進化における次のステップです。ここでは、CA とクライアントが連携して更新プロセスを最適化し、証明書の有効性のずれが過去のものになるようにします。その結果、どこにいても、誰にとっても、より安全でプライバシーを尊重するインターネットが実現します。

いつものように、私たちはこの旅でコミュニティと交流できることを楽しみにしています。ACME で可能なことの限界を押し広げ続ける上で、皆様の洞察、経験、およびフィードバックは非常に貴重です。

オープン・テクノロジー・ファンドの寛大なご支援により、ACME更新情報に関する作業でプリンストン大学と提携できることを感謝いたします。

インターネットセキュリティリサーチグループ(ISRG)は、Let’s EncryptProssimoDivvi Upの親組織です。ISRGは501(c)(3)の非営利団体です。私たちの活動を支援していただける場合は、参加寄付、または貴社にスポンサーになることをお勧めください。