AES-256 リビジョン 6 の PDF が、正しいと分かっているパスワードを拒否するとき、第一の容疑者は R6 強化ハッシュ(ISO 32000-2 Algorithm 2.B)のラウンドカウンタです。Delphi 向け PDFium Component は v2.57.2 より前、まさにこのバグを抱えていました。TPdf.SaveAsEncrypted が誤った基数からラウンドを数え、生成したファイルの約 30% が FPDF_LoadDocument に FPDF_ERR_PASSWORD で拒否されていたのです
厄介なのは算数ではありません。厄介なのは、ファイルを暗号化した後に同じコードで検査するテストすべてに対してこの欠陥が見えず、1 回の成功したラウンドトリップが何も証明しなかったことです。本記事では、Algorithm 2.B が実際に何を要求するか、1 ラウンドのずれがどこに隠れるか、なぜ時々しか失敗しないか、そして顧客より先にこの手のバグを捕まえる回帰テストの作り方をたどります
R6 ハッシュの終了規則は実際には何と言っているのか
Algorithm 2.B は、少なくとも 64 ラウンドの後でのみ、しかも直近の AES 出力 E の最終バイトが、ラウンド番号から 32 を引いた値以下であるときにのみ停止します。ラウンド番号とは、既に実行されたラウンドの 1 始まりのカウントです。N 番目のラウンドの後では N と比較します。N − 1 でも、次のラウンドのインデックスでもありません。この 1 つの定義がバグ表面のすべてです
アルゴリズムの残りは機械的です。K を、パスワード、ソルト、(オーナーキーなら)48 バイトの /U 値の SHA-256 として始めます。各ラウンドは、パスワード ‖ K ‖ UK を 64 回繰り返したものを K1 として組み立て、K の最初の 16 バイトを鍵、次の 16 バイトを IV として、パディングなしの AES-128-CBC でそれを暗号化し、E の最初の 16 バイトをビッグエンディアンの 128 ビット整数として mod 3 を取り、それに応じて SHA-256、SHA-384、SHA-512 を選んで次の K を生みます。最終ハッシュは K の最初の 32 バイトです。ISO 32000-2 §7.6.4.3 が定義しています。データ依存のループ長は意図的なもので、パスワード推測 1 件あたりの仕事量を GPU クラッカーにとって予測不能にするためです
以下はループの正しい形です。そのまま差し込める実装ではなく概念例として書いてあります。BuildK1、AesCbcNoPad128、FirstBlockMod3 とハッシュのヘルパーは、自分のコードベースが既に持つプリミティブの代役です
// 概念例: Algorithm 2.B のループ骨格(ISO 32000-2、リビジョン 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0; // これまでに実行したラウンド数
repeat
Inc(RoundNumber); // これがラウンド 1、2、3 ...
K1 := BuildK1(Password, K, UK); // (Password || K || UK) を 64 回繰り返したもの
E := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
case FirstBlockMod3(E) of // E の最初の 16 バイト、ビッグエンディアン、mod 3
0: K := Sha256(E);
1: K := Sha384(E);
2: K := Sha512(E);
end;
until (RoundNumber >= 64) and (E[High(E)] <= RoundNumber - 32);
Result := Copy(K, 0, 32);
終了の行は、カウンタを念頭に読んでください。インクリメントは本体の先頭に座っているため、until の判定が走る頃には、RoundNumber は完了したラウンド数を正確に保持しています。規格がラウンド番号と言うのはこのことです
Delphi 移植で off-by-one が隠れる場所
PDFium Component のバグは、−1 から始まりループ本体の先頭でインクリメントするカウンタでした。until 節で判定される値は常に、実行されたラウンド数から 1 を引いたものです。2 つのことが同時に壊れます。64 ラウンドの最小値は 65 になり、バイトの閾値は規格の言うところより 1 段きつくなります。どちらも、効くケースでは出口をちょうど 1 ラウンドずらします
同じずれには、同じくらいもっともらしい変装がいくつもあります。0 から始めて本体の末尾でインクリメントする。判定が既にインクリメント前の値に対して書かれた後に。0 始まりの for ループのインデックスを使い、それを比較にも再利用する。ループ変数が ++ の前に判定される C のリファレンスを翻訳する。どれもコードレビューでは自然に読め、どれも完璧にランダムに見えるハッシュを生みます。なぜなら実際にランダムだからです。他のどのリーダーも計算しない、別の、同じくらい正当に見える SHA 出力にすぎません。修正済みの FPdfEncrypt.pas の CalculateR6Hash は今、1 始まりのセマンティクスを釘付けにするコメントを運んでいます。間違った版は間違って見えないからこそ、です
AES-256 R6 の PDF が時々しか失敗しないのはなぜか
失敗が確率的なのは、余分なラウンドが結果を変えるのは、終了バイトが正しい閾値とずれた閾値の間の 1 ラウンド幅の窓に着地したときだけだからです。その他のすべてのケースでは両版が同じラウンドで抜けて同じ K を生み、ファイルは普通に開きます。PDFium Component の計測では、60 回の暗号化のうち 18 回(約 30%)が、標準実装と食い違う /U、/O、/UE、/OE 値を生みました。残る 70% はバイト単位で正しかったのです
保存のたびに新しいランダムソルトが引かれるため、同じ文書、同じパスワードが、ある実行では成功し次の実行では失敗します。サポート窓口から見れば、不安定なストレージ、壊れたアップロード、ユーザーのパスワード打ち間違いに見えます。10 本中 7 本が開くとき、ループカウンタを疑う者はいません。自分のコードが暗号化した PDF に断続的な「パスワードが違う」報告が来て、しかもリビジョン 6 に限るなら、最初に調べるのはここです
リビジョン 5 は免疫があります。反復なしの単一 SHA-256 パスを使うからです。ISO/TS 32003 の AESV4 経路は違います。TPdfEncryptionRevision の値 erR7 はパスワードハッシュに Algorithm 2.B を再利用するため、同じ修正がISO/TS 32003 による AES-256-GCM 暗号化と PDF MAC 完全性で扱う GCM ファイルもカバーします
自己整合の罠: ラウンドトリップテストが通る理由
自分の暗号化と自分の検証を通すラウンドトリップは常に成功します。両側が同じ間違ったループを走り、同じ間違ったハッシュで合意するからです。この欠陥は他人の実装との関係でのみ存在するため、判定者は独立したものでなければなりません。pdfium.dll の PDFium エンジン、Acrobat、qpdf、あるいは自分が書いていない任意のリーダーです
これは、暗号周辺のドキュメントコードを書く誰にとっても、居心地の悪い一般教訓です。ハッシュを計算してテスト対象関数の出力と比較するユニットテストが証明するのは決定論であって、正しさではありません。規格やリファレンスツールからの known-answer ベクトルのほうがましで、実装をまたぐラウンドトリップは、手に入る中で最も ground truth に近いものです。PDFium Component はここで幸運な位置にいます。暗号化は EncryptDocumentBytes が Pascal で行い、ロード時の復号は PDFium 自身の C++ セキュリティハンドラを通るため、TPdf で保存ファイルを開き直すのは鏡ではなく、本物の独立した検査です
uses
SysUtils, PDFium, FPdfEncrypt;
procedure SaveR6(const Source, Target: string);
var
Pdf: TPdf;
Opts: TPdfEncryptOptions;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := Source;
Pdf.Active := True;
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'userpw';
Opts.OwnerPassword := 'ownerpw';
Opts.Revision := erR6; // AESV3、Algorithm 2.B のパスワードハッシュ
if not Pdf.SaveAsEncrypted(Target, Opts) then
raise Exception.Create('SaveAsEncrypted failed');
finally
Pdf.Free;
end;
end;
TPdfEncryptOptions.Default はリビジョン 5 から始まり、すべての権限を許し、メタデータを暗号化します。R6 を望むなら Revision を明示的に設定してください。出力がファイルではなく TStream に行くときは、TPdf.SaveAsEncryptedToStream が同じオプションレコードを取ります。パスワードはオプションレコードでは AnsiString である点にも注意してください。ASCII 以外の資格情報を渡すなら効いてきます。レコードに届く前に正規化し、符号化してください
確率的な暗号化バグをどう回帰テストするのか
実装をまたぐラウンドトリップを、30% の失敗率がすり抜けられない回数だけ繰り返します。1 回の実行は PDFium Component のバグを約 70% の確率で見逃します。新しいソルトでの 20 回の実行は、見逃し率を約 0.08%(0.7 の 20 乗)まで追い込みます。出荷された回帰テストが 1 回ではなく 20 回ループするのはこのためです
procedure TEncryptTests.R6_EncryptedDocumentReopensWithUserPassword;
var
Pdf: TPdf;
I: Integer;
Target: string;
begin
Target := TempOutputPath('encrypt_r6_roundtrip.pdf');
for I := 1 to 20 do
begin
SaveR6(SamplePdf, Target); // 保存ごとに新しいソルト
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'userpw';
Pdf.FileName := Target;
Pdf.Active := True; // PDFium が /U を独立に検証する
AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
finally
Pdf.Free;
end;
end;
end;
これに否のケース、必ず失敗すべき間違ったパスワードを組み合わせます。壊れたローダーが陽のテストを空虚に通させないためです。そして Lazarus でビルドするなら、Lib\*.pas に触れた後はテストプロジェクトを実行する前にライブラリパッケージを再ビルドしてください。テストビルドはパッケージのプリコンパイル済み .ppu ユニットをリンクするため、古いオブジェクトコードに対して実行すると、実際には直していないバグの上で、安心させる緑をくれます。シードも同じパスで見直す価値のある関連の罠です。定数の暗号化鍵を生む FPC Random は、出力がランダムに見えて静かに間違っている、もう 1 つのケースです
FAQ
R6 のラウンドカウンタのバグはリビジョン 5 で暗号化したファイルに影響するのか
いいえ。リビジョン 5 はパスワードを単一の SHA-256 パスでハッシュし、反復カウントを持ちません。間違え得るラウンドカウンタは存在しません。影響を受けるのは、どちらも Algorithm 2.B を使うリビジョン 6 と ISO/TS 32003 の AESV4 リビジョンだけです
バグ版が書いたファイルはまだ開けるのか
約 70% は、ハッシュがたまたま一致したため普通に開きます。残りは、どの標準リーダーも再現しない /U と /O 値を運んでおり、パスワードチェックはどこでも失敗します。実務上の修正は、平文ソースから v2.57.2 以降で再暗号化することです
自分の復号コードではなく PDFium に対してテストするのはなぜか
自分の復号器は、暗号器と同じ Algorithm 2.B 実装を共有しています。間違ったラウンド数は打ち消し合い、すべてのラウンドトリップが通ります。PDFium のセキュリティハンドラのような独立実装こそ、自分と食い違い得る唯一の検査です
暗号化、PDF MAC 検証、保存パイプラインの残りは、Delphi と C++Builder 向け PDFium Component に搭載されています。Delphi と Lazarus の両方のソースと回帰テスト付きです