技術文章

針對大量文件的高速 AES-256 PDF 加密

加密一個 2 GB PDF,直觀上看起來像是流式處理問題:開啟檔案、把整個檔案丟進 AES-256、再寫回輸出。這個心智模型在效能上會出錯,因為它決定了整個預算邊界。ISO 32000-1 §7.6 將 PDF 加密粒度定義在每個物件上,每個 stream 和每個字串都要獨立加密,各自擁有自己初始化向量與 padding。2 GB 的掃描歸檔若包含 500,000 個物件,就是五十萬次小型 CBC 作業,而不是一個長流程;在這種規模下,每次作業前後的固定成本比 AES 本身的加解密運算更決定成敗

本文聚焦這個固定成本:在 Delphi 中套用 AES-256 到超大文件時,時間花去哪裡、如何拿回來。設定方面:密碼、權限旗標、修訂版 5 與 6 相容性的呼叫,請參考 HotPDF 安全加密設定篇,這裡不再重複

五十萬次 CBC 作業,不是一次通關

PDF 的骨架通常保持明文。交互參照表、物件編號、字典鍵、頁面樹都不加密,否則檢視器就無法先建立位址地圖再驗證密碼。真正被加密的是內容:stream 資料(頁面描述、影像、字型、附件),以及像中繼資料值和註解文字這類字串。每個 AES-256 加密後的項目都會自帶一組流程:一組新的 16 位元組隨機 IV、CBC 對資料加密、補齊到 16 的倍數,最後輸出 IV 放在密文前面

這帶來兩個後果。第一,密文長度一定比明文長。IV 多 16 位元組,padding 再多 1 到 16 位元組,所以 100 位元組字串會落在磁碟上變成 128 位元組,空 stream 仍會產生 32 位元組。若輸出緩衝區只按輸入長度配置,或只回寫實際讀到的位元組,就會讓最後一個 block 在解密時失敗。第二,成本跟物件數成正比,而不只是位元組數。掃描歸檔的位元組可能集中在少數幾個大影像 stream,但大量小 stream 和小字串仍主導每次的「每物件」固定開銷,真正主導的是 overhead,不是 AES 運算

AES-256 的唯一安撫在於金鑰處理是可優化的。到修訂 4 前,安全處理器會針對每個物件以檔案金鑰和物件編號、產生序號做散列,等於每個物件都要建一次 key schedule,浪費巨大。修訂 5 開始改掉了每物件導出,整個檔案只用一個隨機 256-bit 檔案金鑰加密所有物件。這一點是本文所有最佳化的前提,也就是說昂貴的密碼學狀態只需要為整個檔案建立一次

R6 的 /Encrypt 字典:一次初始化,整個物件共用

修訂 6 文件在 trailer 的 /Encrypt 字典上表述自己的加密,重點欄位很簡單:

/Filter /Standard
/V 5  /R 6  /Length 256
/CF << /StdCF << /CFM /AESV3  /Length 32  /AuthEvent /DocOpen >> >>
/StmF /StdCF    /StrF /StdCF
/O ...48 bytes...   /U ...48 bytes...
/OE ...32 bytes...  /UE ...32 bytes...
/Perms ...16 bytes...  /P -3904  /EncryptMetadata true

/V 5 定義了 256-bit 金鑰體系,/R 6 定義 ISO 32000-2 的加固握手機制。/CF 指定了命名加密過濾器,/AESV3 就是 CBC + IV 前置,/StmF/StrF 分別將這套過濾器套到 stream 與字串。/O/U/OE/UE放置了密碼驗證與金鑰包裝資料,/Perms 則放可否變更的權限位元,避免惡意編輯者悄悄翻轉 /P

真正的成本在 /OE/UE。要從這兩者還原檔案金鑰,會走 Algorithm 2.B,它將 SHA-256、SHA-384、SHA-512 串接迭代,至少 64 輪且具條件提前收斂,設計上就是為了讓密碼猜測成本足夠高。這個成本只在寫檔時產生一次、讀檔時再產生一次,而且在現代 CPU 上是個只有單位位數毫秒的成本。對 50 萬物件文件而言,KDF 幾乎可忽略,真正慢的是每物件循環本身

重用 key handle,重用 scratch buffer

最直覺的作法常是做個 EncryptAes256Cbc 小工具:開啟 CNG provider、選擇 CBC、產生金鑰、加密一個 buffer,然後釋放全部。邏輯上正確、測試也容易,但在 50 萬次迴圈裡會造成巨大浪費。Microsoft 文件明確指出 BCryptOpenAlgorithmProvider 很昂貴,建議快取 handle;而 BCryptGenerateSymmetricKey 會完整建立 AES key schedule 且配置 provider 狀態,若檔案金鑰不變,這些都在迴圈裡重複執行是浪費

Delphi RTL 沒有內建 bcrypt 匯入單元,所以要直接宣告入口。以下類別把所有密碼學狀態建立一次,之後可重複加密任意物件,且不在 steady state 做持續分配:

uses
  Winapi.Windows, System.SysUtils, System.Classes;

const
  BCRYPT_AES_ALGORITHM  = 'AES';
  BCRYPT_CHAINING_MODE  = 'ChainingMode';
  BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
  BCRYPT_OBJECT_LENGTH  = 'ObjectLength';
  BCRYPT_BLOCK_PADDING            = $00000001;
  BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;

type
  NTSTATUS = Integer;
  BCRYPT_HANDLE = Pointer;

function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
  Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
  Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
  cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
  cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
  out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
  cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
  Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
  out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
  cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';

procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
  if Status <> 0 then
    raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // CNG key-object workspace, allocated once
    FScratch: TBytes;    // ciphertext scratch, grows and then stays
  public
    constructor Create(const FileKey: TBytes);
    destructor Destroy; override;
    procedure EncryptObject(const Plain: TBytes; Dest: TStream);
  end;

constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
  Mode: string;
  ObjLen, Got: ULONG;
begin
  inherited Create;
  if Length(FileKey) <> 32 then
    raise Exception.Create('AES-256 file key must be 32 bytes');
  CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
    'BCryptOpenAlgorithmProvider');
  Mode := BCRYPT_CHAIN_MODE_CBC;
  CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
    PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
    'BCryptSetProperty');
  CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
    SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
  SetLength(FKeyObject, ObjLen);
  // The AES key schedule is built once here and reused for every object
  CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
    PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;

destructor TPdfObjectEncryptor.Destroy;
begin
  if FKey <> nil then
    BCryptDestroyKey(FKey);
  if FAlg <> nil then
    BCryptCloseAlgorithmProvider(FAlg, 0);
  inherited;
end;

procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
  IV, IVWork: array[0..15] of Byte;
  Need, Written: ULONG;
  Src: PByte;
begin
  // Fresh random IV per object; it travels in the clear ahead of the data
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil for an empty input is valid: padding-only block

  // Size query: CBC padding always adds 1..16 bytes, so Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt advances the IV buffer while it chains
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');

  if ULONG(Length(FScratch)) < Need then
    SetLength(FScratch, Need);  // grows a handful of times, then stays put

  IVWork := IV;
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');

  // AESV3 layout: the 16-byte IV, then the padded ciphertext
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

三個細節是關鍵。第一,先做一次大小查詢:第一次 BCryptEncrypt 呼叫把輸出 buffer 先留空,拿回 padding 後大小,這個結果永遠不會等於輸入長度,公式是 ((Len div 16) + 1) * 16。為了效能可自己計算,但遵循 API 合約時,這個查詢是最穩的。第二,BCryptEncrypt 會原地更新 IV,所以要傳入可寫工作緩衝,否則會被破壞。第三,FScratch 只會在遇到更大輸出時擴張,後續固定最長物件大小後不再擴張

重複使用 handle 的實際收益

測試案例採用 1.8 GB 的掃描貸款封存:412,000 個已加密物件,扣除明文結構後承載 1,710 MB

  • 每次建立 handle 的作法(在 helper 裡打開 Windows CNG provider、建立 key 並在 helper 結束時釋放):加密階段 71.3 秒,1,710 MB ÷ 71.3 s ≈ 24 MB/s
  • 提升後的狀態複用(上述類別):9.6 秒,1,710 MB ÷ 9.6 s ≈ 178 MB/s

差異是 61.7 秒,平均每次呼叫約 150 微秒,都是在開啟 provider、設定鏈結模式與重建 key schedule。這些都不是 AES 演算法,AES-NI 讓 1.5GB 檔案的大量區塊運算本身大約只要 1.2 秒。剩下主要是兩次 CNG 進出切換和每物件 IV 產生。一次把 BCryptGenRandom 擴充為一次填 4,096 個 IV,能再把時間縮到 8.9 秒。再往下只有每物件平行化能做:/V 5 物件共享檔案金鑰,只要四個 worker thread、各自一個 key 物件,就能把加密降到 3.1 秒,輸出串流寫回成瓶頸

完整重寫與增量儲存

粒度也會改變「儲存」成本。給已存在明文文件加密,會改寫所有物件,因為每個 stream 或字串都會在內容與長度上變動,交叉參照也都移動,所以不可能做增量。把它當完整順序重寫,並先寫到暫存檔再改名,否則在中途崩潰會留下半個密文無法開啟的檔案

反向流程是快很多。檔案一旦加密,增量更新會再附加新物件(仍用同一檔案金鑰)並保留原始位元組,2 GB 檔案只需在尾端追加幾 KB 的簽核註記;流程是「最後的操作」最便宜,避免把整個檔案再次重寫。若同時變更密碼導致檔案金鑰改變,仍屬完整重寫,請按完整流程規劃

正確測量吞吐量,避免自欺

吞吐量報表常常被分母、分子同時算錯。分母應是 payload bytes,定義為壓縮後實際要喂進 AES 的 stream 與字串長度,作者可在寫入時累加統計。檔案大小往往過大誤導:上面範例是 1.8 GB 檔案,但只有 1,710 MB 真正在密碼學路徑。分子應是加密階段純時間,不要把 parsing、deflate 或 I/O 也一起算進去;一旦把這些排除,兩組資料對比才公平。本文的幾組數字都只保留加密階段

這些觀念並不一定要您自己重寫一條加密管線。HotPDF 已在元件層把同一套邏輯包裝為 ActivateProtectionCryptKeyLengthUseAES256R6,適合互動式 VCL 應用。若是無人值守管線,PDFlibPas 提供 EncryptFile Strength 4 一次呼叫就能完成既有檔案加密,且能在寫回後驗證結果,完整流程可參考 PDFlibPas 加密權限稽核篇

本文講述的路徑分別內建於 HotPDF Component(Delphi 與 C++Builder)以及 PDFlibPas,兩者產品頁都有完整加密參考