PDFlibPasは、FIPS 204で標準化されたML-DSA(Module-Lattice-Based Digital Signature Algorithm)を、完全にObject Pascalで実装しています。三つのパラメータセットすべてが通常の関数として同梱されます。MLDSA44Sign、MLDSA65Sign、MLDSA87Signと、対応するKeyGenおよびVerifyエントリです。OpenSSLも、プラットフォームDLLも、Cの接着コードもありません。単一ユニットPDFlibMLDSAが依存するのはライブラリのSHAKEスポンジだけで、その出力は公式のFIPS 204既知回答テストベクトルとバイト単位で一致します
最後の一文こそ、本当の作業が要った部分です。格子演算をPascalで書くのは機械的ですが、それをNISTと一致させるのは機械的ではありません。以下は移植の技術的記録です。三つのパラメータセットがどうして一つのエンジンを共有するに至ったか、そしてコンパイルされ実行されることとKATと一致することを分けた具体的な欠陥です。DelphiやC++Builderの文書パイプライン向けに耐量子の選択肢を評価しているなら、役に立つのは欠陥の部分です。その一つ一つが、相互運用性に静かに失敗する、もっともらしい出力を生むからです
なぜ純Object Pascalで耐量子署名器を書くのか
代替案はターゲットごとに一つのネイティブ依存を抱えることであり、Delphi PDFライブラリはすでにその手の依存を十分抱えているからです。PDFlibPasはDelphi、C++Builder、FPC/LazarusをWin32、Win64、Unixターゲットでビルドできます。Cの耐量子ライブラリをバインドすれば、そのスロット一つ一つについてビルドを追いかけることになり、呼び出し規約とメモリー所有権の境界まで引き受けます。純Pascalのユニットは、ライブラリの残りがコンパイルできる場所ならどこでもコンパイルできます。論拠はそれで全部です
ML-DSAはこれを異例なく安くします。唯一のプリミティブ依存がSHAKEだからです。多倍長整数レイヤーも、楕円曲線も、独立したハッシュ群もありません。PDFlibPasは移植の直前のリリースでストリーミングXOFを獲得しました。PDFlibDigestのTPLShakeXOFであり、PLShakeXOFInitがSHAKE128(レート168)かSHAKE256(レート136)を選び、続いてPLShakeXOFAbsorb、PLShakeXOFFinalize、そして任意の出力長のために並べ替えを続けるPLShakeXOFSqueezeループが続きます。ML-DSAユニットのすべての棄却サンプリングルーチンは、その四呼び出しAPIに対して直接書かれています
一つのエンジン、三つのパラメータセット:TMLDSAParams
PDFlibPasはML-DSAパラメータセット全体を一つのレコードで記述し、セット番号で選択します。そのためML-DSA-44、65、87は同じコード経路を走ります。最初に動いた実装はML-DSA-44に固定配線された4x4のビルドでした。これを一般化するには、kとl、eta、tau、beta、gamma1とgamma2、omega、チャレンジ長をTMLDSAParamsへ持ち上げ、残りをすべて派生させる必要がありました。公開エントリポイントは三行のラッパーになりました
Type
TMLDSAParams= Record
K, L, D, Eta, Tau, Beta, Gamma1, Gamma2, Omega: Integer;
Alpha, MW1: Cardinal;
W1BW, EtaBW, Gamma1BW, T1BW: Integer;
T0Rng: Cardinal;
CTildaBytes: Integer;
PublicKeyBytes, SecretKeyBytes, SignatureBytes: Integer;
End;
// 派生フィールドは計算され、表から書き写されることはない
Params.Alpha:= 2* Cardinal(Params.Gamma2);
Params.MW1:= (Q- 1)div Params.Alpha;
Params.W1BW:= BitWidth(Params.MW1- 1);
Params.EtaBW:= BitWidth(2* Cardinal(Params.Eta));
Params.Gamma1BW:= BitWidth(Cardinal(Params.Gamma1));
Function MLDSA65Sign(Const SecretKey, Message, Context, Rnd: AnsiString;
Out Signature: AnsiString): Boolean;
Var
Params: TMLDSAParams;
Begin
BuildMLDSAParams(65, Params);
Result:= MLDSASignInternal(Params, SecretKey, Message, Context, Rnd,
Signature);
End;
五つの派生フィールドは、FIPS 204の表から書き写すのではなく計算されています。意図的な選択です。手で書き写したビット幅は、レビューでは正しく見え、実運用では一つずれるという種類の定数そのものであり、今回の移植での実際の欠陥のうち二つはまさにその形でした。宣言されたサイズは検証用に名前付き定数のまま保たれます。ML-DSA-44では公開鍵、秘密鍵、署名が1312 / 2560 / 2420バイト、ML-DSA-65では1952 / 4032 / 3309、ML-DSA-87では2592 / 4896 / 4627です
ゼロからのML-DSA移植はどこで最初に間違えるのか
expand_a、つまりFIPS 204アルゴリズム32であり、失敗モードは見事なほど紛らわしいです。行列Aは、SHAKE128にrhoと二つのインデックスバイトを種として与えてサンプリングされるため、種バッファーは34バイトです。rho(32バイト)、次にj、次にi。1始まりのAnsiStringインデックスで書くと、この二つのバイトはMsg[33]とMsg[34]です。この移植の初稿はこれをMsg[34]とMsg[35]に書き込み、ちょうど一バイトずれていました。結果としてできた鍵ペアは、rhoがテストベクトルと完璧に一致するのに、tの係数がすべて間違っているものでした。汚されたのは行列だけであり、行列こそ公開鍵がそのまま運ばない唯一のものです
同じルーチンには欠陥がもう二つ住んでいました。吸収長は35ではなく34でなければならず、余計なゴミバイトが一つあるだけで押し出されるストリーム全体が変わります。そして内側の棄却ループは、168バイトのSHAKE128ブロックのオフセット165に始まるものを含め、ブロックが供給できる三バイトグループをすべて消費しなければなりません。これはブロックあたり56グループです。オフセット162で止まったクロスチェックスクリプトは、毎ブロックの尻尾を落とし、サンプリングされたt1の接頭辞をおよそ十三バイト目から先へずらしました
SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J); // 先に列インデックス
Msg[34]:= AnsiChar(I); // 次に行インデックス
PLShakeXOFInit(Ctx, True); // SHAKE128、レート168
PLShakeXOFAbsorb(Ctx, @Msg[1], 34);
PLShakeXOFFinalize(Ctx);
Cnt:= 0;
While Cnt< N Do
Begin
PLShakeXOFSqueeze(Ctx, @Buf[0], 168);
BOff:= 0;
// BOff+2 <= 167 がオフセット165のグループを保持する:ブロックあたり56トリプル
While (BOff+ 2<= High(Buf))And (Cnt< N) Do
Begin
T3:= ((Buf[BOff+ 2]and $7F)shl 16)xor (Buf[BOff+ 1]shl 8)xor Buf[BOff];
If T3< Q Then
Begin
Poly^[Cnt]:= T3;
Inc(Cnt);
End;
Inc(BOff, 3);
End;
End;
この三つを直すと、完全なML-DSA-44公開鍵と秘密鍵のSHA-256ダイジェストがFIPS 204既知回答ベクトルと一致しました。デバッグの教訓も一つ、名指しする価値があります。セッション一つを費やしたからです。XOF駆動の棄却ループ向けにPythonのクロスチェックを組むとき、hashlib.shake_128().digest(n)はストリームを継続せず、毎回同じ接頭辞を返します。全体長を一度に取り、それをレートサイズのブロックへ切り分けてください。さもないとリファレンス実装は、Pascal側が正しく棄却したまさにその値を、喜んで再消費します
etaのサンプリング:ML-DSA-65に独自分岐が要る理由
PDFlibPasはexpand_sに二つの別個の経路を保ちます。FIPS 204アルゴリズム33が本当に二つを定義しているからです。eta = 2では各ニブルは15に達した時点で棄却され、それ以外はmod 5で還元されます。eta = 4ではニブルは9以上で棄却された後、そのまま使われ、剰余還元は一切ありません。ML-DSA-65は出荷セットの中で唯一eta = 4であり、ここでmod 5経路を流用すると、s1とs2が最初の係数から揃わなくなります。出来上がる鍵ペアは内部整合的で、自分自身に対しては検証が通り、他の誰の実装とも一致しないものです
Procedure StoreNibble(Nibble: Byte);
Var
M: Integer;
Centered: Cardinal;
Begin
If Cnt>= N Then
Exit;
If Eta= 4 Then
Begin
If Nibble>= 9 Then // 拒否してからニブルをそのまま採用
Exit;
M:= Nibble;
End
Else
Begin
If Nibble>= 15 Then // eta = 2: 15を拒否してからmod 5で還元
Exit;
M:= Nibble mod 5;
End;
If Eta>= M Then
Centered:= Eta- M
Else
Centered:= Q- (M- Eta);
Vec[I][Cnt]:= Centered;
Inc(Cnt);
End;
サイズこそテスト:cチルダ長とgamma1のビット幅
二つの符号化パラメータはセキュリティレベルとともに変わりますが、動くML-DSA-44ビルドが目の前に座っていると見落としやすいものです。チャレンジハッシュcチルダは2 × lambda / 8バイトで、ML-DSA-44では32、ML-DSA-65では48、ML-DSA-87では64です。これを32に固定したままだと、ML-DSA-65の署名は標準の3309ではなく3293バイトになり、KATの接頭辞は即座に乖離します。レコードのフィールドCTildaBytesは、まさにこの数字が忘れられないように存在します
二つ目はマスク多項式zのパッキング幅です。PDFlibPasはこれを指数としてではなくBitWidth(Gamma1)として計算します。ML-DSA-65と87のgamma1 = 2^19は、係数あたり19ビットではなく20ビットを要し、その一ビットが、各z多項式が640バイトを占めるか、どの検証器も解釈しない何かを占めるかを決めます。検証器には移植の途中で対応する欠陥があり、z逆シリアライズバッファーが576ではなく192バイトで確保されていました。署名長は、あなたが書く中で最も安い回帰テストです。Length(Signature)に対して2420、3309、4627を表明すれば、大半のパラメータ化ミスは、暗号的な表明に一つも到達する前に自ら名乗りを上げます
無限ループなしの署名
ML-DSAの署名は棄却ベースなので、候補署名がノルムとヒントの判定を通るまで、kappaを増やして再試行します。PDFlibPasはこれを65535回という明示的な外部予算で打ち止めにします。予算を使い切るとMLDSASignInternalはFalseを返し、署名を空のままにします。文書生成スレッドの中で回り続ける代わりです。実際には公式のML-DSA-44ベクトルはkappa = 4で、omega上限80に対して55個のヒントで成功するため、この予算は実働の制限ではなく安全レールです
そのレールを必要に感じさせたバグは、数値とは全く関係ありませんでした。署名がハングしたように見え、疑いはdecomposeとmake_hint(FIPS 204アルゴリズム36と39)へ落ちましたが、本当の原因は蓄積先の取り違えでした。ヒント計算に与えるベクトルはc*t0を蓄積しなければならず、元のc*t0はノルム判定のために手つかずで生き延びなければなりません。両者を同じバッファーへ向けると、完全に正しい演算のままループは永遠に棄却します。成功経路でも予算枯渇経路でも、ユニットは派生シード、秘密多項式、マスク、チャレンジ、符号化バッファーをゼロ化します。呼び出し側が与えたシード、秘密鍵、rndは呼び出し側の責任のままです。それらの文字列がどこから来たか知り得ないライブラリとしては、これが正しい分担です
ML-DSAは今日、PDF署名スタックのどこで接するのか
存在するものについては正確に述べます。PDFlibPasはML-DSAを、検証済みの署名プリミティブとPKCS #11メカニズムのバインディングとして出荷しており、現行のPAdES出力の差し替え品ではありません。トークン経路はTPDFlibPKCS11Client.SignMLDSAで、これが意図的に別のエントリポイントであるのは、CKM_ML_DSAが事前計算済みダイジェストではなく生メッセージを消費するためです。したがって既存のSignHashと外部ダイジェストのコールバックは再利用できません。証明書なしの探索には、秘密鍵のラベルかIDと一緒にCertificateOptionalを明示的に有効化することが要ります。クライアントは接続時にCKA_PARAMETER_SETをCKP_ML_DSA_44 / 65 / 87のホワイトリストに対して検証するため、既定のRSAとECDSAの証明書ペアリングが偶然緩められることはありません
文書レベルの統合は、ライブラリコードではなく標準化作業に律される部分です。ISO 32000-2 §12.8が署名辞書とそのCMSペイロードを定め、ISO/TS 32002がその対応を新しいハッシュおよび署名アルゴリズムへ広げる乗り物です。検証器と取引先が追いつくまでは、クラシック署名が本番経路のままです。実務の構えは並行線です。今日サードパーティが検証しなければならないものには、タイムスタンプと長期検証データ付きのPAdES B-BからB-LTA署名を出荷し続け、その傍らでML-DSAの鍵扱いとトークン統合を実証します。ローカルの実験には、同じCryptoAPI上に構築した自己署名証明書のワークフローが、公開CAを介さずに署名アイデンティティを与えてくれます
パラメータセットの変更は、他の署名変更と同じやり方でテストします。まずサイズ、次に公式ベクトル、そして否定ケースです。改ざんされた署名バイト、一致しないコンテキスト文字列、切り詰められた鍵。PDFlibPasはこれらすべてをDUnitXスイートでカバーしており、同じ規律が自分のパイプラインにも属します。望ましくは、文書コーパス全体に検証を一括適用するコンプライアンスと署名のワークベンチを脇に置いて、回帰が顧客に気づかれないまま届かないようにすることです
文書ソフトウェアの耐量子対応は、一つのスイッチとしてやって来ません。テストできるプリミティブ、配線できるトークン経路、そして現行リリースを賭けずに追従できる標準の道筋としてやって来ます。ML-DSAユニットがネイティブObject Pascalコードベースの署名、暗号化、PDF/Aツール群の隣にどう並ぶかは、PDFlibPas Delphi PDFライブラリの製品ページにコンポーネント一式と対応コンパイラーマトリクスが一覧されています