HTTPメッセージ署名(RFC 9421): Signature-Inputは実際に何に署名しているのか

RFC 9421のSignature-InputとSignatureがそれぞれ何を運ぶのか、署名ベースの組み立て方、そしてkeyid・created・nonceとContent-Digest(RFC 9530)が保証しないことを整理します。

securityhttphttp-message-signaturesrfc-9421

リクエストにSignature-InputとSignatureヘッダーが付いていて、検証も通ったとします。それでもリクエスト全体が署名されたとは限りません。RFC 9421のHTTPメッセージ署名は、メッセージから選んだ一部のコンポーネント(component)で「署名ベース(signature base)」という文字列を作り、その文字列に署名します。何を選んだかはSignature-Inputに書かれていて、そこにない部分は署名と関係なく変わり得ます。検証が通ったときに何が保証され、何が保証されないのかは、この構造を知らないと判断できません。

この記事はRFC 9421 HTTP Message Signaturesと、本文のダイジェストフィールドを定義したRFC 9530 Digest Fieldsを根拠にしています。どちらも2024年2月に発行されたIETFのStandards Track文書で(RFC Editorの表記はProposed Standard)、2026-10-20に確認しました。その日のRFC Editor情報ページでは、どちらの文書にも更新・廃止する文書はなく、RFC 9530はRFC 3230を廃止(obsolete)すると表示されていました。RFC 9421には確認済み(Verified)の正誤表が2件あり、7.2.8節と7.5.3節の@signature-inputを@signature-paramsに直すという内容です。特定のライブラリやサービスの実装方法は扱いません。

2つのフィールド: Signature-InputとSignature

RFC 9421の4節によると、Signature-Inputは署名がどのコンポーネントを対象にし(covered components)、どのパラメーターで作られたかを示し、Signatureは署名値そのものを運びます。どちらもStructured FieldsのDictionary形式で、辞書のキーが署名を指すラベル(label)です。

  • Signature-Inputの値は、対象コンポーネント識別子の順序付きリスト(Inner List)に、createdやkeyidなどの署名パラメーターを付けたものです。
  • Signatureの値は、署名のバイト列を収めたStructured FieldsのByte Sequenceです。RFCのシリアライズされた例では、そのBase64の値がコロンで区切られています。
  • 1つの署名は両方のフィールドを使い、両方に同じラベルがなければなりません。片方にしかないラベルはエラーです。

ラベル自体は署名の対象ではありません。7.2.5節は、中間者がメッセージを処理するときにラベルを付け替えてもよいので、アプリケーションは特定のラベルに意味を持たせるべきではないとしています。署名の用途を区別したいなら、署名パラメーターのtagを使います。ただしtagの値も公開されていて攻撃者が同じ値を使えるため(7.2.7節)、検査する署名を選ぶためだけに使うべきです。

次はRFC 9421の3.2節にある検証例のリクエストです。RFCの折り返し表記(RFC 8792の\)を戻して1行につなげ、長いSignatureフィールドはこの抜粋から省きました。テスト用の鍵(test-key-rsa-pss)を使う非規範的な例で、この記事を書く際に実際に署名や検証はしていません。

POST /foo?param=Value&Pet=dog HTTP/1.1
Host: example.com
Date: Tue, 20 Apr 2021 02:07:55 GMT
Content-Type: application/json
Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:
Content-Length: 18
Signature-Input: sig1=("@method" "@authority" "@path" "content-digest" "content-length" "content-type");created=1618884473;keyid="test-key-rsa-pss"

{"hello": "world"}

署名されるのは署名ベース

署名者と検証者は、それぞれが持っているメッセージから署名ベースを別々に作ります。1.2節はその理由を次のように説明しています。HTTPは中間者によるメッセージの変換を許し、ときには求めるので、受信側のメッセージが送信側とビット単位で同じだとは期待できません。そのため、送られた生のバイト列(raw bytes)を署名ベースにすることはできません。

上のリクエストから2.5節のアルゴリズムで作った署名ベースは次のとおりです(RFC 9421 Figure 1、折り返し表記を戻しています)。

"@method": POST
"@authority": example.com
"@path": /foo
"content-digest": sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:
"content-length": 18
"content-type": application/json
"@signature-params": ("@method" "@authority" "@path" "content-digest" "content-length" "content-type");created=1618884473;keyid="test-key-rsa-pss"

作り方の規則は単純です。

  • Signature-Inputに書かれた順に1行ずつ、"コンポーネント識別子": 値の形で書き、行末にLFを1つ付けます。
  • @で始まる名前は派生コンポーネント(derived component)です。@methodはメソッド、@authorityは対象URIのauthority、@pathはクエリを除いたパスというように、メッセージの制御データから値を取り出します(2.2節)。
  • それ以外はHTTPフィールドです。名前は小文字で書き、同じフィールドが複数回現れたら、値をカンマと空白1つ(, )でつなぎます(2.1節)。
  • 最後の行は必ず"@signature-params"で、その値はSignature-Inputの該当メンバーの値(ラベルを除く)と同じシリアライズです。最後の行の後には改行を付けません。
  • 対象にしたフィールドがメッセージにない、または値を求められない場合は、署名ベースを作らずにエラーで終わります。

アルゴリズムはこのバイト列に署名します。3.3節は署名関数をHTTP_SIGN(M, Ks)、検証関数をHTTP_VERIFY(M, Kv, S)と定義していて、このMが署名ベースです。最後の行がパラメーターをすべて含むので、createdやkeyidを変えると署名ベースも変わります。3.1節は、これによって署名が常に自分のメタデータを対象にし、メタデータだけを差し替えることはできないと説明しています。

例の署名ベースを元のリクエストと比べると、入っていないものが見えてきます。

  • リクエストにはDateヘッダーがありますが、対象リストにないので署名ベースには現れません。
  • リクエストの対象は/foo?param=Value&Pet=dogですが、@pathの値は/fooです。クエリ文字列は@queryや@query-paramを別に入れないと対象になりません。
  • 本文{"hello": "world"}は署名ベースにありません。入っているのはContent-Digestフィールドの値です。

1.3節は、署名の対象でないコンポーネントへの変更は検証を妨げるべきではないとしています。上の例では、クエリ文字列やDateが変わっても署名はそのまま検証を通るということです。

何を対象にするかが安全性を決める

7.2.1節は、署名が対象にしていない部分は、署名に影響を与えずに攻撃者が変更できると警告しています。攻撃者が処理結果を変えるヘッダーを追加したり書き換えたりしても、署名は有効なままです。そのため、アプリケーションはできるだけ多くの部分の署名を求め、検証者は署名されたコンポーネントだけを信頼するのがよいと、この節は勧めています。

極端な例が、対象リストが空の署名です。付録B.2.1の例Signature-Input: sig-b21=();created=...;keyid=...;nonce=...は、メッセージのどの部分も対象にしていません。RFCは、このような署名を傍受した攻撃者は無関係な別のメッセージに付けられるので、空のリストは推奨しないと書いています。

署名者側への勧告もあります(3.1節、7.2.4節)。

  • リクエストの署名者は、@method、@authority、@target-uriなどの制御データの一部またはすべてを対象にするべきです(SHOULD)。
  • 署名の作成時刻を示すcreatedパラメーターを入れるべきです(SHOULD)。
  • 多くの場合、Hostヘッダーより@authorityを、時刻の判断にはDateヘッダーよりcreatedを使うほうが適切です。

逆に対象を広げすぎると、正常な転送の途中で署名が壊れます。7.2.3節は、ViaやForwardedのようにプロキシが書き換えたり削除したりするフィールドは、ごく限られた場合を除いて対象にしないよう求め、クエリパラメーターを書き換えるフレームワークなら@queryの代わりに個別の@query-paramを使う方法を例に挙げています。1.3節によると、フィールドの並べ替えやHTTP/1.1とHTTP/2の間の変換のように、対象コンポーネントに起きても検証を妨げるべきではない変換もあります。一方で、対象フィールドの値をパースして再シリアライズしたり、派生コンポーネントの値を変えたりすると、検証が失敗することがあります。

リバースプロキシの背後にあるサーバーが典型例です。プロキシがHostを内部アドレスに書き換えると、サーバーが受け取ったメッセージの@authorityはクライアントが署名した値と違ってしまいます。7.4.3節は、クライアント側から見た外部の対象URIをサーバーに設定しておき、その値からコンポーネントを作る方法と、プロキシがクライアントの署名を検証したうえで自分の署名を追加する方法(4.3節)を紹介しています。どちらにしても、署名者と検証者が同じコンポーネントの規則を共有している必要があります。1.4節が、RFC 9421を使うアプリケーションやプロファイルに対して、対象にすべきコンポーネントとパラメーター、鍵の取得方法、許可するアルゴリズムなどを必ず(MUST)定めるよう求めているのもそのためです。

keyidは鍵を指すだけ

keyidは2.3節で「鍵素材の識別子」である文字列と定義されています。署名者がどの鍵を使ったかを検証者に伝えるヒントにすぎず、その鍵が誰のものかを証明するものではありません。誰でも自分の鍵で署名し、好きなkeyidの文字列を書くことができます。

3.2節の検証手順の5番目は、検証者が鍵識別子を実際の鍵素材に解決し、その鍵をこの文脈で信頼できるかを自分で判断しなければならないとしています。知らない鍵、このリクエストについて信頼していない鍵、事前の設定と一致しない鍵であれば、検証は必ず失敗しなければなりません(MUST)。7.3.4節は同じことを逆側から説明しています。有効な署名があるだけでは、適切な当事者が署名した証拠にはならず、検証者が鍵とアルゴリズムの妥当性を確かめなければ、攻撃者が自分の鍵で署名したメッセージを受け入れてしまいます。1つのメッセージには複数の署名が付き得るので、暗号的に有効かどうかに関係なく、想定した署名者の署名だけを受け入れるべきです(7.2.6節)。

アルゴリズムも同じ原則に従います。

  • 検証者はアプリケーションが許可したアルゴリズムの集合から出発し、それ以外のアルゴリズムが選ばれたら検証は失敗します。設定、鍵素材、algパラメーターなど複数の場所でアルゴリズムが決まる場合は、すべて一致しなければなりません(3.2節の手順6)。
  • 7.3.6節はダウングレードの例として、RSA-PSSだけで使うはずのRSA鍵に対してRSA PKCS#1 v1.5の署名を受け入れる場合と、RSA公開鍵をHMACの鍵として使ってしまい攻撃者のHMAC署名が検証を通る場合を挙げています。そのため、実行時のalgの値より、静的な設定など別の方法でアルゴリズムを決めるよう勧めています。
  • HMACのような対称鍵を使うと、検証者も同じ鍵で有効な署名を作れます。検証者が侵害されると、署名者になりすませるようになります(7.3.3節)。

暗号的な検証が成功してわかるのは「この鍵がこれらのコンポーネントに署名した」というところまでです。その鍵の持ち主がその操作をしてよいかは、別の判断です。RFC 9421は1.4節で、この仕様は全体のセキュリティシステムの一部しか定義しないと明言し、RFC 9530も1.2節で、認証、認可、プライバシーの手段は定義しないと書いています。署名を認証の仕組みとして使う場合の失敗応答として、1.4節は401や403を例に挙げています。2つのコードの違いは401と403の違いで整理しました。

created、expires、nonceとリプレイ

2.3節のパラメーターのうち、時刻とリプレイに関係するのは3つです。

パラメーター 型 RFC 9421での意味
created 整数(UNIXタイムスタンプ、秒単位) 署名の作成時刻。含めることが推奨(RECOMMENDED)
expires 整数(UNIXタイムスタンプ、秒単位) 有効期限。署名者がその署名を保証しなくなる時点を伝えるヒント
nonce 文字列 この署名のために生成したランダムで一意な値

これらの値は署名ベースの最後の行に入るので、変えると署名が壊れます。しかし値があるだけでリプレイが防げるわけではありません。7.2.2節は、メッセージの一部だけを署名できる以上、異なる2つのメッセージが同じ署名で検証を通り得ること、対象範囲が十分でも、似たメッセージに署名をそのまま付けてリプレイできることを説明しています。対策として挙げられているのは次のとおりです。

  • ほかのメッセージと区別できるだけの範囲を対象にする。
  • nonceを入れると、同じ値が繰り返されたときに検証者がリプレイに気づける。繰り返しに気づくには、検証者がすでに見た値を覚えておく必要がある。
  • createdとexpiresは、傍受された署名を使える期間を短くする。

3.2.1節は、署名の最大経過時間の強制、expiresを過ぎた署名の拒否、nonceの一意性の強制を、いずれもアプリケーションが追加で定められる要件の例として挙げています。つまり、許容する時間幅、時計のずれ、nonceをどれだけの間覚えておくかはアプリケーションのポリシーで、RFCは数値を定めていません。expiresはヒントなので、検証者は期限前でも署名を拒否できます。付録B.2.1は、nonceが同じ署名の繰り返しを防いでも、攻撃者が署名を傍受して本来の受信者に届かないようにすれば、その署名を別のメッセージに使えると補足しています。

7.1.2節は、メッセージ署名はTLSの代わりにならないと書いています。署名は対象コンポーネントの完全性を与えるだけで機密性は与えず、TLSは署名データそのものが傍受されるのを防ぐことで、リプレイ対策にも役立ちます。検証者が署名者に新しい署名を求めたい場合は、新しいnonceを入れたAccept-Signatureフィールドを送れます(7.2.2節)。

本文はContent-Digestを通してしか結び付かない

RFC 9421は1節と7.2.8節で、この仕様だけではHTTPメッセージの本文(content)を署名の対象にせず、本文のハッシュはDigest仕様(RFC 9530)に任せると明言しています。

RFC 9530の2節のContent-Digestは、実際のメッセージコンテンツにハッシュアルゴリズムを適用したダイジェストを運ぶフィールドです。Dictionary形式で、キーはアルゴリズム(sha-256、sha-512など)、値はダイジェストのバイト列を表すByte Sequenceです。同じ文書の3節のRepr-Digestは、メッセージコンテンツではなく、選択された表現データ(selected representation data)全体に対するダイジェストです。Rangeリクエストへの部分応答やHEADへの応答のように、メッセージに載ったコンテンツと表現データが異なるときに、この区別が意味を持ちます。RFC 9530の1.3節は、旧来のDigestフィールド(RFC 3230)を、実装によってコンテンツ基準、あるいは表現基準とばらばらに解釈していたことが、廃止の主な理由だったと説明しています。

Content-Digestを付けただけでは、署名とは何の関係もありません。署名で本文を結び付けるには、次のすべてが必要です。

  1. content-digestがSignature-Inputの対象リストに入っていること。そうして初めてダイジェストの値が署名ベースに入ります。
  2. 検証者が、受け取った本文でダイジェストを計算し直し、フィールドの値と比べること。7.2.8節は、署名が直接対象にするのはフィールドの値だけなので、この手順を飛ばすと、攻撃者がContent-Digestはそのままに本文だけを差し替えられると警告しています。
  3. ダイジェストが依存する表現メタデータも対象にすること。RFC 9530の6.3節は、ダイジェストがContent-TypeやContent-Encodingなどの値に依存するので、ダイジェストフィールドだけを署名してこれらを署名しないと、攻撃者がContent-Typeを変えて受信側のダイジェスト検証を失敗させられると書いています。

上の例がcontent-digestと一緒にcontent-typeとcontent-lengthを対象にしているのは、この文脈で読むことができます。3.2節の例も、検証が通るには、メソッド、authority、パス、content-digest、content-length、content-typeが署名されていて、作成時刻が十分に新しいというアプリケーションの要件を前提にしています。

ほかにもいくつか制約があります。

  • Content-Digestの値はコンテンツエンコーディングによって変わります。中間者がコンテンツエンコーディングを変えるとダイジェストが変わって署名も無効になるので、そのような中間者は新しいダイジェストで署名し直す必要があります(RFC 9421の7.2.8節)。
  • 署名に使う場合は、敵対的な環境と見なされる可能性が高いとされています(RFC 9530の6.3節)。そうした環境では、レジストリで「Deprecated」のアルゴリズム(md5、shaなど)を使ってはならず(MUST NOT)、初期レジストリで「Active」なのはsha-256とsha-512です(RFC 9530の5節、7.2節)。
  • ダイジェストだけでは悪意ある改ざんを防げません。経路上の攻撃者がダイジェストを削除したり、改ざんした本文から計算し直した値に差し替えたりできるので、TLSや署名と組み合わせる必要があります(RFC 9530の6.1節)。
  • SignatureとSignature-Inputはトレーラーで送ることもできますが、中間者はトレーラーを捨ててよいので、ヘッダーとしてだけ送ることが推奨されています(RFC 9421の4.1節、4.2節)。

検証者が確認すること

次のリストは、RFC 9421の3.2節の検証手順に、3.2.1節と7節の考慮事項、RFC 9530の内容を加えて整理したものです。順序とまとめ方はこの記事によるもので、RFCが定めた形式ではありません。

  1. SignatureとSignature-Inputから同じラベルの組を見つけ、複数の署名のうちどれを処理するかをポリシーに従って選ぶ。
  2. 対象リストに、アプリケーションが求めるコンポーネント(制御データ、content-digest、認可に関わるヘッダーなど)がすべて入っているか確認する。
  3. keyidや事前設定から鍵を見つけ、その鍵をこのリクエストについて信頼しているか確認する。知らない鍵なら失敗とする。
  4. 許可リストからアルゴリズムを決め、複数の場所で決まった値が一致しているか確認する。
  5. 受け取ったメッセージから署名ベースを作り直す。プロキシの背後なら、どの対象URIを基準にするかを決めておく。
  6. アルゴリズムの検証関数で署名を検証する。RSA-PSSやECDSAのような非決定的なアルゴリズムは、同じベースに署名し直して結果を比べる方法では検証できない(7.3.5節)。
  7. created、expires、nonceをアプリケーションのポリシーに沿って確認する。
  8. 受け取った本文でContent-Digestを計算し直して比べる。
  9. 鍵の持ち主に、要求された操作を行う権限があるか確認する。

7.1.1節は、署名がなくてもメッセージは有効なHTTPメッセージなので、検証結果を無視して処理してしまう誤りが起きやすいと指摘しています。正しい署名だけでテストしてもこの誤りは見つからないので、不正な署名が実際に拒否されるかもテストする必要があります。

参照した仕様

すべて2026-10-20に確認しました。

ブログへ