當一份 AES-256 revision 6 的 PDF 拒收您明知正確的密碼,頭號嫌犯通常是 R6 強化雜湊(ISO 32000-2 Algorithm 2.B)裡的輪數計數。PDFium Component for Delphi 在 v2.57.2 之前就有這個 bug:TPdf.SaveAsEncrypted 從錯的基數起算輪數,產出的檔案約 30% 被 FPDF_LoadDocument 以 FPDF_ERR_PASSWORD 拒之門外
討厭的不是算術本身。討厭的是:這個缺陷對「加密之後再用同一段程式碼驗證」的每一種測試都隱形,而且一次成功的往返什麼也證明不了。本文走過 Algorithm 2.B 到底要求什麼、差一輪的失誤藏在哪裡、為什麼它只是偶爾失敗,以及怎麼建一個能搶在客戶之前抓住這種 bug 的迴歸測試
R6 雜湊的終止規則到底怎麼說?
Algorithm 2.B 只有在至少跑滿 64 輪之後才可能停,而且還要最近一次 AES 輸出 E 的最後一個位元組小於等於「輪數減 32」,這裡的輪數是已完成輪數、從 1 起算。第 N 輪跑完,您對照的是 N。不是 N − 1,也不是下一輪的索引。整個 bug 的爆炸面就是這一條定義
演算法其餘部分是機械性的。K 的初值是密碼、鹽與(擁有者金鑰情境下的)48 位元組 /U 值的 SHA-256。每一輪把 K1 建成「密碼 ‖ K ‖ UK」重複 64 次,用 K 的前 16 位元組當金鑰、次 16 位元組當 IV,以不補位的 AES-128-CBC 加密,取 E 的前 16 位元組當 big-endian 128 位元整數對 3 取模,據此挑 SHA-256、SHA-384 或 SHA-512 產生下一個 K。最終雜湊是 K 的前 32 位元組。ISO 32000-2 §7.6.4.3 定義了它;資料相關的迴圈長度是刻意的,這樣每個密碼猜測的工作量對 GPU 破解器來說不可預測
下面是這個迴圈的正確形狀,寫成示意而非可直接套用的實作。BuildK1、AesCbcNoPad128、FirstBlockMod3 與各雜湊輔助函式,代表您程式庫裡現有的任何原語
// 示意:Algorithm 2.B 迴圈骨架(ISO 32000-2,revision 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 位元組,big-endian,對 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 裝的正好是已完成輪數——這就是標準說的輪數
off-by-one 在 Delphi 移植版裡藏在哪裡
PDFium Component 的 bug 是計數器從 −1 起步、遞增又在迴圈開頭,於是 until 子句裡測的值永遠是已執行輪數減一。兩件事同時出錯:64 輪下限變成 65,位元組門檻也比標準緊了一格。在有影響的情況下,兩者都把出口整整挪後一輪
同一個失誤有好幾種同樣合理的偽裝。從 0 起步、遞增放在本體結尾,而測試已經先照遞增前的值寫好了。用一個從 0 起算的 for 迴圈索引、又在比較裡重用它。照搬一份 C 參考實作、而它的迴圈變數在 ++ 之前就被檢查。這幾種在 code review 裡讀起來都自然,產出的雜湊也看起來完全隨機——因為它確實是隨機的:只是另一個同樣像樣、卻沒有任何其他讀取器算得出來的 SHA 輸出。修好的 CalculateR6Hash 在 FPdfEncrypt.pas 裡如今帶著一條釘死「從 1 起算」語意的註解,正是因為錯的版本看起來不像錯的
AES-256 R6 的 PDF 為什麼只是偶爾失敗?
失敗是機率性的,因為多出來的那一輪只有在終止位元組落在正確門檻與偏移門檻之間那一輪寬的窗口時才改變結果。其他所有情況下,兩個版本在同一輪退出、產出同一個 K,檔案照開。在 PDFium Component 的量測裡,60 次加密有 18 次(約 30%)產出的 /U、/O、/UE 與 /OE 值跟標準實作對不上;其餘 70% 逐位元組正確
每次存檔都重新抽隨機鹽,所以同一份文件、同一組密碼,這次成功、下次失敗。在支援桌上,這看起來像儲存不穩、上傳損壞,或使用者打錯密碼。十個檔案有七個能開的時候,沒有人會想到怪迴圈計數器。如果您的產品在自己程式碼加密的 PDF 上收到斷斷續續的「密碼錯誤」回報、而且只發生在 revision 6,第一個該查的就是這裡
Revision 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,或任何不是您寫的讀取器
這是寫加密相鄰文件程式碼的人都逃不掉的不舒服教訓。算出一個雜湊、再跟受測函式的輸出比對的單元測試,證明的是確定性,不是正確性。標準或參考工具給的已知答案向量更好,跨實作往返則是最接近地面真相的東西。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 從 revision 5 起步、全部權限開放、詮釋資料加密,所以要 R6 請明確設 Revision。輸出走 TStream 而不是檔案時,TPdf.SaveAsEncryptedToStream 接受同一個選項記錄。注意選項記錄裡的密碼是 AnsiString,傳非 ASCII 憑證時這件事就有影響;在它們抵達記錄之前先正規化並編碼
機率性的加密 bug 怎麼做迴歸測試?
把跨實作往返重複到 30% 的失敗率鑽不進來為止。單跑一次有約 70% 的機率漏掉 PDFium Component 的這個 bug。跑二十次、每次新鹽,漏掉率降到約 0.08%(0.7 的 20 次方),這就是出貨的迴歸測試迴圈跑 20 次而不是 1 次的原因
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 單元,拿舊的目標碼跑測試,會讓您在還沒真正修好的 bug 上收穫一片安心的綠。亂數播種是同一輪檢查裡值得一併複查的相關陷阱:FPC Random 產生固定加密金鑰就是另一個輸出看起來隨機、實則悄悄出錯的案例
FAQ
R6 輪數計數 bug 影響 revision 5 加密的檔案嗎?
不影響。Revision 5 用一遍 SHA-256 雜湊密碼、沒有迭代次數,沒有輪數計數器可以出錯。受影響的只有都使用 Algorithm 2.B 的 revision 6 與 ISO/TS 32003 的 AESV4 revision
有 bug 版本寫出的檔案還打得開嗎?
約 70% 能正常打開,因為它們的雜湊碰巧一致。其餘的帶著任何標準讀取器都重現不出來的 /U 與 /O 值,密碼檢查到處都會失敗;務實的修法是用 v2.57.2 或更新版本、從明文來源重新加密
為什麼對著 PDFium 測,而不是對著自己的解密程式碼?
您的解密器與加密器共用同一份 Algorithm 2.B 實作,錯的輪數互相抵消,每次往返都通過。像 PDFium 安全處理器這樣的獨立實作,是唯一可能跟您意見相左的檢查
加密、PDF MAC 驗證與其餘存檔管線,都隨 PDFium Component for Delphi and C++Builder 出貨,附 Delphi 與 Lazarus 兩邊的原始碼與迴歸測試