バージョン3.114.8より前のPDFiumPasは、Windows以外のターゲットでPDF暗号化の鍵素材をRTLのRandom関数で生成していました。ところがどこでもRandomizeを呼んでいないため、すべてのプロセスが同じバイト列を出力します。その結果、LinuxとmacOSのFree Pascalビルドは、実行のたびに同一のファイル暗号化キー、ソルト、CBC IV、AES-GCMノンスプレフィックスを書き出していました。バージョン3.114.8は代わりに/dev/urandomを読み、読めない場合は例外を投げます
欠陥そのものは4行のループです。より有用な教訓は、AESV3とAESV4、PDF MACありとなしで何百もの文書を暗号化・復号してきたテストスイートが、なぜずっとグリーンであり続けたのか、という点にあります。プロセスごとに定数の乱数は、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キー
すべてのプロセスが同じキーを生成した理由
AesGenerateRandomBytesはOSのジェネレーターを使っていたのはWindowsだけで、それ以外ではRTLの擬似乱数ジェネレーターでバッファを満たしていました。そしてこのジェネレーターは、プログラムがRandomizeを呼ばない限りRandSeed = 0から始まります。ループの上のコメントは、ジェネレーターがGetTickCount64からシードされると語っていました。そんなコードは1行も存在せず、コメントこそがシードの存在した唯一の場所だったのです
// 3.114.8より前のAesGenerateRandomBytesのWindows以外の分岐
// (その上のコメントは、適用されたことのない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ビルドは一度も影響を受けていません。Windows分岐は常にadvapi32経由でCRYPT_VERIFYCONTEXT付きのCryptGenRandomを呼び、失敗時には例外を投げてきたからです。露出したのは3.114.8より前のWindows以外ビルドの出力で、実際にはLinuxとmacOS上のLazarus・Free Pascalアプリケーションを意味します。PDFiumビルドにおけるDelphi対FPCの落とし穴のリストに、もう1つ追加される項目です
Randomizeが正しい修正になり得なかった理由
Randomizeを呼べば症状は隠せますが、ソースは直りません。RandSeedは32ビット値で、Randomizeはそれをクロックから導くからです。あり得るキーストリームの数は2^32で頭打ちになり、ファイルが書かれた大まかな時刻が分かれば探索空間はそれよりはるかに小さくなります。256ビットのAESキーの前では無に等しい数字です。鍵素材はカーネルのエントロピープールから来なければならないため、3.114.8のAesGenerateRandomBytesは/dev/urandomを読み、短い読み取りをループで処理し、プールが要求されたバイトをすべて届けられなければ例外を投げます
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');
拒否するのは意図的で、Windows分岐がCryptGenRandomが使えないときにずっと取ってきた態度と同じです。失敗した暗号化保存はその日のうちに気づける事故ですが、予測可能なキーでの保存に成功した場合は、他人から教わるまで気づけません。実務上の帰結が2つあります。/devが整備されていない最小構成のコンテナやchrootは、静かに劣化する代わりに暗号化で失敗するようになるため、マウントしてください。そして例外は、ターゲットファイルがfmCreateで開かれた後でTPdf.SaveAsEncryptedの外へ伝播するため、空の出力ファイルが残ります。エラーハンドラで削除してください
ラウンドトリップテストが捕捉できなかった理由
ラウンドトリップテストには定数の乱数が見えません。復号は、暗号化が選んだファイルキーを何であれ復元するからです。テストは文書を暗号化し、パスワードで再オープンし、/UEからキーを取り出し、全オブジェクトを復号します。予測可能なキーでもランダムなキーと同じように取り出せ、復号でき、GCMタグは同じキーで計算されたものだから検証も通ります。2回暗号化して出力が違うことをアサートするテストでさえ通ります。同じプロセス内の2回目の呼び出しはシーケンスの続きのバイトを取るからです。大事な性質、つまりプロセスごとにキーが違うという性質は、プロセスをまたいで出力を比較することでしか観測できません。同じコードが値の生成と消費の両方を担うとき、テストは丸ごと1クラスの欠陥に盲目になります。乱数はその最も純粋な例です
プロセスをまたいでキーの乱数性をテストする方法
小さなプローブをターゲットプラットフォームで別プロセスとして2回実行し、出力を比較します。下のプローブはDeriveEncryptionKeysを呼び、/Uエントリのバイト32から47に格納されたソルトを出力します。この値はすべての暗号化ファイルに丸見えで書き込まれているため、CIログに出力しても何も漏れません。それでいて、ファイルキーと同じジェネレーターから来るのです
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行が一致したらジョブを失敗させます。同じ比較は、すでに出回っているファイルにも使えます。同じアプリケーションの別実行が書いた暗号化PDFを2つ取り、Encryptディクショナリから/U文字列を読み、末尾16バイトを比較してください。同一のソルトは影響を受けたビルドを特定します。文書は平文から3.114.8以降で再暗号化し、それぞれが新しいファイルキーを受け取るようにします。一般論として、Windowsの実行結果を信用するのではなく、Windows以外のコードパスはそのプラットフォーム上で動かすのが習慣です。Windows以外ビルド向けlibcurlタイムスタンプバックエンドの背後にあるのも同じ考え方です
PDFiumPasはPDFiumエンジンの上に構築されたDelphi・Lazarus向けPDFコンポーネントで、AES-256、AES-GCM、PDF MACトークンをPascalでネイティブに実装し、鍵素材はすべてのプラットフォームでOSのジェネレーターから取得します。詳細とダウンロードはPDFium Delphiコンポーネントのページにあります