HotPDF 通过三个 THotPDF 方法在已加载 the 的 PDF 文档中验证数字签名:GetLoadedSignatureInfo、VerifyLoadedSignature 和 VerifyLoadedSignatureEx,这些方法是在 v2.259.0 中引入的。该组件重新对原始文件的 /ByteRange 片段进行哈希计算,检查 CMS 的 messageDigest 属性,并针对嵌入的签名者证书运行 RSA PKCS#1 v1.5 验证,在文档字节完好无损时返回 svValid
场景很平常,但利害关系却不一般。交易对手返回了一份已签署的合同,您的工作流需要归档它,这时有人提出了唯一重要的问题:这是否是交易对手送回的、逐字节与我们发送的完全一致的文档,且由它声称的证书签署?在代码中回答这个问题是签名故事的验证端;而最初构建和嵌入 PAdES 签名的签署端,已在关于使用 HotPDF 创建 PAdES 数字签名的配套文章中有所介绍。本文是关于另一个方向的:一个 PDF 送达时已经签了名,而您想要一个程序判决结果,而不是 Acrobat 绿色对勾的屏幕截图
已签名的 PDF 如何证明它没有被篡改?
PDF 签名保护的是文件的特定字节范围,而不是“文档”这一抽象概念。ISO 32000-1 §12.8 定义了该机制:签名表单字段携带一个字典,根据 §12.8.1,其 /Contents 条目保存一个 CMS SignedData 容器(RFC 5652),其 /ByteRange 数组命名了签名覆盖的准确文件区域。该数组是一个偏移量和长度对的列表,实际上是两个片段:/Contents 十六进制字符串之前的每一部分,以及其后的每一部分。签名值不能覆盖自身,因此文件是围绕该空洞进行哈希的
这种设计产生了一个塑造了整个 API 的后果:验证必须对原始序列化的字节(即它们在磁盘上的确切存在形式)进行哈希计算。解析后的对象模型对此毫无用处,因为即使重新序列化未更改的文档也会产生不同的字节。因此,HotPDF 针对加载该文档的源文件或针对您提供的原始字节 TStream 进行验证,绝不针对其内存中的表示形式进行验证
在验证任何内容之前读取签名元数据
GetLoadedSignatureInfo 解析签名字典及其 CMS 容器,而不触及单个文档字节,当您仅需要显示谁签署了以及何时签署时,这使其成为最合适的第一步调用。签名字段按表单字段顺序从 0 开始索引,而 GetLoadedSignatureFieldCount 会告诉您存在多少个签名字段。返回的 THPDFSignatureInfo 记录携带字段名称、/SubFilter、签名者证书的常用名称(CN)、主题(subject)和颁发者(issuer)的可区分名称(DN)、序列号、有效日期、签署时间(存在已签署属性时来自该属性,否则来自字典的 /M 条目)、摘要算法名称,以及 /Reason、/Location 和 /ContactInfo 字符串。其 Status 成员保持为 svNotVerified — “已解析,未验证”的坦诚标签
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('signed-contract.pdf');
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
begin
Info := Pdf.GetLoadedSignatureInfo(I);
Writeln('Field: ', Info.FieldName);
Writeln('Signer: ', Info.SignerName);
Writeln('Issuer: ', Info.IssuerDN);
Writeln('Algorithm: ', Info.HashAlgorithm);
Writeln('SubFilter: ', Info.SubFilter);
end;
finally
Pdf.Free;
end;
end;
运行加密学检查
VerifyLoadedSignatureEx 一次性完成文件加载文档的完整验证并返回页面中填充好的信息记录:它重新打开源文件,使用 SignerInfo 摘要算法对 /ByteRange 片段进行哈希计算,将结果与 messageDigest 已签署属性进行比较(RFC 5652 §5.4),并对已签署属性的 DER SET 重新编码进行 RSA 签名验证。当签名不携带已签署属性时,RSA 检查则直接在文档哈希上运行。支持的签名包括使用 SHA-1、SHA-256、SHA-384 或 SHA-512 摘要的 RSA PKCS#1 v1.5,这覆盖了主流签名工具生成的 adbe.pkcs7.detached 和 ETSI.CAdES.detached 子过滤器
var
Status: THPDFSignatureVerifyStatus;
Info: THPDFSignatureInfo;
begin
Status := Pdf.VerifyLoadedSignatureEx(0, Info);
case Status of
svValid:
if Info.CoversWholeDocument then
Writeln('Valid; signature covers the whole file')
else
Writeln('Valid; file was extended after signing');
svDigestMismatch:
Writeln('Document bytes changed after signing');
svSignatureInvalid:
Writeln('RSA check failed over signed attributes');
svUnsupportedAlgorithm:
Writeln('Non-RSA key or unknown digest algorithm');
svMalformed:
Writeln('CMS container could not be parsed');
svSourceUnavailable:
Writeln('No source bytes; use the TStream overload');
end;
end;
有两个实现细节值得了解,因为它们解释了从外部看起来很神秘的失败。第一,已签署属性检查对编码非常挑剔:在文件内部,属性被标记为 [0] IMPLICIT,但签名是针对它们的 DER SET OF 形式计算的,因此验证器在哈希前重新贴标签,完全符合 RFC 5652 §5.4 的要求。直接对文件中呈现的字节进行哈希计算的自研验证器会拒绝每一个正确签署的文档。第二,/Contents 按照惯例会被零填充至预留的字节预算,因此验证器在解析前将 DER blob 截断为它的外层 SEQUENCE 的实际长度;看起来像垃圾的尾随零是正常的,而不是损坏。证书导入端面临的同一系列 ASN.1 解析风险,是关于 HotPDF 中 PKCS#12 和 ASN.1 安全加固的文章的主题
一个有效的签名到底能保证什么?
svValid 确切地意味着这一点:/ByteRange 命名的字节经哈希计算后等于签名者签署的值,并且签名在 CMS 容器中嵌入的证书公钥下通过了验证。这就是字节完整性加上密钥绑定,别无其他。证书链和信任验证显式地超出了 HotPDF 验证器的范围:它不向根证书遍历证书链、不检查吊销状态,也不咨询任何信任存储。来自重新签署修改文档的攻击者的自签名证书也会验证为 svValid,因为数学计算在内部是一致的。签名者是否是他们所声称的人,以及是否应该信任他们,是一个属于独立层的策略决策,无论那是您组织的安全证书白名单、Windows 证书存储还是证书验证权威机构(CA)
CoversWholeDocument 标志防范了一个更微妙的空白。签名永远只覆盖其 /ByteRange,并且 PDF 的增量更新机制允许在签名后追加内容而不使其失效,这是故意设计的,也是多签名工作流运行的方式。该标志在验证过程中计算,只有当两个片段加上 /Contents 空隙跨越整个文件时才为真。当 svValid 返回且 CoversWholeDocument 为假时,已签署的修订版本是完整的,但文件包含后来的追加内容,而这些追加内容改变了什么,是您的工作流应当决定是否容忍的
流加载和加密的文档需要它们自己的源字节
无参数的 VerifyLoadedSignature 和 VerifyLoadedSignatureEx 依赖于组件记住文档来自哪个文件。从流中加载文档,便没有要重新打开的文件名;这也适用于针对加密文档使用的密码重新加载路径,该工作流已在关于 HotPDF 的 AES-256 PDF 加密的文章中进行了描述。在这两种情况下,文件支持的重载会返回 svSourceUnavailable 而不是瞎猜。解决方法是 TStream 重载,它允许您从保留它们的任何地方递交原始的原始字节 — 一个您仍然拥有的文件、内存缓冲区或数据库 blob
var
Src: TFileStream;
Status: THPDFSignatureVerifyStatus;
Info: THPDFSignatureInfo;
begin
// 流加载文档:组件没有保存源
// 文件名,因此请自己提供原始字节。
Src := TFileStream.Create('signed-contract.pdf',
fmOpenRead or fmShareDenyWrite);
try
Status := Pdf.VerifyLoadedSignature(0, Src, Info);
if Status <> svValid then
Writeln('Verification failed: ', Ord(Status));
finally
Src.Free;
end;
end;
报告您无法验证的内容
一个只知道“有效”和“无效”的验证器会误报它仅仅是无法理解的文档,因此状态枚举将您的 UI 应当区分的情况分隔开来。svDigestMismatch 意味着签署后文档字节发生了改变,这是典型的篡改信号。svSignatureInvalid 意味着字节哈希正确但 RSA 校验失败,这指向损坏或伪造的签名值。svUnsupportedAlgorithm 是对于 ECDSA 密钥和未识别摘要的坦白回答:签名可能是完全正常的,HotPDF 只是无法检查它,如果将其报告为“无效”将是对一个健康文档的退步。svMalformed 标记一个完全无法解析的 CMS 容器。对于网关式的检查,VerifyAllLoadedSignatures 仅在至少存在一个签名字段且其中每一个都验证为 svValid 时才返回真,这对于拒绝任何不合规文档的归档摄取管道来说是一个方便的单布尔值
签名验证、PAdES 签名、AES-256 加密和已加载文档编辑 API 全都包含在面向 Delphi 和 C++Builder 的同一个原生 VCL 库中发售,没有外部 DLL 依赖;完整的特征列表和支持的 IDE 版本位于 HotPDF Component 产品页面上