同一份發票程式碼間隔一秒寫出的兩份 PDF,幾乎從來不是同一個檔案。二進位比較先在 Info 字典裡絆倒——/CreationDate 走了一秒——接著是 XMP 封包,然後是 trailer 的 /ID。開了 AES-256,比較在幾乎每個串流上崩潰,因為檔案加密金鑰本身就是新產生的。比較前先把日期遮掉,只修得好前兩個差異,其餘照舊。黃金檔測試要可信,得讓寫入器從文件之外拿來的每一個值,在讀取的當下就被釘死
四個從不來自文件本身的值
HotPDF Delphi Component 和任何合規的 PDF 寫入器一樣,會從頁面內容之外填四種值:時鐘、文件識別碼、密碼學亂數來源,以及字典條目在記憶體裡的順序。ISO 32000-1 對每一樣都有它的理由
- 時鐘。Info 字典以帶時區後綴的
D:YYYYMMDDHHmmSS字串攜帶/CreationDate與/ModDate(ISO 32000-1 §14.3.3,表 317;§7.9.4),XMP 封包則以xmp:CreateDate與xmp:ModifyDate重複同一時刻。它們全都由同一個建立時間戳渲染而來,而那個時間戳是元件建構時向Now要的 - 識別碼。trailer 的
/ID陣列(§14.4)放著一個永久識別碼與一個修改識別碼。預設情況下,前者是檔名加上精確到毫秒的當前時間的 MD5,後者再把那個值連同系統 tick 數一起雜湊 - 亂數位元組。AES-256 文件需要 32 位元組的檔案加密金鑰、
/U的 8 位元組驗證鹽與 8 位元組金鑰鹽、/O的另一對、/Perms裡的四個填充位元組,以及每個加密字串與串流前面的 16 位元組初始向量。ISO 32000-2 §7.6.4.4.7 要求鹽是隨機的。RC4 與 AES-128 的金鑰從密碼與第一個/ID元素導出(ISO 32000-1 §7.6.3.3),所以光是一個新識別碼就足以讓整個檔案重新上鎖 - 順序。PDF 字典是無序映射(§7.3.7)。沿著記憶體清單走訪的寫入器,按程式碼碰巧加入的順序發出鍵,所以兩條以不同順序建出同一個資源字典的程式路徑,會產生合法但文字上不同的檔案
其餘一切都已由輸入決定:同樣的繪圖呼叫會產生相同的內容串流、壓縮影像資料與交叉參照位移。連看起來隨機的那一欄都不是隨機的:內嵌字體子集上的六字母前綴(ISO 32000-1 §9.6.4,ABCDEF+Arial 裡的 ABCDEF+ 部分)是從子集保留的字形集合算出來的,所以同樣的文字每次跑都得到同樣的前綴。雜訊只坐在詮釋資料與安全層裡,這正是單一屬性就能把它清掉的原因
ReproducibleOutput 釘死了什麼
在 BeginDoc 之前設定 ReproducibleOutput := True,會在原本去拿時鐘或亂數產生器的同一批程式路徑裡,把四個來源各自換成固定值。事後沒有清理程序。屬性預設為 False,程式庫裡也沒有任何東西會替您打開它
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // 必須在 BeginDoc 之前設定
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
在這個旗標下,BeginDoc 把建立時間戳設為 2026 年 1 月 1 日 00:00 當地時間;由於 Info 日期與 XMP 日期都由這同一個值渲染,一個指派就覆蓋全部。同一個分支也改用常數字串、而非檔名加時鐘,來播種內部文件識別碼。寫檔時,trailer 的 /ID 是對整個物件圖的規範化匯出算 MD5——其中每個 D: 日期字串的數字會先歸零,時間戳沒辦法從雜湊裡偷渡回來。/ID 的兩個元素都拿到這個值。從不呼叫 BeginDoc 也直接加密的已載入文件(在 LoadFromFile 之後設定 ActivateProtection),拿到同樣的內容衍生識別碼
亂數位元組被換成常數。AES-256 檔案金鑰、全部四個鹽與 /Perms 填充都以 $5A 填滿,而每個 CBC 初始向量——AES-128 的字串與串流和 AES-256 一樣——都變成位元組 I 為 14 * (1 + I) 的樣式。這個樣式刻意不為零。全零 IV 是 CBC 的教科書級弱選擇,迴歸測試套件不該把它常態化。金鑰、鹽與向量固定之後,/U、/UE、/O、/OE、/Perms 與每個加密串流在第二次執行時完全相同
最後,只要設了可重現旗標,SaveToStream 就會打開 DeterministicDictionaryOrder,而且是在線性化、物件串流與傳統版面之間做選擇之前就打開,所以帶 LinearizeOutput 或 UseObjectStreams 的輸出同樣被排序。鍵按原始位元組排序,某鍵是另一鍵的前綴時排前面,平手時按原始位置。這正是診斷寫入器使用的排序,見手工編輯 PDF 之後再修復的文章;可重現旗標借用了那個排序,但沒借用那個寫入器的純文字版面。單獨打開 DeterministicDictionaryOrder 是半套做法:鍵排齊了,但 Info 日期與兩個 trailer /ID 字串仍帶著時鐘,兩次存檔還是在同樣四個地方不同
不會靠運氣通過的測試
日期在 BeginDoc 裡固定,因為建構子執行時,呼叫端還沒有任何機會設定屬性。正常順序是 Create,然後 ReproducibleOutput := True,然後 BeginDoc,三步裡只有最後一步看到的旗標才是最終值。這個順序對測試設計有直接影響。落在同一個牆上時鐘秒內的兩次存檔,會碰巧寫出相同的 D: 字串,所以跑得快的測試無論日期有沒有釘死都會過,幾個月後卻在較慢的建置代理上翻車
修法很便宜:兩次存檔之間等 1100 毫秒,保證時間戳跨過秒邊界;對未加密、AES-128 與 AES-256 各跑一遍,加密變體用真實密碼;回報第一個不同的位元組位移,而不是光禿禿的真或假。位移指向一個物件;布林值指向整個檔案
uses
System.SysUtils, System.IOUtils, HPDFDoc;
function SaveOnce(const Target: string; Encrypt: Boolean;
KeyType: THPDFKeyType): TBytes;
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.VerifySavedOutput := True; // EndDoc 之後立刻嚴格重新載入
if Encrypt then
begin
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := KeyType; // aes128 或 aes256
Pdf.ActivateProtection := True;
end;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Result := TFile.ReadAllBytes(Target);
end;
// 您自己的輔助函式:相等時回傳 -1,否則是第一個不同的位移
function FirstDifference(const A, B: TBytes): Integer;
var
I, N: Integer;
begin
N := Length(A);
if Length(B) < N then
N := Length(B);
for I := 0 to N - 1 do
if A[I] <> B[I] then
Exit(I);
if Length(A) <> Length(B) then
Result := N
else
Result := -1;
end;
procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
A, B: TBytes;
Offset: Integer;
begin
A := SaveOnce('run-a.pdf', Encrypt, KeyType);
Sleep(1100); // 強制時間戳跨到不同的一秒
B := SaveOnce('run-b.pdf', Encrypt, KeyType);
Offset := FirstDifference(A, B);
if Offset >= 0 then
raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;
呼叫 CheckReproducible(False, aes128)、CheckReproducible(True, aes128) 與 CheckReproducible(True, aes256);未加密那趟會忽略金鑰類型。VerifySavedOutput 在這個測試裡物有所值:開著它,EndDoc 會用一個全新的 THotPDF 執行個體、關掉交叉參照修復,重新載入剛寫出的檔案(文件有保護時用使用者密碼),載入失敗就丟出例外。一個讓輸出穩定卻同時變得不可讀的改動,靠綠色的差異比對會一路放行;重新載入這一步攔下它
可重現的加密 PDF 還安全嗎
不安全。在 ReproducibleOutput 下加密的文件沒有真正的保護,凡是要離開測試目錄的東西,這個旗標都該關著。AES-256 檔案金鑰是三十二個 $5A。密碼仍然把守著 /UE 與 /OE 外殼,但它們包著的是一個公開常數,知道的人不需要密碼就能解開每個內容串流。固定的鹽也拿掉了 ISO 32000-2 §7.6.4.4.7 賴以防止同一密碼在兩個檔案裡產出相同 /U 字串的逐檔唯一性。AES-256 設定一文描述的是亂數來源完好時這些加密屬性交付的保護;在可重現旗標下全都不適用。把這個屬性藏在一個只有測試用的設定值後面,別讓正式程式路徑碰得到
AES-GCM 的處理不同。設 CryptKeyLength := aesgcm(僅 PDF 2.0)時,無論 ReproducibleOutput 怎麼說,元件都從系統亂數來源取 8 位元組的 nonce 前綴,之後為每個加密字串或串流往上數。這是正確行為。同一把金鑰下 nonce 重複,GCM 會敗得很慘:兩份密文洩漏它們明文的 XOR,攻擊者能還原出足以偽造驗證標籤的資訊。釘死的金鑰配上釘死的 nonce,測試套件產出的每個檔案都必然重複。所以加密的黃金檔用跑 CBC 模式的 aes128 或 aes256
識別碼是較溫和的取捨。ISO 32000-1 §14.4 的本意是第二個 /ID 元素每次修改都要變,而可重現存檔在兩個槽裡寫同一個值。因為那個值雜湊的是物件圖,不同內容仍會得到不同識別碼。以 /ID 為快取鍵的閱讀器——註解儲存庫或表單資料側檔之類——仍會把同一內容的兩次可重現存檔當成同一個檔案。在測試套件裡,這正是您要的
留在寫入器之外的輸入
ReproducibleOutput 覆蓋的是寫入器自己引入的熵。還有幾個輸入來自建置機器,或來自定義上就會記錄新時刻的操作
- 時區後綴。每個 PDF 日期字串都以本地 UTC 位移結尾,同一個固定日期在這台建置代理上是
D:20260101000000+08'00',在另一台上是D:20260101000000-05'00'。黃金檔在同一台機器的多次執行之間、或共用時區的機器之間成立。檔案若在辦公室之間流動,請釘死代理的時區 - 增量更新。
SaveIncrementalUpdate保留來源的永久識別碼,並從前一個修改識別碼、目標路徑、tick 數與當前時間建出新的修改識別碼。附加的區段按定義就是一次新修改。請比較完整改寫,不要比較附加的差量 - 直通捷徑。
SaveLoadedDocument對未修改、未加密的來源,通常逐位元組直接複製而不重新序列化。旗標之下檔案一律重新改寫,讓排序與識別碼規則生效,存檔因此變慢,也永遠不會是輸入的複本。請與上一次可重現存檔比對,不要與原始輸入比 - 您自己加的時間戳。
AddDocumentAttachment以呼叫當下蓋住內嵌檔的/Params/CreationDate與/ModDate(ISO 32000-1 §7.11.4),簽章則把簽署時間記在/M。這些日期描述的是呼叫端要求的動作,所以旗標不碰它們。位元組級的測試案例請避開附件與簽章,或在比較時把那些字串遮掉 - 已安裝的字體。TrueType 子集是從執行測試那台機器上安裝的字體檔切出來的。兩台裝了不同版本 Arial 的代理,同一串文字內嵌的字形程式不同,子集前綴也可能跟著變。請把字體檔放進建置映像
已載入的文件遵循同樣規則。載入前設好屬性,做完編輯,經 SaveLoadedDocument 存檔;輸出是一份規範化改寫,可以提交為下一次執行的參考基準
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.ReproducibleOutput := True; // 載入前設定
if Pdf.LoadFromFile('incoming.pdf') <= 0 then
raise Exception.Create('incoming.pdf did not load');
// ... 受測的編輯 ...
Pdf.SaveLoadedDocument('normalized.pdf'); // 完整改寫,鍵已排序
finally
Pdf.Free;
end;
end;
逐位元組相同的存檔還能逼出閃爍的合規檢查。寫入器一旦穩定,這次過、下次不過的檢查,反應的就是測試建出的物件圖本身的問題——例如同一個直接字典交給了兩個父節點——所以先檢查測試輸入,再懷疑驗證器。從 catalog 向下的結構差異比對會指出兩次執行之間哪個物件動了
上面用到的 ReproducibleOutput、DeterministicDictionaryOrder、VerifySavedOutput 與加密屬性,都是 HotPDF Delphi Component 的一部分,Delphi 與 C++Builder 兩種版本都有出貨