一个把合规校验与数字签名串起来的工作台,必须按固定顺序协调四个步骤,而且整个过程都要牢牢绑在同一组字节上。先跑一次 PDF/A 或 PDF/UA 预检;再根据发现的问题做修复,并保存出一个修正后的修订版;然后对这个精确的修订版签名;最后再把签完的文件读回来,确认签名确实覆盖了它。这个顺序不是形式主义。跳过回读,你信任的只是自己的写入路径;让预检针对了错误的修订版,你的合规报告描述的就是一个根本没有交付出去的文件
大多数自制流水线真正出错的地方,是校验与签名之间那道缝。把它们拆成两个独立工具,中间再夹一个修复步骤,就至少会产生三个不同修订版,每一个都有自己的一组字节。你交给审计员的预检报告描述的是其中一个,签名冻结的却是另一个;文件里没有任何地方能证明它们是同一个修订版,而现实里通常也不是。PDFlibPas 这套面向 Delphi 和 C++Builder 的 losLab PDF Developer Library,把预检和 PAdES 签名都放在同一个门面类后面,因此整个序列可以待在同一个进程里,从头到尾不丢失自己在跟哪一组字节打交道。下面每一个调用今天都真实存在于库里,旁边提到的每一个坑也都真实存在
同一份文档的三个修订版,以及这道缝是怎么打开的
数一数保存次数。原始文件从上游进来;修复步骤加载它,打开某种合规模式,写出一个修正后的修订版;签名步骤再通过增量更新追加签名,这就是第三次写入。三次保存,三种字节布局,如果预检报告不明确说清它覆盖的是哪一个,那么这份报告就没有意义。对文件做一个 SHA-256,并把它记录在每一次预检和每一次签名旁边,是最低成本、却极有效的锚点,能让你证明被验证的修订版就是被签署的修订版
库里有一个行为,会把这种纪律再收紧一步。通过 SetPDFAMode 或 SetPDFUAMode 请求的合规修复,并不会在你调用它们的那一刻生效,而是在保存期间才真正落地。像强制批注启用打印标志、为 PDF/UA 指定标签页顺序这类自动修复,只会进入输出文件,不会出现在别的地方。所以如果你对内存里刚刚“修复”过的文档做检查,其实并不能说明即将送给签名器的那组字节是什么样。先保存,再对保存出来的文件做预检。内存状态只是草稿,磁盘上的文件才是真的
从磁盘做预检,以及那个一值两义的零
平面 API 的预检入口是 CheckFileCompliance(FileName, Password, ComplianceTest, Options)。测试值 1 选择 PDF/A,也就是 ISO 19005;测试值 2 选择 PDF/UA,也就是 ISO 14289。它通过库自己的流式读取器打开文件,所以不需要先 LoadFromFile,返回的是一个字符串列表句柄,里面每个条目对应一条发现
var
PDF: TPDFlib;
ListID, I: Integer;
begin
PDF := TPDFlib.Create;
try
ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0); // 1 = PDF/A
if ListID = 0 then
begin
if PDF.LastErrorCode <> 0 then
raise Exception.Create('Preflight could not read the file')
else
Writeln('No PDF/A findings');
end
else
begin
for I := 0 to PDF.GetStringListCount(ListID) - 1 do
Writeln(PDF.GetStringListItem(ListID, I));
PDF.ReleaseStringList(ListID);
end;
finally
PDF.Free;
end;
end;
坑就在返回值上,而且这种坑能轻松通过所有 happy path 测试。零既可能表示“没有发现问题”,也可能表示“文件根本打不开”,因为只要结果列表为空,实现都会返回 0,包括读取失败的情况。如果工作台把 0 一律当成绿灯,它就会兴高采烈地批准一个只是被别的进程锁住了的文件。像上面那样把调用与 LastErrorCode 绑在一起,才是区分这两种情况的方法。检查器还会用拒绝写入的共享模式打开文件,所以如果你的修复步骤还握着一个写句柄,预检失败的原因就与合规无关,而纯粹是因为你忘了释放流
当需要让人而不是流水线来阅读这些发现时,CreatePreflightReport 会把它们渲染成可读报告。ComparePreflightReports 则可以对两次运行做差异比较,这是一种非常干净的方式,用来证明修复清掉了原始问题,同时没有悄悄引入新的问题
用 SignProcess 给已检查的修订版签名
一旦保存出来的修订版通过了预检,而且它的哈希已经留档,就只签这个文件,不签别的。SignProcess API 用起来像 builder:先打开一个流程句柄,再一行一行配置,提交之后,再把结果码读回来
ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached'); // PAdES baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2); // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192); // room for a later timestamp
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);
这个序列里有两行看起来不起眼,实际上分量很重。用 SetSignProcessCustomSubFilter 配上 ETSI.CAdES.detached,选的是符合 ETSI EN 319 142-1 轮廓的 PAdES 签名,而不是老旧的 adbe.pkcs7.detached 家族;对欧洲验证器来说,这就是“接受”与“标红”的区别。SetSignProcessReserveContentsBytes 则是在给 /Contents 占位符预留空间,而你在这里选的大小,本质上是在为未来做决定:如果以后还要追加签名时间戳,增大的 CMS 必须塞进你现在留出的空间里,因为这个占位符之后不能再长大,除非你把整份文件重新签一遍。留得宽裕,代价不过是多占几 KB;留得太紧,几个月之后时间戳步骤会因为溢出而失败,而你几乎很难把那个故障追溯回今天这一行代码
GetSignProcessResult 返回的是一个代码,不是布尔值,而这些代码值得保留。1 表示成功;4 表示 PDF 密码错误;7 表示证书密码错误;9 表示 PFX 里根本没有私钥;11 表示应用签名时发生失败。把这些结果压扁成 true 或 false,你就扔掉了区分“用户输错密码”和“证书根本没私钥”这两类工单的唯一线索。把整数码记下来
回读:审计你刚刚产出的文件
任何工作台都不该盲信自己刚刚用来写文件的那条路径。审计类 TPDFlibSignDoc 会重新打开签完的输出文件,并直接从磁盘读取签名字典里的条目
var
Doc: TPDFlibSignDoc;
Names: TStringList;
FS: TFileStream;
I: Integer;
SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
// Capture the size before Open: the audit object holds a share lock on the file
FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
SourceSize := FS.Size;
FS.Free;
Doc := TPDFlibSignDoc.Create;
Names := TStringList.Create;
try
if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
Doc.GetSignatureFieldNames(Names);
for I := 0 to Names.Count - 1 do
if Doc.GetSignatureValueObjNum(Names[I]) > 0 then // > 0 means the field is signed
begin
RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
GapStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
TailStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
TailLen := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
Writeln(Names[I], ': signature covers the file to EOF')
else
Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
end;
Doc.Close;
finally
Names.Free;
Doc.Free;
end;
end;
这里的 ValueKey 参数会映射到不同字典项。键 0 返回 /Contents 里的原始 CMS,键 2 和 3 返回 /Filter 与 /SubFilter 的名称,键 11 到 14 则是四个 ByteRange 数字。文本值则要通过 GetSignatureTextValueByName 读取:键 0 是声称的签名时间,键 5 用来区分普通 Sig 和 DocTimeStamp,而一旦一份文档同时拥有两者,这个区分就变得很重要
示例最前面那段“先抓文件大小”的代码,并不是打扫卫生,而是承重件。TPDFlibSignDoc.Open 会在整个生命周期里用比较严格的共享锁持有文件,所以任何还需要原始字节的步骤,比如对已签名范围做哈希、重新计算 CMS 摘要,都必须在调用 Open 之前把文件读出来。库自带的 SigningWorkbench 演示之所以会先把整个文件读进内存,原因正是如此。忽略这个顺序的工作台,会在某些机器上间歇性失败,只取决于谁刚好输掉了那场竞争
能证明覆盖范围的 ByteRange 算术
一个健康的单签名文件,其 ByteRange 形式通常是 [0 a b c]:覆盖从偏移 0 开始,在 a 与 b 之间跳过十六进制 /Contents 占位符,然后从 b 继续覆盖 c 个字节。如果 b+c 恰好等于文件大小,就说明签名覆盖到了文件末尾,这通常正是你想要的结果。如果它小于文件大小,就说明有人在签名写入之后又追加了一次增量更新。按照 ISO 32000-1 第 12.8 节的规则,这完全合法,因为后续表单填写、第二个签名以及 DSS 字典,都是这样追加进来的。但这也正是工作台应该在签名当时就记录下来的事实,而不是等争议发生后再在高压下慢慢还原
做这类算术时要留意整数宽度。平面 API 的 GetSignProcessByteRange 返回 32 位 Integer,但底层值实际上是 Int64,所以一旦文件超过 2 GB,这个扁平访问器就会悄悄截断。更稳妥的做法,是用类层的 TPDFlibSigner.GetByteRange,它返回 Int64;或者像上面的审计代码那样,直接从 GetSignatureValueByName 里把数值读出来自己解析
库留给你自己处理的部分
有两个边界,最好在设计阶段就认清,而不是到最后冲刺时才发现。第一,平面 TPDFlib API 根本没有签名验证的封装;真正的密码学验证位于下一层,由 TPDFlibSignatureVerifier 提供,它的 VerifySignature 会返回 valid、invalid 或 unknown。第二,库也没有内置面向 RFC 3161 时间戳机构的 HTTP 客户端。库能算出要提交的哈希,也能在令牌回来后把增强过的 CMS 嵌回去,但和 TSA 之间的网络往返要你自己写。这两件事都不难封装,但如果你在发布前一周才发现它们缺席,会非常痛苦,所以最好一开始就把它们纳入设计
还有一个与合规相关的问题值得直接说清,因为它决定最后一道闸门放在哪里:加上签名会不会破坏 PDF/A。单靠签名本身不会。签名是以增量更新方式追加进去的,而从 ISO 19005-2 开始,标准明确允许已签名文档。真正的约束在于签名外观,它和其他页面内容一样,必须遵守嵌入字体、不得依赖设备颜色等规则。因此工作台里的最后一道关卡,应该是再跑一次预检,这一次针对的是已签名输出。把 CheckFileCompliance 当作流水线内的快速检查,同时仍然用 veraPDF 这类独立工具去验证候选发布版,因为不同验证器的规则集虽有重叠,却并不完全一致;当两边结论不一致时,发现文本通常会直接告诉你该去查哪一条规范
由此还会自然推导出一个时序点:签名和时间戳不是一个单次过程。先写入基础签名,然后再由独立的时间戳流程扩充保留在 /Contents 里的 CMS,这正是前面那行预留字节代码之所以那么重要的原因。对于在这个工作台之上继续叠加的时间戳与长期验证层,PAdES 签名与验证演练 会把签名从 baseline 一路带到 B-LT,而预检这一半则会在PDF/A 与 PDF/UA 预检指南里讲得更深。完整 API 文档和试用下载则位于PDFlibPas 产品页