技術記事

DelphiのSHAKE256 XOF:Keccakのドメイン分離

PDFlibPasはSHA-3とSHAKE256を、Object Pascalによる単一のKeccak-f[1600]置換の上で実装しています。2つのファミリが異なるのは正確に2か所だけです。FIPS 202 §6.2と§6.3に従うpad10*1ドメインバイト(SHA-3は$06、SHAKEは$1F)と、レート規則です。SHAKE256は出力長に関係なくレートを136バイトに固定します

2つ目の違いこそ、人々が間違えるところです。まったく違いに見えないからです。SHA3-256もレート136で動作するため、SHA-3のコード経路をコピーして作ったSHAKE256は、投げたすべての32バイトテストを通り、誰かが初めて64バイトを要求したときにゴミを生成します。以下では、2つのファミリがどうやってPDFlibDigestで1つのコンテキストレコードを共有するに至ったか、ワンショットのエントリポイントがどこで止まるか、そしてEd448やML-DSAが実現する前にストリーミングXOFがなぜ存在しなければならなかったかを説明します

SHA-3とSHAKE256がなぜ1つのKeccakコンテキストを共有するのか

ファミリごとの状態が単一のバイトだけだからで、PDFlibPasはそれをTSHA3ContextにSuffixとして格納します。ドメインセパレータがファイナライズルーチンにハードコードされる代わりにコンテキストに住むようになれば、SHA3AbsorbBlock、SHA3Update、SHA3Finalは両方のファミリに無変更で使え、ライブラリ内には24ラウンド置換のコピーがちょうど1つだけになります。2つの経路が分岐するのは初期化子の場所で、完全に分岐します

PDFlibPasで1つのKeccakコンテキストが2つのファミリに使われる様子。TSHA3Contextはドメインセパレータを単一のSuffixバイトに格納し、SHA3Initはsuffixを$06に設定してRateを出力長の2倍を200から引いた値とし、SHAKE256Initは$1Fを設定してレートを136バイトに固定します。そのためSHA3AbsorbBlock、SHA3Update、SHA3Finalは両方に無変更で使えます
ファミリごとの状態が1バイトだけなので、両方のハッシュファミリが24ラウンド置換の単一コピーを共有し、2つの初期化子だけが経路の分岐点です
Type
  TSHA3Context= Record
    State: Array [0.. 24] Of UInt64;
    Buffer: Array [0.. 135] Of Byte;
    BufferLength: Integer;
    Rate: Integer;
    OutputLength: Integer;
    // ドメインセパレータ:SHA3は$06、SHAKEは$1F(FIPS 202 §6.2/6.3)
    Suffix: Byte;
  End;

Procedure SHA3Init(Var Context: TSHA3Context; OutputBits: Integer);
Begin
  FillChar(Context, SizeOf(Context), 0);
  Context.OutputLength:= OutputBits div 8;
  Context.Rate:= 200- 2* Context.OutputLength;   // レートはダイジェストサイズに従う
  Context.Suffix:= $06;
End;

// SHAKE256:呼び出し側が何を求めてもレートは136バイトに固定
// SHAKEは拡張可能出力関数だから
Procedure SHAKE256Init(Var Context: TSHA3Context; OutputBytes: Integer);
Begin
  FillChar(Context, SizeOf(Context), 0);
  Context.OutputLength:= OutputBytes;
  Context.Rate:= 136;
  Context.Suffix:= $1F;
End;

SHA3Initの式は、SHA3-256、SHA3-384、SHA3-512にそれぞれ136、104、72バイトのレートを与えるため、固定長ファミリではレートとセキュリティ強度が溶接されています。SHAKE256Initはそのリンクを意図的に壊します。OutputLengthはセキュリティ強度の宣言ではなくバイト数の要求になり、スプリンジ容量はどれだけ出力を引き出しても512ビットのままです。この分離こそが拡張可能出力関数の定義であり、それを1つのコンテキスト上の2つの初期化子として書くことで、区別はコメントに埋もれずソースの中で目に見えます

ワンショットのエントリポイントに掛かる136バイトの上限

PDFlibPasの便利関数は1..136バイトの範囲外を拒否します。この上限は恣意的ではなく正直です。SHAKE256StreamRangeとSHAKE256ByteArrayはどちらもSHA3Finalを通ります。これは最後のブロックをパディングし、一度だけ置換し、状態から直接出力を読みます。その状態の最初のRateバイトだけが正当な出力なので、1回のスクイーズは最大136バイトです。それを超えるとブロックごとに新しい置換が必要です。実際の消費者はどちらも窓の中に収まります。PDF署名プロファイルは32バイトをダイジェストし、Ed448はRFC 8032 §5.2により114バイトを必要とします。SHAKE256StreamRangeはSHA256StreamRangeやSHA3_256StreamRangeと同じFirstLength、SecondStart、SecondLengthの3つ組を受け取るため、検証コードはどのアルゴリズムでも同じ方法で署名/ByteRangeを歩き、XOFを特別扱いしません

位置がrate-1に落ちたときpad10*1で何が起こるのか

両方のパッドバイトを代入ではなくXORで状態に取り込むなら、何も問題はありません。FIPS 202 §B.2はドメインサフィックスを現在の吸収位置に置き、$80をブロックの最後のバイトに置きます。メッセージがたまたま位置をRate- 1に残すと、その2つの書き込みは同じバイトを狙い、$9Fに合成されなければなりません。XORではなくストアすると一方が勝ち、パディングは136分の1のメッセージ長で黙って間違い、所有する回帰ベクトルはどれもそこに落ちないためすべて通り続けます。PDFlibPasのPLShakeXOFFinalizeは両方をXORするので、重複ケースは分岐なしに一般ケースから導かれます

PDFlibPasのpad10*1重複ケース。PLShakeXOFFinalizeはドメインサフィックスを現在の吸収位置に、$80をブロックの最後のバイトに置きます。位置がRate-1に落ちると両方の書き込みが1つのバイトを狙い、XORはそれらを$9Fに合成します。素のストアでは136分の1のメッセージ長が壊れます
XORではなくストアすると一方のパッドバイトが勝ちます。欠陥が136分の1のメッセージ長だけに現れるため、パディングが黙って間違っていても所有するすべての回帰ベクトルが通ります
// 最後の部分ブロックをパディングしてスクイーズ段階に入る
Procedure PLShakeXOFFinalize(Var Ctx: TPLShakeXOF);
Begin
  If Not Ctx.Absorbing Then
    Exit;
  Ctx.Absorbing:= False;
  // suffixをSqueezePosに、$80をRate-1に置く。SqueezePos = Rate-1のとき2つの
  // XORは同じバイトに着地し、正しく$9Fに合成される
  Ctx.State[Ctx.SqueezePos div 8]:= Ctx.State[Ctx.SqueezePos div 8]xor
    (UInt64(Ctx.Suffix)shl (8* (Ctx.SqueezePos and 7)));
  Ctx.State[(Ctx.Rate- 1)div 8]:= Ctx.State[(Ctx.Rate- 1)div 8]xor
    (UInt64($80)shl (8* ((Ctx.Rate- 1)and 7)));
  SHA3Permute(Ctx.State);
  Ctx.SqueezePos:= 0;
End;

TPLShakeXOF:スプリンジへ直接吸収する

PDFlibPasは制限のない出力を別のレコードTPLShakeXOFで公開します。これは25個のUInt64ワードのState、Rate、Suffix、Absorbingフラグ、SqueezePosだけを持ちます。メッセージバッファはありません。PLShakeXOFAbsorbは入ってくるバイトを直接状態にXORし、ブロックがいっぱいになるたびに置換します。SqueezePosは両方の段階でオフセットとして機能します。PLShakeXOFInitは1つのbooleanでバリアントを選びます。Trueはレート168のSHAKE128、Falseはレート136のSHAKE256です。PLShakeXOFSqueezeは、呼び出し側が求めたバイト数を手にするまで置換し続けます。ブロックをまたぐ出力は200バイトで検査されました。両バリアントの最初のブロック境界を超えています

TPLShakeXOFを通じたPDFlibPasのストリーミングSHAKE。State、Rate、Suffix、Absorbingフラグ、SqueezePosを持つがメッセージバッファを持たないレコードです。PLShakeXOFInitはレート168のSHAKE128かレート136のSHAKE256を選び、PLShakeXOFAbsorbはバイトを直接スプリンジへXORし、PLShakeXOFFinalizeはパディングして置換し、PLShakeXOFSqueezeは呼び出し側がすべてのバイトを得るまで置換し続けます
最初に連結せず複数の呼び出しで吸収できることが、Ed448が114バイトを引き出しML-DSAポートが無制限にスクイーズできる理由です。136バイトの上限を持つワンショットのエントリポイントにはできないカバレッジです
Var
  Ctx: TPLShakeXOF;
  Digest: Array [0.. 113] Of Byte;   // Ed448は114バイト必要。RFC 8032 §5.2
Begin
  PLShakeXOFInit(Ctx, False);        // False = SHAKE256、レート136
  PLShakeXOFAbsorb(Ctx, @Dom4[1], Length(Dom4));
  PLShakeXOFAbsorb(Ctx, @Prefix[1], Length(Prefix));
  PLShakeXOFAbsorb(Ctx, @Msg[1], Length(Msg));
  PLShakeXOFFinalize(Ctx);
  PLShakeXOFSqueeze(Ctx, @Digest[0], SizeOf(Digest));
End;

// あるいは、入力1バッファと出力1バッファだけで足りるなら:
//   PLShakeXOF(False, Input, Output, 114)

複数の呼び出しで吸収できることは、見た目以上に重要です。Ed448はハッシュの前にメッセージへdom4文字列と57バイトの値を前置し、rejection samplerは文字列を先に連結せずにシードとインデックスバイトを渡したいと考えます。ストリーミングレコードをどちらの消費者よりも先に用意しておくことがポイントでした。純PascalのEd448とBrainpool曲線の作業は一度に114バイトを必要とし、FIPS 204 ML-DSAポートは両バリアントから無制限にスクイーズします。どちらもワンショットAPIでは書けなかったでしょう

PDFlibPasはなぜSHAKE256をダイジェストセレクタとして公開しないのか

それを選ぶ正当な方法がないからです。PDFlibDigitalSignのTPDFlibDigestAlgorithmはdaAuto、daSHA1、daSHA256、daSHA384、daSHA512、daSHA3_256、daSHA3_384、daSHA3_512までで止まります。daSHAKE256はなく、その省略は見落としではなく決定です。RFC 8702はCMSでSHAKEを登録していますが、PDF署名の内部でISO/TS 32002がSHAKE256を置く場所は、署名アルゴリズムがそれを選び呼び出し側が選ばないEd448の内部ダイジェストだけです。独立したDigestAlgorithm値として公開すると、誰でもSHAKE256をRSAやECDSAと組み合わせ、どのバリデータも受け入れない署名辞書を作れてしまいます。機能は同梱され、セレクタはされません。検証側のコードは/ByteRangeレイアウト上でSHAKE256StreamRangeを直接呼びます。相手が今日検証しなければならない署名のダイジェストを選ぶなら、タイムスタンプと長期検証データを備えたPAdES B-BからB-LTAへのワークフローの中のdaSHA256からdaSHA3_512が依然として答えです。SHA-3がPDFで日常的になる条件を支配するのはISO/TS 32001です

XOFのテスト:通っているように見えるベクトル

完全な出力長で比較し、複数のベクトルを使ってください。空文字列のSHAKE256結果は誰もが暗記しているもので、あまりに広く引用されているがゆえに弱いテストです。abcの32バイトSHAKE256出力はもっと悪い。最初の16バイト483366601360a8771c6863080cc4114dが隣接する複数のベクトルと一致するため、切り詰めた比較は成功と報告しますが、判別力のある半分8db44530f8f1e1ee4f94ea37e78b5739は検査されません。PDFlibPasの回帰は両方のベクトルを全長で比較します。ブロックをまたぐケースも追加してください。レート境界を一度も越えないスクイーズループは、まったくテストされていないからです

SHAKEを既存のSHA-3実装に折り込んだことからの広い教訓は、共有コードがリスクではなかったということです。Keccak-f[1600]は両ファミリで同じ置換であり、ベクトルに一致するかしないかのどちらかです。リスクは異なる2バイトと1つの整数、そして呼び出し側に発見させるのではなく正直に宣言しなければならない上限にあります。ダイジェストレイヤーが、ネイティブObject Pascalコードベースの中で署名、暗号化、PDF/Aツールとどう並ぶかを見るには、PDFlibPas Delphi PDF libraryの製品ページが全コンポーネントセットとサポートされるコンパイラマトリックスを挙げています