技术文章

PDFium 按 ISO/TS 32002 校验 ECDSA/EdDSA 签名

PDFium Component for Delphi 会按 ISO/TS 32002 算法 profile 检查每一条 ECDSA 和 EdDSA PDF 签名,并把结论报在 TPadesSignatureValidation.AlgorithmPolicyStatus 里。只有 P-256、P-384、P-521、三条 Brainpool r1 曲线、Ed25519 和 Ed448 能通过,且各自要求匹配的摘要,不匹配则抛出 ppeiSignatureAlgorithmMismatch。这条政策比听起来更重要:brainpoolP160r1 上的签名,或者 P-256 密钥签 SHA-512 摘要,在数学层面都能验证通过,于是 Windows CryptoAPI 报告签名值良好,严格的 PDF 2.0 校验器却拒收文件。政策检查补上了这个缺口,而且它刻意与「签名字节在密码学上是否正确」这个问题分开

ISO/TS 32002 到底允许椭圆曲线签名用什么?

ISO/TS 32002 在 PDF 签名里恰好允许六条 ECDSA 曲线和两种 EdDSA 方案,并把每条曲线和它可携带的摘要尺寸绑定。PDFium Component 把这张表编码在 PadesCurveDigestAllowed 里,以签名者证书中的曲线 OID 为键。NIST 曲线很严:摘要必须与曲线同位宽,SHA-2 或 SHA-3 皆可。Brainpool 曲线宽松些,接受自身位宽或任何更宽的:

  • P-256(1.2.840.10045.3.1.7):仅 SHA-256 或 SHA3-256
  • P-384(1.3.132.0.34):仅 SHA-384 或 SHA3-384
  • P-521(1.3.132.0.35):仅 SHA-512 或 SHA3-512
  • brainpoolP256r1(1.3.36.3.3.2.8.1.1.7):256 到 512 位的任意 SHA-2 或 SHA-3 摘要
  • brainpoolP384r1(1.3.36.3.3.2.8.1.1.11):384 或 512 位的 SHA-2 / SHA-3
  • brainpoolP512r1(1.3.36.3.3.2.8.1.1.13):仅 SHA-512 或 SHA3-512
PDFium Component 中 ISO TS 32002 算法 profile 矩阵:PadesCurveDigestAllowed 里 P-256、P-384、P-521 只接受匹配的摘要位宽,brainpoolP256r1 接受 256 到 512 位,brainpoolP384r1 接受 384 和 512,Ed25519 和 Ed448 声明 SHA-512 与长度 512 的 SHAKE256;不匹配抛 ppeiSignatureAlgorithmMismatch,profile 之外的曲线返回 pcsUnsupported
能通过的是六条 ECDSA 曲线和两种 EdDSA 方案,每条曲线都绑定可携带的摘要位宽;其余一律 invalid 或 unsupported,绝不悄悄放行

EdDSA 没有曲线可选、也没有摘要可选,这恰恰说明它的规则关乎编码而非强度。按 RFC 8419,Ed25519 的 SignerInfo 必须声明 SHA-512 为其 digestAlgorithm 且不带参数;Ed448 的 SignerInfo——在 PAdES 永远走的 signed-attributes 路径上——必须声明 id-shake256-len(2.16.840.1.101.3.4.2.18)并带恰好 512 的 INTEGER 参数。两种方案的签名 AlgorithmIdentifier 和证书公钥 AlgorithmIdentifier 都必须完全不带参数。在此处写 NULL 的生成方——RSA 编码器把这个习惯训练进了许多 ASN.1 库——产出的签名不合规,哪怕密钥和签名值都没问题

PDFium Component 如何从 CMS 中提取算法三元组

PDFium 本身回答不了这个问题,因为它的公开签名 API 只读签名字典,既不验证 CMS,也不暴露签名者证书的曲线。因此是构建在 PDFium 之上的 PAdES 检查层自己解析 CMS SignedData(RFC 5652)。InspectPadesSignatureAlgorithm 读取第一条 SignerInfo 的 digestAlgorithm 和 signatureAlgorithm,然后找到签名者证书、读它的 SubjectPublicKeyInfo 拿到密钥算法和曲线。证书查找刻意设了界:CMS certificates 集合最多检查 64 张证书,匹配是对 issuerAndSerialNumber 里颁发者与序列号的精确字节比较,只有集合里恰好只有一张可解析证书时才回退到「就用那唯一一张」。从无序集合里挑第一张 EC 证书很容易,但那等于让一张 CA 证书决定签名者「应该」用的曲线

PDFium Component 如何检查 PDF 签名算法三元组:InspectPadesSignatureAlgorithm 读取 CMS 中第一条 SignerInfo 的 digestAlgorithm 和 signatureAlgorithm,在至多 64 个候选中用精确的 issuerAndSerialNumber 匹配钉住签名者证书,读 SubjectPublicKeyInfo 拿曲线,EvaluatePadesSignatureAlgorithm 返回 AlgorithmPolicyStatus
PDFium 本身既不验证 CMS 也不暴露签名者曲线,所以 PAdES 层自己解析 SignedData,并把每个原始 OID 留在记录里,让每次拒收都可解释
uses
  PDFium, FPdfPades;

const
  StatusNames: array[TPadesCryptoStatus] of string =
    ('not checked', 'valid', 'invalid', 'unsupported', 'indeterminate');

var
  Pdf: TPdf;
  R: TPadesValidationResult;
  A: TPadesSignatureAlgorithmInfo;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice-ecdsa-signed.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePades;
    for I := 0 to Length(R.Signatures) - 1 do
    begin
      A := R.Signatures[I].AlgorithmInfo;
      Writeln('Signature ', I);
      Writeln('  digestAlgorithm    : ', string(A.DigestAlgorithmOid));
      Writeln('  signatureAlgorithm : ', string(A.SignatureAlgorithmOid));
      Writeln('  public key / curve : ', string(A.PublicKeyAlgorithmOid),
        ' / ', string(A.CurveOid));
      Writeln('  policy             : ',
        StatusNames[R.Signatures[I].AlgorithmPolicyStatus]);
    end;
    if ppeiSignatureAlgorithmMismatch in R.Issues then
      Writeln('At least one signature violates the ISO/TS 32002 profile');
  finally
    Pdf.Free;
  end;
end;

TPdf.ValidatePades 在合规检查 pass 里执行这套政策,从它在 CMS 里定位到的证书出发;TPdf.ValidatePadesTrust 则针对 Windows CryptoAPI 验证时实际使用的签名者证书重跑一遍,所以 CryptoAPI 报告的证书拥有最终决定权。所有原始输入都落在 TPadesSignatureAlgorithmInfo 里,包括 DigestParametersPresent、DigestParameterBits、SignatureParametersPresent 和 PublicKeyParametersAreNamedCurve,拒收永远可以从记录本身解释,而不必翻日志

P-256 签名配 SHA3-256 为什么过不了政策?

只要 CMS 的 digestAlgorithm 与 ECDSA signatureAlgorithm 暗示的摘要不一致,P-256 签名就过不了 PDFium Component 的政策——哪怕两者单独看对这条曲线都可接受。EvaluatePadesSignatureAlgorithm 先把 ecdsa-with-SHA256、ecdsa-with-SHA3-256 和同族标识映射成摘要,与声明的 digestAlgorithm 比对,查曲线表之前任何差异都返回 pcsInvalid。这个案例是真实存在的:签名工具把哈希切到 SHA3-256,却留着硬编码的 ecdsa-with-SHA256 标识,产出的文件任何合规验证器都无法一致地解读。这个函数是公开的,所以矩阵可以不建 PDF 就在单元测试里钉死:

var
  Info: TPadesSignatureAlgorithmInfo;
begin
  Info := Default(TPadesSignatureAlgorithmInfo);
  Info.Family := psafEcdsa;
  Info.PublicKeyAlgorithmOid := '1.2.840.10045.2.1';      // id-ecPublicKey
  Info.PublicKeyParametersPresent := True;
  Info.PublicKeyParametersAreNamedCurve := True;
  Info.CurveOid := '1.2.840.10045.3.1.7';                 // P-256
  Info.DigestAlgorithmOid := '2.16.840.1.101.3.4.2.8';    // SHA3-256
  Info.SignatureAlgorithmOid := '2.16.840.1.101.3.4.3.10'; // ecdsa-with-SHA3-256
  Assert(EvaluatePadesSignatureAlgorithm(Info) = pcsValid);

  Info.SignatureAlgorithmOid := '1.2.840.10045.4.3.2';    // ecdsa-with-SHA256
  Assert(EvaluatePadesSignatureAlgorithm(Info) = pcsInvalid); // 摘要不匹配

  Info.CurveOid := '1.3.36.3.3.2.8.1.1.1';                // brainpoolP160r1
  Info.DigestAlgorithmOid := '2.16.840.1.101.3.4.2.1';    // SHA-256,此时一致
  Assert(EvaluatePadesSignatureAlgorithm(Info) = pcsUnsupported); // 曲线不在 profile 内
end;

曲线编码执行同样的严格度。RFC 5480 §2.1.1 允许 ECParameters 是命名曲线 OID、隐式曲线(NULL)或完整的显式参数集,而 PKIX profile 要求命名形式。id-ecPublicKey 证书若无参数、带隐式参数或显式参数,PDFium Component 返回 pcsInvalid,因为显式参数让攻击者可以描述一条只是长得像标准曲线的曲线。命名正确、只是不在 ISO/TS 32002 清单里的曲线——比如上面的 brainpoolP160r1 或 secp256k1——得到的则是 pcsUnsupported

invalid、unsupported 还是 indeterminate:诚实地读状态

AlgorithmPolicyStatus 的三个非 valid 状态含义各不相同,把它们折叠进同一个「失败」桶,等于丢掉审计者需要的信息。pcsInvalid 意味着可识别的算法组合畸形或不匹配;它会往 TPadesValidationResult.Issues 加 ppeiSignatureAlgorithmMismatch,并把汇总的 IntegrityStatus 压到 pcsInvalid,于是即使 CMS 签名值本身验证无误,IsCryptographicallyValid 也返回 False。pcsUnsupported 意味着曲线或摘要不在 profile 点名的范围内,这是能力判定,不是篡改证明。pcsIndeterminate 意味着没能钉住签名者证书,通常是 CertificateSet 里有多个候选、又没有精确的 issuerAndSerialNumber 匹配,代码因此拒绝猜测曲线;从 v3.124.0 起,它还标记对 SHA-1 或 SHA-224 这类 112 位摘要的 RSA 签名——这类摘要已不再被现行验证认可。同一套划分也适用于 CryptoAPI 验证不了 Ed25519 或 Ed448 的机器上的 EdDSA:CmsSignatureStatus 停在 pcsUnsupported,而 AlgorithmPolicyStatus 仍可以是 pcsValid,因为编码是对的、缺的只是验证器。如果你在追 Adobe 或基于 DSS 的校验器给出的拒收,校验器为何拒收 PAdES 签名指南覆盖了其余常见原因

PDFium Component 中 EvaluatePadesSignatureAlgorithm 的判定路径:摘要不匹配置 pcsInvalid 与 ppeiSignatureAlgorithmMismatch,ISO TS 32002 profile 之外的命名曲线(如 brainpoolP160r1)置 pcsUnsupported,钉不住签名者证书置 pcsIndeterminate;v3.124.0 起 RSA 分支套用 ETSI TS 119 312 摘要套件,密钥长度仍留给应用层
三个非 valid 状态含义各不相同:invalid 证明组合损坏,unsupported 是能力判定,indeterminate 表示代码拒绝猜测
var
  Pdf: TPdf;
  Options: TPadesTrustValidationOptions;
  R: TPadesValidationResult;
  S: TPadesSignatureValidation;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice-ecdsa-signed.pdf';
    Pdf.Active := True;
    Options := TPadesTrustValidationOptions.Default; // 离线,不做吊销检查
    R := Pdf.ValidatePadesTrust(Options);
    for I := 0 to Length(R.Signatures) - 1 do
    begin
      S := R.Signatures[I];
      if S.IsDocumentTimeStamp then
        Continue;
      case S.AlgorithmPolicyStatus of
        pcsInvalid:       Writeln(I, ': reject, algorithm combination is invalid');
        pcsUnsupported:   Writeln(I, ': manual review, curve or digest outside the profile');
        pcsIndeterminate: Writeln(I, ': manual review, signer not identified or digest no longer agreed');
        pcsValid:
          if S.AlgorithmInfo.Family = psafRsa then
            Writeln(I, ': RSA digest suite accepted, check key size yourself')
          else
            Writeln(I, ': EC/EdDSA profile satisfied');
      end;
    end;
    Writeln('Cryptographically valid: ', R.IsCryptographicallyValid);
  finally
    Pdf.Free;
  end;
end;

pcsValid 不保证什么?

AlgorithmPolicyStatus = pcsValid 只证明 ECDSA 或 EdDSA 签名用了获批曲线加一个一致且编码正确的摘要,RSA 签名用了现行套件中的摘要;它对签名值本身是否正确只字未提。v3.124.0 之前,EvaluatePadesSignatureAlgorithm 的 RSA 分支刻意放得很宽:PKCS #1 arc 1.2.840.113549.1.1.* 之下的任何 signatureAlgorithm 都返回 pcsValid,包括老掉牙的 sha1WithRSAEncryption。从 PDFiumPas v3.124.0 起,RSA 分支改用 ETSI TS 119 312 签名套件:MD2、MD4 和 MD5 摘要判 pcsInvalid;SHA-1 和 SHA-224 这类 112 位摘要判 pcsIndeterminate,所以 SHA-1 签名保留整体完整性结论、被标记待人工复核而不是直接拒收。与签名算法固定的摘要不一致的 digestAlgorithm——比如 sha256WithRSAEncryption 配 SHA-1 摘要——或非 RSA 签名者密钥上的 RSA 签名算法,判 pcsInvalid、以 ppeiSignatureAlgorithmMismatch 上报并判完整性失败。找不到签名者证书给 pcsIndeterminate(ECDSA 一直如此),无法识别的摘要或非签名的 RSA OID 给 pcsUnsupported。模长仍然不检查;PSS 参数不在这里验证(RFC 4055 RSASSA-PSS-params 一文讲了签名侧如何编码它们);SHA-1 或 MD5 摘要还会额外触发独立的 ppeiBadDigestAlgorithm 问题。同样,签名数学、证书链和吊销仍是 CmsSignatureStatus、CertificateTrustStatus 和 RevocationStatus 的职责,它们来自 Windows CryptoAPI。把 pcsValid 当作「算法 profile 成立」,永远别当作「这把密钥够强」

对接收签名 PDF 发票、合同或归档包的 Delphi 应用,实用配置很短:跑 ValidatePadesTrust,遇 ppeiSignatureAlgorithmMismatch 拒收,pcsUnsupported 和 pcsIndeterminate 转人工,并自己执行 RSA 密钥长度下限——政策不会替你把这道关。PDFium Component for Delphi and Lazarus 附带 PAdES 校验器、证据报告生成器和签名管线,同一个库既能产出这些签名,也能端到端地检查它们