技术文章

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

带着 ML-DSA-65 签名的 PDF 可以通过产出它的那条工具链的检查,然后在另一个验证器那里被打回「不支持的算法」——只要那个验证器坚持文件要声明到底哪个扩展定义了这种算法。公钥 1,952 字节,正是 FIPS 204 为该参数集规定的尺寸;签名字节完好,字节范围盖住整个文件。这样的验证器找的是文档 Catalog 里的一条扩展声明,因为 ISO 32000-2 压根没列出 ML-DSA

签名那一侧挡着另一堵墙。硬件令牌、云 HSM 或国产 eID 卡都愿意按要求算一份签名,但谁都不会把私钥交到你的应用手里,所以任何指望加载 PFX 文件、进程内签名的签名代码,根本就没有东西可加载。两个问题的共同点是「决定住在哪里」:算法声明住在文件里,而密钥住在你的 Delphi 代码够不到的地方

为什么有效的 ML-DSA 签名会被报为不支持?

只有当已加载文档在 Catalog 里声明了 HotPDF 开发者扩展时,HotPDF 才验证 ML-DSA-44、ML-DSA-65 和 ML-DSA-87 的 CMS 签名。没有这条声明,格密码签名一律返回 svUnsupportedAlgorithm,无论密码学本身多么可靠。理由是可复现性:一份使用基础标准未命名算法、又不说明自己依赖哪个扩展的文件,其判定结论任何其他实现都没有依据去复现

产出侧,THotPDF.EnsureMLDSAExtensions 把 /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >> 写进 Catalog,文档目标版本低于 2.0 时顺带提升到 PDF 2.0。读取侧,只有当这本字典存在、/BaseVersion 等于 2.0 且 /ExtensionLevel 至少为 1 时,LoadedDocumentDeclaresMLDSAExtension 才返回 True。VerifyLoadedSignatureWithOptions 把两头接起来:先取调用方的 AllowMLDSA 标志,与声明检查相与之后才开始任何验证,所以在未声明的文件上设这个标志等于什么都没做

HotPDF 把 ML-DSA PDF 验证关在显式 Catalog 扩展声明之后——该声明由 EnsureMLDSAExtensions 在保存文档前写入;对未声明的文件即使设置了 AllowMLDSA 也拒绝格密码算法
HotPDF 只在 Catalog 仍声明该扩展时才接受 ML-DSA,从而保证后量子判定可复现;在 EndDoc 之前调用 EnsureMLDSAExtensions,可把声明放进签名字节范围之内而非之外
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'contract-pq.pdf';
    Pdf.StrictVersionLock := False;   // 默认值;允许该调用把文件提升到 PDF 2.0
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
    Pdf.EnsureMLDSAExtensions;        // 在任何签名盖住 Catalog 之前声明
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

这段代码里有两处细节举足轻重。调用必须发生在文档保存并签名之前,因为 Catalog 位于签名字节范围内;事后补进去,要么是对已签名文件的一次未签名改动,要么是一份任何验证器都会列为修改的新增量修订。第二处是 StrictVersionLock:锁开着而文档钉在 PDF 2.0 以下时,EnsureMLDSAExtensions 什么都不写直接返回,因为 PDF 2.0 扩展没法在 1.7 文件里声明。如果你的生成器出于归档原因钉住版本,依赖这条声明之前先检查 Version = pdf20

EdDSA 与 ML-DSA 共用一个验证调用

VerifyLoadedSignatureWithOptions 接受签名索引、源流、一份 THPDFCMSVerifyOptions 记录和一个 out 参数 THPDFSignatureInfo。记录从 THPDFCMSVerifyOptions.Default 起步:默认 AllowMLDSA 关闭,CMS 容器最多 256 个签名者,会签嵌套最多 8 层。对新算法要紧的是三个字段:SignatureProvider 让你自己的验证器优先应答;OpenSSLLibraryPath 指定执行纯模式 Ed25519 和 Ed448 验证的 OpenSSL 3 libcrypto;AllowMLDSA 选择启用格密码算法,仍受上面那道 Catalog 检查约束。记录还带签名策略钉扎字段(RequireSignaturePolicy、ExpectedSignaturePolicyOID、ExpectedSignaturePolicyHash),对所有算法族一视同仁

内置路径按算法分叉。Ed25519 和 Ed448 走 OpenSSL 3;不给路径时,平台提供者在 Win64 上按正常 DLL 搜索顺序尝试 libcrypto-3-x64.dll、libcrypto-3.dll 和 libcrypto.dll。ML-DSA 走 Windows CNG 的 ML-DSA 算法提供者,不提供它的 Windows 构建上结果就是不支持。把 OpenSSLLibraryPath 指向一份你随可执行文件自带的拷贝。留空意味着结论取决于哪个安装程序碰巧把 libcrypto 丢在了搜索路径上——这种事在审计里很难解释

你自己的 SignatureProvider 先被问到。它返回 spsUnsupported 时,同样的请求转给内置平台提供者,所以一个只懂 ML-DSA 的提供者可以挡在默认 EdDSA 处理前面,而不必重新实现后者

var
  Pdf: THotPDF;
  PQ: THPDFCallbackSignatureProvider;
  Opts: THPDFCMSVerifyOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Src: TFileStream;
begin
  PQ := THPDFCallbackSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest): THPDFSignatureProviderStatus
    begin
      if not (Req.Algorithm in [hsaMLDSA44, hsaMLDSA65, hsaMLDSA87]) then
        Exit(spsUnsupported);            // 把 EdDSA 交给平台提供者
      // MyFips204Verify 是你自己包的 ML-DSA 库封装;
      // Req.Input 装着 DER 编码的 signed attributes(纯模式)
      Result := MyFips204Verify(Req.AlgorithmOID, Req.PublicKey,
        Req.Input, Req.Signature);
    end);
  Pdf := THotPDF.Create(nil);
  Src := TFileStream.Create('contract-pq.pdf', fmOpenRead or fmShareDenyNone);
  try
    Pdf.LoadFromFile('contract-pq.pdf');
    Opts := THPDFCMSVerifyOptions.Default;
    Opts.SignatureProvider := PQ;
    Opts.OpenSSLLibraryPath := ExtractFilePath(ParamStr(0)) + 'libcrypto-3-x64.dll';
    Opts.AllowMLDSA := True;             // 仍会与 Catalog 声明相与
    Status := Pdf.VerifyLoadedSignatureWithOptions(0, Src, Opts, Info);
    if Status = svValid then
      Memo1.Lines.Add(string(Info.SignatureAlgorithm) + ' / ' +
        string(Info.SignatureAlgorithmOID));
  finally
    Src.Free;
    Pdf.Free;
    PQ.Free;                             // options 记录不拥有它
  end;
end;

日志里记 Info.SignatureAlgorithmOID,而不是你配置里期望的那个算法。ML-DSA-65 对应 2.16.840.1.101.3.4.3.18,一份记录文件中实际发现 OID 的审计轨迹,将来能回答配置值回答不了的问题。完全不需要自定义提供者的话,更短的 VerifyLoadedSignature(SigIndex, Source, Info) 重载会构建默认选项,并替你根据 Catalog 声明设置 AllowMLDSA

让外部产出的 ML-DSA 与 EdDSA CMS 载跟头的编码规则

两个算法族都以纯模式验证完整的 DER 编码 signed attributes,没有 signed attributes 的 SignerInfo 一律报为不支持。EdDSA 的 AlgorithmIdentifier 参数必须缺省。手搓 CMS 常见的一个错误,是先用 SHA-512 或 SHAKE256 哈希 signed attributes 再签那份摘要——这悄悄把 PureEdDSA 变成了 CMS 里无人声明的 prehash 变体。ML-DSA 还检查参数集与消息摘要的配对:ML-DSA-44 接受 SHA-256、SHA-384、SHA-512 或 SHAKE256,ML-DSA-65 要 SHA-384 起步,ML-DSA-87 要 SHA-512 或 SHAKE256。配错对会出现在 Info.DigestConsistencyIssue 里——别的工具链来的文件验不过时,先读这个字段

EdDSA 有自己的注册故事。ISO/TS 32002 是为 PDF 2.0 签名注册 Ed25519 和 Ed448 的扩展,EnsureISO32002Extensions 会把对应的 ISO_ 条目和 ExtensionLevel 32002 加进 Catalog。验证不要求这个条目也接受 EdDSA,但你产出的文件还是应该带上它——理由与 ML-DSA 声明相同:可复现

签名提供者被要求做什么

签名提供者收到一个 THPDFSignatureProviderRequest,返回一个 THPDFSignatureProviderStatus,成功时外加签名字节。请求携带算法及其 OID、摘要算法 OID、PSS 盐长度、Input 是消息(sikMessage)还是预计算摘要(sikDigest)、输入字节、签名者证书、一个密钥标识符和本次调用唯一的操作标识符。这就是令牌驱动或签名服务早已在说的那套词汇,其中没有任何一项要求密钥跨进你的进程

每个提供者还声明一组能力,比如 spcSign、spcCancel、spcHardwareKey 和 spcRemoteKey。库里自带这几个实现:

  • THPDFCallbackSignatureProvider 包装验证、签名和取消的匿名方法,是从既有的内部签名例程通往 PDF 签名的最短路径
  • THPDFRemoteSignatureProvider 包装一个带重试上限、取消注册表和输入与签名尺寸边界的传输函数,行为不端的远程服务没法把超大的应答塞进 CMS 编码器
  • THPDFPKCS11SignatureProvider 借用你的代码已经打开并登录的会话和私钥句柄。HotPDF 永远看不到 PIN,也不管理令牌会话;它把 C_Sign 调用串行化,为 RSA PKCS#1 v1.5 前置 DigestInfo,通过机制参数把真实盐长度传给 RSA-PSS,并把裸 ECDSA r||s 结果转成 CMS 期待的 DER 形式
  • THPDFWindowsKeyStorageSignatureProvider 通过 Windows CNG 密钥存储签名,覆盖智能卡和 TPM 背书的密钥,还可以强制要求硬件背书的密钥

PKCS#11 提供者接收摘要输入,所以它处理 RSA PKCS#1 v1.5、RSA-PSS 和 ECDSA。Ed25519 和 Ed448 签的是完整的 signed-attributes 消息,这些走回调或远程提供者,由你的代码把 Req.Input 作为消息交给令牌或服务

HotPDF 可插拔签名提供者交换一份简单请求记录并返回六值状态——图中展示回调、远程与 PKCS#11 三种实现,私钥始终不进入 Delphi 进程
回调、远程与 PKCS#11 提供者说同一种请求记录、以同一个六值状态应答,硬件令牌、远程 HSM 服务和国家 eID 卡因此能继续持有密钥而不必导出
var
  Provider: THPDFRemoteSignatureProvider;
  Opts: THPDFCMSSignOptions;
  CertDER: TBytes;
  InStream, OutStream: TFileStream;
begin
  CertDER := TFile.ReadAllBytes('signer.cer');   // System.IOUtils;远程密钥的 DER 证书
  Provider := THPDFRemoteSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
      out Signature: TBytes): THPDFSignatureProviderStatus
    begin
      // PostToSigningService 是你自己的 HTTP 客户端:把超时和 5xx 映射为
      // spsProviderError,被拒绝的审批映射为 spsCancelled,未知密钥映射为 spsUnsupported
      Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
        Req.Input, Signature);
    end,
    3,          // RetryLimit:总共最多四次尝试
    1048576,    // 最大输入字节数
    65536);     // 最大签名字节数
  Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
  Opts.SignatureScheme := cmsRSAPSS;
  Opts.DigestAlgorithm := cmsdaSHA256;
  Opts.PSSSaltLength := 32;
  // contract-unsigned.pdf 保存时带了 AddSignedSignatureField 布置的占位符
  InStream := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
  OutStream := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    HPDFCMSSignPDFStreamWithProvider(InStream, OutStream, CertDER,
      Provider, 'contracts-2026', Opts);
  finally
    OutStream.Free;
    InStream.Free;
    Provider.Free;
  end;
end;

六种状态,以及每种该触发什么反应

THPDFSignatureProviderStatus 有六个值,因为正确的反应各不相同。spsInvalid 是密码学失败,该进安全日志。spsUnsupported 是提供者没实现这个算法,属于部署问题。spsMalformed 是请求或应答形状不对。spsProviderError 是传输或设备故障,重试也许能成。spsCancelled 是有人拒绝了令牌提示,重试只会惹人烦

远程提供者把这套策略直接建了进去:只在 spsProviderError 上重试,每次尝试前查取消注册表,调用传输之前把空输入或超尺输入按 spsMalformed 拒掉,把带着空签名或超大签名的「有效」应答降级成 spsMalformed。验证一侧,提供者的应答映射到验证状态:spsInvalid 变 svSignatureInvalid,spsMalformed 变 svMalformed,spsProviderError 和 spsCancelled 都变 svCryptoProviderError,spsUnsupported 在平台提供者也拒绝之后变 svUnsupportedAlgorithm

如果你自己写提供者,忍住别把所有意外都映射到 spsInvalid。那会把一个缺失的 DLL 或过期的服务令牌变成「客户签名是伪造的」报告,到时候总得有人向客户解释。数学真的失败了才返回 spsInvalid。签名方向,只有连同非空签名一起才返回 spsValid;提供者返回成功却不带字节时,两个入口都会抛 EHPDFCMSError

提供者的签名落在文件里的哪里

两个函数把提供者接到真实的 PDF 字节上。HPDFCMSBuildSignedDataWithProvider 从你在别处算好的文档摘要构建分离式 CMS;摘要长度必须匹配 Options.DigestAlgorithm,SHA-384 配置期待 48 字节,遇到 32 字节的 SHA-256 摘要以异常拒绝。HPDFCMSSignPDFStreamWithProvider 签署 AddSignedSignatureField 布置好的占位符:哈希两个 /ByteRange 窗口,向提供者要 signed attributes 上的签名,把十六进制结果补进 /Contents。CMS 超出占位符时,调用抛异常并在输出流里恢复未签名的占位符,而不是截断任何东西

布局与基于 PFX 的签名路径写出的是同一种:两段范围,跳过十六进制签名窗口。验证器对每份文件都按这个形状要求,检查两段之间的间隙里恰好是这份签名的 /Contents 字符串而没有别的,否则报 svInvalidByteRange。因此经 PKCS#11 令牌签署的文档,用与从 PFX 文件签出的文档相同的签名验证代码来验证。算法选择之上的 profile 规则在Delphi 中的 PAdES 基线签名里,PKCS#11 转换所处理的 ECDSA 编码陷阱在ECDSA CMS 验证与 P1363 签名格式里

HotPDF 两个提供者签名入口——面向外部算好摘要的 HPDFCMSBuildSignedDataWithProvider 与面向已布置占位符的 HPDFCMSSignPDFStreamWithProvider——汇入同一个 CMS 布局,保留验证器最先检查的 /ByteRange 范围
任一入口写出的都是同一种签名形状:十六进制窗口两侧各一段覆盖范围,所以提供者签名的文档与 PFX 签出的文档字节布局相同

推出后量子签名而不搁浅阅读器

没实现 ML-DSA 的 PDF 阅读器会把这种签名报为无法验证,看文档的人看到的是警告而不是绿色对勾。这让迁移成为一个排期问题。保留 RSA 或 ECDSA 作为今天的验证器会评判的那份签名。策略要求抗量子证据的地方,用 EnsureMLDSAExtensions 声明扩展,并在经典签名旁边加上由具备 ML-DSA 能力的签名者产出的第二份签名。那份签名来自你自己的 FIPS 204 签名者——HotPDF 的 CMS 构建器不创建 ML-DSA 签名,库负责的是验证它。等消费你文档的系统能验证它了,再把主签名换过去

在这个计划里,HotPDF 把你从 Delphi 能控制的两端都覆盖了。它的 CMS 构建器经上面任意提供者用 RSA PKCS#1 v1.5、RSA-PSS、ECDSA、Ed25519 和 Ed448 签名,它的验证器一次调用检查 ML-DSA、EdDSA 和经典算法,并把实际发现的 OID 记录在结果里。HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件,不需要外部 PDF 运行时,签名和验证代码直接编进你的可执行文件。版本、签名功能集与试用下载见HotPDF Delphi PDF 组件页