你刚签完的 PDF 只是一个 B-B 签名,仅此而已。它能证明谁签的名、字节没有被动过,却不携带任何证明签署者证书在签名时刻有效的材料,于是多年后的验证者只能去寻找可能已不复存在的吊销数据。补上这个缺口意味着把 OCSP 响应与 CRL 写入文档级的 Document Security Store,而在 HotPDF 里这只是一次调用:PopulatePAdESLTVEvidence 遍历每个已加载的签名,从证书集合推导吊销请求,经由你提供的传输层执行它们,再把取回的材料连同 CMS 链一起写入 DSS。它返回证据成功落地的签名数量,文档完全没有签名字段时返回负一
使用它之前值得理解的设计决策是:库绝不自己打开套接字。每一个从网络到达的字节都经过你写的回调。这不是为谨慎而谨慎;这是该功能能真正在那些要求长期验证的环境里工作的唯一方式
为什么库拒绝自己做 HTTP?
因为要求 B-LT 签名的场所,恰恰是网络不能托付给库的地方。签名服务运行在带企业根证书的认证代理之后。物理隔离的签名层没有到响应服务器的路由,必须喂缓存证据。审计制度要求每个出站请求都由应用记录在案,而不是埋进依赖里。测试套件需要确定性响应,如果库自行拨号就无从谈起
传输层是一个形状固定的普通函数引用,策略仍然归你。HotPDF 递给你一个请求记录,准确描述要取什么,包括内容类型与响应大小上限;你返回字节外加一个状态
function FetchEvidence(const Request: THPDFSignatureEvidenceRequest;
Attempt: Integer; CancellationToken: THPDFCancellationToken;
out Response: TBytes; out RetryAfterMS: Cardinal;
out ErrorMessage: UnicodeString): THPDFSignatureEvidenceTransportStatus;
begin
RetryAfterMS := 0;
try
// Request.Kind 说明这是 OCSP POST 还是 CRL GET;
// Request.ContentType 与 Request.Body 已准备好,
// Request.MaxResponseBytes 是你必须遵守的上限
Response := HttpExchange(Request.URI, Request.ContentType,
Request.Body, Request.MaxResponseBytes);
Result := setsSucceeded;
except
on E: Exception do
begin
ErrorMessage := E.Message;
// setsRetry 让重试策略退避;404 或坏 URL 用
// setsPermanentFailure
Result := setsRetry;
end;
end;
end;
// 对已加载文件中的每个签名做一次调用完成 B-B 到 B-LT 升级
var
Pdf: THotPDF;
Upgraded: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('signed.pdf');
Upgraded := Pdf.PopulatePAdESLTVEvidence(FetchEvidence,
THPDFSignatureEvidenceRetryPolicy.Default);
if Upgraded > 0 then
// 只追加保存:既有签名覆盖的字节
// 原样保留
Pdf.SaveIncrementalUpdate('signed-lt.pdf');
finally
Pdf.Free;
end;
end;
失败按签名计,不按文档计。为某签署者超时的响应服务器只跳过该签署者的材料,其余处理原样保留——这正是批处理想要的行为:部分证据好过中途作废,返回值则告诉你有多少签名真正得到了改善
CMS 忘了装进去的证书链
吊销检查需要颁发者证书,而相当多的签名技术栈在 CMS 容器里省略了中间证书。恢复路径是 Authority Information Access 扩展,访问方法 1.3.6.1.5.5.7.48.2,它公告一个可下载颁发者证书的 URL。HPDFFetchAIAIntermediates 经同一传输层遍历这些 URL,从每个响应解析 DER,只返回 CMS 尚未携带的证书,以 DER 哈希为键,重复与环路都转不起来
两个细节决定它在真实证书颁发机构面前是否管用。第一个是编码:CA 端点以裸 DER 提供证书的频率与以 PEM 盔甲提供相当,而没有任何可靠的内容类型可区分两者。稳健的探测是先文本后结构:寻找 -----BEGIN CERTIFICATE----- 标记,若存在则剥掉盔甲并解码 base64;两条路径都要确认结果首字节是 $30,即 SEQUENCE 的 DER 标签。第二个是深度:取回的中间证书自身可能为其颁发者公告 AIA URL,因此遍历会把新候选追加进队列,补全差两三跳的链。必须设上限,这正是 MaxFetch 参数的用途
什么是签名种子值,它为什么静默失效?
种子值(seed value)是文档作者附加在签名字段上的约束,用来告诉签署者什么样的签名可接受:哪个 SubFilter、哪个摘要算法、哪些理由、最低 PDF 版本是多少、是否必须内嵌吊销信息。它位于字段的 /SV 字典中,定义于 ISO 32000-1 §12.7.5.5。HotPDF 用 AttachPAdESSeedValue 写入它,用 CheckLoadedSignatureSeedValue 检查它:字段无约束或所有存在的约束都通过时返回 True,返回 False 时通过输出参数给出第一个未通过的约束,可直接放进错误消息
让种子值容易出错的是 §12.7.5.5.3 描述的 /Ff 标志条目。置位的比特把它的约束标记为必需:不匹配即错误,签署者必须拒绝。清零的比特把同一约束标记为偏好:该值只是过滤 UI 应提供什么,仅此而已。由此引出两个陷阱。第一,/Ff 位于 /SV 字典内部,不在部件注释(widget annotation)上,所以读取字段级 /Ff 的代码永远得到空答案,并断定没有任何东西是强制的。第二,比特分配不是 1、2、4、8 的简单连跑;HotPDF 的写入器为 SubFilter 发 2,MinVersion 发 4,AddRevInfo 发 32,DigestMethod 发 64。假定比特连续的读取器会把每个约束都解码成可选,通过除真正要紧那一项之外的每一个测试
var
Violation: AnsiString;
begin
// 询问字段:我们即将使用的签名 profile 是否被允许
if not Pdf.CheckLoadedSignatureSeedValue(0, 'ETSI.CAdES.detached',
'SHA256', 'Approved for payment', 1, Violation) then
raise Exception.Create('Signature field rejects this profile: ' +
String(Violation));
// 约束满足:继续签名流程
end;
暴露最初解码 bug 的测试不是一个正向测试,而是一条断言:被强制的失配必须被拒绝。这也是唯一能抓住这类缺陷的测试:读错字典或读错比特位的解码器对任何输入都产出"没有违反约束",看起来与正确行为一模一样,直到你故意违反一条
它在 LTV 阶梯上的位置
四级阶梯,每级都需要它下面那级。B-B 是裸签名。B-T 加可信时间戳,固定签名时刻,让验证者知道该以哪个时间点评估吊销。B-LT 把吊销证据加进 DSS,这正是 PopulatePAdESLTVEvidence 自动化的部分。B-LTA 加文档时间戳并在前一个减弱之前续期,把有效性无限延长;HotPDF 以 RenewPAdESLTATimestamp 暴露它,以增量修订的形式追加新时间戳,且不碰任何更早的签名、时间戳与 DSS 条目
增量更新模型是向已签文档添加证据的唯一正确方式,因为重写文件会破坏既有签名覆盖的字节范围。如果你需要推理修订之间变了什么、以及那些变化是否属于签名允许的类别,该分析在DocMDP 与 FieldMDP 修订分析中单独展开。签名管线本身,包括证书来源与字节序陷阱,见PAdES 签名演练;验证一侧见验证已加载文档上的签名
关于时序有一条务实的警告。签名之后尽快收集证据,理想情况下在同一个作业里。能为证书作答的响应服务器在证书有效期内在线,多年后便消失无踪,所以一份以 B-B 状态离开你管线的文档可能永远失去升级机会。HotPDF 以原生 VCL 组件运行于 Delphi 与 C++Builder,除你自己的传输层外整个证据流程都在进程内;支持的 profile 列在 HotPDF Delphi PDF component 产品页