技術文章

Delphi 的 PDF 憑證加密:RSA-OAEP 與 ECDH

一份憑證加密的 PDF,其加密字典裡帶著單一一個 /P 值,不管這個檔案是為誰寫的;在本文後面建出的檔案裡,它是 -64。三個收件者帶三種不同的權限集合,仍然共用那一個數字。想從 /P 回答「這個使用者可以列印嗎?」的程式碼,讀到的只是一個佔位符,因為在公開金鑰安全處理器之下,真正的權限字放在每個收件者的加密信封裡,只有等那個收件者的私鑰把它打開之後才會現身

這個設計正是憑證加密值得費事的理由。一份季度稽核包要送給三位外部審閱者;法務要他們全部都能讀,只有一位可以列印,而沒有人想在郵件串裡、附件旁邊留著一個密碼。密碼表達不了這件事。憑證可以:每個收件者用一把他們已經持有的金鑰解鎖檔案,每個信封帶著自己的權限字

憑證式 PDF 加密與密碼有何不同?

公開金鑰加密的 PDF 從一把隨機 seed 加上每個收件者信封的確切位元組導出檔案金鑰,人輸入的任何東西都不進入這個計算。這個處理器規範於 ISO 32000-1 §7.6.4(ISO 32000-2 的 §7.6.5),信封則是來自 RFC 5652 的 CMS EnvelopedData 結構。HotPDF 寫出 /Filter /Adobe.PubSec 與 /SubFilter /adbe.pkcs7.s5。對 AES-256 來說,這代表 /V 5、/CF 底下的一個 /DefaultCryptFilter 項目搭配 /CFM /AESV3,以及放在那個 crypt filter 裡面、而不是頂層字典裡的 /Recipients 陣列

每個信封加密 24 個位元組:20 位元組的 seed,後面接著該收件者的 32 位元權限字。載入時,讀取器解開一個信封、取回 seed,再把 seed 與每一個信封按 /Recipients 順序一起雜湊(AES-256 用 SHA-256,較舊的加密法用 SHA-1)來重建檔案金鑰。審閱者 B 永遠不知道審閱者 A 可以做什麼,但 B 的讀取器仍然要雜湊 A 的信封位元組,所以整個陣列必須逐位元組保持原樣。如果你還在這個模型與普通密碼之間抉擇,AES-256 密碼加密與權限旗標指南 涵蓋了這個取捨的另一面

HotPDF 公開金鑰加密圖解:EnablePubKeyEncryption 固定一把 20 位元組 seed,每個 CMS EnvelopedData 信封在 /Filter /Adobe.PubSec 搭配 /SubFilter /adbe.pkcs7.s5 與 /CFM /AESV3 之下加密那 20 位元組加一個 32 位元權限字;讀取器解開一個信封、取回 seed,再按陣列順序與每個 /Recipients 項目一起雜湊以重建檔案金鑰
加密字典裡的 /P 值只是佔位符,因為真正的權限放在每個信封裡,而摘要覆蓋的陣列,下游任何東西都不得重排或重新編碼

用 EnablePubKeyEncryption 寫出 RSA 收件者

對 RSA 憑證來說,順序很短:先以 aes256 呼叫 EnablePubKeyEncryption,然後每張 DER 編碼的憑證一次 AddPubKeyRecipientCertificate,全部在 BeginDoc 之前完成。這個輔助函式在行程內建立 RSAES-OAEP 信封。它的最後兩個參數從 THPDFRSAOAEPHash(rohSHA256、rohSHA384 或 rohSHA512)挑選 OAEP 摘要與 MGF1 摘要,兩者預設都是 rohSHA256,信封內容以 AES-256-CBC 加密

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // 恰好 20 位元組,AES-256 也一樣
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // 預設金鑰類型是 aes128
    // 審閱者 A 可以列印;審閱者 B 只能讀取與擷取
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

那段程式碼裡有三個細節有分量。seed 對每種金鑰類型都是 20 位元組,AES-256 也不例外,EnablePubKeyEncryption 對任何其他長度都會丟例外。金鑰類型預設是 aes128,而兩個憑證輔助函式都堅持 aes256,所以漏掉第二個參數,會讓第一個 AddPubKeyRecipientCertificate 呼叫丟出 "certificate envelopes require aes256"。舊式加密法(k40、k128、aes128)仍然可透過 AddPubKeyRecipient 使用,它接受由其他工具建好的完整 DER 信封

第三個細節是版本。公開金鑰處理器下的 AES-256 是 PDF 2.0 功能,HotPDF 會自己把文件版本提高到 2.0。較低版本設了 StrictVersionLock 時不允許這次提升:EnablePubKeyEncryption 什麼都沒開就回傳,錯誤晚一行才浮現——收件者輔助函式回報 "call EnablePubKeyEncryption first"。那則訊息會讓人去找呼叫順序的錯誤。其實該查的是版本鎖。在增量更新期間更改加密,會以 EInvalidOpException 直接拒絕

在 P-256、P-384、P-521、X25519 與 X448 上加入 ECDH 收件者

橢圓曲線憑證走 AddPubKeyAgreementRecipientWithSecret,它寫出一個 CMS 金鑰協議收件者(來自 RFC 5753 的 KeyAgreeRecipientInfo,搭配來自 RFC 8418 的 X25519 與 X448 profile),並在行程內計算 ECDH 共享密鑰。THPDFPubKeyAgreementScheme 值指名曲線:pkasECDHP256、pkasECDHP384、pkasECDHP521、pkasX25519 或 pkasX448。方案必須與憑證的金鑰演算法、曲線與金鑰長度相符,否則呼叫會丟出 "Certificate key does not match the requested agreement scheme"

每個信封都拿到一把新的隨機 32 位元組 UKM、一把來自 stdDH KDF 的金鑰加密金鑰(P-256 與 X25519 用 SHA-256,P-384 用 SHA-384,P-521 與 X448 用 SHA-512),以及 RFC 3394 定義的 AES-256 key wrap。曲線算術是純 Pascal,底下沒有任何平台加密提供者;純 Pascal 的 NIST 曲線算術一文 走過那個層次的組裝方式。在 Montgomery 曲線上,整對臨時金鑰可以在本地產生:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // 每個信封一把新的臨時純量;夾取在梯內部發生
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint:只對 NIST 曲線有意義
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

NIST 曲線對呼叫端要求更多。HotPDF 只為 X25519 與 X448 衍生發起者公鑰(HPDFX25519PublicFromScalar、HPDFX448PublicFromScalar),所以對 P-256、P-384 與 P-521,臨時金鑰對得靠你自己的工具。傳入一個恰好為體大小的 big-endian 純量(32、48 或 66 位元組),以及對應的未壓縮 0x04||X||Y 點(65、97 或 133 位元組)作為 OriginatorPublicKey,並把同一個點交給 OwnPublicPoint——保留給 NIST 方案的參數。長度錯誤立刻丟例外。從憑證取出的收件者點,在任何乘法執行之前都會先對曲線方程式驗證

沒有任何函式庫能檢查的,是你的發起者公鑰是否真的屬於你的純量。配錯的兩半仍然會產出格式良好的信封。寫入端從「純量乘收件者點」衍生金鑰,收件者則從「私鑰乘錯誤的發起者點」衍生他們的金鑰,而收件者端第一個發現問題的,是 RFC 3394 的完整性檢查——那時檔案早就出貨了。在你的建置流程裡放一個對同一曲線上測試金鑰的往返載入;一次成功的 EndDoc 與一個看似合理的檔案大小,在這裡什麼也證明不了

那個配對步驟,正是曲線選擇在實務上要緊的原因。如果你能控制憑證的簽發,X25519 是從 Delphi 更容易做對的曲線:一把 32 位元組隨機純量、一次輔助函式呼叫,沒有需要手動保持同步的點。當收件者的憑證來自只簽發 P-256 或 P-384 的 PKI 時,NIST 曲線就有它們的位置,而在那種情況下,往返載入不再是可選項

HotPDF ECDH 協議圖解:AddPubKeyAgreementRecipientWithSecret 用純 Pascal 曲線程式碼衍生共享密鑰,把一把新的 32 位元組 UKM 經 stdDH KDF 混入——P-256 與 X25519 用 SHA-256、P-384 用 SHA-384、P-521 與 X448 用 SHA-512——再用 RFC 3394 的 AES-256 key wrap 包裝內容金鑰,建成 KeyAgreeRecipientInfo 信封
從 pkasECDHP256 到 pkasX448 的方案值必須與憑證金鑰相符,而配錯的純量與公點兩半仍然會產出格式良好、卻沒有任何收件者打得開的信封

為什麼 /Recipients 的順序要緊?

/Recipients 的順序要緊,因為檔案金鑰是對 seed 加上陣列順序中每個信封的摘要,所以寫入端與讀取端必須以相同的順序雜湊相同的位元組。HotPDF 按你加入的順序保存信封並原樣寫出,這讓你可以自由挑選任何順序。陣列之後發生什麼事才是風險所在。後處理工具、簽章管線與自製的信封建構器,是它壞掉的地方,而且這些失敗共享同一個形狀——兩邊雜湊的位元組略有不同:

  • Delphi 的無型別 TList 存的是原始指標。用 List.Add(Pointer(Bytes)) 收集信封,會讓參照計數留在區域變數身上,於是下一次對那個變數 SetLength 就會釋放或重用緩衝區,而每個槽位都可能最後都別名到同一個信封。TList<TBytes> 或單純的 TArray<TBytes> 才會把參照計數顧好
  • 就地運作的 DER 剖析器會改動它剖析的緩衝區。對每個信封雜湊一份乾淨副本,在任何解開信封的嘗試碰到它之前就取好
  • Unicode 的 TStringList 不是放二進位 DER 的地方:達到 $80 以上的位元組會在路上被字碼頁重新編碼。信封放在 TBytes 裡,或先做十六進位編碼
  • 密文屬於十六進位字串。放在字面字串裡,CR、LF 與 CRLF 讀回來都變成單一個 LF(ISO 32000-1 §7.3.4.2),所以一個碰巧含有 $0D $0A 的信封會少一個位元組。HotPDF 把每個 /Recipients 項目寫成十六進位字串,並讓它避開字串加密,因為讀取器在拿到任何金鑰之前就得先拿到信封
  • DER BIT STRING 的第一個內容位元組記的是未用位元數,對位元組對齊的金鑰必須是 $00。手寫的 DER 若漏掉這個位元組、把 0x04||X||Y 點直接塞進內容,會讓整把金鑰位移一個位元組,而嚴格的剖析器會直接拒收發起者金鑰
  • 當一把理應可用的金鑰仍然解不開時,按這個順序逐層比對:檔案金鑰、密文前端的 IV、逐物件金鑰、明文。故障就坐在第一個對不上的層之後

以私鑰載入憑證加密的 PDF

私鑰必須在 LoadFromFile 執行之前註冊,因為 HotPDF 在結構掃描階段就取回檔案金鑰。用 HPDFParsePFX 剖析的 RSA 身分放進 PubSecKeyMaterial,更多 RSA 金鑰放進 AddPubSecKeyMaterial,後者會拒收沒有模數與私有指數的材料。這兩個金鑰槽都以 RSA 模數與私有指數做比對,所以 EC 金鑰(包括 PFX 型的)走第三條路:AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint),搭配 HPDFOIDX25519、HPDFOIDX448、HPDFOIDECP256、HPDFOIDECP384 或 HPDFOIDECP521 常數之一。NIST 曲線在那裡要求收件者自己的未壓縮公點;Montgomery 曲線則忽略它

以 PFX 檔案形式到達的 EC 身分也合用同一個呼叫。HPDFParsePFX 會以 KeyAlgorithm = pfxkaECDSA 回報它(X25519 與 X448 身分也在內),並填好 CurveOID、PrivateKey 裡的私有純量與 PublicKey 裡的憑證點,正好對應那三個參數

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
  EcKey: THPDFPFXKeyMaterial;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    // RSA 身分成為主金鑰
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    // 來自 PFX 的 EC 身分:曲線、純量與點交給金鑰協議清單
    EcKey := HPDFParsePFX(TFile.ReadAllBytes('reviewer-c-p256.pfx'), 'pfx-password');
    if EcKey.KeyAlgorithm = pfxkaECDSA then
      Reader.AddPubSecAgreementKeyMaterial(EcKey.CurveOID,
        EcKey.PrivateKey, EcKey.PublicKey);
    // 法務團隊持有的原始 X25519 純量;不需要點
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

不加干預的話,HotPDF 會拿每個信封對每把已註冊的金鑰嘗試:先主 RSA 金鑰,再每把額外的 RSA 金鑰,然後是 EC 材料。PubSecRecipientQuery 可以把它縮到單一信封。回呼收到信封總數,必須回傳陣列內一個從零起算的索引;其他任何值,包括 -1,都會在載入過程中丟例外。要嘗試所有信封,就把屬性留空。在 Delphi 下這個型別是函式參照,所以匿名方法可以用;在 FPC 下它是單純的函式指標,其 Context 引數來自 PubSecRecipientQueryContext

有一個行為值得單獨寫一項測試。當沒有任何已註冊金鑰能解開任何信封時,金鑰取回會在沒有檔案金鑰的情況下結束,而且不會為此丟例外。對你確定文件裡有的內容下斷言,例如一段文字或一個中繼資料值;一次正常回傳的 LoadFromFile 只說明檔案結構被剖析了

HotPDF 私鑰載入圖解:PubSecKeyMaterial 帶著來自 HPDFParsePFX 的主 RSA 金鑰,AddPubSecKeyMaterial 只加 RSA 金鑰,AddPubSecAgreementKeyMaterial 在 HPDFOIDX25519 到 HPDFOIDECP521 曲線 OID 之下註冊原始 ECDH 純量;LoadFromFile 時,提供者對每個信封先試主金鑰,再每把額外 RSA 金鑰,最後是 EC 材料
當沒有任何金鑰解得開任何信封時,取回步驟回傳而不丟例外、也沒有檔案金鑰,所以要驗證內容真的解密了,或透過 PubSecRecipientQuery 把信封釘住

在目標檢視器裡該檢查什麼?

HotPDF 的寫入端與讀取端逐位元組一致,信封遵循上面引用的 CMS 結構。第三方檢視器是另一個問題:對 RSA-OAEP 金鑰傳輸的支援——更不用說 X25519 或 X448 收件者——在不同讀取器之間、甚至同一讀取器的不同版本之間都有差異。如果文件必須在某個特定檢視器裡打開,先為一張同金鑰類型的測試憑證加密一份測試檔,在那裡開開看,再決定方案。當檢視器不在你控制範圍內時,它的支援清單決定方案,這一點的優先序高過前面為 X25519 講的便利論證

信封權限仍然是合規軟體會遵守的政策,與密碼加密之下完全一樣。Seed 的品質是你的責任:AESGenerateRandomBytes 就是為這件事而存在,而 HotPDF 在檔案金鑰衍生完成後會抹掉自己手上的那一份 seed 副本。如果某個字串、串流或附件需要不同的 crypt filter,StmF、StrF 與 EFF 的 crypt filter 政策指南 說明了怎麼指派;公開金鑰處理器接受 DefaultCryptFilter 或 Identity

RSA 與橢圓曲線憑證的逐收件者信封,連同回頭載入時的金鑰註冊,都是 HotPDF PDF 元件 的一部分,適用 Delphi 與 C++Builder,與密碼加密和數位簽章並列