技术文章

在 Delphi 中检查 PDF 签名和 PAdES 级别

您收到了一份已签名的 PDF,并且需要在查看器中显示是谁签的名、何时签的名、签名是否覆盖了整个文件,以及它在长期合规方面达到了什么程度。适用于 Delphi 和 Lazarus 的 PDFium Component 通过只读调用回答了所有这四个问题:FPDF_GetSignatureCountFPDFSignatureObj_* 系列暴露了签名字典,而 TPdf.ValidatePades 则对 PAdES 基线级别进行分类。这是关于使用 PDFium 进行 PDF 签名的三篇文章中的第一篇;接下来的两篇涵盖了创建 B-B 签名和添加长期时间戳。不过,有一个边界需要放在前面:这里的一切都是检查,读取签名所声称的内容与验证其密码学或决定是否信任签名者是两件不同的工作

为什么 PDF 签名字典不仅仅是一个字节块

PDF 签名是一个字典,而不是一个不透明的附件,它两个最重要的条目会告诉您文件实际上有多少受到了保护。ISO 32000-1 §12.8 定义了带有 /ByteRange/Contents 条目的签名字典。/Contents 保存了一个十六进制编码的 CMS SignedData 结构(RFC 5652),即承载签名者证书、已签名属性和签名值本身的密码学信封。/ByteRange 是开发人员低估的部分:它是一个由两个偏移量-长度跨度组成的数组,这两个跨度共同覆盖了除 /Contents 十六进制字符串之外的整个文件。该间隙正是签名字节所在的位置,而两侧 of 两个跨度正是签名所承诺的内容

ByteRange 设计使得增量保存可被审计。因为签名者无法对尚不存在的签名字节进行哈希处理,所以文件围绕 /Contents 占位符进行拆分,其他所有内容都哈希处理到签名中。其 ByteRange 未到达文件末尾的签名是一个警告信号:通过后面的增量更新在所覆盖范围之后追加的内容不会破坏签名,即使它改变了读者看到的内容。因此,严肃的检查员检查的第一件事不是谁签的名,而是签名是否覆盖了它似乎认可的字节

使用 PDFium 的只读 API 读取签名字典

PDFium 组件通过两个只读成员呈现签名字典:SignatureCountSignature[Index] 记录。在底层,它们调用 FPDF_GetSignatureCountFPDF_GetSignatureObject 以及 FPDFSignatureObj_* 访问器来获取 /SubFilter/ByteRange/Contents/Reason 和签名时间。只读是这里的核心词汇:PDFium 可以枚举和读取签名,但没有 API 来创建或写入签名,这就是为什么本系列的签名端是由库本身而不是由 PDFium 实现的原因

var
  Pdf: TPdf;
  i: Integer;
  Sig: TPdfSignature;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-signed.pdf';
    Pdf.Active := True;
    for i := 0 to Pdf.SignatureCount - 1 do
    begin
      Sig := Pdf.Signature[i];
      Writeln('SubFilter : ', Sig.Encoding);        // ETSI.CAdES.detached, adbe.pkcs7.detached, ...
      Writeln('Signed at : ', Sig.Time);            // 签名者日期字符串,例如 D:20260708120000+02'00'
      Writeln('Reason    : ', Sig.Reason);
      Writeln('CMS length: ', Length(Sig.Content)); // 从 /Contents 中获取的原始 DER SignedData
      Writeln('DocMDP    : ', Sig.Permission);      // 0 = 无认证,1..3 = MDP 级别
    end;
  finally
    Pdf.Free;
  end;
end;

每个 TPdfSignature 记录直接映射到该字典上。Encoding/SubFilter,这是最具诊断性的字段,因为它命名了签名处理程序,并立即将现代的 ETSI.CAdES.detached 签名与遗留或禁止的签名区分开来。Time 是作者声明的签名时间,作为一个 PDF 日期字符串,这是签名者的主张,而不是可信的时间。Content 是原始 CMS SignedData,而 Permission 则暴露了 DocMDP 认证级别(0 代表普通的批准签名,1 到 3 代表锁定后续更改的认证签名)。该记录唯一没有呈现的字段是解析后的 ByteRange,这一遗漏是刻意的,因为 ValidatePades 会为您执行 ByteRange 覆盖范围计算,而不是让您手动重新计算

PAdES B-B、B-T、B-LT 和 B-LTA 有什么区别?

这四个 PAdES 基线级别形成了一个阶梯,从最低限度有效的签名到为了能经历数十年存档而构建的签名,且每个级别都严格包含其下方的级别。ETSI EN 319 142-1 将它们定义为 B-B、B-T、B-LT 和 B-LTA。B-B(基础级)是签名加上强制性的已签名属性,别无其他。B-T(时间戳级)在签名上添加了可信的 RFC 3161 时间戳,因此签名时刻是由时间戳机构证明的,而不是由签名者时钟断言的。B-LT(长期级)在文件内部嵌入了验证材料——证书链,以及可选的 OCSP 或 CRL 响应,因此在多年后发证基础设施消失时仍可以验证签名。B-LTA(带有存档时间戳的长期级)在该材料周围包裹了文档时间戳,保护长期数据本身,并为您在底层密码学老化前提供重新添加时间戳的时间点

实际的解读是关于时间跨度的。B-B 签名回答了“是否有人签了这个”。B-T 回答了“以及何时签的,可证明地”。B-LT 回答了“以及在证书过期后我是否仍能检查它”。B-LTA 回答了“以及该检查在 20 年后是否仍然有效”。监管规范会选择某一级:许多电子发票和 eIDAS 上下文要求至少达到 B-T,而存档指令则趋向于 B-LT 或 B-LTA。在接受或拒绝文档前,了解它实际达到了哪一级,正是检查步骤的全部意义所在

使用 TPdf.ValidatePades 检测基线级别

PDFium 组件将整个级别问题简化为单次调用。TPdf.ValidatePades 返回一个 TPadesValidationResult 记录,其 Level 字段是一个 TPadesLevelplNoneplUnknownplB_BplB_TplB_LTplB_LTA),此外还有一组问题、签名计数和文档时间戳计数。该级别是单调推导出的:验证器首先确定 B-B,然后如果存在签名时间戳或文档时间戳,则提升至 B-T;如果目录携带了包含证书的 /DSS 以及 /Extensions /ESIC 1 级标记,则提升至 B-LT;如果文档时间戳和 ESIC 2 级标记同时存在,则提升至 B-LTA。两个辅助函数使结果可付诸行动:仅当级别至少达到 B-B 且问题集为空时,IsCompliant 才为 True;而 IsCompliantAt 则允许您断言策略底线(例如 plB_T

var
  Pdf: TPdf;
  R: TPadesValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-signed.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePades;
    case R.Level of
      plNone:    Writeln('No PAdES signature present');
      plUnknown: Writeln('Signature present but level undeterminable');
      plB_B:     Writeln('PAdES B-B   (基础级)');
      plB_T:     Writeln('PAdES B-T   (可信时间戳)');
      plB_LT:    Writeln('PAdES B-LT  (嵌入长期材料)');
      plB_LTA:   Writeln('PAdES B-LTA (存档时间戳)');
    end;
    Writeln('Signatures   : ', R.SignatureCount);
    Writeln('DocTimeStamps: ', R.DocTimeStampCount);
    if R.IsCompliantAt(plB_T) then
      Writeln('Meets the B-T policy floor')
    else
      Writeln('Below the required B-T level');
  finally
    Pdf.Free;
  end;
end;

为什么 adbe.pkcs7.sha1 是被禁止的 SubFilter?

因为 SHA-1 已经破裂,且 adbe.pkcs7.sha1 处理程序将其烘焙在内。该 SubFilter 在将文档包装在 PKCS#7 中之前,先使用 SHA-1 对文档进行预哈希,而 SHA-1 多年来一直存在碰撞漏洞,因此 EN 319 142-1 第 6.3 条明令禁止将其用于基线签名。当 ValidatePades 看到 adbe.pkcs7.sha1adbe.x509.rsa_sha1 时会引发 ppeiForbiddenSubFilter,而当 CMS 本身使用 MD5 或 SHA-1 作为消息摘要(第 6.2.1 条)时会引发 ppeiBadDigestAlgorithm。这是在两个不同层面上捕获同一类弱点的两项不同检查

问题集总共有 26 个成员,您最常遇到的那些集中在结构和覆盖范围上。ppeiByteRangeNotCoveringFile 是前面描述的覆盖范围检查。当签名字典携带 /Cert 条目时,会触发 ppeiForbiddenCertKey,PAdES 禁止这样做,因为证书链必须改为存在于 CMS SignedData.certificates 内部。ppeiMissingSigningCertificateppeiMissingContentTypeppeiMissingMessageDigest 标记了缺失的强制性已签名属性,而 ppeiDetachedContentViolation 则捕获了错误地嵌入了已签名内容而不是将其分离的签名。遍历该集合可将简单的拒绝转化为您可以记录的诊断

var
  R: TPadesValidationResult;
  Issue: TPadesValidationIssue;
begin
  R := Pdf.ValidatePades;
  if R.Issues <> [] then
    for Issue := Low(TPadesValidationIssue) to High(TPadesValidationIssue) do
      if Issue in R.Issues then
        Writeln('Issue: ',
          GetEnumName(TypeInfo(TPadesValidationIssue), Ord(Issue)));
end;

ValidatePades 不检查的内容

ValidatePades 验证结构,而不是信任,将其中一个误认为另一个是危险的错误。plB_LTA 的结果意味着文档包含一个格式良好的 B-LTA 签名,所有规定的属性、材料和时间戳都位于正确的位置——它并不意味着签名在密码学上有效、证书链能够连接到您信任的根,或者链中的证书没有被吊销。验证器刻意不进行密码学验证:它不在 ByteRange 上重新计算签名,不构建或评估信任链,也不检查 OCSP 或 CRL 吊销状态。这种划分是有意且有用的,因为结构检查速度快、完全确定,且不需要密钥、网络和平台密码学,因此 ValidatePades 在 Windows、Linux 和 macOS 上作为纯 Pascal 在文件字节上运行是完全相同的。相比之下,信任链验证与策略密不可分(您信任哪些根、如何获取吊销、时间戳容差有多严格),它属于依赖于平台证书库的后期阶段,因此将通过的 ValidatePades 视为签名外形正确的必要关口,然后在您依赖它之前将结构完好的签名交给真正的密码学验证

该结构性传递是正确的起点,它自然地与 使用 PDFium 组件审计 PDF 安全风险 中更广泛的只读检查以及诸如 验证印刷就绪的 PDF/X 文档 的格式符合性工作相配对。一旦您能读取并分类签名,下一步就是生成一个:本系列的第二篇文章涵盖了 使用 PAdES B-B 对 PDF 进行签名,而第三篇则将其扩展到可信时间戳以及长期 B-LT 和 B-LTA 签名。这里展示的只读签名检查和 ValidatePades 分类器都是适用于 Delphi、C++Builder 和 Lazarus 的 PDFium Component 的一部分