证书加密的 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 密码加密与权限标志指南讲的是那笔交易的另一面
用 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 曲线才有其位置——那种情况下,往返加载不再是可选项
为什么 /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 不保证什么
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 的一部分,与密码加密和数字签名并列