技術文章

為什麼已簽章的 AES-256 PDF 在 Delphi 載入慢:HotPDF 修復

HotPDF 在 Delphi 中載入已簽章、AES-256 加密的 PDF,比過去快得多,因為載入路徑上拿掉了三筆各自獨立的開銷:對來源流的無緩衝 token 解析、解密期間第二次跑 ISO 32000-2 的演算法 2.A 密碼檢查,以及 R6 強化雜湊內部緩慢的 AES-128-CBC 迴圈。在 Delphi 目標上,光是 R6 修復就把一份文件的載入從約 66 ms 壓到 16 ms

這三個問題在多數測試套件用的那些小檔案上都不會現形。一兩 KB 的未簽章、未加密樣本,解析器再怎麼寫,幾百分之幾秒就開了。麻煩從客戶實際寄來的文件開始:一份帶內嵌 CMS 簽章、約 35 KB、用 revision 6 密碼保護的合約。這個組合一口氣打開所有昂貴的代碼路徑,而每一條都有自己的理由比必要的更慢。本文按發現順序逐一過一遍,連同簽章驗證結果開始被暫存之後冒出來的快取鍵錯誤

34 KB 的已簽章 PDF 為什麼要 170 ms 才載得完?

34 KB 的已簽章 PDF 載入要 170 ms,是因為解析器直接讀裸的 THandleStream,每個 token 位元組都變成一次獨立的 ReadFile 系統呼叫。PDF tokenizer 找分隔符時一次推進一個位元組,於是無緩衝的 handle stream 把廉價的記憶體內掃描換成了成千上萬次核心態切換

細節解釋了這個 bug 為什麼活得下來。THotPDF.LoadFromFile 很久以前就把檔案來源包進內部讀取緩衝,AutoDecrypt 探測也因為同樣的理由修過一次——它曾花 1.4 秒反覆查詢一個 56 MB 檔案的大小。但已簽章且加密的文件走另一條路。為了保證延遲物件讀取與簽章檢視永遠看到同一份不可變修訂版,即使呼叫方釋放了自己的流也一樣,THotPDF.LoadFromStream 會對來源拍快照、再解析第二次。那第二次解析,連同用於已驗證修訂版的重複載入,讀快照 handle 時前面都沒有緩衝。兩者現在都走檔案路徑用的同一個內部緩衝讀取器

Delphi 中 HotPDF 已簽章 AES-256 加密 PDF 的載入管線:快照流、緩衝的結構解析、演算法 2.A 預掃描、重複使用已取回金鑰的單次解密,以及呼叫原生 AES-128-CBC 的 R6 強化雜湊,把載入從 66 ms 砍到 16 ms
三個修復疊在同一條路上:緩衝拿掉成千上萬次系統呼叫,預掃描出的金鑰在範圍旗標保護下重複使用,R6 內層迴圈則接上原生 CBC 後端

那個緩衝是內部的,您永遠不必自己建。您能控制的是交給 LoadFromStream 的流,HotPDF 在第一趟結構解析期間按原樣讀它。資料已在記憶體裡,就傳 TMemoryStream。在磁碟上,就呼叫 LoadFromFile,或在 Delphi 10.1 Berlin 及以後給載入器一個 TBufferedFileStream,而不是裸的 TFileStream

uses
  System.SysUtils, System.Classes, HPDFDoc;

function LoadEncryptedFromBytes(const FileName: string;
  const Password: AnsiString): THotPDF;
var
  Mem: TMemoryStream;
begin
  Result := THotPDF.Create(nil);
  Mem := TMemoryStream.Create;
  try
    Mem.LoadFromFile(FileName);   // 一次整批讀取,取代逐 token 讀取
    Mem.Position := 0;
    // LoadFromStream 回傳頁數,文件未載入時為 0
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

載入完就釋放記憶體流,對已簽章且加密的輸入是安全的,因為元件已經持有它自己對那些位元組的快照。不過,如果之後打算驗證簽章,原始檔案或緩衝還是留著,因為驗證雜湊的是檔案的原始 /ByteRange 區段,不是解密後的物件模型

把載入與驗證當成兩個階段分開量測

載入時間與簽章驗證時間應該分開量,因為兩者失敗的方式不同、修復的位置也不同。載入階段涵蓋解析、密碼檢查與解密。驗證階段重新雜湊被簽署的位元組區間並檢查 CMS 簽章。把它們折成一個數字,任何一邊的迴歸都會藏進另一邊

RTL 的 TStopwatch(System.Diagnostics)就夠用。下面的測試架構先用 THotPDF.LoadFromFile 的密碼重載載入,再透過 VerifyLoadedSignature 的 TStream 重載驗證第一個簽章。加密檔案該選流重載,因為密碼載入路徑不會保留來源檔名,無參數重載無從重開

uses
  System.SysUtils, System.Classes, System.Diagnostics,
  HPDFDoc, HPDFSignVerify;

procedure TimeSignedEncryptedLoad(const FileName: string;
  const Password: AnsiString);
var
  Pdf: THotPDF;
  Src: TFileStream;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Watch: TStopwatch;
  Pages: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Watch := TStopwatch.StartNew;
    Pages := Pdf.LoadFromFile(FileName, Password);
    Writeln(Format('Load + decrypt: %d ms, %d pages, encrypted=%s',
      [Watch.ElapsedMilliseconds, Pages, BoolToStr(Pdf.IsEncrypted, True)]));
    if (Pages < 1) or (Pdf.GetLoadedSignatureFieldCount = 0) then
      Exit;

    Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
    try
      Watch := TStopwatch.StartNew;
      Status := Pdf.VerifyLoadedSignature(0, Src, Info);
      Writeln(Format('Verify signature 0: %d ms, status %d, signer %s',
        [Watch.ElapsedMilliseconds, Ord(Status), string(Info.SignerName)]));
    finally
      Src.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

多跑幾次,忽略第一次。第一次呼叫要付一次性建置成本,包括建 AES 表與跑它們的自我測試,磁碟快取也會讓第一次讀取失真。您要找的是真實客戶檔案的穩態數字,對照同一份文件的未簽章、未加密副本一起看

AES-256 金鑰為什麼被推導了兩次?

HotPDF 把 AES-256 檔案金鑰推導了兩次,因為同一次載入的兩個階段各自跑了完整的密碼檢查。結構解析之前,RecoverReloadKeysFromStreamRanges 會預掃描原始位元組,讓加密的物件流容器能在 filter 解碼期間被解密。那次預掃描已經執行 ISO 32000-2 §7.6.4.3.3 的演算法 2.A 檢查,對使用者與擁有者鹽值驗證密碼並取回檔案加密金鑰。DecryptLoadedDocument 卻無視這個結果,把整個推導又做了一遍

revision 4 與更早的版本,重複的成本不過多跑一輪 MD5。revision 6 的話,演算法 2.A 呼叫的是演算法 2.B 強化雜湊,重複等於把開檔最貴的部分乘以二。重複使用取回的金鑰很簡單;安全地重複使用才是難的部分,因為一把活得比自己的密碼檢查還久的快取金鑰,會變成繞過身分驗證的後門

修法用一個範圍旗標限制重複使用,旗標只在 LoadFromFile 與 LoadFromStream 的密碼重載內、預掃描與解密呼叫之間為真,並在 finally 區塊清除。只有預掃描確實驗證過密碼時旗標才會立起來。純載入後直接呼叫 DecryptLoadedDocument 一律執行完整推導,因為那個密碼從未被預掃描。沒有這條限制的話,更早一次成功開啟留下的過期重載金鑰,會悄悄替後來給錯密碼的呼叫方解密文件——那比載入慢嚴重得多

R6 強化雜湊的時間花在哪裡?

R6 強化雜湊幾乎所有時間都花在 AES-128-CBC 加密上。ISO 32000-2 §7.6.4.3.4 的演算法 2.B 至少重複 64 輪。每輪組一個緩衝,裝 64 份密碼、當前雜湊,以及擁有者側的 /U 字串,用雜湊前 16 位元組當金鑰、接下來 16 位元組當 IV,以 AES-128-CBC 加密這個緩衝,再把密文前 16 位元組當數字對 3 取模,選出下一輪的 SHA-256、SHA-384 或 SHA-512。雜湊很短。被加密的緩衝每輪好幾 KB

Delphi 的 HotPDF R6 演算法 2.B 強化雜湊迴圈示意:至少 64 輪中的每一輪組出裝 64 份密碼、雜湊與 /U 字串的緩衝,以雜湊為金鑰與 IV 做 AES-128-CBC 加密,再從密文前段位元組選 SHA-256、SHA-384 或 SHA-512
R6 的成本落在 AES-128-CBC 緩衝加密而非摘要,這正是 PDF20HashDanceR6 內改用原生 CBC 常式能把整份文件載入從約 66 ms 壓到 16 ms 的原因

HotPDF 對這個迴圈的實作 PDF20HashDanceR6,過去透過面向位元組的參考 AES 一次加密一個區塊。現在它先嘗試原生的無填充 CBC 常式,在 Delphi 目標上直通連結的 C AES 後端。這一改,整份文件載入從約 66 ms 降到 16 ms。原生常式不可用或回傳的長度不符時,代碼退回歷史的區塊迴圈,而那個迴圈又能使用表驅動的 AES-128 區塊,其 T 表由既有的 S-box 生成。各條路徑的密碼演算法完全相同,所以無論哪條路徑算出,導出的金鑰都逐位元組相同

本文談的是速度。把演算法 2.B 的終止規則寫對是另一個正確性問題,那裡差一就會只拒絕一部分的正確密碼。R5 對 R6 的設定面在HotPDF 的 AES-256 PDF 加密設定指南裡

快的 AES 區塊騙得了您的四種方式

表驅動的 AES 區塊可以產出錯的密文、卻照樣通過自己的自我測試。下面四個陷阱每一個都是在打造快速路徑時撞上的,其中兩個還越過了最初設計的自我測試

  • 自我測試重入。就緒常式在跑自我測試之前就把已初始化旗標立起來,測試期間的重入呼叫因此走了參考路徑。測試的第一版於是拿參考實作跟自己比。修法是留一個無防護的原始快速區塊讓自我測試直接呼叫,被測的才真的是表路徑
  • 表版面互換。四張輪表裡的兩張(Te1 與 Te3)位元組序互換,產出錯密文,而重入的自我測試無從察覺。逮到它的是一支獨立探測程式,它對金鑰 000102…0f 與明文 00112233…ff 印出 FIPS-197 附錄 C.1 的密文 69c4e0d86a7b0430d8cdb78070b4c55a。自我測試現在要求該向量加上與參考區塊的逐位元組相等,兩者都過才信任快速路徑
  • FPC 範圍與溢位檢查。在 Free Pascal 下開著 -Cr 與 -Co,Delphi 風格的顯式 Byte() 轉換與位元組寬度位移運算會丟範圍或溢位例外。表索引與 GF(2^8) 倍乘現在都以原生整數寬度計算,再用 and $FF 掩罩
  • FPC inliner 誤編譯。收 PByte 參數的小型 big-endian 讀寫輔助,在 FPC 下標 inline 時被編譯成存取違規。拿掉 inline 就好了,相對於表查找,呼叫成本微不足道

最終接線背後也是同一段經歷。讓程式庫裡每個 AES-128 區塊都走快速表,在某次發布候選載入時觸發了確定性的 FPC 存取違規,於是那個改動被回退。出貨的設計是保守的:原生 CBC 優先,R6 迴圈內一條顯式的快速表分支,其餘場景全部用參考區塊。通用的教訓是:必須在多個編譯器下建置的新密碼學代碼,需要在每個目標編譯器上跑一支獨立的已知答案探測。DLL 層級的測試失敗分不清您是表錯了還是崩潰了。Free Pascal 這邊的更多內容見HotPDF 在 Free Pascal 下的 Deflate 與 AES 後端筆記

怎麼暫存簽章驗證結果,又不把錯的答案一起暫存?

簽章驗證快取只有在它的鍵涵蓋每一個可能改變判定的輸入時才是安全的,包括住在被簽署位元組區間之外的輸入。HotPDF 的 headless 文件層對 CMS 驗證結果做暫存,鍵由 CMS 位元組、/Contents 間隙裡簽章字典的位元組、被涵蓋區間的內容、原則指紋、驗證預算、來源總大小與一分鐘時間桶一起雜湊而成。其中三個輸入是一個 bug 暴露出缺漏之後才補上的

Delphi 的 HotPDF 簽章驗證快取鍵輸入:CMS 位元組、/Contents 間隙中的簽章字典、被涵蓋區間內容、原則指紋、預算、來源總大小與一分鐘時間桶,在 CMS 判定被暫存前一起雜湊
判定只有當每個可能改變它的輸入都在鍵裡時才能重用——來源大小所以要緊,是因為基準修訂版與其增量更新可以共用完全相同的被涵蓋區間

信任憑證檔按內容而不是按路徑作鍵。操作層每條命令都把同一份 PEM 材料寫到全新的暫存檔名,按路徑作鍵永遠命中不了,快取從未生效。來源總大小必須進鍵,因為回報的 UnsignedTrailingBytes 依賴它:一份基準修訂版與其後的增量更新可以共用完全相同的被涵蓋內容、卻有不同的尾部落長度,只按區間作鍵的快取會把基準修訂版的判定交給更新後的檔案。那些尾部位元組的背景在HotPDF 的 Delphi PDF 數位簽章驗證指南裡

第三個 bug 是 Pascal 語言陷阱,不是設計錯誤。雜湊常式收無型別的 const 緩衝,把動態陣列變數傳給無型別參數時,傳的是陣列參照的位址,不是陣列資料

function Checksum(const Data; Count: Integer): Cardinal;
var
  P: PByte;
  I: Integer;
begin
  Result := 0;
  P := @Data;
  for I := 0 to Count - 1 do
  begin
    Result := Result + P^;
    Inc(P);
  end;
end;

procedure DigestCMS(const CMS: TBytes);
begin
  // 錯誤:讀到的是陣列參照與堆疊上跟在後面的東西。
  // 從那裡讀 16 KB 會越過堆疊頁並觸發 access
  // violation,且 Delphi 與 FPC 在不同位置失敗。
  Writeln(Checksum(CMS, Length(CMS)));

  // 正確:傳第一個元素,並對空陣列做防護
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

快取鎖用的是 SyncObjs.TCriticalSection 而不是 Windows 的 InitializeCriticalSection API,因為同一個單元也要在 Linux 的 FPC 下建置,那個 API 在那裡不存在

哪些東西刻意保持緩慢

R6 金鑰推導本身從不跨載入暫存,這是刻意的。外部位元組與顯式提供的密碼永遠走真正的身分驗證路徑,所以 R6 文件的每一次密碼載入都至少付一次演算法 2.B 雜湊,嘗試擁有者密碼時則是使用者側與擁有者側各一次。上述帶範圍的重複使用,拿掉的只是單次載入內部的重複,僅此而已

  • 66 ms 降到 16 ms 這個數字適用於連結了原生 AES 後端的 Delphi 目標。FPC 建置走表驅動區塊,同樣的斷言下,Windows 上的 FPC 代碼生成比 Delphi 慢 2 到 4 倍
  • 驗證快取綁定一分鐘時間桶,短視窗內對同樣位元組的重複檢查很便宜,而暫存的判定絕不活得比計算它的那一分鐘久
  • 合併修訂版的權限快取實作過、除錯過、然後被移除。它沒能縮小 FPC 差距,還讓 Delphi 的安全回呼量測得到地變慢,這是個很好的提醒:留下快取之前先量測
  • 簽章驗證需要原始的加密檔案位元組,所以來源要留著。更快的解密完全免除不了這個要求

FAQ

重複使用預掃描的金鑰會削弱密碼檢查嗎?

不會。HotPDF 只在同一個以完整演算法 2.A 檢查驗證過密碼的密碼重載呼叫內重複使用取回的檔案金鑰,範圍旗標也在 finally 區塊清除。獨立的 DecryptLoadedDocument 呼叫一律執行完整推導,所以更早開啟留下的金鑰不可能被錯誤密碼借用

為什麼不跨載入暫存 R6 雜湊結果?

跨載入暫存雜湊,等於不對照正在開啟的檔案就接受密碼。HotPDF 把每個外部位元組來源與每個顯式密碼都當成必須自行通過身分驗證的不可信輸入,所以量得到的加速來自把演算法 2.B 做快,而不是跳過它

該把 TFileStream 傳給 LoadFromStream 嗎?

磁碟上的檔案請優先用 LoadFromFile,它內部已有緩衝。確實需要流重載時,傳 TMemoryStream 或 TBufferedFileStream,因為 HotPDF 在第一趟結構解析期間直接讀您給的流,裸的 handle stream 每次小讀取都要付一次系統呼叫

密碼保護載入、AES-256 R6 加密、CMS 簽章驗證與其餘已載入文件 API,一起隨單一原生 VCL 程式庫提供給 Delphi 與 C++Builder。功能細節與支援的 IDE 版本在HotPDF Component 產品頁