校验一个 PAdES 签名意味着检查三件互相独立的事,而查看器里那个绿色对勾只告诉你第三件。第一,/ByteRange 数组必须覆盖正确的字节:它所指明的各个区段必须能重建出 CMS 摘要当初所取的那份输入,且不留任何已签名字节在区段之外。第二,CMS 内部的证书必须能链到你信任的某个根,并携带 PAdES 所要求的那条已签名的 signing-certificate 属性。第三,如果所声称的档次带时间戳,就必须有一个 RFC 3161 令牌把签名值绑定到证书过期之前的某个时间点上。Acrobat 把这三件事坍缩成一个图标;一致性检查器会把它们分开看待,而生成这些文件的代码也应当如此。losLab PDF Library(PDF Library for Delphi)为你提供其中的签名一侧、时间戳的重新嵌入,以及在你信任某个 ByteRange 之前用来检视它的审计调用
有一处区分几乎绊倒了每一次初次实现 PAdES 的尝试,因此值得在任何代码之前先说清楚。用 /SubFilter /adbe.pkcs7.detached 写出的签名是一个完全站得住脚的 ISO 32000-1 §12.8 签名,Acrobat 会报告它有效。它同时不是 PAdES 签名,因为 ETSI EN 319 142-1 在每个基线档次上都要求 ETSI.CAdES.detached。一个 eIDAS 一致性检查器会拒绝前者、接受后者,哪怕两者的密码学部分完全相同。档次是文档对自身作出的一项声明,而在 PDF Library for Delphi 里把这项声明写对只需一次调用
是什么让一个 PDF 签名成为 PAdES 签名
ETSI EN 319 142-1 在 CMS 格式之上定义了四个逐层堆叠的基线档次。PAdES-B-B 是入口:一个位于 PDF 签名字段中的 CAdES 签名,带 ETSI.CAdES.detached SubFilter 和一条已签名的 signing-certificate 属性。PAdES-B-T 在签名值之上加一个 RFC 3161 时间戳,证明该签名在某个无人能倒填的时间点之前就已存在。PAdES-B-LT 把校验所需的证书、CRL 和 OCSP 响应嵌入一个文档安全存储,使得文件在签发 CA 关停其基础设施之后仍可验证。PAdES-B-LTA 则用一个文档时间戳给这堆东西封顶,在算法逐渐变弱的过程中重新保护已积累的证据
PDF Library for Delphi 把这些概念映射到它的签名过程 API 上。档次标记是 SetSignProcessCustomSubFilter。如果你的策略需要承诺类型指示(来源证明、批准证明,或 ETSI 编号 1 到 6 的其他标识符之一),那就走 SetSignProcessCommitmentType。显式签名策略经 SetSignProcessSignaturePolicy 附加,它接受策略 OID 及其摘要。有一个默认值值得留意:把摘要算法留在自动挡时,库会为 ETSI 和 adbe.pkcs7.detached 签名选择 SHA-256,仅在遗留的 adbe.pkcs7.sha1 路径上退回 SHA-1。即便如此也请显式设定它。审计人员会问你用了哪种哈希,而代码里一个明写的值,比一个要翻手册才能解释的默认值好辩护得多
生成基线签名
扁平 API 把签名驱动成一台一次性的状态机:在源文件上开一个过程,配置它,收尾输出到目标文件,再读取结果码。下面这段序列产出一个采用 SHA-256 的 PAdES-B-B 签名。其中最要紧的那一行与签名本身毫无关系。它是那个刻意开大的 /Contents 预留空间,因为一旦日后要给这个签名加时间戳,这就是你唯一改不了的东西
var
Pdf: TPDFlib;
SignId: Integer;
begin
Pdf := TPDFlib.Create;
try
SignId := Pdf.NewSignProcessFromFile('invoice.pdf', '');
if SignId = 0 then
raise Exception.Create('cannot open source PDF');
Pdf.SetSignProcessField(SignId, 'Sig1');
Pdf.SetSignProcessPFXFromFile(SignId, 'company.pfx', PfxPassword);
Pdf.SetSignProcessInfo(SignId, 'Approved', 'Vienna', 'billing@example.com');
Pdf.SetSignProcessCustomSubFilter(SignId, 'ETSI.CAdES.detached');
Pdf.SetSignProcessDigestAlgorithm(SignId, 2); // SHA-256
Pdf.SetSignProcessReserveContentsBytes(SignId, 8192); // 给日后的时间戳留出空间
Pdf.EndSignProcessToFile(SignId, 'invoice-signed.pdf');
if Pdf.GetSignProcessResult(SignId) <> 1 then
raise Exception.CreateFmt('signing failed, code %d',
[Pdf.GetSignProcessResult(SignId)]);
Pdf.ReleaseSignProcess(SignId);
finally
Pdf.Free;
end;
end;
源文件根本打不开时,NewSignProcessFromFile 返回 0。在那之后,GetSignProcessResult 把生产环境里真正会出现的几种失败模式区分开来:4 表示 PDF 口令不对,7 表示 PFX 口令不对,9 表示证书文件里没有私钥,10 表示输出路径不可写,11 表示写入签名字节时失败。把这个数字码连同输入文件名一起记进日志,能把一张含混的支持工单变成一分钟就能下的诊断
加上库不会替你去取的那个 RFC 3161 时间戳
PDF Library for Delphi 不附带 TSA 客户端,这是一条刻意划下的边界,而不是一处缺失。库负责算出时间戳机构需要联署的那个哈希,并在事后把增强过的 CMS 重新嵌回去;中间的 HTTP 交互和 CMS 手术属于调用方。这种分工有一个硬性的技术理由。名义上用来添加未签名属性的那个 Windows CryptoAPI 控制码 CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR,在 PAdES 所用的分离式 SignedData 布局上会以 CRYPT_E_INVALID_INDEX 失败。因此增强后的 CMS 必须出自一个由你自己掌控的 CMS 编码器。没有哪个库能靠一次系统调用悄悄把令牌折进去,凡是声称能做到的,都是在你看不见的地方动了刀
var
Pdf: TPDFlib;
StsId: Integer;
HashHex, TstDer, TsAttr, AugmentedCms: AnsiString;
begin
Pdf := TPDFlib.Create;
try
StsId := Pdf.NewPAdESSignatureTimeStampProcessFromFile('invoice-signed.pdf', '');
Pdf.SetPAdESSignatureTimeStampField(StsId, 'Sig1');
Pdf.SetPAdESSignatureTimeStampDigestAlgorithm(StsId, 2);
HashHex := Pdf.GetPAdESSignatureValueHashHex(StsId);
// 下面两个调用都属于应用代码:一次发往你的 TSA 的 HTTP POST,
// 以及一次把令牌作为未签名属性挂上去的 CMS 重编码
TstDer := RequestTimeStampToken(HashHex);
TsAttr := Pdf.BuildPAdESSignatureTimeStampAttribute(TstDer);
AugmentedCms := AttachUnsignedAttribute(Pdf.GetPAdESSignatureCMSBytes(StsId), TsAttr);
Pdf.SetPAdESSignatureCMSBytes(StsId, AugmentedCms);
Pdf.EndPAdESSignatureTimeStampProcessToFile(StsId, 'invoice-bt.pdf');
if Pdf.GetPAdESSignatureTimeStampProcessResult(StsId) <> 1 then
raise Exception.Create('timestamp embedding failed');
Pdf.ReleasePAdESSignatureTimeStampProcess(StsId);
finally
Pdf.Free;
end;
end;
这里要盯住结果码:12 表示指定的签名字段不存在,11 表示既有 CMS 无法解析,13 表示增强后的 CMS 已经塞不进预留的 /Contents 占位符。真正让人肉痛的是 13,因为唯一的修法是重新签名:一个典型的时间戳令牌连同它的证书链要占 4 到 6 KB,而在 B-B 那一步预留的 8192 字节,存在的意义正是让这一步有地方落脚
校验从 ByteRange 开始,而不是从证书链开始
查看器里的绿色对勾,是针对那台机器的证书存储所作的一次信任判定,而不是关于文件结构的裁决。程序化校验应当从更低的位置起步,从那个被增量更新弄得微妙起来的问题开始:每个签名到底覆盖了哪些字节?本文讨论的每一项增强,无论是第二个签名、一个 DSS 字典还是一个文档时间戳,都是经增量更新到来的,而每一次更新都会在先前签名的 /ByteRange 之外追加字节。这些追加的字节是正当的。校验器仍然要拿文档的修改策略去给它们定性,而承载该策略的按字段 DocMDP 档次可以用 GetSignatureDocMDPLevelByName 读出
var
Doc: TPDFlibSignDoc;
Names: TStringList;
I: Integer;
B0, B1, B2, B3, FileSize: Int64;
begin
FileSize := TFile.GetSize('invoice-bt.pdf'); // 要在 Open 之前:SignDoc 会持有共享锁
Doc := TPDFlibSignDoc.Create;
try
if not Doc.Open('invoice-bt.pdf', '', False) then
raise Exception.Create('cannot open for audit');
Names := TStringList.Create;
try
Doc.GetSignatureFieldNames(Names);
for I := 0 to Names.Count - 1 do
if Doc.GetSignatureValueObjNum(Names[I]) > 0 then // >0 表示确实已签名
begin
B0 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
B1 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
B2 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
B3 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
if (B0 = 0) and (B2 + B3 = FileSize) then
Writeln(Names[I], ': covers the file to EOF')
else
Writeln(Names[I], ': earlier revision, or unexpected ByteRange layout');
end;
finally
Names.Free;
end;
Doc.Close;
finally
Doc.Free;
end;
end;
这条审计路径上住着两个陷阱。TPDFlibSignDoc.Open 会以独占共享锁持有文件,因此一个还想读取原始文件字节来做 CMS 验证的校验器,必须在为审计打开它之前先把文件读进内存。把顺序反过来,读取就会栽在你自己设下的锁上。第二个陷阱是无声的而非喧哗的:扁平 API 里的对应函数 GetSignProcessByteRange 返回 Integer,而底层偏移量是 Int64,于是超过 2 GB 之后这个扁平调用会一声不吭地截断,这也正是本例改用审计类来取偏移量的原因。还有一处缺席也值得点名。扁平层完全没有 VerifySignature 这样的封装。密码学层面的裁决来自类层的 TPDFlibSignatureVerifier,它返回 vsValid、vsInvalid 或 vsUnknown,也可以来自你的合规策略本就信任的某个外部校验器
长期校验:DSS、VRI 与文档时间戳
PAdES-B-LT 之所以存在,是因为吊销基础设施也有寿命。ETSI EN 319 142-1 §5.4.2.2 规定了文档安全存储:一个文档级字典,承载证书、CRL 和 OCSP 响应,还可以通过以各签名 /Contents 哈希为键的 VRI 条目按签名建立索引。PDF Library for Delphi 的流程与时间戳那套设计如出一辙。NewPAdESDSSProcessFromFile 开启过程;AddPAdESDSSCertificate、AddPAdESDSSCRL 和 AddPAdESDSSOCSP 接受 DER 二进制块;AddPAdESDSSVRI 把选定的材料绑定到某一个签名上;EndPAdESDSSProcessToFile 把所有内容作为增量更新写出。难的部分仍在你这一侧。取回吊销材料,并判断它是否新鲜到值得嵌入,是调用方的活儿。库保证这些字典在结构上是合规的;它保证不了你的 OCSP 响应方说的是实话
归档终点 B-LTA 添加的是一个文档时间戳:一个类型为 DocTimeStamp 而非 Sig 的独立签名字段,经 SetSignProcessDocTimeStamp 并配一个预留签名长度产出。它并不取代 B-T 那一步得到的签名时间戳。签名时间戳证明的是某一个特定签名何时存在;文档时间戳保护的是整份文件,连同 DSS 证据在内,也正是长期归档在算法变弱时每隔几年就要续期的那个元素。一套成熟的归档档次两者都带。对于早于这些结构的阅读器,TPDFlibSignDoc.EnsurePAdESExtensions 会在文档目录中登记 ESIC 开发者扩展,声明该文件使用了 ETSI 定义的特性
有一种反应值得提前挡掉,因为它看起来像 bug,其实不是。对一份 PAdES 结构完全正确的文件,查看器常常报告「有效性未知」。信任与结构是两条互相独立的轴。查看器只是无法在那台机器上把签名者链到某个受信任的根,这在使用私有 CA 和测试证书时属于家常便饭,而与此同时 ByteRange 审计和 CMS 验证两者都能通过。修法是把根证书正确分发出去,或者在真正目标是取得 eIDAS 合格状态时改为对照欧盟信任清单来评估,而不是去动签名代码
关于审计一侧的视角,也就是跨语料枚举签名字段、导出 ByteRange 布局并批量读取 DocMDP 档次,参见配套文章合规与签名工作台。既要签名又要满足归档策略的文档,属于 Delphi 中的 PDF/A 与 PDF/UA 预检所描述的那套工作流。完整的 API 文档和评估版下载见 losLab PDF Library for Delphi 产品页