技术文章

在 Delphi 中用 HotPDF 进行后量子与 EdDSA PDF 签名

HotPDF 能够验证已加载 PDF 文档中的 ML-DSA-44、ML-DSA-65、ML-DSA-87、Ed25519 和 Ed448 CMS 签名,并通过可插拔的提供者进行签名,从而让私钥永远不必驻留在你的 Delphi 进程里。后半句正是大多数团队最先需要的部分。硬件令牌、远程签名服务和国民电子身份证(eID)都拒绝交出密钥,在签名流程与密钥库分离之前,它们一个都用不上

这种分离正是 THPDFSignatureProvider 的立意所在。HotPDF 保留它本该负责的部分——解析 CMS、构建 SignedData、布置 /ByteRange——而把那一件它无法负责的操作委托出去,也就是用一把它不被允许看见的密钥把摘要变成签名。下文的一切都源自这一分工

为什么一份有效的 ML-DSA 签名会校验失败

因为 HotPDF 拒绝在一份未声明对应扩展的已加载文档上接受 ML-DSA。ML-DSA——即被标准化为 FIPS 204 的格密码签名方案,也是人们说"后量子 PDF"时的所指——尚未在 ISO 32000-2 中注册。携带它的 PDF 使用的是基础标准未命名的算法,而一份悄悄使用未命名算法的文件,其判定结论任何其他人都无法复现

所以 HotPDF 把这项声称变得显式。EnsureMLDSAExtensions 在允许的情况下把文档提升到 PDF 2.0,并把 /Extensions /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> 写入 Catalog。读取侧的 LoadedDocumentDeclaresMLDSAExtension 报告这项声明是否还在,而 VerifyLoadedSignatureWithOptions 在遵从 Options.AllowMLDSA 之前会先施加同一道检查。在未声明的文档上设置这个标志,它会保持关闭——选项可以放宽策略,却永远不能放宽结构性要求

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'contract-pq.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
    Pdf.EnsureMLDSAExtensions;   // declare before the signature is written
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

请在保存之前调用它,而不是之后。这项声明属于已签名的字节范围的一部分,事后补丁式地改 Catalog,要么是对已签名文件的一次未签名改动,要么是一份会被校验器报告为修改的第二版修订

三族算法,一个校验入口

三族算法都通过 VerifyLoadedSignatureWithOptions 进入,它接收一个签名索引、源流、一份 THPDFCMSVerifyOptions 记录,以及一个用于回传签名详情的 out 参数。这份记录恰好有三个字段,每个都回答一个过去需要重新构建才能回答的问题

SignatureProvider 用你自己的提供者替换内置的平台提供者。OpenSSLLibraryPath 选择一个 OpenSSL 3 库,正是它提供了 Windows CNG 未必到处都有的纯模式 Ed25519 和 Ed448 校验。AllowMLDSA 在上述扩展检查的约束下选择启用格密码算法。被识别出来的确切算法 OID 通过 THPDFSignatureInfo.SignatureAlgorithmOID 返回,所以审计日志记录的是实际校验了什么,而不是请求了什么

var
  Opts: THPDFCMSVerifyOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Src: TFileStream;
begin
  Opts := THPDFCMSVerifyOptions.Default;
  Opts.OpenSSLLibraryPath := 'C:\openssl3\libcrypto-3-x64.dll';
  Opts.AllowMLDSA := Pdf.LoadedDocumentDeclaresMLDSAExtension;
  Src := TFileStream.Create('contract-pq.pdf', fmOpenRead or fmShareDenyWrite);
  try
    Status := Pdf.VerifyLoadedSignatureWithOptions(0, Src, Opts, Info);
    if Status = svValid then
      Memo1.Lines.Add('signed with OID ' + string(Info.SignatureAlgorithmOID));
  finally
    Src.Free;
  end;
end;

Ed25519 和 Ed448 不需要扩展声明,因为 ISO 32000-2 已经接纳了它们。它们确实需要一个实现了它们的提供者,在大多数 Windows 部署上,这意味着把 OpenSSLLibraryPath 指向你自带并掌控的库,而不是机器上碰巧存在的那一个

签名提供者到底承诺了什么

一个提供者只承诺一件事:给定一个请求,返回一个状态,并在签名时一并返回字节。THPDFSignatureProviderRequest 携带算法及其 OID、摘要 OID、PSS 盐长度、输入是消息还是已算好的摘要、输入本身、公钥或证书、一个密钥标识符和一个操作标识符。这份记录里没有任何 HotPDF 专有的东西——它就是令牌驱动程序或签名服务早已在使用的那套词汇

库附带三个实现。THPDFCallbackSignatureProvider 包装匿名方法,这是从一条既有内部签名例程到一份可用 PDF 签名的最短路径。THPDFRemoteSignatureProvider 包装一个传输回调,带有重试上限、取消注册表以及对输入和签名大小的边界,所以一台卡住的 HSM 不会变成一个卡住的应用程序。THPDFPKCS11SignatureProvider 把 RSA 操作串行化到一条由调用方拥有、已经过认证的 PKCS#11 会话和私钥句柄上——HotPDF 从不登录、从不见到 PIN、也从不关闭一条它没有打开的会话

var
  Provider: THPDFRemoteSignatureProvider;
begin
  Provider := THPDFRemoteSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
      out Signature: TBytes): THPDFSignatureProviderStatus
    begin
      // POST Req.Input to the signing service; Req.KeyIdentifier selects the key
      if PostToSigningService(Req.KeyIdentifier, Req.Input, Signature) then
        Result := spsValid
      else
        Result := spsProviderError;
    end,
    3,          // RetryLimit
    1048576,    // MaxInputBytes
    65536);     // MaxSignatureBytes
  try
    // hand Provider to the signing call
  finally
    Provider.Free;
  end;
end;

为什么状态枚举有六个值而不是一个布尔值

THPDFSignatureProviderStatus 区分了 spsValidspsInvalidspsUnsupportedspsMalformedspsProviderErrorspsCancelled,把它们合并起来会让你失去正确应对的能力。一份密码学上不对的签名(spsInvalid)是一次安全事件。提供者未实现的算法(spsUnsupported)是一次部署缺口。一次传输失败(spsProviderError)值得重试,而用户取消了令牌提示(spsCancelled)根本不值得重试

对签名的规则很窄:签名提供者只有在伴随非空签名时才返回 spsValid。校验提供者返回 spsValidspsInvalid,另外四个值在两条路径上都保持独立。如果你在编写一个提供者,请抵制把一切不认识的东西都映射到 spsInvalid 的诱惑——那会把一个缺失的 DLL 变成"客户签名是伪造的"这样的报告

签名实际落在文件里的什么地方

两个函数把提供者接到真实的 PDF 字节上。HPDFCMSBuildSignedDataWithProvider 从文档 SHA-256 摘要构建分离式(detached)CMS,当你的工作流在别处算摘要时它就是合适的入口。HPDFCMSSignPDFStreamWithProvider 签署 PDF 流里既有的签名占位符,并保留标准的 /ByteRange 流水线,当占位符由 HotPDF 自己布置时它就是合适的入口

保留这条流水线比听上去更重要。/ByteRange 约定——两段跳过十六进制签名窗口的范围——是每个校验器最先检查的东西,一条改写了它的基于提供者的路径会破坏 PAdES 一致性,无论密码学多么健全。HotPDF 让布局与内置签名路径保持一致,所以一份通过 PKCS#11 令牌签署的文档,用与从 PFX 文件签署的文档相同的 签名校验代码来校验。关于位于算法选择之上的那些 profile 规则,参见 Delphi 中的 PAdES 基线签名的详解,而对于早于这套提供者模型的 ECDSA 专属编码陷阱,参见 ECDSA CMS 校验与 P1363 签名格式的笔记

一种不会让你的文档搁浅的迁移顺序

后量子就绪是一个进度问题,而不是一个开关。今天几乎没有任何已部署的 PDF 阅读器能校验 ML-DSA,所以仅用它签署的文档,从阅读器角度看就是一份带不可校验签名的文档。能扛过真实归档考验的顺序是:保留 RSA 或 ECDSA 作为校验器将评判的那份签名,在策略要求量子抵抗证据的地方加上扩展声明和第二份 ML-DSA 签名,只有当消费侧系统跟上之后,才把主签名迁移过来

HotPDF 今天给你的,是从同一份代码同时写入和校验两者的能力,并且算法在文件里和在校验结果里都被诚实地记录。HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件,没有外部 PDF 运行时,所以签名和校验路径随你的可执行文件一起分发,而不是作为附属件——完整功能清单与试用下载见 HotPDF Delphi PDF 组件页