技術文章

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

HotPDF 透過 ISO 32000 的公鑰安全處理器,把 PDF 加密給特定的憑證持有者:EnablePubKeyEncryption 接收一個 20 位元組的隨機 seed,每位收件者拿到自己的 CMS 信封,由 AddPubKeyRecipientCertificate 為 RSA 金鑰建(RSA-OAEP 金鑰傳送),或由 AddPubKeyAgreementRecipientWithSecret 為橢圓曲線金鑰建(P-256、P-384、P-521、X25519 或 X448 上的 ECDH)。沒有人需要共享密碼;誰握有配對的私鑰,誰就打得開檔案

使用情境永遠是同一個故事的某個版本。季度稽核包要送給三位外部審查人,法務要每個人都讀得到,其中只有一個人可以列印,而且沒有人想讓密碼躺在郵件討論串裡、就貼在附件旁邊。密碼加密表達不了這些。憑證加密可以,因為每位收件者用自己的金鑰解鎖文件,而且每位收件者可以在自己的信封裡帶不同的權限組合

憑證式 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 底下一個帶 /CFM /AESV3 的 /DefaultCryptFilter 條目,而 /Recipients 陣列就住在那個 crypt filter 裡。每個信封加密 24 個位元組:20 位元組 seed,後面跟著該收件者的 32 位元權限字組。加密字典裡的 /P 值只是佔位,真正的權限在每個信封裡旅行。載入時,讀取器拆開一個信封、取回 seed,再把 seed 與每個信封按 /Recipients 陣列順序一起雜湊(AES-256 用 SHA-256,較舊的密碼演算法用 SHA-1)重建檔案金鑰。如果您還在這個模型與普通密碼之間抉擇,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,然後在 BeginDoc 之前,對每張 DER 編碼的憑證呼叫一次 AddPubKeyRecipientCertificate。這個輔助函式在行程內建 RSAES-OAEP 信封,OAEP 摘要與 MGF1 摘要用 THPDFRSAOAEPHash 值指定(rohSHA256、rohSHA384 或 rohSHA512),信封內容用 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 對其他任何長度都丟例外。第二,EnablePubKeyEncryption 預設 aes128,而兩個憑證輔助函式在金鑰型別不是 aes256 時拒絕執行,所以忘了第二個參數,就會拿到「certificate envelopes require aes256」這個例外。舊密碼(k40、k128、aes128)仍然可用,但只能透過 AddPubKeyRecipient、配您自己在外頭建好的信封。第三,AES-256 公鑰加密是 PDF 2.0 功能,所以 HotPDF 會自動把文件版本拉到 2.0。在較低版本上設了 StrictVersionLock 時,EnablePubKeyEncryption 會直接返回、什麼都不啟用,失敗到下一行才以「call EnablePubKeyEncryption first」現形。增量更新中途切換加密,立刻丟 EInvalidOpException

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

橢圓曲線憑證由 AddPubKeyAgreementRecipientWithSecret 處理:寫出 CMS 金鑰協議收件者(RFC 5753 的 KeyAgreeRecipientInfo,即 KARI 結構,X25519 與 X448 沿用 RFC 8418 的設定檔),並在行程內算出 ECDH 共享秘密。曲線用 THPDFPubKeyAgreementScheme 值選:pkasECDHP256、pkasECDHP384、pkasECDHP521、pkasX25519 或 pkasX448。scheme 必須與憑證裡的金鑰相符,否則呼叫丟出「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 金鑰包裹。共享秘密本身來自純 Pascal 曲線程式碼,沒有任何平台加密提供者摻和;純 Pascal 的 NIST 曲線運算那篇講過這一層怎麼建、怎麼驗。Montgomery 曲線的整個臨時金鑰對可以本地生成:

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

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // 每個信封一支全新的臨時 scalar;clamping 在 ladder 內部發生
  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 scalar(32、48 或 66 位元組),外加配對的未壓縮 0x04||X||Y 點當 OriginatorPublicKey。HotPDF 會驗收件者點滿足曲線方程,但它無從檢查您的 originator 公鑰真的屬於您的 scalar。兩半對不上,照樣產出一個完美成形、卻沒有任何收件者打得開的信封,這就是為什麼來回載入要進您的測試套件,而不是只查一下檔案大小

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 金鑰包裹包住內容金鑰,組出 KeyAgreeRecipientInfo 信封
從 pkasECDHP256 到 pkasX448 的 scheme 值必須與憑證金鑰相符;scalar 與公鑰點兩半對不上,照樣產出成形卻無人能開的信封

/Recipients 的順序為什麼要緊?

因為檔案金鑰是對 seed 加上按 /Recipients 陣列順序排列的每個信封做的摘要,寫入端與讀取端必須用同一順序雜湊同一批位元組。HotPDF 按您加入的順序保留信封、原樣寫出,所以收件者想怎麼加就怎麼加,但下游任何環節都不許重排、重新編碼或「整理」那個陣列。這個領域裡大多數真實的 bug,都是同一個主題的變奏,兩邊雜湊的位元組差了一點點:

  • 用 Add 把動態陣列存進 TList,只留下一個裸指標,參照計數還在局部變數手上。下一個 SetLength 釋放緩衝區、可能重用同一塊記憶體,於是每個槽都別名到最後一個信封,多收件者檔案導出了錯的金鑰。修法是用 List.Add(Pointer(System.Copy(Bytes))) 存一份自己擁有的副本
  • 信封拆解原位剖析 DER,而當年的金鑰取回流程雜湊的正是這些活著的陣列。讀取器現在在任何拆解碰它們之前,先為每個信封拍下完好副本,摘要跑在快照上
  • 二進位 DER 過一道 Unicode TStringList,$80 以上的位元組會被字碼頁重新編碼,所以 HotPDF 內部把信封存成 hex 文字
  • 加密後與二進位字串必須寫成 hex string。literal string 會被行尾正規化處理,CR、LF 與 CRLF 全部變成單一 LF(ISO 32000-1 §7.3.4.2),這會悄悄改寫密文。HotPDF 把每個 /Recipients 條目寫成 hex string,並把它排除在字串加密之外,因為每個讀取器都得先看得到信封,才握有任何金鑰
  • DER BIT STRING 的第一個位元組數的是未用位元,對位元組對齊的金鑰必須為零。SetLength 之後留著不初始化,寫進去的就是堆疊上的殘渣,嚴格的拆解器拒收 originator 金鑰,檔案於是偶爾連為它而寫的那把金鑰也打不開
  • 同一把金鑰還是解不開時,逐層比對:檔案金鑰,然後密文前綴(IV),然後物件金鑰,然後明文。bug 就住在第一個對不上的層後面

怎麼用私鑰打開憑證加密的 PDF?

打開憑證加密的 PDF,要在呼叫 LoadFromFile 之前註冊私鑰材料,因為 HotPDF 在結構剖析那一輪就取回檔案金鑰。把用 HPDFParsePFX 解析出的 RSA 或 EC 金鑰指給 PubSecKeyMaterial,用 AddPubSecKeyMaterial 加更多 RSA 金鑰,用 AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint) 註冊裸 ECDH scalar,曲線常數用 HPDFOIDX25519、HPDFOIDX448、HPDFOIDECP256、HPDFOIDECP384 或 HPDFOIDECP521。NIST 曲線需要收件者自己的未壓縮公鑰點;Montgomery 曲線無視它

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

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // 可選:直接挑定信封,而不是全試一輪
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = 按順序試每個信封
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

沒有回呼時,HotPDF 對每個信封試每把已註冊的金鑰:先主金鑰,然後每把額外 RSA 金鑰,然後 EC 材料。PubSecRecipientQuery 收到信封數,回傳 0 基索引或 -1,超出陣列的索引丟例外、不會被夾進範圍。注意 AddPubSecKeyMaterial 只收 RSA 材料(它堅持要 modulus 與私指數),EC 金鑰該去 PubSecKeyMaterial 或 AddPubSecAgreementKeyMaterial。當沒有任何金鑰拆開任何信封,取回步驟返回時不帶檔案金鑰、也不丟例外,所以要驗證期待的內容真的解密了,別因為載入呼叫返回了就放心

HotPDF 的私鑰載入示意圖:PubSecKeyMaterial 帶來自 HPDFParsePFX 的主 RSA 或 EC 金鑰,AddPubSecKeyMaterial 只加 RSA 金鑰,AddPubSecAgreementKeyMaterial 以 HPDFOIDX25519 到 HPDFOIDP521 的曲線 OID 註冊裸 ECDH scalar,LoadFromFile 時提供者先試主金鑰、再試每把額外 RSA 金鑰、最後試 EC 材料,對每個信封輪番上陣
沒有任何金鑰拆開任何信封時,取回步驟不帶檔案金鑰返回、也不丟例外,所以要驗證內容真的解密了,或用 PubSecRecipientQuery 把信封釘死

HotPDF 不保證什麼

HotPDF 保證自己的寫入端與讀取端逐位元組一致,也保證建出的信封遵循上文引用的 CMS 結構。它不保證每個 PDF 檢視器打得開每種組合。RSA-OAEP 金鑰傳送與 X25519、X448 收件者的支援,各檢視器與各版本之間參差不齊,我們沒有為那些組合發布相容性結果。文件必須在特定檢視器打開的話,先為一張同金鑰型別的測試憑證加密一份測試檔、在那裡打開看看,再決定用哪個方案。信封裡的權限仍然是政策,靠合規軟體尊重,跟密碼加密下的處境一樣。seed 品質也是您的責任:AESGenerateRandomBytes 就是幹這個的,HotPDF 在檔案金鑰導出後會抹掉自己手上那份 seed。如果還需要讓某個字串、串流或附件走不同的 crypt filter,StmF、StrF 與 EFF 的 crypt filter 政策指南列出了公鑰處理器接受哪些過濾器名稱

憑證加密、RSA-OAEP 與 ECDH 收件者信封,以及私鑰載入,都隨 HotPDF Delphi PDF component 出貨,與密碼加密、數位簽章以及面向 Delphi 與 C++Builder 的其餘 ISO 32000 工具組一起