技術記事

PDF向け Pure Pascal の Ed448 と Brainpool ECDSA

PDFlibPas は pure Object Pascal だけで、Ed448 と 3 つの Brainpool ECDSA 曲線による署名と検証を行います。外部暗号ライブラリも、プラットフォームプロバイダーも、DLL も不要です。PDFlibEd448 が edwards448 上の RFC 8032 PureEdDSA を実装し、PDFlibBrainpool が RFC 5639 の brainpoolP256r1、brainpoolP384r1、brainpoolP512r1 を実装します。両者とも同じやり方、すなわち Pascal コードを書き始める前に独立に生成した既知解答ベクトルに対する検証によって構築されました。そして、この両者について書く価値があるのは、何といってもバグのおかげです

体の演算は、とび抜けて正直なコードです。公開ベクトルとバイト単位で一致するか、あるいは一致しないかのどちらかであり、「ほぼ動く」が入り込む余地はありません。厄介なのは、誤った実装でも署名を生成でき、その自分自身の署名を検証でき、完全にもっともらしく見えてしまうところです

なぜこれらの曲線なのか、そしてなぜ Pascal なのか

Brainpool 曲線は欧州の合格署名(qualified signature)プロファイルに登場するため、この市場向けに文書へ署名するライブラリは、これらを特殊な曲線として扱い続けるわけにはいきません。Ed448 は ISO/TS 32002 が PDF にもたらすアルゴリズムセットに含まれており、その内部ダイジェストは SHA-2 ではなく SHAKE256 です。どちらの系統も一般的に使われている Pascal の暗号ライブラリには用意されていないため、これらを必要とする PDF ライブラリは自ら実装を抱え込むしかありません

配布の観点からの理由は、このライブラリの暗号機能全体に共通するものです。暗号への依存なしに 1 つのバイナリとして出荷されるアプリケーションには、検出すべきプロバイダーがなく、一致させるべきバージョンもなく、ホストにパッチが当たったときに変わってしまう振る舞いもありません。署名こそ、変動する依存関係を最も持ち込みたくない領域です

定数は仕様書テキストから取得。記憶からの取得は禁止

edwards448 の基底点についての最初の試みは記憶から書き起こしたもので、誤っていました。特別に珍しいミスではなく、しかし非常に高くつくミスです。誤った基底点は自己整合的な系を作り出すからです。鍵生成、署名、検証が互いには一致し、世界の他のすべてとは一致しなくなります

機能する手順は、すべてのドメインパラメータを仕様書テキストから取り出し、相互検証することです。edwards448 の場合は、RFC 8032 から素数、曲線定数、群の位数、そして基底点の 10 進表記の両座標を取り出し、内部の limb 表現へ変換し、同じ文書の公開テストベクトルと照合することを意味します。Brainpool 曲線の場合は、RFC 5639 からのパラメータ、ベクトル生成用に書かれた独立実装、そして Pascal コードを 1 行も動かす前に行うシステムライブラリとの双方向のクロスチェックを意味します

Ed448 と Brainpool のドメインパラメータが RFC 8032 と RFC 5639 の仕様テキストから limb 形式へ流し込まれ、Pascal コードを動かす前に相互検証される。
edwards448 と Brainpool 曲線のドメインパラメータは RFC テキストから取り出して limb 形式へ変換し、独立したベクトルと相互検証します

ある導出の近道には注意が必要です。普遍的に使えるように見えて、そうではないからです。固定の y 値から基底点を復元する手法は 25519 曲線では機能しますが、その値が平方根を持たない edwards448 では機能しません。スクリプトなら数秒でこれを反証でき、デバッガで発見するよりはるかに安上がりです

手法:Pascal コードより先に limb レベルのミラー実装

両ユニットを扱いやすくした技法は、無限精度整数を持つ言語でボトムアップに構築したミラー実装です。まず演算レイヤーだけを対象にします。体の乗算、減算、桁上がりの伝播を、代数的な不変量に対して数百件のランダムケースでストレステストします。次にミラー内で鍵生成全体を動かします。意味的なバグが潜むのはここであり、ここなら発見も安価です。その後に初めて Pascal への転記を行います

無限精度整数によるミラー実装が Ed448 と Brainpool の Pascal 体演算と鍵生成を検証するワークフロー。
ボトムアップのミラーワークフロー:まず演算、次にミラー内での鍵生成、その後 Pascal への転記と中間値の比較

得られる利益は開発というより診断にあります。ミラーの正しさが一度確認されれば、ミラーと Pascal の間のいかなる不一致も転記ミスを意味し、両実装で同じ中間値を調べれば即座に位置を特定できます。これにより、スカラー乗算の深部にある 1 つの誤った limb という、それ以外ではほぼデバッグ不可能な類のバグが、5 分の比較に変わります

Ed448 の 4 つの根本原因

4 つすべてが中間値の調査によって発見されたもので、いずれももっともらしく見える出力を生み出す類のものです

1 つ目は記法の罠です。公開されている unified Edwards 加算の公式の多くは曲線定数がマイナス 1 であると仮定しますが、edwards448 はプラス 1 です。そのまま持ち込むと、y 座標の分子は差であるべきところを和として書かれてしまいます。修正は符号へのパッチではなく、正しい曲線のアフィン加法公式から逆元不要の積形式を導き直すことです。これで 4 つの座標式が導かれ、誤った出所から符号が引き継がれる余地はなくなります

2 つ目は点の圧縮解除にあります。射影座標からアフィンの x を復元するには、Z の逆数による乗算が 1 回必要です。逆数の 2 乗を掛けてしまうと、得られる値は依然として有効な射影表現でありながら、誤ったアフィン座標になります。そのため症状は、y が正しく x が誤りという形で現れます。一方の座標だけが正しくもう一方が誤っているときはいつでも、バグは演算ではなく正規化にあります

3 つ目は、より短い曲線から持ち込まれた習慣です。署名ごとのスカラーもチャレンジスカラーも、完全なダイジェストから還元しなければなりません。Ed448 の場合は 114 バイトであり、その最初の 57 バイトからではありません。32 バイトの曲線も全 64 バイトのダイジェストを使うので、規則としては一貫しています。誤っているのは「ダイジェストの半分がスカラー幅である」という前提だけです

4 つ目は順序です。ドメイン分離プレフィックスは、コンテキストプレフィックスとメッセージよりも先に来ます。これは仕様書の RA を直感的に読んだだけでは導かない順序です。ここを誤ると、自分の実装に対してのみ検証が通り、他のどの実装でも通らない署名が生まれます。あり得る失敗の中で最も紛らわしいものです

// 体の桁上がり設計:純粋な floor セマンティクスの伝播により、
// 正と負の両方の limb が機能し、減算にバイアスが不要になる。
// 最上位の桁上がりは 2^448 = 2^224 + 1 (mod p) を経由して折り返され、
// limb 0 と limb 8 に影響する。上限は 4 ラウンド。実運用では 2 ラウンドを確認
procedure FeCarry(var A: TFe448);
var
  I, Round: Integer;
  Carry: Int64;
begin
  for Round := 1 to 4 do
  begin
    Carry := 0;
    for I := 0 to 15 do
    begin
      A[I] := A[I] + Carry;
      Carry := Floor28(A[I]);          // floor、切り捨てではない
      A[I] := A[I] - (Carry shl 28);
    end;
    if Carry = 0 then
      Break;
    A[0] := A[0] + Carry;              // 2^448 == 1
    A[8] := A[8] + Carry;              // 2^448 == 2^224
  end;
end;

このルーチンの初期バージョンは伝播の前にバイアスを適用しており、大きな入力の下では誤った桁の偽の桁上がりを下位 limb へ折り込んでいました。バイアスベースの桁上がり方式は、この種の欠陥の根強い発生源です。上限付きの繰り返しループによる floor セマンティクスのほうが考察しやすく、実測でも十分な速度が出ます

Brainpool の 2 つの根本原因

1 つ目は暗号とはまったく無関係なものです。動作する表現は 33 limb であるため、2 つの値の積には 66 が必要ですが、積の配列は 64 で宣言されていました。末尾を越えた書き込みは隣接メモリを破壊し、最初は誤った結果として現れ、より広いスキャンを追加して初めてクラッシュとして現れるようになりました。ここから得られた規則は、あらゆる固定サイズの数値バッファに適用する価値があります。最悪ケースの積の幅からサイズを決めてマージンを足す。そうすれば後は二度と考える必要がありません。出荷コードの配列は 68 limb です

2 つ目は、混同されたべき乗算の構造です。正しい square-and-multiply 形式は 2 種類あり、指数を逆方向へ消費します。右から左(right-to-left)形式は乗算してから基底を 2 乗し、最下位側からビットを読まなければならず、左から右(left-to-right)形式は 2 乗してから乗算し、最上位側からビットを読みます。モジュラ逆数のループには、右から左の本体に最上位ビット優先のビット走査が組み合わされていました。どちらの部分も教科書どおりであり、組み合わせだけが教科書外です。結果として、依然としてもっともらしい体の元に見える誤った逆数が生まれます

ビット方向が正反対の 2 つの square-and-multiply べき乗形式と、誤った Brainpool モジュラ逆数を計算した混成形式。
square-and-multiply の両形式はそれぞれ単独では正しい。右から左の本体に最上位優先の走査を組み合わせると、もっともらしい誤った逆数になります
// 出力先レコードが入力元と同じ変数になり得る Jacobian 倍算と加算。
// エントリ時にレコード全体をコピーするのが唯一の信頼できる防御。
// R の limb への書き込みは、その後の P の読み取りを汚染するため
procedure BPPointDouble(var R: TBPPoint; const P: TBPPoint;
  const Curve: TBPCurve);
var
  Pin: TBPPoint;
begin
  Pin := P;        // まずコピーし、Pin だけから計算する
  // ... M = 3X^2 + A*Z^4, S = 4*X*Y^2, X3 = M^2 - 2S, ...
end;

バグより高くついた 2 つのプロセス上の教訓

段階的なホットフィックスでは、暗号ユニットは収束しません。あるドラフトは繰り返しパッチを当てられ、32 個の重複ルーチンと損傷した構造を抱えるに至り、書き直して初めて修正されました。採用すべきパターンは、検証済みミラーから一発で書くか、あるいは書き直すかのどちらかです。まだ理解していない演算への局所修正の連鎖は、修正よりも速く誤りを蓄積します

そして、テスト結果を信じる前に実行ファイルのタイムスタンプを確認してください。コンパイルはされるがリンクし直されないインクリメンタルビルドは、以前のバイナリを実行します。これにより、プローブの欠落や出力の重複に関する誤った手がかりが、一巡分まるごと生産されました。暗号をデバッグするとき、説明のつかない結果には「アルゴリズムが誤っていないか」の前に「これは今ビルドしたバイナリか」を問うべきです

パフォーマンス、適用範囲と呼び出し方

Brainpool ユニットのモジュラ還元は、積の最上位セットビットからのビット直列のシフト減算であり、したがって 1 回の乗算のコストはほぼビット幅のオーダーになります。P-256 の検証は数百ミリ秒の下位帯に収まり、文書の署名や検証には何ら問題のない水準で、TLS ターミネータには不十分です。Barrett 還元が明白なアップグレードですが、現在の表現が保持するよりも広い作業値が必要です。したがって、ワークロードが要求するときに行う変更であり、先回りして行うものではありません

uses
  PDFlibEd448, PDFlibBrainpool;

var
  PublicKey, Signature: AnsiString;
  Curve: TBPCurve;
  R, S, PubX, PubY: TBPValue;
begin
  // Ed448: PureEdDSA、内部は SHAKE256、鍵は 57 バイト
  if Ed448PublicKeyFromSeed(Seed, PublicKey) and
     Ed448Sign(DocumentDigest, Seed, Signature) then
    Assert(Ed448Verify(DocumentDigest, PublicKey, Signature));

  // Brainpool: 署名ごとの nonce は呼び出し側が供給するため、
  // nonce ポリシーはアプリケーション側に留まる
  Curve := BPLoadCurve(bpP256r1);
  if BPKeyGen(PubX, PubY, PrivateD, Curve) and
     BPSignFixedK(R, S, Hash, PrivateD, Nonce, Curve) then
    Assert(BPVerify(R, S, Hash, PubX, PubY, Curve));
end;

Brainpool の署名エントリポイントは nonce を生成するのではなく受け取ることに注意してください。これは意図的な設計です。nonce の生成は ECDSA において誤ったときの被害が最も壊滅的な箇所であり、繰り返されたり予測可能だったりする値は秘密鍵を漏らします。乱数の出所を決める判断は、アプリケーションとそのコンプライアンス要件に属するものであって、PDF ライブラリに属するものではありません

これらの曲線は、FIPS 204 ML-DSA の記事で述べた耐量子の取り組みと並ぶものであり、PAdES 署名と検証で扱うのと同じ署名・検証パイプラインに接続します。これらの曲線向けのテスト証明書については、CryptoAPI による自己署名証明書の記事にローカル生成の手順を述べています。アルゴリズムの全マトリックスは、losLab PDF Developer Library の製品ページに一覧があります