技術記事

FPC で PDF 暗号化キーに /dev/urandom が必要な理由

ランタイムライブラリの Random 関数で PDF 暗号化の鍵素材を生成するのは、Free Pascal では trap です。どこかが Randomize を呼ばないかぎり、すべてのプロセスが同じバイト列を生み出します。Linux と macOS のビルドは、実行のたびに同一のファイル暗号化キー、ソルト、CBC IV、AES-GCM ノンスプレフィックスを書き出してしまうのです。PDFiumPas は非 Windows ターゲットでは代わりに /dev/urandom を読み、読めないときは例外を上げます

trap の本体なら 4 行のループに収まります。より有用な教訓は、なぜ暗号化のテストスイートが、AESV3 と AESV4、PDF MAC のありなしをカバーしながら、その上を平気で green のまま通ってしまえるかです。プロセスごとに定数となる乱数は、1 つのプロセスの中で走るテストには見えません。そして暗号化テストスイートは、たいていまさにそのように構成されているのです

PDFiumPasがランダムバイトを必要とする場所

PDFiumPas暗号化スタックのランダムバイトはすべて、1つのプロシージャ、すなわちFPdfAesユニットのAesGenerateRandomBytesから来ます。悪いソースが1つあれば全体が汚染されます。ISO 32000-2 §7.6.4の標準セキュリティハンドラとISO/TS 32003のAESV4拡張は、次の場所でこれらのバイトを消費します

  • 32バイトのファイル暗号化キー。DeriveEncryptionKeysが文書ごとに新しく生成し、パスワード由来のキーでラップして/UEと/OEへ収める
  • 16バイトのソルト2つ。1つは/Uの末尾16バイトに、もう1つは/Oの末尾16バイトに格納され、それぞれ8バイトの検証ソルトと8バイトのキーソルトに分割される
  • /Permsの背後の平文のバイト12から15。ISO 32000-2は、ブロックがファイルキーで暗号化される前にここをランダムデータで満たす
  • 16バイトのCBC IV。AESV3文書の暗号化された文字列とストリームすべての先頭に付く
  • AESV4文書用の8バイトノンスプレフィックス。その後にゼロから始まる4バイトのオブジェクトごとカウンタが続く
  • EnableIntegrityProtectionがセットされているときの32バイトの/KDFSaltとMACキー
PDFiumPas 暗号化スタックのすべてのランダムバイトは、FPdfAes の AesGenerateRandomBytes から 6 つの消費者へ流れます。/UE と /OE へ wrap される 32 バイトのファイル暗号化キー、/U と /O のソルト、/Perms のパディングバイト、AESV3 の CBC IV、AESV4 の GCM ノンスプレフィックス、そして KDF ソルトと MAC キーです
ジェネレーターが 1 つだけということは、悪いソースが 1 つあれば鍵素材全体へ一気に汚染が広がり、良いソースが 1 つあればすべての呼び出し箇所をカバーできるということです

なぜすべてのプロセスが同じキーを生み出すのか

RTL の疑似乱数ジェネレーターは、プログラムが Randomize を呼ばないかぎり RandSeed = 0 から始まります。そこからキーバッファを満たす非 Windows ブランチは、どのプロセスでも同じシーケンスを受け取ります。ジェネレーターは GetTickCount64 からシードされる、と書いたコメントは、実際にはその 1 行も存在しないときには何の役にも立ちません。シードが存在する唯一の場所が、そのコメントになってしまうのです

// trap の本体: キー源としてシード未設定の RTL ジェネレーター
// (GetTickCount64 によるシードを約束するコメントは、何も設定しない)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

このシーケンスはプロセスごとに最初からやり直し、プロセス内では前へ進みます。つまりどのプロセスも暗号化する1つ目の文書は、同じビルドで走る他のすべてのプロセスの1つ目の文書とファイルキーを共有し、2つ目は2つ目と、という具合です。R5、R6、R7のファイルキーはパスワードにまったく依存しません。パスワードはラップするだけだからです。シーケンスを再現できる者は、パスワードを知らずにキーを手にします。AESV4は2つ目の失敗を加えます。同じキー、同じ8バイトプレフィックス、ゼロからやり直すカウンタという組み合わせはGCMノンスを繰り返します。これはNIST SP 800-38D §8が禁止を明言しているものです。同一キーの下でGCMノンスが重複すると2つの平文のXORが漏れ、認証サブキーが露出します。AESV4-GCM暗号化とPDF MACトークンが頼るタグは、何の意味も持たなくなります。機密性と完全性が同時に失われるのです

非 Windows の FPC ビルドでシード未設定の RTL ジェネレーターを使ったときの図。RandSeed 0 のためすべてのプロセスが同じシーケンスを生み出し、プロセス A の 1 文書目とプロセス B の 1 文書目が同じファイルキーを持ります。AESV4 では同じキーに同じプレフィックスとゼロからやり直すカウンタが組み合わさり、GCM ノンスが重複します
ファイルキーはパスワードにまったく依存しないため、シーケンスを再現できる者はキーをそのまま手にします。重複した GCM ノンスは機密性と完全性を同時に壊します

影響範囲は、前の段落から受ける印象より狭いです。Windows ではこの問題は発生しません。Windows ブランチは advapi32 経由で CRYPT_VERIFYCONTEXT を付けて CryptGenRandom を呼び、失敗したら例外を上げるからです。trap は非 Windows ターゲットに固有のもので、実際には Linux と macOS 上の Lazarus と Free Pascal アプリケーションを意味します。PDFium ビルドにおける Delphi と FPC の罠の一覧への、もう 1 つの追記です

Randomize はなぜ答えにならないのか

Randomize を呼べば症状は隠せますが、源はそのままです。RandSeed は 32-bit の値で、Randomize はそれを時計から導くためです。これで可能なキーストリームの数は 2^32 に頭打ちになり、ファイルが書かれた時刻のあたりをつけられれば探索空間はその遥か下まで狭まります。256-bit の AES キーの前では、ほぼ無意味です。鍵素材はカーネルのエントロピープールから来なければなりません。そこで AesGenerateRandomBytes は /dev/urandom を読み、short read をループで処理し、要求されたバイトすべてが得られなければ例外を上げます

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // 失敗またはストリームの予期しない終端
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

拒否するのは意図的であり、CryptGenRandom が使えないときの Windows ブランチの挙動と一致しています。失敗した暗号化保存は、その日のうちに気づけるインシデントです。予測可能なキーでの保存の成功は、人から聞いて初めて知るインシデントです。実務上の帰結は 2 つあります。/dev が用意されていない最小限のコンテナや chroot では、暗号化は静かに劣化するのではなく失敗するので、マウントしてください。そして例外は、ターゲットファイルが fmCreate で開かれた後で TPdf.SaveAsEncrypted から伝播するため、空の出力ファイルが残ります。エラーハンドラーで削除してください

なぜラウンドトリップテストでは捕捉できないのか

ラウンドトリップテストには定数の乱数が見えません。復号は、暗号化が選んだファイルキーを何であれ復元するからです。テストは文書を暗号化し、パスワードで再オープンし、/UEからキーを取り出し、全オブジェクトを復号します。予測可能なキーでもランダムなキーと同じように取り出せ、復号でき、GCMタグは同じキーで計算されたものだから検証も通ります。2回暗号化して出力が違うことをアサートするテストでさえ通ります。同じプロセス内の2回目の呼び出しはシーケンスの続きのバイトを取るからです。大事な性質、つまりプロセスごとにキーが違うという性質は、プロセスをまたいで出力を比較することでしか観測できません。同じコードが値の生成と消費の両方を担うとき、テストは丸ごと1クラスの欠陥に盲目になります。乱数はその最も純粋な例です

プロセスをまたいでキーの乱数性をテストする方法

小さなプローブをターゲットプラットフォームで別プロセスとして2回実行し、出力を比較します。下のプローブはDeriveEncryptionKeysを呼び、/Uエントリのバイト32から47に格納されたソルトを出力します。この値はすべての暗号化ファイルに丸見えで書き込まれているため、CIログに出力しても何も漏れません。それでいて、ファイルキーと同じジェネレーターから来るのです

PDFiumPasのプロセス横断乱数テスト:SaltProbeプログラムがDeriveEncryptionKeysを呼び/Uのバイト32から47の16進を出力し、ジョブはそれを別プロセスとして2回実行して行が一致したら失敗とし、出荷済みPDFは/U文字列の末尾16バイトで比較します
定数の乱数は1つのプロセスの中では見えません。復号は暗号化が選んだキーを何であれ復元するためで、大事な性質はプロセスをまたぐ出力比較でしか観測できません
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = 32バイトのハッシュ + 16バイトのソルト
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // 実行ごとに違うはず
end.

非 Windows ターゲットごとに、このプローブをビルドへ組み込んでください。2 回実行し、2 行が一致したらジョブを失敗させます。同じ比較は、すでに出回っているファイルにも使えます。同じアプリケーションの別々の実行が書き出した 2 つの暗号化 PDF を取り、Encrypt 辞書から /U 文字列を読み、末尾 16 バイトを比較してください。ソルトが同一なら、キー源は予測可能です。それらの文書は、それぞれが新しいファイルキーを受け取れるよう、平文からもう一度暗号化し直す必要があります。大原則は、Windows での実行を信頼するのではなく、非 Windows のコードパスをそのプラットフォーム上で動かすことです。非 Windows ビルド向け libcurl タイムスタンプバックエンドも、同じ発想に基づいています

PDFiumPasはPDFiumエンジンの上に構築されたDelphi・Lazarus向けPDFコンポーネントで、AES-256、AES-GCM、PDF MACトークンをPascalでネイティブに実装し、鍵素材はすべてのプラットフォームでOSのジェネレーターから取得します。詳細とダウンロードはPDFium Delphiコンポーネントのページにあります