技術文章

Delphi 的 AES-256 PDF 加密:HotPDF 設定與陷阱

PDF 的權限旗標不是一把鎖。它是檔案對任何開啟它的東西所提出的一項請求,而檢視器大可不理它。光是這一件事,就決定了您該怎麼思考本頁上其餘每一項選擇。真正的機密性只來自一個地方:以讀者手上沒有的密碼為金鑰的 AES-256 加密。其餘一切,那些「不可列印」與「不可複製」的核取方塊,是合規軟體同意遵守、而懷有敵意的軟體不遵守的政策。把這兩層搞混,您出貨的東西就會在展示時感覺很安全,在實地外洩

HotPDF 是給 Delphi 與 C++Builder 用的原生 VCL PDF 元件,它透過一小組屬性公開 ISO 32000 的保護模型。這些屬性很容易設定。難的是知道哪一個買到的是密碼學上的保護、哪一個買到的只是一句客氣的建議,以及把指派順序弄對,好讓您要求的加密真的就是您拿到的加密

那兩個密碼實際上承諾了什麼

PDF 加密定義了兩種職責不同的認證資訊,而把它們混為一談是保護式輸出程式碼裡最常見的設計錯誤。使用者密碼把守的是解密。少了它,或少了擁有者密碼,合規的閱讀器就重建不出檔案金鑰,內容在密碼學意義上維持不可讀。擁有者密碼把守的則是權限設定:拿到擁有者密碼的閱讀器會取得完整存取權,不管那些限制旗標怎麼說

權限位元立足的地面就弱得多。列印、內容擷取、表單填寫:每一項都是檢視器讀到之後選擇要不要尊重的旗標(ISO 32000-2 §7.6.4)。加密保護的是位元組。權限旗標只是對合規軟體下指示,而且是事後才下的。任何用使用者密碼打開文件的人,記憶體裡早已握有解密後的內容,所以「不可複製」與「不可列印」對一個守規矩的檢視器有意義,對一個鐵了心的則毫無意義。請圍繞這條界線來建構威脅模型。機密性住在使用者密碼裡。權限塑造的是主流檢視器提供什麼功能,而那就是它們所做的全部

HotPDF 加密 PDF 認證資訊圖解:使用者密碼推導出檔案金鑰並解鎖解密,擁有者密碼給予完整存取並凌駕權限旗標,警示帶則指出 ProtectOptions 的權限位元只是請求、僅合規軟體會遵守
使用者密碼承載機密性,擁有者密碼只是解除限制;權限旗標引導合規檢視器,卻約束不了任何懷有敵意的一方

設定順序:一切都要在 BeginDoc 之前

HotPDF 在 BeginDoc 執行的那一刻建構加密字典並推導檔案金鑰。那個瞬間各保護屬性持有的值,就是這份文件會拿到的東西,事後再改也改不動任何事。這裡最要緊的屬性是 CryptKeyLength,它從 THPDFKeyTypek40k128aes128aes256 之中挑出方案。在 BeginDoc 之後才指派它,您不會拿到例外,不會拿到警告,只會拿到一個默默保留了原本設定的檔案。那種無聲的分歧是最糟的一種:它通過每一項本機測試,然後在幾個月後以一條合規檢出項的形式出現在客戶桌上

並排的 Delphi 流程,顯示 CryptKeyLength aes256 之類的保護屬性在 BeginDoc 之前指派會產出 AES-256 檔案,而同樣的指派放在 BeginDoc 之後則原方案原封不動,既無例外也無警告
BeginDoc 會凍結加密字典,所以順序正確的那條線產出所要求的 AES-256 修訂版,順序錯誤的那條線則無聲地出貨預設方案
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'statement.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aes256;        // 必須在 BeginDoc 之前設定
    Pdf.UserPassword := 'open-secret';
    Pdf.OwnerPassword := 'admin-secret';
    Pdf.UseAES256R6 := False;            // R=5:檢視器支援度最廣
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Account statement, June 2026');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

密碼是 UTF-8,上限 127 位元組,那是 ISO 32000-2 對 AES-256 各方案所設的限制。如果您的密碼政策交給您更長的祕密,請自己在自己這一側做截斷,在您能精確掌控切點的地方做。放著讓它碰運氣,函式庫跟某個未來的檢視器就可能對切點意見不合,於是產生出一個您這邊開得起來、換個地方卻拒收同一組密碼的檔案

修訂版 5 還是修訂版 6:一個布林值,兩個生態系

UseAES256R6 在兩種 AES-256 交握之間做選擇,而這個選擇的後果比它的布林型別所暗示的更重大。留成 False,HotPDF 就寫出修訂版 5,那是以 PDF 1.7 延伸形式登場的 AES-256 方案,大約十五年份的檢視器都打得開。設成 True,您就得到修訂版 6,那是為 PDF 2.0 而在 ISO 32000-2 中標準化的強化金鑰推導,它補上了修訂版 5 驗證密碼方式裡的一個已知弱點

所以在密碼學上,修訂版 6 是比較好聽的故事。它同時也是會把東西弄壞的那一個。一份修訂版 6 的檔案需要一個為 PDF 1.7 Extension Level 3 或 PDF 2.0 打造的檢視器,而許多已部署的軟體兩者都不是:記錄管理封存系統、其他產品裡的內嵌算繪器、多年沒人碰過的業務工具。那些會直接拒收這份檔案,而且是在客戶的機器上拒收,永遠不會在您這裡。因此實務上的預設值是修訂版 5。只有在某項安全政策指名 ISO 32000-2 的修訂版本、而且您確實確認過每一個消費端都讀得了它時,才伸手去拿修訂版 6。不論選哪一個,都請把您選了哪一個、為什麼選,寫下來,因為下一個碰這段程式碼的人一定會納悶

比較舊的金鑰型別值得一句話,好讓您知道要跳過它們。THPDFKeyType 仍然列出 k40k128aes128,但它們存在的目的是重現歷史封存,不是保護新的東西。40 位元的 RC4 在市售硬體面前就垮了,而那些 128 位元方案早於任何當代安全審查都會期待的 AES-256 修訂版。對一份您在 2026 年建立的文件,真正的問題只有修訂版 5 對修訂版 6;如果您發現自己在一個新設計上伸手去拿那些舊型別,那代表上游有什麼地方出錯了

沒有開啟密碼的權限旗標

需求常常正好與保密相反。任何人都應該讀得到這份文件,但列印或擷取要受限。您用一個空的使用者密碼加上一個非空的擁有者密碼來表達這件事,PDF 稱之為開啟密碼模式,然後在 ProtectOptions 裡列出您想允許的操作

Pdf.ActivateProtection := True;
Pdf.CryptKeyLength := aes256;
Pdf.UserPassword := '';                      // 任何人都能開啟這個檔案
Pdf.OwnerPassword := 'rotate-me-quarterly';  // 守住權限集合
Pdf.ProtectOptions := [prPrint, prPrint12bit, prExtractContent];
Pdf.BeginDoc;
// ... 頁面內容 ...
Pdf.EndDoc;

THPDFProtectOptions 集合對映到 ISO 的權限位元:高解析度列印用 prPrintprPrint12bit,一般複製與擷取用 prInformationCopy,輔助科技擷取用 prExtractContent,另外還有 prModifyStructureprEditAnnotationsprFillAnnotationsprAssemble。其中兩個值得警告。在您建的幾乎每一份設定檔裡,都把 prExtractContent 保持開啟。那是螢幕閱讀器搆到文字所需要的位元,把它清掉會悄悄地把一項權利決策變成一個無障礙缺陷,那是身心障礙者會撞上、而您永遠看不到的東西。另一個陷阱是單獨開 prPrint 而不開 prPrint12bit:好幾款檢視器會以降低列印品質作為回應,而您的使用者會把那當成算繪臭蟲回報,而不是當成它實際上就是的權限設定

驗證花五分鐘,而且屬於您的發行檢查清單。在 Acrobat 裡打開每一種設定檔的樣本、開啟文件內容,讀「安全性」分頁,它會把演算法明白寫出來(「AES 256-bit」),並逐項列出被允許的操作。然後再用您客戶真正在跑的最舊那台檢視器打開同一個檔案,不是您機器上最新的那台。第二次開啟,就是防止一份修訂版 6 的檔案一路順風地通過開發、卻死在一個從沒升級過的客戶那裡的廉價保險

從既有檔案移除保護

解密把同一套屬性模型倒著跑。用一組有效的認證資訊載入文件,把保護關掉,然後把結果存成不帶保護的檔案

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('encrypted.pdf', 'open-secret');
    if PageCount > 0 then
    begin
      Pdf.ActivateProtection := False;   // 存檔時卸掉加密
      Pdf.SaveLoadedDocument('plain.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

那條路線會把整份文件剖析進記憶體,這對一般檔案沒問題,對巨大的檔案則是浪費。當輸入來到好幾百 MB 時,DecryptFile 是比較便宜的選項:它在一次檔案層級的複製過程中解密,只要輸入允許,就走一條跳過建構完整物件樹的直接 AES-256 改寫路徑。它屬於 Direct File API,涵蓋於 從 Delphi 處理大型 PDF 的姊妹篇

與加密互相牽動的限制

有兩項限制值得在您圍繞加密做設計之前、而不是之後才知道。第一項是封存合規。ISO 19005 禁止 PDF/A 中出現加密,所以任何一個既把文件加密、又宣稱 PDF/A 合規的工作流程,在構造上就自相矛盾;HotPDF 不會讓您在同一個檔案裡兩者兼得。當您真的兩者都需要時,答案是兩份產物:一份加密的副本供散布,另一份未加密的副本供封存

第二項限制更嚴峻。PDF 加密沒有代管,也沒有復原。在一份 R5 或 R6 的檔案上弄丟使用者密碼,您的選項就只剩暴力破解或放棄。所以請把擁有者與使用者的祕密,當成您對待任何生產環境認證資訊那樣對待。產生它們、把它們存進保險庫、按排程輪替它們。唯一絕對不能做的,是把它們硬寫成某個單元裡的常數,那樣它們會直接搭順風車進入版本控制,並永遠躺在每一位開發者的工作副本裡

最後還有一個值得養成的反射。更動一份不是您建立的檔案上的保護,跟解密是同一套機制,不是另一項功能:用 LoadFromFile 連同它的密碼載入它,就地編輯 ProtectOptions 或那些密碼,再用 SaveLoadedDocument 寫回去。如果您解得開一個檔案,您就能重設它的權限,而程式碼看起來跟上面那個範例幾乎一模一樣

這裡展示的保護屬性屬於給 Delphi 與 C++Builder 用的標準 HotPDF Delphi Component;產品頁載有完整的加密參考,含完整的權限列舉