封緘済み PoE
Label 309 の暗号化エンベロープ。送信者がコンテンツを 1 つ以上の受信者の鍵に向けて封緘する仕組みを規定します。チェーンが運ぶのは平文のハッシュ値とラップ済みの鍵スロットだけであり、平文も受信者も一切チェーンには現れません。
封緘済み存在証明(Sealed PoE) は、平文へのタイムスタンプ付きコミットメントをブロックチェーン上に記録しつつ、その平文を特定の対象者だけが読めるように保ちます。オンチェーンのレコードには平文のハッシュ値(他のあらゆるレコードと同じく、タイミングの証明)と、コンテンツ暗号化鍵を復元するための素材を保持する暗号化エンベロープ(enc)が含まれます。暗号文自体はチェーンに記録されることなく、コンテンツアドレス指定の URI(ar:// または ipfs://)に置かれます。チェーン上には平文を推測させるものは何もなく、受信者が誰かを示すものも一切ありません。
このページでは enc エンベロープの仕様を説明します。相互に排他的な二つの鍵配信パス、受信者ごとの鍵スロット、スロットセット MAC、セグメント化されたコンテンツ STREAM、そして受信者が自分宛てのメッセージを発見して開封するために行う試行復号について定義します。受信者の鍵そのもの(シードから導出される X25519 および X-Wing 鍵ペア)は 鍵 で定義されており、このページではそれらを利用します。enc マップがレコードマップ内に占める位置、および それをチェーン上で運ぶ本体全体の転送については、レコード で定義されています。
モデルとプライバシー特性
送信者は、特定の平文が特定の対象者向けに時刻 T に封緘されたことを証明する、永続的なタイムスタンプ付きコミットメントを発行しつつ、その対象者だけが読めるようにしたいと考えます。ハッシュ値のみの存在証明(PoE)は時刻の主張は提供しますが対象者の束縛はありません。平文の暗号文をそのまま含む PoE は機密性を全く確保できません。封緘済み PoE はその二つを橋渡しします。レコードは平文のハッシュ値(公開、タイムスタンプ付き)にコミットし、鍵配信素材を enc に保持する一方、ar:// または ipfs:// URI に置かれた暗号文は、対応する鍵がなければ復号できません。
この構成は、メッセージについても対象者についても、チェーンからできる限り少ない情報しか漏れないよう意図的に設計されています。
- 平文はチェーン上に存在しません。 チェーンにあるのはハッシュ値とラップ済み鍵のみです。後から平文を入手した人は「この正確な平文がブロック時刻 T にコミットされた」ことを証明できますが、それ以外の人には封緘された内容は分かりません。
- 受信者の公開鍵はチェーン上に存在しません。 受信者の公開鍵は
encのどこにも現れません。受信者は、スロットの試行復号に成功することによってのみ、そのメッセージが自分宛てであると認識できます。読み取るべき宛先フィールドは存在しません。候補鍵を持たない観測者が知れるのは、スロットの数、KEM ファミリー(enc.kem)、そして封緘済みか否かの区別だけです。これより強い性質、すなわち候補となる受信者鍵を 持っている 敵対者でさえ、あるスロットがそのうちのどれを(もしあれば)対象としているか試せない、という性質は鍵プライバシーであり、クラシカルなx25519の経路についてのみ主張され、ハイブリッドのmlkem768x25519の経路については主張しません(匿名性と KEM ごとの分かれ目 を参照)。 - 受信者はお互いについて何も知ることができません。 受信者ごとのスロットはそれぞれ不透明なラップ済み鍵です。自分のスロットを開いた受信者が他の受信者の鍵を導出することはできませんし、他に誰が宛先に含まれているかも判断できません。
- スロットの順序からは何も漏れません。 送信者が受信者を列挙する順序(「主要な受信者を最初に」など)は機密性のあるメタデータです。スロット配列は発行前に CSPRNG によってシャッフルされるため、位置の並び順も何の情報も持ちません。
- 署名なしの封緘済み PoE は送信者の匿名性を保ちます。 著作者性の署名は任意です(署名 を参照)。
sigs[]のない封緘済みレコードはチェーン上に送信者の身元を一切紐付けません。これは内部告発者によるリークや封緘入札オークション、証拠エスクローなどに必要な特性です。
チェーンが実際に明らかにすることは限られています。レコードが封緘済み PoE であること(enc が存在する)、平文のハッシュ値、ブロックのタイムスタンプ、そしてスロットの数(配列の長さ)です。スロット数は受信者に隣接する唯一の公開情報であり、「何人か」を示すのみで「誰か」は示しません。レコード間のタイミング相関はメタデータ上の問題であり、ワイヤーレベルの暗号で解決できるものではありません。これを防ぐ必要がある送信者は、機微なタイムラインから切り離して発行をまとめて行わなければなりません。
受信者の公開鍵はアウト・オブ・バンドで交換されます。Label 309 は発見メカニズムを一切規定しません。受信者は自分のウェブサイト、DNS レコード、ソーシャルプロフィール、QR コード、あるいはオンチェーンの自己証明など任意の方法で鍵を公開できます。検証者は受信者の鍵バイト列を入力として受け取り、それが誰の鍵かについては何の主張もしません。鍵の出所の信頼は送信者の判断事項であり、PGP 鍵をメールで送る場合と全く同じです。
エンベロープと二つのパス
enc マップは共通フィールドと、相互に排他的な二つの鍵配信パスのうちちょうど一方を保持します。構造バリデーターはこの排他性を強制します。両方を持つレコードも、どちらも持たないレコードも拒否されます。
| フィールド | ステータス | 意味 |
|---|---|---|
scheme | REQUIRED | 構成ファミリーのバージョン。v1 では scheme = 1 を定義します。 |
aead | REQUIRED | コンテンツフォーマット識別子。v1 では "chacha20-poly1305-stream64k" を定義します。 |
nonce | REQUIRED | 24 ランダムバイト — コンテンツ鍵とすべてのスロット KEK のエンベロープごとに一意なソルト。 |
kem | slots パスのみ | スロットごとの KEM セレクター("x25519" または "mlkem768x25519")。 |
slots | いずれか一方のパス | 受信者ごとの鍵スロットの配列(複数受信者)。 |
slots_mac | slots パスのみ | スロットセットとアイテムのハッシュ主張をコンテンツ鍵に束縛する 32 バイト HMAC。 |
passphrase | もう一方のパス | パスフレーズ KDF ブロック(パスフレーズ導出鍵)。 |
enc.slots— 複数受信者。 エンベロープは受信者ごとに独立してラップされた N 個の鍵スロットを保持します。暗号文はいずれかのスロットに対応する秘密鍵なしには復号できません。詳細は以下のスロットとスロットセット MAC を参照してください。enc.passphrase— パスフレーズ導出。 エンベロープにスロットはなく、コンテンツ鍵は正規化されたパスフレーズから直接導出されます。詳細は以下のパスフレーズパス を参照してください。
両パスは scheme、aead、nonce を共有します。異なるのは存在する鍵の種類、そして結果として鍵コミットメントがどこに置かれるかです。slots パスでは、コミットメントはオンチェーンにあります。slots_mac は、ヘッダーフィールド、スロットセット、そしてアイテムのハッシュ主張を固定したトランスクリプトに対する CEK 鍵付き HMAC であり、受信者は何かを取得する前に正しい鍵であることを確認できます。パスフレーズパスには束縛すべきスロットがないため、コミットメントは暗号文ブロブの内側に運ばれる 32 バイトのヘッダーです。パスフレーズの推測を試すにはブロブそのものが必要であって、公開チェーンだけでは決して足りません。各パスは同じ canonicalEncode 関数でトランスクリプトをシリアライズし、プロデューサーまたは検証者は slots / passphrase のいずれが存在するかを検査してパスを選択します。この二つのパスは網羅的かつ相互排他的です。
enc.scheme はレコードの v フィールドとは独立に、構成のファミリーを名付けます。検証者は enc.scheme === 1 を要求し、それ以外の値はすべて拒否しなければなりません(MUST)。このフィールドは将来のクロスカッティングな変更(スロットセット MAC スケジュールやコンテンツフォーマットの変更)のために予約されており、KEM の追加目的ではありません。スロットごとの KEM は enc.kem で選択されており、以下の両 KEM は最初のリリースから scheme = 1 の下に存在します。より広く言えば、enc.scheme: 1 は MAC とコンテンツフォーマットだけでなく、暗号スイート全体を識別します。canonicalEncode のルール、スロットスキーマ、HKDF ハッシュ、HMAC ハッシュ、スロットごとのラップ AEAD、セグメント化された STREAM のコンテンツフォーマット、slots と passphrase のトランスクリプトスキーマ(hashes_hash のアイテム束縛を含む)、暗号文内のパスフレーズコミットメント、固定された X-Wing リビジョン、ドメイン分離ラベル、Argon2id のバージョンとプロファイル、そしてパスフレーズの正規化プロファイルが、すべてこれによって固定されます。したがって、それらのいずれかひとつでも変更するには、新しい enc.scheme 値が必要です。
コンテンツレイヤー
両パスは平文に対する一回の対称演算で収束しますが、その鍵は単一の 32 バイトコンテンツ暗号化鍵(CEK) から導出された値です。CEK は、スロットが配送するもの(各スロットがそれをラップします)、あるいはパスフレーズ KDF が生み出すものです。コンテンツは CEK で直接暗号化されるのではありません。代わりに各パスは、CEK の HKDF リーフとして別個の 32 バイトのコンテンツ鍵を導出します。これはエンベロープごとに一意な enc.nonce をソルトとし、パスごとの info のもとで導出されるため、鍵配送層とコンテンツ層が同じバイト列で同じプリミティブを鍵付けすることはありません。
content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce,
info = <"cardano-poe-payload-v1" on the slots path,
"cardano-poe-payload-passphrase-v1" on passphrase>,
L = 32)コンテンツはその後、コンテンツフォーマット識別子 chacha20-poly1305-stream64k で名付けられたセグメント化された STREAM で封緘されます。これは age v1 仕様の STREAM レイアウトです。すなわち、固定サイズのチャンクに分割した平文に対する ChaCha20-Poly1305(RFC 8439、12 バイトノンスのバリアント)であり、各チャンクはコンテンツ鍵のもと、チャンクごとのカウンターノンスで封緘されます。
cipher : ChaCha20-Poly1305 (RFC 8439; 12-byte nonce, 16-byte tag)
CHUNK_SIZE : 65536 plaintext bytes per non-final chunk
chunk nonce : uint88_be(counter) || final_flag ; 12 bytes
counter starts at 0, +1 per chunk;
final_flag = 0x01 on the final chunk, 0x00 otherwise
per-chunk AAD: empty
final chunk : 0 to 65536 plaintext bytes; every non-final chunk is exactly 65536
empty input : exactly one final chunk of zero-length plaintext (a lone 16-byte tag)
ciphertext = seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final)
; each sealed chunk = plaintext length + 16 bytesファイナルフラグは最後のチャンクをそれ以外から分離します。これこそが切り詰めを検知可能にするものです。最後のチャンクが 0x01 フラグを持たないストリーム、最後でないチャンクに付いた 0x01 フラグ、ファイナルチャンクに続くデータ、あるいは CHUNK_SIZE より短い非ファイナルチャンクは、いずれも復号に失敗しなければなりません(MUST。TAMPERED_CIPHERTEXT)。封緘されたすべてのチャンクは少なくともその 16 バイトのタグを含むため、このレイアウトは構造的な下限も含意します。整形式の slots パスの暗号文ブロブが 16 バイト(空のファイナルチャンクの単独のタグ)より短くなることは決してありません。
チャンクごとの AAD は設計上空です。 すべてのコンテキストは推移的にコンテンツへ束縛されているからです。コンテンツ鍵は CEK から導出され、CEK は slots パスでは slots_mac(そのトランスクリプトは scheme、path、aead、kem、nonce、スロットセット、そしてアイテムのハッシュ主張を網羅します)によって、パスフレーズパスでは暗号文内のコミットメントによって、ヘッダー全体にコミットされています。ヘッダーフィールドをどれか一つでも変えれば、受信者は別の鍵を導出または受理するため、復号は失敗します。チャンクごとの AAD は、安全性を加えることなく同じコンテキストをチャンクごとに再束縛するだけになります。
カウンターベースのチャンクノンスが安全なのは、コンテンツ鍵が一度しか使われないからです。それはエンベロープごとに一意な enc.nonce をソルトとする新鮮な CEK から導出されるため、2 つのストリームが (key, nonce) の組を共有することは決してなく、状態を持たないプロデューサー(ブラウザのタブ、CLI 実行、ワーカー、リトライ)がエンベロープをまたいでノンスを調整することもありません。88 ビットのカウンターは 2^88 個のチャンクを許容し、これは実現可能ないかなるペイロードもはるかに上回るため、フォーマットは暗号的なペイロード上限を課しません。実用上の最大値は、ワイヤーの定数ではなく、デプロイメントのサービス拒否(DoS)ポリシーです。
平文の入力はオリジナルのコンテンツバイト列そのものです。この構成はファイル名、MIME タイプ、サイズフィールド、マニフェストを先頭・末尾に付加したり暗号化したりはしません。ストリームを復号すると、それらのバイト列のみが得られます。
放出されたチャンクはハッシュの再確認まで暫定的
セグメント化されたフォーマットが存在するのは、検証者が数 GiB のペイロードを限られたメモリで逐次的に認証し放出できるようにするためです。各チャンクの平文が放出される前にそのチャンクのタグが検証され、切り詰めはファイナルフラグによって捕捉されます。しかし平文ハッシュの再確認は、最後のチャンクの後に、平文全体に対して行われます。したがってストリーミングのコンシューマーは、その最終チェックが通るまで、放出されたバイト列を暫定的なものとして扱わなければなりません(MUST)。副作用も、確認応答も、「受信済み」のステータスも、いずれも与えてはなりません。
発行される暗号文は単一のオブジェクトです。slots パスでは、それはちょうど STREAM のチャンクです。パスフレーズパスでは、32 バイトの鍵コミットメントヘッダーが同じブロブの内側に前置されます(同じオブジェクト、同じ URI、同じ取得であり、第二の格納オブジェクトになることは決してありません)。
slots path : ciphertext blob = [ STREAM chunks ]
passphrase path : ciphertext blob = [ commitment: 32 bytes ] || [ STREAM chunks ]items[].hashes の平文ハッシュ値は enc が存在する場合でも常に平文にコミットします。これは重要な特性です。復号できない検証者でも、レコードの存在、エンベロープが正しい形式であること、URI が取得可能であることを確認できますが、暗号文を復号してハッシュ値を再計算することで何にコミットされているかを確認できるのは、対応する受信者の鍵を持つ人だけです。したがって構造バリデーターは、ハッシュ値を「検証する」ために復号してはなりません(MUST NOT)。平文ハッシュ値の検証はバイト列を復元した後に受信者が行います。コンテンツとハッシュ化 および 検証 を参照してください。
スロットとスロットセット MAC
複数受信者パスでは enc.slots は受信者ごとのスロットの空でない配列です。すべてのスロットは、受信者ごとの鍵暗号化鍵(KEK)のもとで同じ CEK をラップしています。いずれかのスロットを開いた受信者は、コンテンツを復号するための唯一の CEK を取得できます。送信者は次の手順を実行します。
- レコード全体に使用する KEM を一つ選択し、CEK(32 ランダムバイト)と
nonce(24 ランダムバイト)を生成します。 - 受信者ごとに、スロット単位の KEK を導出し、その KEK で CEK をラップします(KEM ごとの詳細は以下を参照)。
- CSPRNG でスロット配列をシャッフルします(偏りのない Fisher-Yates)。
- シャッフル済みの配列、KEM をまたぐヘッダーフィールド、そしてアイテムのハッシュ主張からスロットトランスクリプトを構築し、それを
slots_hashへハッシュし、slots_macをそのハッシュに対する CEK 鍵付き HMAC として計算します。 - CEK と
enc.nonceからコンテンツ鍵を導出し、上記のセグメント化された STREAM でコンテンツを封緘します。
スロットごとのラップ
各スロットは、スロット単位の KEK のもとで ChaCha20-Poly1305(RFC 8439、12 バイトノンスバリアント)によって CEK をラップし、48 バイトの wrap(32 バイトの CEK 暗号文 + 16 バイトの Poly1305 タグ)を生成します。
wrap = ChaCha20-Poly1305_seal(
key = KEK, ; per-slot, 32 bytes
nonce = bytes(12, 0x00), ; ZERO nonce
ad = <KEM info literal>, ; the KEK info string for the chosen KEM
plaintext = CEK)12 バイトの全ゼロノンスが安全なのは、各スロットの KEK がレコードごとに一意であるためです。KEK は正確に一回のラップにしか使われないため、同一の鍵のもとでノンスが衝突することは決してありません。これは厳格な不変条件です。将来の改訂で KEK の再利用(キャッシュ、決定論的な一時鍵、スロットの重複排除によるスロット再利用など)が許可されるようなことがあれば、その変更と同時にゼロノンスをランダムノンスに置き換えなければなりません。
スロットセット MAC
slots_mac はスロットセット全体を、スロットの解釈の仕方を固定する KEM をまたぐヘッダーフィールド、そしてアイテムの平文ハッシュ主張とともに CEK に束縛し、スロットの置換・削除・並び替え、そしてエンベロープの継ぎ接ぎによる改ざんを防ぎます。この束縛は二段階の構成です。スロットトランスクリプトを一度ハッシュして 32 バイトの slots_hash を得て、そのハッシュを CEK 鍵付き HMAC のメッセージとします。
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) ; 32 bytes
SLOTS_TRANSCRIPT = { ; closed 7-key map; keys are a set, not an order
"scheme": 1, ; uint
"path": "slots", ; text
"aead": <enc.aead>, ; text: the content-format identifier
"kem": <enc.kem>, ; "x25519" | "mlkem768x25519"
"nonce": <enc.nonce>, ; bytes(24)
"slots": <slots>, ; the shuffled on-wire slot array
"hashes_hash": hashes_hash} ; bytes(32), over this item's hashes map
slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT)) ; 32 bytes
HMAC_KEY = HKDF-SHA-256(ikm = CEK, salt = "",
info = "cardano-poe-slots-mac-v1", L = 32)
slots_mac = HMAC-SHA-256(key = HMAC_KEY, msg = slots_hash) ; 32 bytesSLOTS_TRANSCRIPT はちょうどその七つのキーの集合を持つ閉じたマップであり、両側がバイト単位で同一のバイト列を生むよう canonicalEncode でシリアライズされます。そのキー順序は RFC 8949 §4.2.1 のバイト単位のソートであって、決して手で並べたものではありません。slots の値は、ワイヤー上に現れるとおりの閉じたスロットマップ(x25519 では {epk, wrap}、mlkem768x25519 では {kem_ct, wrap})のシャッフル済み配列であり、すべてのスロットの完全なスロットごとのワイヤー内容がトランスクリプトの中に入ります。トランスクリプトはさらに scheme、path、aead、kem、nonce を固定します。これらのヘッダーフィールドのいずれかを、スロットの形を有効なまま保ったまま書き換えるリレーは、異なる slots_hash を生むため、MAC が失敗します。slots_hash と hashes_hash の SHA-256 プレフィックス(cardano-poe-slots-transcript-v1、cardano-poe-item-hashes-v1)は、終端文字も長さプレフィックスも持たない厳密な ASCII です。
hashes_hash は、エンベロープをこのアイテムのハッシュ主張に束縛するものです。それは、アイテムの完全な hashes マップの canonicalEncode に対するラベル付き SHA-256 です。受信者はオンチェーンのバイト列だけから slots_mac を再計算するため、MAC の一致は、エンベロープがこの正確な主張のために封緘されたことを確認します。別の hashes マップを持つアイテムに継ぎ接ぎされたエンベロープは、いかなる暗号文の取得よりも前に、オンチェーンの一致ステップで失敗します。アイテムの uris[] は意図的に束縛されません。そのため暗号文は、エンベロープを無効にすることなく新しいコンテンツアドレス指定の URI に再ホストできます。URI リストが主張の一部である送信者は、代わりにレコードレベルの署名でそれを束縛します。
HMAC_KEY の導出では salt = "" は長さゼロのオクテット列であり、RFC 5869 §2.2 のソルト不在の慣行です(HKDF-Extract は HashLen 個のゼロバイト、SHA-256 では 32 バイトを代入します)。これはライブラリの既定値に委ねるのではなく、バイト単位で厳密な適合性ベクトルで固定されています。そのため、ソルト不在の扱いを誤る実装は、黙って異なる鍵を導出するのではなく、そのベクトルに不合格となります。
slots_hash はレコードごとに一度だけ計算され、受信者の試行復号ループ全体を通じて一定です。スロットごとの MAC チェックは候補 CEK ごとに HMAC を再鍵化しますが、常に同じ 32 バイトの slots_hash に対して行います。HMAC 鍵が依然として HKDF-SHA-256(CEK, …) であるため、コミットメントの性質は保たれます。トランスクリプトの事前ハッシュは、HMAC のメッセージを完全なトランスクリプトからその SHA-256 へ変えるだけで、CEK 鍵付きの束縛はそのまま残します。
スロットセット MAC は enc.scheme によって固定されています。ワイヤー上に識別子はなく、スキーム値ごとにちょうど一つの構成が存在し、どちらの KEM でも同一です。slots_mac はちょうど 32 バイトでなければならず(長さが誤っている場合は ENC_SLOTS_MAC_INVALID_LENGTH)、定数時間で検証されなければなりません(MUST)。
トランスクリプトは各スロットのワイヤーバイト列に直接依存します。両方のスロットフィールドは単一の CBOR バイト文字列であり(epk は 32 バイト、kem_ct は 1120 バイト)、正規化すべきフィールドごとのチャンク化も、チャンク境界の曖昧さもありません。Label 309 が行うチャンク分割は レコード の本体全体の転送分割だけであり、これらの処理が走る前に解かれています。スロット内のどこか 1 バイトでも変われば slots_hash が変わり、MAC が失敗します。
コンテンツ層は、スロットセットへのパスごとの別個の束縛を必要としません。コンテンツ鍵は CEK の HKDF リーフであり、CEK はすでに slots_mac によってヘッダー全体(hashes_hash を含む)にコミットされているからです。スロットやヘッダーフィールドをどれか編集すれば、受信者が導出するものが変わるため、コンテンツストリームは単に開かなくなります。したがってチャンクごとの AAD は空です(コンテンツレイヤー を参照)。
二つの KEM
enc.kem でレコードごとに選択される KEM は、スロットの形状と KEK 導出を決定します。どちらも最初のリリースから enc.scheme = 1 の下に登録されています。
enc.kem | KEM | 受信者の公開鍵 | スロットの形状 | KEK info 文字列 |
|---|---|---|---|---|
"x25519" | X25519(クラシカル) | 32 バイト | { epk: bstr(32), wrap: bstr(48) } | "cardano-poe-kek-v1" |
"mlkem768x25519" | X-Wing = X25519 + ML-KEM-768 | 1216 バイト | { kem_ct: bstr(1120), wrap: bstr(48) } | "cardano-poe-kek-mlkem768x25519-v1" |
プロデューサーはデフォルトとして mlkem768x25519 を使用するべきです(SHOULD)。 このハイブリッド暗号はクラシカルな攻撃者と、現在収集して後に量子コンピューターで復号する(harvest-now-decrypt-later)攻撃者の両方に対して安全であり、X25519 のクラシカルなセキュリティを下限として保ちます。X-Wing のコンバイナが両方の共有秘密を束ねるためです。その「X25519 のクラシカルなセキュリティを決して下回らない」というフロアは、正当に生成された受信者鍵に限って適用されます。すなわち、公開鍵が(カプセル化時に適用される)固定された X-Wing リビジョンの鍵有効性チェックに通ることを前提とします(下の ハイブリッド: mlkem768x25519 を参照)。クラシカルの x25519 KEM は、公開している鍵が X25519 のみの受信者のために引き続き利用可能です。識別子 mlkem768x25519 はハイフンなしで意図的に表記されており、X-Wing/age エコシステムの綴りに合わせています。
両 KEM は同じ age スタンザパターン(受信者ごとの KEM 材料に、ファイル鍵の対称ラップを加えたもの)と同じヘッダー束縛(スロットセット MAC とコンテンツ AEAD)を用いるため、一つの統一された構成が HPKE 依存なしに両者をカバーします。クラシカルな x25519 の経路は、age のネイティブな X25519 受信者を忠実になぞります。ハイブリッドの mlkem768x25519 の経路は、age 自身のポスト量子の選択から意図的に分岐します。age v1.3.0 はネイティブのポスト量子受信者(見える形のプレフィックス age1pq…)を出荷しており、ML-KEM-768 + X25519 の KEM 上で HPKE の SealBase(RFC 9180)によってファイル鍵をラップします。スタンザパターンではありません。ハイブリッドの経路でスタンザラップを保つことが、一つの統一されたラップと一つの統一されたヘッダー束縛で両 KEM をカバーできる理由です。したがってハイブリッドのラップは age の HPKE 構成を継承せず、それについて age 継承の主張は一切しません。独自の age1pqc 受信者エンコーディング(鍵 を参照)は、二つのハイブリッドエンコーディングが独立であることを反映しています。
クラシカル: x25519
送信者は受信者ごとに新しい一時 X25519 鍵ペアを生成し、受信者の公開鍵に対して ECDH を行い、ラベル付きハッシュのソルトのもとで HKDF(RFC 5869)で KEK を導出します。
shared = X25519(priv_epk, pub_R) ; per RFC 7748; reject all-zero output
kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R) ; 32 bytes
KEK = HKDF-SHA-256(ikm = shared,
salt = kek_salt, ; binds nonce, ephemeral, recipient
info = "cardano-poe-kek-v1",
L = 32)
slot = { "epk": pub_epk, "wrap": wrap } ; epk = 32 bytesワイヤー上に存在する鍵素材は 32 バイトの一時公開鍵 epk のみです。受信者の公開鍵は発行されません。ソルトは三つの値を束縛するラベル付き SHA-256 です。pub_epk はすべてのスロットの KEK を一意にし、pub_R はそれを特定の受信者に束縛し(epk を別の受信者に転用しようとする試みを防ぎます)、エンベロープごとに一意な enc.nonce は KEK を一つのエンベロープに錨で留めます。これにより、二つのエンベロープで KEM の乱数が繰り返される CSPRNG の故障が起きても、エンベロープをまたぐリンク可能性に劣化するだけであって、(KEK, ゼロノンス) のラップの組が繰り返されることは決してありません。X25519 の実装は RFC 7748 §6.1 に従い、全ゼロの共有秘密を拒否しなければなりません(MUST)。主要なライブラリはこれを推移的に実施しています。
ハイブリッド: mlkem768x25519(X-Wing)
このハイブリッド KEM は X-Wing 構成(draft-connolly-cfrg-xwing-kem-10)であり、ML-KEM-768(FIPS 203)と X25519 を組み合わせます。各カプセル化は新たな ML-KEM 乱数と新しい X25519 一時鍵を生成し、1120 バイトの暗号文と 32 バイトの結合共有秘密を出力します。KEK 導出は、スロット自身のワイヤーバイト列に対して計算される外部ソルトを通じて受信者を束縛します。
enc = XWing.Encapsulate(pub_R) ; named fields — MUST NOT consume positional order
kem_ct = enc.ct ; 1120 bytes
shared = enc.ss ; 32 bytes
kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R) ; 32 bytes
KEK = HKDF-SHA-256(ikm = shared,
salt = kek_salt, ; binds nonce, kem_ct, recipient
info = "cardano-poe-kek-mlkem768x25519-v1",
L = 32)
wrap = ChaCha20-Poly1305_seal(key = KEK, nonce = zeros(12),
ad = "cardano-poe-kek-mlkem768x25519-v1", plaintext = CEK)
slot = { "kem_ct": kem_ct, "wrap": wrap } ; kem_ct = single 1120-byte byte stringX-Wing の鍵と暗号文のサイズ:
| コンポーネント | サイズ | 構成 |
|---|---|---|
| 公開鍵 | 1216 バイト | ML-KEM-768 ek (1184) ‖ X25519 pk (32) |
| 暗号文 | 1120 バイト | ML-KEM-768 ct (1088) ‖ X25519 ephemeral (32) |
| 共有秘密 | 32 バイト | X-Wing コンバイナの出力 |
| 脱カプセル化鍵 | 32 バイト | シード。公開鍵はここから導出されます。 |
ハイブリッドスロットには epk フィールドはありません。X25519 の一時鍵は 1120 バイトの kem_ct の末尾 32 バイトです。XWing.Encapsulate は、固定された X-Wing リビジョンの公開鍵有効性チェックを pub_R に適用しなければならず(MUST)、無効な鍵にカプセル化するのではなく、それを拒否しなければなりません。これは、ハイブリッドのフロアが X25519 のクラシカルなセキュリティを決して下回らないための前提条件です。構成は X-Wing を、名前付きフィールドのみのアダプタを通じて消費します。すなわち Encapsulate(pk) は .ct(1120 B)と .ss(32 B)を生み、Decapsulate(sk, ct) は 32 バイトの共有秘密を生みます。実装は、固定されたリビジョンの API へ名前で対応づけなければならず(MUST)、位置に基づく戻り値を消費してはなりません(MUST NOT)。固定されたリビジョンは、カプセル化から (ss, ct) を返し、脱カプセル化を Decapsulate(ct, sk) と書きます。これは素朴な左から右への読みとは逆です。KEK 導出は固定長のラベル付きソルト SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R) を通じて受信者を束縛します。ここで kem_ct はスロットに運ばれるとおりの 1120 バイトの暗号文、pub_R は 1216 バイトの X-Wing 受信者公開鍵です。これは、クラシカルなソルトが自身のラベルのもとで用いるのと同じ三つの値の形です。すなわち kem_ct が KEK をスロット固有の値に錨で留め、pub_R がそれを特定の受信者に束縛し、enc.nonce がそれを一つのエンベロープに錨で留めます。ハイブリッドの入力は素のソルトには大きすぎるため、SHA-256 ダイジェストを通して表現します。どちらのソルトでも pub_R の項は受信者鍵の正規ワイヤーエンコーディングです。x25519 ではちょうど 32 バイトの x25519_publicKey(priv_R)、mlkem768x25519 ではちょうど固定の 1216 バイトの X-Wing 公開鍵バイト列です。プロデューサーと検証者はこの正確なエンコーディングを使わなければならず(MUST)、非正規な、あるいは再エンコードされた等価物で代用してはなりません(MUST NOT)。さもなければ両者は異なる KEK を導出し、正直なレコードが開かなくなります。決定的に重要なのは、この束縛が KEM の外側で、スロット自身のワイヤーバイト列に対して計算される点です。これにより構成は X-Wing をブラックボックスの KEM として扱います。すなわち公開の KEM インターフェース(カプセル化・脱カプセル化・32 バイトの共有秘密)だけを消費し、コンバイナー内部のハッシュについては何も仮定しません。KEM ごとに異なる info ラベル cardano-poe-kek-mlkem768x25519-v1 はさらに、同一の 32 バイト共有秘密上であっても、ある KEM で導出した KEK が別の KEM で導出したものと等しくなることが決してないよう保証します。1120 バイトの暗号文は slot.kem_ct の中に単一の CBOR バイト文字列として保持されます。チャンク化されるのは転送のためのレコード本体全体だけであって(レコード を参照)、個々のフィールドではありません。
レコードごとに KEM は一つ
封緘済み PoE の一アイテムは enc.kem をちょうど一つ持ちます。すべてのスロットはその KEM の形状と KEK 導出を使用します。ファイルはすべてクラシカルかすべてハイブリッドのいずれかです。異なる KEM のスロットを同じ slots 配列に混在させてはなりません(MUST NOT)。また検証者は、スロットの形状が宣言された enc.kem と矛盾するレコードを拒否しなければなりません(MUST。ENC_SLOT_INVALID_SHAPE)。
カプセル化材料はさらに、一つの slots 配列の中で互いに異なっていなければなりません。x25519 ではすべての epk 値が、mlkem768x25519 ではすべての kem_ct 値が異なっていなければなりません(MUST)。重複は、いかなる KEM や AEAD のプリミティブが走る前に、ENC_SLOTS_DUPLICATE_KEM_MATERIAL で拒否されます。これは、全ゼロノンスのラップが依拠するスロットごとの KEK 一意性の不変条件のうち、検証可能な部分です。レコードをまたぐ、あるいは鍵をまたぐ KEK の再利用は、いかなる検証者も検知できないプロデューサーの義務ですが、レコード内の重複は構造的に見えるため、必ず失敗させなければなりません。
受信者による試行復号
受信者は秘密鍵を持っています(x25519 の場合は 32 バイトの X25519 スカラー、mlkem768x25519 の場合は 32 バイトの X-Wing 脱カプセル化シード。どちらもシードから導出されます。鍵 を参照)。どのスロットが(もしあれば)自分のものかを事前に知ることはできないため、配列を試行復号します。ループは二つの性質によって形づくられます。スロットセット MAC チェックが織り込まれていること(スロットが受け入れられるのは、その候補 CEK がワイヤー上の slots_mac も再現する場合のみ)、そしてループが早期離脱なしにすべてのスロットを走査し、一致を定数時間で選択すること(タイミングを観測する者がどのスロットインデックスが一致したかを推測できないように)です。
いかなる KEM や AEAD のプリミティブを呼ぶ前に、検証者は構造の形のチェック(パーティショニングオラクル対策)を実行しなければなりません(MUST)。scheme == 1、aead / kem が登録済み、nonce が 24 バイト、slots_mac が 32 バイト、slots が空でない、受信者秘密が 32 バイト、各 slot.wrap がちょうど 48 バイト、各 x25519 epk が 32 バイトで kem_ct なし、各 mlkem768x25519 kem_ct が再構成して 1120 バイトで epk なし、そして slots 内のすべてのカプセル化材料の相異性(さもなければ ENC_SLOTS_DUPLICATE_KEM_MATERIAL)です。
同じプリミティブ前のパスで、検証者はパーサのリソース使用も制限しなければなりません(MUST)。参照上限は MAX_SLOTS = 1024 スロットと、デコード後の enc エンベロープに対する 65536 バイトです。いずれも、正直なレコードを制約する ≈ 16 KiB の Cardano トランザクションメタデータ天井をはるかに上回ります。したがってどちらかを超えるレコードは不正形式であり、ここで拒否されます。スロットが多すぎる場合は ENC_SLOTS_TOO_MANY、エンベロープが過大な場合は ENC_ENVELOPE_TOO_LARGE で、いかなる KEM や AEAD のプリミティブが走る前に拒否されます。これらの上限は、検証者が強制するデプロイ固定の定数であり、ワイヤーフィールドではありません。デプロイ側でより厳しくしてもかまいません(MAY)。
; hashes_hash, SLOTS_TRANSCRIPT and slots_hash are recomputed once, before the loop, and held constant:
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))
slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))
if kem == "x25519": pub_R = x25519_publicKey(priv_R) ; recipient public key, 32 B
else: pub_R = XWing.publicKey(priv_R) ; recipient X-Wing public key, 1216 B
found = false
cek_conflict = false
selected_CEK = zeros(32)
for slot in enc.slots: ; iterate ALL slots — no early break
kem_ok = true
if kem == "x25519":
shared = x25519(priv_R, slot.epk)
kem_ok = NOT constant_time_eq(shared, zeros(32)) ; explicit all-zero reject, secret-independent
kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || slot.epk || pub_R)
real_KEK = HKDF-SHA-256(shared, salt = kek_salt, info = "cardano-poe-kek-v1", L = 32)
dummy_KEK = HKDF-SHA-256(zeros(32), salt = kek_salt, info = "cardano-poe-kek-v1", L = 32)
KEK = ct_select(kem_ok, real_KEK, dummy_KEK) ; constant-time, no early exit
ad_wrap = "cardano-poe-kek-v1"
else: ; mlkem768x25519
shared = XWing.Decapsulate(sk=priv_R, ct=slot.kem_ct) ; pinned API writes Decapsulate(ct, sk)
kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || slot.kem_ct || pub_R)
KEK = HKDF-SHA-256(shared, salt = kek_salt,
info = "cardano-poe-kek-mlkem768x25519-v1", L = 32)
ad_wrap = "cardano-poe-kek-mlkem768x25519-v1"
open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), ad_wrap, slot.wrap)
HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
mac_ok = constant_time_eq(HMAC-SHA-256(HMAC_KEY, slots_hash), enc.slots_mac)
ok = kem_ok AND open_ok AND mac_ok ; kem_ok folded into acceptance
first = ok AND NOT found ; first matching slot
cek_conflict = cek_conflict OR (ok AND found AND NOT constant_time_eq(candidate_CEK, selected_CEK))
selected_CEK = ct_select(first, candidate_CEK, selected_CEK) ; constant-time
found = found OR ok
if NOT found: reject (single generic failure) ; WRONG_RECIPIENT_KEY / TAMPERED_HEADER
if cek_conflict: reject (single generic failure) ; cek_conflict
content_key = HKDF-SHA-256(selected_CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32)
plaintext = STREAM_open(content_key, ciphertext) ; per-chunk authenticated release
if STREAM_open fails at any chunk: reject (single generic failure) ; TAMPERED_CIPHERTEXTKEK 導出は enc.kem によって分岐します。x25519 の場合、受信者は slot.epk に対して ECDH を行い、enc.nonce || slot.epk || pub_R に対して同じラベル付きソルトを再導出します。mlkem768x25519 の場合は slot.kem_ct(単一の 1120 バイトのバイト文字列)を直接 X-Wing 脱カプセル化し、enc.nonce || slot.kem_ct || pub_R に対して同じラベル付きソルトを再計算します。ここで pub_R は、保持しているシードから導出した、自分自身の 1216 バイトの X-Wing 公開鍵です。X25519 の全ゼロ共有秘密の拒否は、推移的に頼るのではなく、ここで明示的に行います。共有秘密を zeros(32) へ追い込むよう細工されたスロット(RFC 7748 §6.1)は、秘密に依存しない有効性ビット kem_ok を偽にし、KEK は、同じソルトと info のもとで zeros(32) から導出した dummy_KEK へ定数時間で選択されるため、ループは同一の処理を行い、kem_ok は ok へ織り込まれます。こうして、ECDH が無効なスロットは、ラップや MAC の結果によらず決して受理されず、ほかに何も一致しなければレコードは単一の汎用的な失敗を表面化させます。ラップを開いた後のすべての処理(スロットセット MAC チェック、コンテンツ鍵の導出、コンテンツ復号)は KEM に依存しません。
*_open_or_dummy の両 AEAD プリミティブはアトミックです。タグ検証の失敗時には平文を返さず、返す候補(ラップの開封では candidate_CEK、コンテンツの開封では plaintext)は、失敗した暗号文とは独立した固定値または擬似乱数のダミーです。検証されていない平文が呼び出し側に渡されることは決してありません。そのため、開封の失敗が復号オラクルになることはありません。
MAC チェックがループ内にある理由
悪意のある送信者は、受信者の鍵で開くが攻撃者が選んだ CEK
を返すスロットを作ることができます(受信者の公開鍵へのカプセル化に秘密鍵は不要です)。受信者が最初の
AEAD
成功を「自分のスロット」として受け入れてしまうと、その偽造スロットが、配列の後方にある正当なスロットを隠蔽してしまいます。slots_mac
チェックをループ内に組み込むことで、スロットが受け入れられるのは、その候補 CEK が slots_hash
に対して MAC を再現する場合のみとなります。偽造スロットはスキップされ、走査が続きます。slot.wrap
の長さは、いかなる AEAD 呼び出しよりも前に 48
バイトであることを確認しなければなりません(MUST)。これは age v1
でも採用されているパーティショニングオラクル(partitioning-oracle)対策です。
複数マッチするスロット: 重複は許され、CEK の競合は許されない。 受信者の秘密鍵が複数のスロットに正当にマッチすることもありえます(MAY)。プロデューサーは、同じ CEK を同じ受信者へ複数のスロットにわたって、それぞれ自身の新鮮なスロットごとのエフェメラルを伴って封緘し、見かけ上の受信者数をパディングすることがあります。これは正当なプライバシー技法です。検証者は最初のマッチの CEK を選び、複数のスロットがマッチしたというだけで拒否してはなりません(MUST NOT)。これはレコード内の 重複カプセル化材料 の拒否(ENC_SLOTS_DUPLICATE_KEM_MATERIAL、繰り返された epk または再構成済み kem_ct で発火)とは別物です。正直な重複は、出現ごとに新鮮なスロットごとの KEM 乱数を引くため、その epk / kem_ct は異なり、そのチェックと衝突することは決してありません。検証者が必ず拒否すべき(MUST)唯一の異常は、異なる CEK を回収する 2 つのマッチするスロットです(定数時間で比較します)。ループは cek_conflict ビットをすべてのスロットにわたって保持し、後続のいずれかのマッチが、選択済みの CEK と異なる CEK を回収した場合には、単一の汎用的な失敗を表面化させます。これは多層防御です。回収された CEK が供給するコミットメント性質(スロットセット MAC が CEK を単一のスロットトランスクリプトに束縛します。匿名性と KEM ごとの分かれ目 を参照)のもとでは、異なる CEK を生むマッチはすでに実現不可能であり、それはまさにコミットメントが排除するマルチキー衝突です。そのためこのチェックは、壊れた実装や、将来その前提が弱められた場合に対してフェイルクローズします。
単一の汎用的な失敗の形、スロットをまたいで定数時間
信頼できない呼び出し側は、復号が失敗した理由にかかわらず、ちょうど一つの汎用的な失敗の形を受け取らねばなりません(MUST)。どのスロットも開かなかった、スロットセットが改ざんされた、あるいはコンテンツ AEAD が失敗した、のいずれであっても、です。応答はこれらを区別してはならず(MUST NOT)、どのスロットが一致したかも明かしてはなりません。実装は内部の型付きコード、すなわち WRONG_RECIPIENT_KEY(どのスロットも開かない)、TAMPERED_HEADER(スロットは開いたが、どの候補 CEK も slots_hash に対する slots_mac を再現しない)、TAMPERED_CIPHERTEXT(CEK が回収された後にコンテンツ AEAD が失敗)を、診断のために信頼できるローカルな呼び出し側へ表に出してもかまいません(MAY)。ただしそれらのコードは、区別可能な応答を通じて外部の観測者に漏れてはなりません(MUST NOT)。
タイミングについては、検証者はノーマッチのチェック(if NOT found)で、コンテンツ復号の前に戻ってもかまいません(MAY)。その早期の戻りが明かすのは受信者か否かだけであり、どのスロットが一致したかも、いかなる鍵材料も決して明かしません。チェックに到達した時点で、上記のスロット横断のループはすでに完了して走り切っているからです。受信者でないケースと、暗号文の開封に失敗する受信者との間で、タイミングを一様にすることは要求されません(NOT)。ダミーのコンテンツ開封を義務づけてはなりません(MUST NOT)。受信者でないすべての者に完全なコンテンツ復号のコストを強いても、ループがすでに提供しているもの以上のプライバシーは得られないからです。確かに成り立つ定数時間の保証は、スロット横断の不変条件です。すなわちループは秘密鍵あたり一定回数のスロット演算を早期離脱なしに処理するため、ネットワークレベルの観測者が知れるのはスロットの数だけで、どのスロットが(もしあれば)鍵でアンラップされたかは決して分かりません。複数の鍵を持つ受信者(たとえばアイデンティティのローテーションをまたいでアーカイブされた鍵)は秘密鍵 × スロットを走査し、鍵ごとにソルトの pub_R の半分を再導出します。鍵をまたいで短絡してもかまいません(MAY。弱い「どの鍵が一致したか」の信号だけが漏れます)が、いずれか一つの鍵のスロットをまたぐ間は定数時間を保たねばなりません(MUST)。
平文を復元した後、受信者はアプリケーション層で(復号関数の中ではなく)平文のハッシュ値を再計算して items[].hashes と照合します。一致しない場合はレコードのオンチェーンコミットメントが復号されたバイト列と一致しないことを意味し、受信者はその平文に基づいて行動することを拒否しなければなりません(MUST)。この手順が一連の検証を完結させます。チェーンは時刻 T に何らかのコミットメントが存在したことを証言しており、受信者はそれがまさにこれらのバイト列に対するコミットメントであることを確認します。
パスフレーズパス
代替の鍵配信パスは、受信者スロットをパスフレーズに置き換えます。slots 配列も slots_mac もスロットごとの一時鍵も試行復号ループも存在しません。CEK は Argon2id(RFC 9106)によってオンチェーンのソルトとパラメーターを用いた正規化済みパスフレーズから直接導出されます。slots パスで slots_mac が提供する鍵コミットメントは、代わりに暗号文ブロブの内側にある 32 バイトのヘッダーに置かれ、STREAM のチャンクの前に前置されます(同じオブジェクト、同じ URI、同じ取得です)。
passphrase_bytes = utf8(normalize(passphrase)) ; cardano-poe-pw-norm-v1 (see below)
CEK = argon2id(passphrase_bytes,
salt = enc.passphrase.salt, ; 16–64 bytes, on chain
params = enc.passphrase.params, ; { m, t, p }, on chain
L = 32)
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))
PASSPHRASE_TRANSCRIPT = { ; closed 6-key map; keys are a set, not an order
"scheme": 1, ; uint
"path": "passphrase", ; text
"aead": <enc.aead>, ; text: the content-format identifier
"nonce": <enc.nonce>, ; bytes(24)
"hashes_hash": hashes_hash, ; bytes(32), over this item's hashes
"passphrase": { ; closed sub-map
"alg": "argon2id", ; text
"salt": enc.passphrase.salt, ; bytes
"params": { "m": m, "t": t, "p": p }, ; closed map of uints
"normalization": "cardano-poe-pw-norm-v1"}} ; scheme-fixed constant, NOT on the wire
pw_hash = SHA-256("cardano-poe-passphrase-transcript-v1" || canonicalEncode(PASSPHRASE_TRANSCRIPT))
PW_MAC_KEY = HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-passphrase-mac-v1", L = 32)
commitment = HMAC-SHA-256(key = PW_MAC_KEY, msg = pw_hash) ; 32 bytes
content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce,
info = "cardano-poe-payload-passphrase-v1", L = 32)
ciphertext blob = commitment || STREAM chunks ; STREAM under content_key, as on the slots pathワイヤー上の enc.passphrase ブロックは { alg, salt, params } であり、KDF("argon2id")、ソルト、パラメーターを指定します。Label 309 はパラメーターの下限として m ≥ 65536 KiB(64 MiB)、t ≥ 3、p ≥ 1 を定めています。プロデューサーは下限以上の値を選択し、ソルトは 16〜64 バイト(64 バイト上限はメタデータのバイト列キャップです)とします。プラットフォームが対応しているなら、プロデューサーは p = 4(RFC 9106 §4 の 2 番目の推奨プロファイル)を使うべきです(SHOULD)。検証者は、以下のデプロイの上限に従ったうえで、p ≥ 1 のいずれの値を受け入れてもかまいません(MAY)。
PASSPHRASE_TRANSCRIPT は、KDF パラメーター、ヘッダーフィールド、そしてアイテムのハッシュ主張をコミットメントへ束縛します。検証者は受信した enc マップとアイテムの hashes からトランスクリプトを再計算するため、salt、任意の params 値、nonce、aead を改ざんしたり、エンベロープを別のハッシュ主張に継ぎ接ぎしたりすると、異なる pw_hash が生じ、コミットメントのチェックが失敗します。コンテンツはその後、slots パスと同じセグメント化された STREAM で、パスフレーズパスのコンテンツ鍵のもとで封緘されます。"normalization" の値は、CEK がどの正規化プロファイルのもとで導出されたかを正確に固定するためにトランスクリプトへ供給されるスキーム固定の定数であり、ワイヤー上にシリアライズされることは決してありません。
検証の順序。 検証者は、入力されたパスフレーズから候補 CEK を導出し、暗号文ブロブの先頭 32 バイトを読み、コミットメントを再計算して、それを定数時間で — いかなる STREAM チャンクを開くよりも前に比較します。パスフレーズパスのブロブが 48 バイト(32 バイトのコミットメントヘッダーに 16 バイトの最小 STREAM を加えたもの)より短ければ、整形式ではありえず、不正形式の暗号文です(TAMPERED_CIPHERTEXT)。不一致の場合 — 誤ったパスフレーズ、改ざんされた salt / params、改ざんされたヘッダー、あるいは継ぎ接ぎされたエンベロープ — 検証者は、ほかのいかなる復号失敗とも同じ単一の汎用的な失敗を表面化させ、ストリーミングを開始してはなりません(MUST NOT)。したがって、誤ったパスフレーズは改ざんされたレコードと見分けがつきません。
正規化と Argon2id の前に、実装は生のパスフレーズ入力の長さを制限しなければなりません(MUST)。過大なパスフレーズが KDF 前のサービス拒否を引き起こせないようにするためです。参照上限は生の入力 4096 UTF-8 バイトであり、いかなる正規化やハッシュ処理よりも前に拒否されます。slots パスが強制する MAX_SLOTS やデコード後の enc エンベロープの上限と同様に、これは検証者が強制するデプロイ固定の定数であり、ワイヤーフィールドではありません。デプロイ側でより厳しくしてもかまいません(MAY)。パラメーターの下限に加えて、実装は検証者側の DoS に対して m、t、p の上限も強制すべきです(SHOULD)。それらの上限は非規範的(ハードウェア依存)であり、下限と混同してはなりません(MUST NOT)。
コミットメントがオフチェーンである理由
オンチェーンのパスフレーズコミットメントは、あらゆる観測者に無料のオフライン試行オラクルを手渡してしまいます。推測したパスフレーズから候補 CEK を導出してチェーンと照合する、という操作を、あらゆるパスフレーズレコードについて、永久に、暗号文が差し控えられているレコードも含めて行えてしまうのです。コミットメントを暗号文ブロブの内側に運ぶことは、推測の検証にブロブそのものを必要とすることを意味します。暗号文が差し控えられたレコードは、永続的な台帳の上にパスフレーズで推測可能な素材を一切晒さず、すでにブロブを保持している正当な受信者は、まず 32 バイトのヘッダーを読むのに何のコストも払いません。
正規化プロファイル
Argon2id の前にパスフレーズへ適用する正規化は、固定プロファイル cardano-poe-pw-norm-v1 です。これは規範的です。二つの実装は、同じパスフレーズから必ずバイト単位で同一の CEK を導出しなければならず(MUST)、それを保証する唯一の方法はピン留めされた正規化です。プロファイルは、順序どおりに適用すると次のとおりです。
- 未割り当てコードポイントの拒否。 Unicode 16.0 で未割り当てのコードポイントを含むパスフレーズは、いかなる正規化ステップが走るよりも前に
ENC_PASSPHRASE_UNNORMALIZABLEで拒否されます。 - NFKC。 UAX #15 に従う正規化形式 KC を、Unicode 16.0 のもとで適用します。
- 空白。 「空白」を、Unicode 16.0 のもとで Unicode の
White_Spaceプロパティを持つすべての文字と定義し、そのような文字の極大連続を 1 つの U+0020 SPACE へ畳み込みます。 - トリム。 先頭と末尾の空白を取り除きます。
- 空文字列の拒否。 結果が空文字列になる場合は
ENC_PASSPHRASE_EMPTYで拒否します。空白のみの、あるいはそれ以外の空虚なパスフレーズはゼロバイトに正規化され、Argon2id はそれを黙って受け入れてしまうため、レコードが誰でも導出できる CEK に鍵付けされてしまいます。 - エンコード。 結果を UTF-8 でエンコードします。そのバイト列が Argon2id のパスワード入力です。
ステップ 1 こそが、このプロファイルを実装間でも時間をまたいでも決定論的にするものです。Unicode 正規化安定性ポリシーが、ある文字列の正規化が将来の Unicode バージョンをまたいで安定であることを保証するのは、その中のすべてのコードポイントが、正規化を行ったバージョンで割り当て済みである場合に限られます。未割り当てのコードポイントは、後に分解を獲得して、導出される CEK を黙って変えてしまうことがあります。未割り当てコードポイントを拒否することでその穴は完全に塞がれ、しかも正直なユーザーには見えません。実際に使われるすべての文字は割り当て済みだからです。
Unicode のバージョンは Unicode 16.0 に文字どおりピン留めされており、浮動させてはなりません(MUST NOT)。White_Space プロパティ集合、割り当て済みコードポイントの集合、そして NFKC のマッピングテーブルはいずれもバージョン依存であり、異なる Unicode バージョンに対してプロファイルを解決する検証者は、同じパスフレーズから異なる CEK を導出し、正直なレコードを復号できなくなることがあります。新しい Unicode バージョンを採用する将来のリビジョンは、cardano-poe-pw-norm-v1 を再解釈するのではなく、新しいプロファイル識別子のもとで行います。
パスフレーズのエントロピーが唯一の防壁です
ソルトと Argon2id のパラメーターは永久にチェーン上で公開されるため、攻撃者はそれらに対してパスフレーズをオフラインで無制限に総当たりすることができます。このパスではパスフレーズのエントロピーが唯一のセキュリティマージンです。プロデューサーは人間が選んだパスフレーズではなく CSPRNG で生成したダイスウェアパスフレーズを使用するべきです(SHOULD)。また、入力されたパスフレーズを受け付ける場合は、オンチェーンの暗号文が永久にオフライン攻撃に晒されることをユーザーに分かりやすく警告するべきです(SHOULD)。
前方秘匿性とスロット間の独立性
slots の構成はエフェメラル・スタティック ECDH(または新たな X-Wing カプセル化)を使用し、スロットごとに新しい一時鍵を生成します。これにより、スタティック・スタティックや共有一時鍵の設計では失われる二つの特性が得られます。
- 送信者の侵害に対する前方秘匿性。 送信者はこの構成のなかで長期鍵を持ちません。一時鍵は封緘後にゼロ化されます。その後に送信者の状態が侵害されても、侵害前に発行されたレコードを復号することはできません。
- スロット間の独立性。 異なる受信者は異なる一時鍵を受け取り、したがって異なる共有秘密と KEK を持ちます。ある受信者がラップ済み CEK を漏洩しても、CEK が明かされる(これは避けようがありません。ファイル鍵そのものであるため)だけで、他の受信者の KEK は決して明かされません。
封緘済み PoE には、設計上、受信者に対する前方秘匿性はありません。レコードがいったん長期の受信者鍵に向けて封緘されると、合致する秘密鍵の保持者はそれをいつまでも復号できます。これは長期鍵への公開鍵暗号の性質であって、欠陥ではありません。
匿名性と KEM ごとの分かれ目
封緘済み PoE レコードが sigs を持たないとき、そのワイヤー上のバイト列は送信者のアイデンティティから独立です。各スロットはレコードごと・スロットごとの一時 KEM 材料(slot.epk の X25519 一時鍵、または slot.kem_ct の X-Wing 暗号文)だけを運び、送信者の長期鍵は決して現れず、スロットは CSPRNG でシャッフルされ、受信者公開鍵はワイヤー上になく、記述的なフィールド(ファイル名、MIME タイプ、サイズ)も存在しません。したがって署名のない封緘済みレコードは、チェーン上に送信者のアイデンティティを一切束縛しません。内部告発者によるドロップ、封緘入札オークション、証拠エスクローがまさに必要とするものです。
両 KEM について、正直な漏えいは同一であり、避けられません。スロットの数、封緘済みか否かの区別、そしてクラシカルかハイブリッドかの KEM ファミリー(enc.kem)は、あらゆる観測者から見えます。受信者についてそれ以上のことは見えません。
より強い主張、すなわち 候補となる受信者公開鍵の集合を持っている 敵対者が、あるスロットがそのうちの誰か宛てかどうかを試せない(鍵プライバシー / 受信者の匿名性)という主張は、KEM ごとの性質です。
x25519— 鍵プライベート。 スロットごとのカプセル化は、受信者鍵とは統計的に独立な新鮮なエフェメラル公開鍵です。候補となる受信者公開鍵を持つ敵対者は、slot.epkとslot.wrapだけからでは、合致する秘密鍵なしに、どの候補(もしあれば)にそのスロットが宛てられているかを判定できません。したがってクラシカルな経路は鍵プライベートであり、これはレコード間のリンク不能性ももたらします。同じ受信者への二つの封緘済み PoE は、無関係なepk/wrapのバイト列に見えます。mlkem768x25519— 主張しない。 候補となる受信者鍵を持つ敵対者に対する受信者の匿名性は、ハイブリッド KEM の IND-CCA 安全性から導かれない別個の性質です。Label 309 は、X-Wing について独立に正当化されない限り、X-Wing の経路についてこれを主張しません。鍵を持つ敵対者に対する受信者の匿名性を脅威モデルが要求するデプロイメントは、その性質をハイブリッドの経路に依拠してはなりません(MUST NOT)。
レコード間のタイミング相関を懸念する送信者は、機微なタイムラインから切り離して発行をまとめて行わなければなりません(MUST)。ワイヤーレベルの暗号は、メタデータのタイミング攻撃を解決できません。
コミットメントはスロットセット MAC が供給する。ラップはその必要がない
回収された CEK
は、受信者が照合したスロットセットへのコミットメントです。悪意ある送信者は、単一の受信者が自分のものとして受理する二つの異なるスロットセットを構成できません。ここで求められる性質は、RFC
9771 の意味でのエンベロープ CEK
に対する制限付き鍵コミットメントです。すなわち、回収された CEK
は単一のスロットトランスクリプトに結び付き、任意の入力に対する完全なコミット型 AEAD
ではありません。これは CEK ↦ HMAC-SHA-256(HKDF-SHA-256(CEK, "cardano-poe-slots-mac-v1"), slots_hash) の、敵対的に選ばれた CEK
とトランスクリプトに対するマルチキー衝突耐性に依拠します。これは ~128
ビットの一般衝突マージン(256
ビット出力に対する誕生日上限)であり、脅威モデルに対して十分です。トランスクリプト自体の改ざん検知性は、SHA-256
の ~2^128
衝突上限を引き継ぎます。コミット済みのヘッダーフィールドやスロットのバイトに対するいかなる変更も
slots_hash を変え、異なるトランスクリプトに対して同じ slots_hash を偽造することは、まさにその
~2^128 の衝突探索です。このコミットメントが slots_mac によって供給されるため、スロットごとの
wrap の AEAD はコミット型 AEAD である必要がありません。既定の非コミット型 ChaCha20-Poly1305
でもここでは健全です。
禁止されているパターン
準拠した実装は以下のことをしてはなりません(MUST NOT)。
- スロットごとの一時鍵を、スロットや複数のレコードをまたいで再利用する、あるいはその他の方法で KEK を繰り返させる — ゼロノンスのラップはスロットごとの KEK の一意性に依存しています。
- CEK をエンベロープ間で再利用する —
encを持つアイテムごとに、レコード内でもレコードをまたいでも、新鮮な CSPRNG の CEK を用います。 - パスフレーズのソルトを再利用する — パスフレーズエンベロープごとに新鮮な CSPRNG の
enc.passphrase.saltを生成します。ソルトは、再利用されたパスフレーズに対する唯一のレコード間の分離子です。 - 一つの
slots配列内で KEM を混在させる — レコードごとにenc.kemは一つです。 - スロットを入力順に発行する — CSPRNG によるシャッフルが必須です。
- CEK を 12 バイトの全ゼロノンス以外でラップする、あるいは空のラップ AEAD AAD でラップする — ラップ AAD は KEM の
infoラベルリテラルです。 - 受信者の公開鍵をワイヤーに置く — 試行復号の設計がプライバシー機能そのものであり、公開鍵を発行するとそれが無効化されます。
slots_macの検証を省略する — 省略するとスロット置換攻撃が成立します。- 平文を
ar:///ipfs://URI に保存する — 発行されるのは暗号文のみです。平文はアウト・オブ・バンドで配信されるか送信者が保持します。 ar://またはipfs://以外のスキームで暗号文を参照する — コンテンツアドレス指定のスキームは URI をバイト列に束縛します。ホスト提供の URL を使用する場合は別途オンチェーンの暗号文コミットメントが必要となりますが、封緘済み PoE はそれを持ちません。- CEK、KEK、スロットセット HMAC 鍵、パスフレーズ MAC 鍵、コンテンツ鍵、ECDH 共有秘密、一時秘密鍵、または受信者の秘密鍵をログまたは永続化する。
関連ページ
- 鍵 — 受信者と送信者の鍵素材を提供する、シードから導出される X25519 および X-Wing 鍵ペア。
- レコード — レコードマップ内での
encの位置、およびレコードをチェーン上で運ぶ本体全体の転送。 - アルゴリズムレジストリ —
enc.aead、enc.kem、およびパスフレーズ KDF の識別子とそれらの基礎プリミティブ。 - コンテンツとハッシュ化 — すべての封緘済みレコードが保持する平文ハッシュ値のコミットメント。
- 検証 — 検証パイプライン、構造バリデーターが復号しない理由、およびエラーカタログ。