技术文章

Delphi PDF 证书加密:RSA-OAEP 与 ECDH

HotPDF 通过 ISO 32000 公钥安全处理器为特定证书持有者加密 PDF:EnablePubKeyEncryption 接收一个 20 字节随机种子,每个接收方拿到自己的 CMS 信封——RSA 密钥由 AddPubKeyRecipientCertificate 构建(RSA-OAEP 密钥传输),椭圆曲线密钥由 AddPubKeyAgreementRecipientWithSecret 构建(P-256、P-384、P-521、X25519 或 X448 上的 ECDH)。没有人需要共享密码;谁持有匹配的私钥,谁就能打开文件

用例永远是同一个故事的某个变体。季度审计包要发给三位外部评审,法务要求每个人都能读、只有其中一位可以打印,而谁都不想看到密码和附件躺在同一封邮件串里。密码加密表达不了这个。证书加密可以,因为每个接收方都用自己已有的密钥解锁文档,而且每个接收方可以在自己的信封里携带不同的权限集

基于证书的 PDF 加密与密码有何不同?

公钥加密的 PDF 从一个随机种子加上每个接收方信封的精确字节派生文件密钥,而不是从任何人输入的东西。这个处理器写在 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 字节种子加该接收方的 32 位权限字。加密字典里的 /P 值只是占位符,真正的权限在各个信封内部传递。加载时,阅读器拆开一个信封、收回种子,然后按 /Recipients 数组顺序把种子与每个信封一起哈希(AES-256 用 SHA-256,旧密码用 SHA-1)重建文件密钥。如果你还在这个模型和普通密码之间摇摆,AES-256 密码加密与权限标志指南讲的是那笔交易的另一面

HotPDF 公钥加密图解:EnablePubKeyEncryption 固定一个 20 字节种子,每个 CMS EnvelopedData 信封在 /Filter /Adobe.PubSec、/SubFilter /adbe.pkcs7.s5 和 /CFM /AESV3 之下加密那 20 字节加一个 32 位权限字;阅读器拆开一个信封收回种子,按数组顺序与每个 /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;

那段代码里有三个细节是承重的。第一,种子长度对所有密钥类型都固定为 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 密钥协商接收方(KeyAgreeRecipientInfo,RFC 5753 的 KARI 结构,X25519 与 X448 按 RFC 8418 的 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 密钥包裹。共享密钥本身来自纯 Pascal 曲线代码,不涉及任何平台 crypto provider;纯 Pascal 的 NIST 曲线运算一文解释了那一层是怎么构建和验证的。对 Montgomery 曲线,整对临时密钥都可以在本地生成:

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

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // 每个信封用全新的临时标量;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 标量(32、48 或 66 字节)加匹配的未压缩 0x04||X||Y 点作为 OriginatorPublicKey。HotPDF 会对照曲线方程验证接收方点,但它没法检查你的 originator 公钥真的属于你的标量。两半不匹配照样产出一个格式完美、但任何接收方都打不开的信封——这就是为什么往返加载测试应该进你的测试套件,而不是只查一下文件大小

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 的方案值必须与证书密钥匹配,而标量与公点两半不匹配照样产出格式良好、却无人能打开的信封

为什么 /Recipients 的顺序要紧?

/Recipients 的顺序要紧,因为文件密钥是对种子加数组顺序里每个信封的摘要,写入方和读取方必须按同一序列哈希同样的字节。HotPDF 按你添加的顺序保存信封并原样写出,也就是说你可以按任意顺序添加接收方,但下游任何环节不得重排、重新编码或「清理」那个数组。这个领域的大多数真实 bug 都是这个主题的变奏——两边哈希了略有不同的字节:

  • 用 Add 把动态数组存进 TList 只留下一个裸指针,而引用计数还在局部变量手里。下一次 SetLength 释放并可能复用缓冲区,于是每个槽位都成了最后一个信封的别名,多接收方文件派生出错误的密钥。修法是存一份自己持有的拷贝:List.Add(Pointer(System.Copy(Bytes)))
  • 信封拆包就地解析 DER,而密钥回收过程最初哈希的就是那些还活着的数组。读取器现在在任何拆包触碰之前先快照每个信封的干净拷贝,摘要在快照上运行
  • 二进制 DER 过一道 Unicode TStringList,大于等于 $80 的字节会被代码页重新编码,所以 HotPDF 内部把信封存成十六进制文本
  • 加密字符串和二进制字符串必须写成 hex 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 标量——曲线 OID 用 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 材料(它坚持要模数和私有指数),所以 EC 密钥归 PubSecKeyMaterial 或 AddPubSecAgreementKeyMaterial。当没有密钥能拆开任何信封时,回收步骤不带文件密钥直接返回而不是抛异常,所以要验证你期望的内容真的解密了,别只信加载调用返回了这一件事

HotPDF 私钥加载图解:PubSecKeyMaterial 携带来自 HPDFParsePFX 的主 RSA 或 EC 密钥,AddPubSecKeyMaterial 只加 RSA 密钥,AddPubSecAgreementKeyMaterial 在 HPDFOIDX25519 到 HPDFOIDP521 曲线 OID 下注册裸 ECDH 标量;LoadFromFile 时 provider 先试主密钥,再试每个追加 RSA 密钥,最后 EC 材料,逐个对每个信封
没有密钥能拆开任何信封时,回收步骤不带文件密钥直接返回而不是抛异常,所以要验证内容真的解密了,或者用 PubSecRecipientQuery 钉住信封

HotPDF 不保证什么

HotPDF 保证自己的写入方和读取方逐字节一致,也保证构建的信封遵循上面引用的 CMS 结构。它不保证每个 PDF 阅读器都能打开每种组合。对 RSA-OAEP 密钥传输和 X25519 / X448 接收方的支持因阅读器和版本而异,我们也没有发布过这些组合的兼容性结果。如果文档必须在特定阅读器里打开,先用同一密钥类型的测试证书加密一个测试文件、在那边打开验证,然后再敲定方案。信封里携带的权限仍然是合规软件会遵守的策略,与密码加密下一样。种子质量也是你的责任:AESGenerateRandomBytes 就是为这活准备的,而且文件密钥派生完成后 HotPDF 会擦掉自己手里那份种子。如果你还需要让某个字符串、流或附件用不同的 crypt filter,StmF、StrF 与 EFF 的 crypt filter 策略指南列出了公钥处理器接受哪些过滤器名

证书加密、RSA-OAEP 与 ECDH 接收方信封以及私钥加载,都随面向 Delphi 和 C++Builder 的 HotPDF Delphi PDF component 发布,与密码加密、数字签名以及 ISO 32000 工具集的其余部分并列