判定一份 PDF 签名在 eIDAS 下是否合格,意味着回答一个与密码学毫无关系的问题:签名作出之时,该证书是否由某个成员国列为合格的信任服务所颁发。答案在可信列表里——按领地发布的 XML 文档——而这份文档的全部价值系于其真实性。所以 PDFium 组件在有人为它作保之前拒绝看它一眼。TPdfEuropeanTrustedList.ParseAuthenticated 在解析任何服务之前,先把完整的原始字节交给调用方提供的 IPdfTrustedListAuthenticator,只有该验证器明确通过,才会创建快照
这个顺序就是设计。此功能的其他一切都由此推出,包括那些看起来不便的部分
解析通过不等于可信
一份解析顺利的可信列表只能告诉你 XML 格式良好,关于谁写的它什么也没说。既然整份合格状态判定都压在这个列表上,仅因解析通过就接受它,判定便失去意义:能替换列表的攻击者可以宣布自己的证书颁发机构合格
同样的推理适用于缓存,这正是值得点名的陷阱。快照缓存把原始 XML 连同 SHA-256 摘要一起存储,加载时摘要匹配很容易被当成列表为真的证明。它不是。由存储文件的同一进程、无密钥参与的摘要,只验证字节自你写下以来没有变过;如果列表在缓存时就是伪造的,摘要只确认它是同一份伪造列表。因此加载缓存快照与解析新鲜列表经过同一个验证器。完整性与真实性是两种属性,只有后者需要密钥
uses
FPdfTrustedList;
type
TListAuthenticator = class(TInterfacedObject, IPdfTrustedListAuthenticator)
public
function Authenticate(const XmlData: TBytes;
out AuthenticationDetails: string): Boolean;
end;
function TListAuthenticator.Authenticate(const XmlData: TBytes;
out AuthenticationDetails: string): Boolean;
begin
// 你的策略在这里:针对你带外固定的列表签名证书
// 校验 XMLDSIG 封装签名,并把核查内容写进审计轨迹
Result := VerifyEnvelopedXmlSignature(XmlData, FPinnedListSigner);
if Result then
AuthenticationDetails := 'XMLDSIG verified against pinned LOTL signer';
end;
var
List: TPdfEuropeanTrustedList;
Cache: TFileStream;
begin
List := TPdfEuropeanTrustedList.ParseAuthenticated(RawXml,
TListAuthenticator.Create, TPdfTrustedListOptions.Default);
// 快照存在,只因为验证器说了通过
Cache := TFileStream.Create('tl-de.snapshot', fmCreate);
try
List.SaveCache(Cache);
finally
Cache.Free;
end;
end;
验证器不掌管网络策略
PAdES 验证器无权决定如何访问可信列表之列表、是否走代理、多久重试一次、领地不可达时怎么办。这些是应用与部署决策,在受监管环境中还经常被审计。因此更新经 IPdfTrustedListSource 到来:它接收一个 URI 和字节上限,返回字节
组件强制的是让更新成为更新而不是替换的不变量。Update 要求领地不变、序列号严格递增、签发时间不倒退。这三项检查挫败最明显的降级攻击:重放仍列着已撤回服务的旧列表,或换成另一个领地、其服务你从未打算信任的列表
解析器上限,以及完全没有 DTD
TPdfTrustedListOptions 限制 XML 大小、标记数、嵌套深度、服务数、证书数量与单个证书大小,并有 Default 类函数提供可用值。可信列表是尺寸可预期的公开文档,所以设上限很便宜,也没有任何合法列表需要超出它们
另外,解析器无条件拒绝 DTD 与实体声明。一次拒绝同时关闭实体膨胀拒绝服务与外部实体信息泄露两条路,而且零成本,因为可信列表不用实体。任何可从不可信输入到达的 XML 解析器都该这样配置;此处不同的是拒绝本身不可配置,所以它不会因一次好心的选项改动而被关掉
合格状态记录在链信任旁边,而不是并入其中
评估一侧刻意独立。TPadesTrustValidationOptions.QualifiedTrustEvaluator 接收一个 IPdfQualifiedTrustEvaluator,由可信列表快照实现。验证期间,评估器接收叶子证书、证书链与验证时间,以精确 DER 比对把服务证书与签署者及链匹配,在该时间点合并服务状态、服务类型标识符与限定符 URI,返回一条评估记录
结果落在每个签名的两处:QualifiedTrustStatus 是粗粒度状态,QualifiedTrust 是带领地、提供者名、服务名、类型标识符、状态与状态起始时间的完整评估。它不做的是改动 CertificateTrustStatus。系统链信任与合格状态回答的是不同的问题,把两者合并的报告无法区分"可信但不合格"与"合格但链不通过"——两者都真实存在,且需要不同处置
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
I: Integer;
begin
Options := TPadesTrustValidationOptions.Default;
Options.CheckRevocation := True;
Options.QualifiedTrustEvaluator := List; // 已验证的快照
Options.QualifiedValidationTime := SigningTime; // 不是 Now
Report := Pdf.ValidatePadesTrust(Options);
for I := 0 to High(Report.Signatures) do
if Report.Signatures[I].QualifiedTrustStatus = pcsValid then
Writeln(Format('signature %d qualified by %s / %s (%s)',
[I, Report.Signatures[I].QualifiedTrust.Territory,
Report.Signatures[I].QualifiedTrust.ProviderName,
Report.Signatures[I].QualifiedTrust.ServiceName]))
else if Report.Signatures[I].QualifiedTrustStatus = pcsIndeterminate then
// 无匹配服务,或快照无法为该时间作答
Writeln(Format('signature %d: qualified status undetermined', [I]));
end;
为什么验证时间不是现在
因为合格是某一时刻的属性。信任服务可以被授予合格状态,之后被撤回,再之后恢复,每次转变在列表里都带一个起始时间。服务合格期间作出的签名此后保持合格;授予之前作出的签名不会追溯变合格。因此按当前时间评估,两个方向都会给错答案
列表为此备齐了材料:每条服务记录有状态起始时间,以及区分历史条目与现行条目的标志,评估器把它们与你提供的时间合并运算。实践中,该时间来自签名上的可信时间戳,而不是 CMS 里声称的签名时间——这就是为什么长期验证材料即便对一个看似策略查询的问题也很关键;时间戳与 DSS 一侧见长期签名一文
你仍需自己构建的部分
三件事,而且没有一件属于 PDF 库。验证器:真正的 XMLDSIG 校验,针对你经可信渠道获得的列表签名证书。抓取策略:如何刷新、多久刷新、刷新失败时应用怎么办。领地范围:你到底携带哪些列表——这是关于交易对手在哪些成员国签署的商业决策
组件交给你的是那些容易错得很微妙的部分:先验证后解析的顺序、有界且无实体的 XML 解析、单调更新不变量、精确 DER 服务匹配、历史状态评估,以及与普通链信任保持分离的结果。如果你眼下的难题更基本——验证器拒绝了一份你认为没问题的签名——常见成因收录于验证器为何拒绝 PAdES 签名,签名检查接口见检查签名与 PAdES 级别。组件能力列在 PDFium Delphi component 产品页