技术文章

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

证书加密的 PDF 在加密字典里只带一个 /P 值,不管文件是写给谁的;本文稍后构建的文件里它是 -64。三个接收方、三套不同权限,共享的还是那一个数字。用 /P 回答“这个用户能打印吗”的代码读到的是占位符,因为在公钥安全处理器下,真正的权限字走在每个接收方的加密信封里,只有那个接收方的私钥打开它之后才会出现

这正是证书加密值得麻烦一场的原因。季度审计包要发给三位外部评审;法务要求人人能读、只有一位可以打印;而谁都不想让密码躺在附件旁边的邮件串里。密码表达不了这个,证书可以:每个接收方用自己已有的密钥解锁文件,每个信封带自己的权限字

基于证书的 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 条目,以及放在那个 crypt filter 里而不是顶层字典里的 /Recipients 数组

每个信封加密 24 字节:20 字节种子,后跟该接收方的 32 位权限字。加载时阅读器拆开一个信封、收回种子,然后按 /Recipients 数组顺序把种子与每个信封一起哈希(AES-256 用 SHA-256,旧密码用 SHA-1)重建文件密钥。评审 B 无从得知评审 A 能做什么,但 B 的阅读器仍然要哈希 A 的信封字节,所以整个数组必须逐字节幸存。如果你还在这个模型和普通密码之间摇摆,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 信封。它的最后两个参数从 THPDFRSAOAEPHash 里挑 OAEP 摘要和 MGF1 摘要(rohSHA256、rohSHA384 或 rohSHA512),都默认 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;

那段代码里有三个承重细节。种子对所有密钥类型都是 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 直接拒绝

添加 ECDH 接收方:P-256、P-384、P-521、X25519 与 X448

椭圆曲线证书走 AddPubKeyAgreementRecipientWithSecret:它写一个 CMS 密钥协商接收方(RFC 5753 的 KeyAgreeRecipientInfo,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 派生 originator 公钥(HPDFX25519PublicFromScalar、HPDFX448PublicFromScalar),所以 P-256、P-384 和 P-521 的临时密钥对得由你自己的工具生成。传入恰好等于域大小的大端标量(32、48 或 66 字节)和匹配的未压缩 0x04||X||Y 点(65、97 或 133 字节)作为 OriginatorPublicKey,并把同一个点交给 OwnPublicPoint——留给 NIST 方案的参数。长度不对立即抛异常。从证书取的接收方点在任何乘法运行之前先对照曲线方程验证

任何库都查不了的是你的 originator 公钥是否真的属于你的标量。两半不匹配照样产出格式良好的信封。写入方从标量乘接收方点派生密钥,接收方从自己的私钥乘错误的 originator 点派生密钥,而接收方侧 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 密钥包裹内容密钥,构建 KeyAgreeRecipientInfo 信封
从 pkasECDHP256 到 pkasX448 的方案值必须与证书密钥匹配,而标量与公点两半不匹配照样产出格式良好、却无人能打开的信封

为什么 /Recipients 的顺序要紧?

/Recipients 的顺序要紧,因为文件密钥是对种子加数组顺序里每个信封的摘要,写入方和读取方必须按同一序列哈希同样的字节。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 条目写成 hex string 并豁免于字符串加密,因为阅读器得在持有任何密钥之前先拿到信封
  • DER BIT STRING 的第一个内容字节计未用位数,字节对齐的密钥必须是 $00。丢掉这个字节、把 0x04||X||Y 点直接塞进内容的手写 DER,会把整个密钥挪动一个字节,严格的解析器直接拒绝 originator 密钥
  • 当本该能用的钥匙还是解不开时,按这个顺序逐层比较:文件密钥、密文前缀的 IV、对象密钥、明文。故障就坐在第一处不一致那层的后面

用私钥加载证书加密的 PDF

私钥必须在 LoadFromFile 运行之前注册,因为 HotPDF 在结构遍历阶段就回收文件密钥。用 HPDFParsePFX 解析出的 RSA 身份放进 PubSecKeyMaterial,更多 RSA 密钥进 AddPubSecKeyMaterial——它拒绝没有模数和私有指数的材料。这两个密钥槽都按 RSA 模数和私有指数匹配,所以 EC 密钥——包括 PFX 来的——走第三条路:AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint),曲线 OID 用 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 把范围收窄到单个信封。回调收到信封数量,必须返回数组内的一个 0 起始索引;任何其他值——包括 -1——都在加载时抛异常。想试每个信封就别赋值这个属性。Delphi 下它是函数引用,匿名方法可用;FPC 下是普通函数指针,Context 参数来自 PubSecRecipientQueryContext

有一个行为值得单独立一项测试。没有任何注册密钥能拆开任何信封时,密钥回收在没有文件密钥的情况下结束,并不因此抛异常。对你知道文档持有的内容做断言,比如某个文本串或元数据值;正常返回的 LoadFromFile 只说明文件结构解析成功了

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

HotPDF 不保证什么

HotPDF 的写入方和读取方逐字节一致,信封遵循上文引用的 CMS 结构。第三方阅读器是另一个问题:对 RSA-OAEP 密钥传输的支持——对 X25519 或 X448 接收方的支持更甚——因阅读器而异,同一阅读器的不同版本之间也不同。文档必须在特定阅读器里打开时,先用同一密钥类型的测试证书加密一个测试文件、在那边打开验证,再敲定方案。阅读器不在你控制范围内时,它的支持列表决定方案,这比前文 X25519 的便利论证优先级更高

信封里的权限仍然是合规软件会遵守的策略,与密码加密下完全一样。种子质量是你的责任:AESGenerateRandomBytes 就是为这活准备的,文件密钥派生完成后 HotPDF 会擦掉自己手里那份种子。某个字符串、流或附件需要不同 crypt filter 时,StmF、StrF 与 EFF 的 crypt filter 策略指南 介绍怎么指定;公钥处理器接受 DefaultCryptFilter 或 Identity

RSA 与椭圆曲线证书的逐接收方信封,以及回头路上的密钥加载,都是面向 Delphi 和 C++Builder 的 HotPDF PDF component 的一部分,与密码加密和数字签名并列