PDFiumPas 把 PAdES 签名拆成两次调用,这样私钥就永远不需要出现在你的进程里。PreparePadesRemoteSignature 写出一次增量更新,其中包含一个空的定长 /Contents 占位符,并返回一条携带 SHA-256 文档摘要、精确 ByteRange 和已准备文件指纹的请求记录。CompletePadesRemoteSignature 接收你的签名服务返回的 detached CMS,把它填进这个预留的位置
在这两次调用之间,可能过去几分钟,也可能过去几个小时,进程可能会重启,工作也可能转移到另一台机器上。正是这段间隔,决定了这个 API 为什么要设计成这个样子
为什么远程密钥不能用普通的签名调用?
因为 SignPadesBytes 假定签名操作是在这次调用内部完成的。它会构建增量更新、对 ByteRange 计算摘要、签名,然后写出结果,这一切都在返回之前完成。当密钥保存在 Windows 证书存储中,或者保存在你已经加载的 PKCS#12 文件里时,这样做完全没问题
但当密钥保存在一个网络 HSM 里、一个由信任服务提供商运营的合格签名创建设备里,或者一个需要用户在手机上确认的云端签名 API 里时,这种做法就行不通了。在这些场景下,整个流程不是一次函数调用,而是一场对话:你发送一个摘要,某个别的东西去认证一个真人身份,稍后才会返回一个 CMS。一个同步 API 无法在不阻塞线程的前提下表达"稍后",而这个操作还可能需要一个第二因素
两阶段协议
第一阶段准备文档。PDFiumPas 追加签名字段和签名值字典,在 /Contents 中预留 ContentsSize 字节的十六进制编码空间,围绕这块预留空间计算 ByteRange,并生成一个包含 FormatVersion、PreparedFingerprint、DocumentDigest、四元素 ByteRange、ContentsHexOffset 和 ContentsSize 的 TPadesRemoteSigningRequest
你的签名服务唯一需要的值就是 DocumentDigest:返回的 CAdES SignedData 必须把它作为自己的消息摘要携带。记录里的其余所有内容,存在的目的都是为了让第二阶段能够证明,它正在完成签名的这份文件,就是当初计算出这个摘要的那份文件
uses
FPdfPades;
var
Options: TPadesRemoteSignOptions;
Request: TPadesRemoteSigningRequest;
Source, Prepared, Session: TFileStream;
begin
Options := TPadesRemoteSignOptions.Default;
Options.Reason := 'Approved by finance';
Options.Location := 'Lisbon';
Options.Name := 'A. Moreira';
Options.SigningTimeUtc := NowUtc;
Options.ContentsSize := 16384; // 为 CMS 预留的十六进制字节数
Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
Prepared := TFileStream.Create('contract.prepared.pdf', fmCreate);
try
PreparePadesRemoteSignature(Source, Prepared, Options, Request);
finally
Prepared.Free;
Source.Free;
end;
// 持久化保存这次会话,方便之后的一次运行——或者另一台机器——把它完成
Session := TFileStream.Create('contract.signreq', fmCreate);
try
SavePadesRemoteSigningRequest(Session, Request);
finally
Session.Free;
end;
SendDigestToSigningService(Request.DocumentDigest);
end;
Complete 会拒绝什么,每一项检查为什么存在?
完成阶段正是远程签名设计通常出问题的地方,因此这里的校验被有意设计得毫不留情。CompletePadesRemoteSignature 会拒绝:指纹已经和请求对不上的已准备 PDF、和记录的占位符坐标不一致的 ByteRange、被修改过的 /Contents 分隔符、不再为空的占位符、超出预留空间的 CMS、不是恰好一个 DER 值的 CMS、不受支持的 SignedData 结构、缺失 signing-certificate-v2 属性,以及消息摘要和准备阶段文档摘要不一致的 CMS
每一项检查对应的都是一次真实的故障。指纹和 ByteRange 检查,抓的是有人在两个阶段之间重新生成了准备好的文件的情况,这会产生一个针对谁都没有的字节序列进行校验的签名。空占位符检查,抓的是重复完成的情况,也就是第二个 CMS 被写到了一个已经存在的签名上面。消息摘要检查,抓的是最危险的一种情况:一个格式完全正确的 CMS,却是针对另一份文档签的名——这正是队列把两个并发签名会话搞混时会发生的事。没有这项检查,你产出的文件看起来像是签过名,却在任何地方校验都会失败,更糟的情况是,它携带的是别人的批准
signing-certificate-v2 这项要求属于 PAdES 合规性问题,而不是完整性问题。ETSI EN 319 142 要求签名证书被绑定进签名属性中,缺少这个属性的 CMS,即使密码学校验能通过,也不是一个 PAdES 签名。在完成阶段就拒绝它,意味着你会在这里发现问题,而不是在客户发来的校验器报告里发现,这个话题在为什么校验器会拒绝 PAdES 签名一文中有更深入的探讨
var
Request: TPadesRemoteSigningRequest;
Session, Prepared, Dest: TFileStream;
CmsDer: TBytes;
begin
Session := TFileStream.Create('contract.signreq', fmOpenRead);
try
Request := LoadPadesRemoteSigningRequest(Session);
finally
Session.Free;
end;
CmsDer := FetchDetachedCmsFromService; // 由 HSM 或 TSP 返回
Prepared := TFileStream.Create('contract.prepared.pdf', fmOpenRead);
Dest := TFileStream.Create('contract.signed.pdf', fmCreate);
try
try
CompletePadesRemoteSignature(Prepared, Dest, Request, CmsDer);
except
on E: EPadesCrypto do
// 每一次拒绝都带有具体原因;原样记录下来
FailSession(E.Message);
end;
finally
Dest.Free;
Prepared.Free;
end;
end;
跨越进程与机器边界
SavePadesRemoteSigningRequest 和 LoadPadesRemoteSigningRequest 通过一种稳定的带版本号的二进制格式来序列化会话,正是这一点让这套设计不仅正确,而且实用。一个 Web 应用可以在一次请求中准备好文档,存储已准备的 PDF 和会话数据块,把摘要返回给浏览器供智能卡签名使用,再在完全不同的另一个请求处理器里完成文件签名
FormatVersion 字段是保证这一切能安全跨版本升级的关键。一个由旧版本写出、被新版本加载的会话,会被明确识别或明确拒绝,而不是被误读成一个形状不同的记录。如果你的队列会把会话保留好几天,就应该把格式版本当作一个值得记录的运维事实,而不是一个实现细节
给占位符定尺寸
ContentsSize 是唯一一个你必须认真考虑的参数,因为它在 CMS 存在之前就已经固定下来了。它统计的是十六进制编码后的预留空间,因此一个 6 KB 的 DER CMS 至少需要 12 KB 的空间,实现把预留空间的上限设在了 64 MiB
预留得太少,会在你的签名服务已经完成工作之后,因为 CMS 超出预留空间而导致完成阶段失败,如果用的是计量计费的合格签名服务,这就意味着白白浪费了一次操作。预留得太多,每一份签名文档就都要永久携带这些填充空间。合理的做法是实际测量:用你真实的证书链签一份文档,看看 DER 长度,十六进制编码后翻倍,如果打算升级到 T 级签名,再为时间戳令牌留出充裕的余量。带有多个中间证书和较长 OCSP 响应的证书链,增长速度往往比人们预期的要快
签名之后还需要做什么
一次完成的远程签名是 PAdES B-B 级。长期校验需要一个时间戳和相应的校验材料,这需要另一次独立的增量更新,添加一个 DSS 及其针对每个签名的 VRI 字典,具体做法在带 RFC 3161 时间戳和 DSS 的长期签名一文中有说明。这一步是本地完成的:它添加的是证书、OCSP 响应和 CRL,全都不需要用到私钥
在正式发布之前,应该用依赖方将来会用到的那条代码路径,去校验你产出的结果,具体做法在检查数字签名与 PAdES 级别一文中有说明。签名和校验是两套不同的代码,一条远程签名流水线正是这两者最容易在没人察觉的情况下悄悄走偏的地方,直到某个外部校验器发出警报才会被发现
PDFiumPas 是基于 PDFium 引擎的 Delphi 和 Lazarus 组件,配备原生 Pascal 实现的 PAdES 技术栈,因此签名、时间戳和校验都无需借助任何外部命令行工具。完整的 API 文档和试用版见 PDFium Delphi 组件页面