HotPDF 这个 Delphi PDF 组件现在会拒绝签名包裹(signature wrapping):从 v2.759.0 起,VerifyLoadedSignatureEx 和批量校验器都要求两个 /ByteRange 段之间的间隙恰好是 /Contents 十六进制串——连定界符一起;v2.761.0 加入 AddLoadedSignedSignatureField,让第二签名可以作为一次干净的增量修订追加到已签名 PDF 上。这两处改动是一对,因为正确的第二签名正是更严格的校验器所期待的那种布局
暴露问题的是个再普通不过的场景。合同由供应商签完,流转到一位必须会签、又不能扰动第一个签名的审批人手里。第二个修订追加在第一个之后,它自己的 /ByteRange 覆盖整个长高了文件,两个签名都应该通过校验。手工做到这一步意味着自己写增量节,而恰好做了这件事的测试夹具,结果是一个教科书级的签名包裹结构——旧校验器欣然接受。如果你还没看过校验 API,用 HotPDF 校验 PDF 数字签名指南讲了本文要踩在上面的基础
ByteRange 间隙里到底该放什么?
间隙必须装着完整的 /Contents 值、别的什么都不装:ISO 32000-1 §12.8.3.3 说十六进制串连同它的 < 和 > 定界符恰好填满两个字节区间之间的空间,ISO 32000-2 §12.8.1 把同一条规则沿用了下来。Table 252 和 PAdES 文档只说摘要不含 Contents 值,这很容易读成只不含那些十六进制数字。更早的 HotPDF 版本就是这么读的:PreparePDFForSigning 和流式 CMS 准备把尖括号也哈希了,源码注释还坚称括号必须被覆盖。拿间隙和签名值比对的校验器会把那种布局标成非法字节区间,所以 v2.759.0 把两个定界符挪出了签名区间。对任何已签文件做一次快速独立检查只看两个字节:偏移 ByteRange[1] 处的字节必须是 <,偏移 ByteRange[2] - 1 处必须是 >
非空间隙检查为什么抓不住签名包裹?
非空间隙检查只证明摘要外留了东西,不证明留的是什么,而这正是整个攻击面。/Contents 占位符以几千个零数字预留,而真实的 CMS 容器很少填满它。攻击者可以在那段零填充内部用 > 提前闭合十六进制串,把新对象或伪造修订写进预留空间的其余部分,字节区间一个都不动。CMS 签名照样通过,因为每个被签的字节都没变;区间照样从 0 开始、到文件大小结束;旧 HotPDF 校验器报 svValid、CoversWholeDocument 置 True。而 PDF 阅读器会把那个未签名洞里的东西照单解析
HotPDF 现在把间隙当作要逐字节校验的数据。校验器读出间隙、剥掉定界符,只接受十六进制数字加 PDF 空白(制表符、换行、换页、回车、空格),解码数字并要求结果与签名字典的 /Contents 完全相等。其余一切把结果降级为 svInvalidByteRange。这个检查在单签名路径和 ValidateLoadedSignatureBatch 里都跑,后者保留了自己的覆盖逻辑、需要同样的修复。v2.759.0 之前由 HotPDF 产出的文件——间隙里只有数字、括号刚好落在区间之内——仍然通过校验,所以归档文档不会一夜变红
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('SignedTwice.pdf');
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
begin
Status := Pdf.VerifyLoadedSignatureEx(I, Info);
case Status of
svValid:
if Info.CoversWholeDocument then
Writeln(Info.FieldName, ': valid, covers the whole file')
else
Writeln(Info.FieldName, ': valid, ',
Info.UnsignedTrailingBytes, ' bytes appended later');
svInvalidByteRange:
Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
else
Writeln(Info.FieldName, ': failed, status ', Ord(Status));
end;
end;
finally
Pdf.Free;
end;
end;
怎么给已签名的 PDF 加第二签名?
用 BeginIncrementalUpdate 打开已签文件,调 AddLoadedSignedSignatureField,用 SaveIncrementalUpdate 保存,然后用类函数 THotPDF.SignPDFWithPFX 签准备好的文件。v2.761.0 之前,文档里写的「BeginIncrementalUpdate 之后调 THPDFPage.AddSignedSignatureField」这个方子根本行不通,因为增量模式下 CurrentPage 是 nil,没有任何东西能把 /V 占位符挂到已加载文档的字段上。新方法在已加载页面上创建 widget,把新文档路径用的同一个占位字典挂到 /V 下,于是两条签名路线共用一套序列化。第一个签名本身怎么来,在 Delphi 中创建 PAdES 数字签名一文走完了 PFX 管线
var
Pdf: THotPDF;
FieldIndex: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('Signed.pdf');
// 第 0 页,widget 矩形以点数计,为 CMS 预留 8192 字节
FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
'ApproverSignature', 8192);
if FieldIndex < 0 then
raise Exception.Create('Page index out of range');
Pdf.SaveIncrementalUpdate('Prepared.pdf');
finally
Pdf.Free;
end;
if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
'approver.pfx', 'pfx-password') then
raise Exception.Create('Second signature failed');
end;
AddLoadedSignedSignatureField 刻意比它的兄弟们安静。其他 AddLoaded* 字段创建器会在 AcroForm 上设 /NeedAppearances true,让阅读器重新生成字段外观;在已签名文档上这种重生成会改写已签内容,所以新方法除非源头本来带着,否则把这个标志再移除掉。/SigFlags 保留原值 OR 3(SignaturesExist 加 AppendOnly,ISO 32000-1 Table 219)。你也不需要对页面调 MarkDirty:往 /Annots 和 /Fields 里加东西会把脏标志传播到所属间接对象上,显式标记页面只会把一个没变化的页面字典拖进新修订,修订分析随后会把它报成一次页面修改。最后,占位符先写 /ByteRange 再写 /Contents,因为补丁器先定位 /ByteRange 哨兵、再向前搜索配对的十六进制串
外部签名器或 HSM 产出 CMS 时有什么变化?
工作流不变,但偏移从此与规范说的含义一致。PreparePDFForSigning 返回两个 0 起始的区间,间隙是整个 /Contents 串,ContentsHexStart 是 AnsiString 里第一个十六进制数字的 1 起始索引。较短的 CMS 在结尾补 0,放在闭合 > 之前。因为 PreparePDFForSigning 打它找到的第一个未打补丁的哨兵,每个修订只准备一个占位符;文件里已有更早签名时,优先用带返回偏移的 InsertSignatureHexAt,而不是基于搜索的 InsertSignatureHex
var
Bytes, ToSign, CmsHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
Bytes := LoadFileAsAnsiString('Prepared.pdf'); // 你自己的辅助函数
if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
R2Start, R2Len, HexStart, HexLen) then
raise Exception.Create('No signature placeholder found');
// 间隙是整个十六进制串:'<' 结束区间 1,'>' 在区间 2 之前
Assert(Bytes[R1Start + R1Len + 1] = '<');
Assert(Bytes[R2Start] = '>');
ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
CmsHex := SignDetachedWithHsm(ToSign); // 你自己的 CMS 签名器,十六进制 DER
if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
raise Exception.Create('CMS does not fit the reserved space');
SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf'); // 你自己的辅助函数
end;
新检查的极限在哪里?
间隙检查堵的是一个具体的洞,别把它吹过头。svValid 仍然意味着字节完整性加一个与内嵌证书匹配的密钥;对那张证书的信不在此列。只有校验器拿得到源字节时才校验间隙,VerifyLoadedSignatureEx 从已加载文件读,TStream 重载由你提供。会签文件里的第一个签名,CoversWholeDocument 正确地为 False,而追加的修订是只加了签名、还是也改了页面,是HotPDF 中的 DocMDP、FieldMDP 与修订分析该回答的问题。还有一点:随附的 PDF MAC 检查拿偏移和 <、> 的位置比对,所以新旧两种布局都接受;任何自己写的、硬编码 v2.759.0 之前偏移的工具,碰到新签的文件会先挂
如果你的 Delphi 或 C++Builder 应用要签、会签或审计 PDF,最稳的路径是让同一个库产出并校验同一种布局。HotPDF,原生 Delphi PDF 组件把更严格的间隙校验、增量第二签名和上面展示的外部签名器钩子装在一个组件里