技术文章

在 Delphi 中用 SHA-3 签署 PDF

把 Delphi 签名例程换成 SHA3-512,OpenSSL 却拒绝结果:CMS 声明的是 SHA3-512,其 messageDigest 属性却只有 32 字节,而该算法产出的是 64 字节。没错,Delphi 代码可以用 SHA-3 签名:PDFium Component 的 SignPadesForDigest 接受 SHA3-256、SHA3-384 或 SHA3-512,并按所选那一个写好每个依赖摘要的 CMS 字段,正如 ISO/TS 32001 所期

ISO/TS 32001 给 PDF 2.0 签名加了什么

ISO/TS 32001 是对 ISO 32000-2 的一份简短扩展,拓宽 PDF 签名可声明的哈希函数集合。基础清单在 ISO 32000-2 §12.8.3.1 的表 260,对基于 CMS 的子过滤器止步于 SHA-256、SHA-384、SHA-512 与 RIPEMD-160,SHA-1 已弃用。本技术规范加入 FIPS 202 的 SHA-3 家族。SHA-3 是海绵结构,与 SHA-2 不共享任何内部构造——对想在架上备好第二族哈希、以防 SHA-2 哪天失守的签名政策而言,这正是它的全部吸引力。在 CMS 里每个变体都是 NIST 弧下的对象标识符,组合签名算法标识符就摆在旁边:

TCmsDigestAlgorithm摘要 OID输出RSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132 字节1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832 字节2.16.840.1.101.3.4.3.142.16.840.1.101.3.4.3.10
cdaSha3_3842.16.840.1.101.3.4.2.948 字节2.16.840.1.101.3.4.3.152.16.840.1.101.3.4.3.11
cdaSha3_5122.16.840.1.101.3.4.2.1064 字节2.16.840.1.101.3.4.3.162.16.840.1.101.3.4.3.12

在 PDFium Component 里,整个选择就是一个枚举:FPdfCms 单元的 TCmsDigestAlgorithm。让密钥挑成员。NIST 曲线期待与自己同宽的摘要,P-256 配 SHA3-256、P-384 配 SHA3-384、P-521 配 SHA3-512,应用 ISO/TS 32002 曲线与摘要政策的校验器会把其他配对判为无效。RSA 没有这种规矩。强度论证反正很短:RSA-2048 密钥大约交付 112 位安全性,RSA-3072 约 128 位,所以 SHA3-256 已经跑赢密钥,而在 RSA-2048 密钥上用 SHA3-512 只给 CMS 增加字节、不增加安全。成文政策未点名更大变体时,RSA 我们推荐 SHA3-256

Delphi 中 PDF 签名的 ISO/TS 32001 SHA-3 增补:EC 密钥配同宽摘要,RSA 在 PDFium Component 中默认拿 SHA3-256
SHA-3 家族作为第二族哈希加入表 260,EC 曲线配同宽摘要,SHA3-256 已跑赢 RSA-2048 密钥

随摘要而变的五个 CMS 字段

换哈希函数只是这活里最小的部分。分离式 CAdES 签名在五处写下自己的摘要,严格的校验器会交叉核对这些地方。任何一处仍写着 SHA-256,签名就失败,哪怕 RSA 或 ECDSA 的算术全对:

  1. SignerInfo 里的 digestAlgorithm(与 SignedData 的 digestAlgorithms 集合互为镜像)携带摘要 OID,比如 SHA3-384 是 2.16.840.1.101.3.4.2.9
  2. 签名属性 messageDigest(RFC 5652)装着两个 /ByteRange 区段的哈希,其 OCTET STRING 长 32、48 或 64 字节
  3. signatureAlgorithm 指名上表中的组合方案。RSASSA-PSS 是例外:其 OID 仍是 id-RSASSA-PSS(1.2.840.113549.1.1.10),摘要挪进参数里
  4. RSASSA-PSS 参数把摘要重复两遍,一次作 hashAlgorithm、一次作 MGF1 哈希,并把 saltLength 设为摘要长度。省掉它们,校验器就按 SHA-1、SHA-1 的 MGF1 和 20 字节盐理解——那是另一个算法;RFC 4055 的 RSASSA-PSS-params 标签规则在这里同样要紧
  5. 签名属性 CMSAlgorithmProtection(RFC 6211)把摘要与签名算法标识符复制进签名属性,让人无从悄无声息地调包。它必须与第 1、3 字段逐字节一致

有一个字段纹丝不动。signing-certificate-v2 属性(RFC 5035)里的 ESSCertIDv2 用自己的 hashAlgorithm 哈希签名者证书,该算法默认 SHA-256、独立于 SignedData 摘要,所以 PDFium Component 在默认编码里让它保持 SHA-256。手写 CMS 代码里最常见的错误是 messageDigest 的长度。为 SHA-256 写的代码往往把哈希存进一个固定 32 字节的记录,再把整个记录复制进属性。塞一个 SHA3-512 结果过去,属性收到前 32 字节,而 digestAlgorithm 写着 .10——也就是开头那记 OpenSSL 拒绝。同一个错误还藏在下一层:OpenSSL 的 EVP_PKEY_sign 把摘要长度当作 tbslen,并与上下文设置的消息摘要核对,于是永远传 32 的签名者收到一个绝口不提 SHA-3 的长度错误。PDFium Component 从解析出的签名者所声明的算法计算文档摘要,并从同一声明派生各属性、PSS 参数和算法保护属性,任何字段都漂移不了

Delphi 中 PAdES 签名随 SHA-3 摘要而变的五个 CMS 字段,PDFium Component 写出时经严格校验器交叉核对
digestAlgorithm、messageDigest、signatureAlgorithm、PSS 参数与 CMSAlgorithmProtection 必须指名同一个摘要,ESSCertIDv2 则保有自己的 SHA-256

用 SignPadesForDigest 签署本地 PDF

TPdf 组件上的 SHA-3 入口是 SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean 及其流版本孪生 SignPadesToStreamForDigest。两者接收与 SignPades 相同的 TPadesSignOptions 记录,cdaSha256 产出的签名与既有重载一致,一条代码路径服务两族。Windows 上 SHA-1 指纹从当前用户 MY 存储挑选证书。其他平台上的 OpenSSL 后端用交给 ConfigureSslSigningCredentials 的密钥与证书签名,并拒绝指纹。文档版本也要查。ISO/TS 32001 扩展的是 PDF 2.0,签名以增量更新进入、保留原始头,按 PDF 2.0 规则校验的依赖方需要你在签名前备好 2.0 文件

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfPades
  {$IFDEF MSWINDOWS}, FPdfCryptoWin{$ELSE}, FPdfCryptoSsl{$ENDIF};

function Sha3SigningAvailable: Boolean;
begin
{$IFDEF MSWINDOWS}
  Result := WinSha3DigestAvailable;   // CNG SHA-3 提供程序,Windows 11 24H2+
{$ELSE}
  Result := SslSha3SigningAvailable;  // 配置好凭据之前为 False
{$ENDIF}
end;

procedure SignWithSha3(const SourceFile, SignedFile, Thumbprint: string;
  const KeyPassword: AnsiString; const Log: TProc<string>);
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Signed: TMemoryStream;
begin
{$IFNDEF MSWINDOWS}
  // OpenSSL 用显式配置的材料签名;本示例不含证书链
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('/etc/signing/signer-key.pem'),
    TFile.ReadAllBytes('/etc/signing/signer.der'), nil, KeyPassword);
{$ENDIF}
  if not Sha3SigningAvailable then
    raise Exception.Create('No SHA-3 capable signing backend on this machine');

  Opts := TPadesSignOptions.Default;          // B-B、RSASSA-PSS
{$IFDEF MSWINDOWS}
  Opts.CertificateThumbprint := Thumbprint;   // 证书在当前用户 MY 存储
{$ENDIF}
  Opts.Reason := 'Approved for release';

  Pdf := TPdf.Create(nil);
  Signed := TMemoryStream.Create;
  try
    Pdf.FileName := SourceFile;
    Pdf.Active := True;
    if Pdf.PdfVersion <> pv20 then
      Log(SourceFile + ' is not a PDF 2.0 file');
    try
      if Pdf.SignPadesToStreamForDigest(Signed, Opts, cdaSha3_256) then
        Signed.SaveToFile(SignedFile);
    except
      on E: EPadesCryptoUnavailable do
        Log('SHA-3 signing unavailable: ' + E.Message);
    end;
  finally
    Signed.Free;
    Pdf.Free;
  end;
end;

后端产不出 SHA-3 时,调用抛出 EPadesCryptoUnavailable,而不是退回 SHA-256。这正是想要的行为。有人点名要 SHA3-256 是有原因的,通常是依赖方的一纸政策;用 SHA-256 顶替签出来的签名,在你这边每项检查都过,在对方那边却于数周后被拒之门外——届时你团队里没人记得当时是哪台机器签的。要在还能告诉操作员哪台主机缺算法的地方接住这个异常

SHA-3 在哪里可用:Windows CNG 与 OpenSSL

可用性属于机器。Windows 上默认后端是 CNG,而 CNG 从 Windows 11 24H2 起自带 SHA-3 算法提供程序;更早的构建上,为 "SHA3-256" 调 BCryptOpenAlgorithmProvider 会以 STATUS_NOT_FOUND 失败。WinSha3DigestAvailable 把 SHA3-256、SHA3-384、SHA3-512 三个提供程序各打开一次并缓存答案,进程存活期间它不会变。其余地方由 OpenSSL 后端签名,能做什么取决于它找到的 libcrypto:

  • OpenSSL 3.x 总是导出 EVP_sha3_256、EVP_sha3_384 与 EVP_sha3_512,只要后端能加载,SHA-3 签名在任何平台都工作
  • 加载器也接受 1.1 系列库(libcrypto-1_1.dll、libcrypto.so.1.1)。SHA-3 进入 OpenSSL 1.1.1,1.1.0 没有,较老的 Linux 镜像上由 SslSha3SigningAvailable 说了算
  • SslSha3SigningAvailable 还要求已配置签名凭据。在 ConfigureSslSigningCredentials 之前问它,即便 SslLibraryVersion 报告的是好好的 3.x 库,它也答 False。看着像缺后端,其实只是次序问题
  • 32 位可执行文件加载不了 libcrypto-3-x64.dll。这个后端最初联调时,一个指向 x64 DLL 的 Win32 构建报 SslAvailable = False,而 SslMissingSymbols 只回显库路径,症状读起来像符号绑定坏了;根因是位数,换上与可执行文件匹配的 DLL 就好了

仍混有 24H2 之前机器的 Windows 机群,可以让有 SHA-3 的继续用 CNG,其余经签名提供程序走 OpenSSL。IPadesSignerProvider 有两个方法。FPdfCryptoSsl 里的 ResolveSslSignerForDigest 返回一个签名者,其声明的摘要、签名算法、摘要长度和 PSS 盐全都跟随所请求的算法;配置了提供程序之后,ResolvePadesSignerForDigest 会核对返回的签名者所声明的摘要正是调用所求,不是则抛 EPadesCrypto。为 SHA3-256 构建的提供程序盖不了 SHA3-384 的请求。不过提供程序是进程级的,并发签名的服务器应在启动时配置一次,而不是像本示例这样每次调用都配;DLL 不在搜索路径上时,由 ConfigureSslLibraryPath 给加载器指路

Delphi 中 SHA-3 签名可用性流程:24H2 起的 Windows CNG 提供程序、OpenSSL 版本与位数守卫,以及 PDFium Component 的 EPadesCryptoUnavailable 失败路径
可用性属于机器,检查就该放在启动时:有 SHA-3 的用 CNG,其余用 OpenSSL,绝不悄悄退回 SHA-256
uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfCryptoWin, FPdfCryptoSsl, FPdfPades;

type
  TOpenSslSha3Provider = class(TInterfacedObject, IPadesSignerProvider)
  private
    FDigest: TCmsDigestAlgorithm;
  public
    constructor Create(ADigest: TCmsDigestAlgorithm);
    function BackendName: string;
    function Resolve(const Options: TPadesSignerOptions): TPadesResolvedSigner;
  end;

constructor TOpenSslSha3Provider.Create(ADigest: TCmsDigestAlgorithm);
begin
  inherited Create;
  FDigest := ADigest;
end;

function TOpenSslSha3Provider.BackendName: string;
begin
  Result := 'OpenSSL';
end;

function TOpenSslSha3Provider.Resolve(
  const Options: TPadesSignerOptions): TPadesResolvedSigner;
begin
  Result := ResolveSslSignerForDigest(Options, FDigest);
end;

procedure SignSha3OnAnyWindows(Pdf: TPdf; Dest: TStream;
  const Opts: TPadesSignOptions; const KeyPassword: AnsiString);
var
  Provider: IPadesSignerProvider;
  Chain: TPadesChainCertArray;
  SslOpts: TPadesSignOptions;
begin
  if WinSha3DigestAvailable then
  begin
    Pdf.SignPadesToStreamForDigest(Dest, Opts, cdaSha3_256);   // CNG 路径
    Exit;
  end;

  Chain := nil;
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('C:\Signing\signer-key.pem'),
    TFile.ReadAllBytes('C:\Signing\signer.der'), Chain, KeyPassword);
  if not SslSha3SigningAvailable then
    raise Exception.Create('No SHA-3 in loaded OpenSSL ' + SslLibraryVersion);

  SslOpts := Opts;
  SslOpts.CertificateThumbprint := '';   // OpenSSL 用配置好的证书签名
  Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
  ConfigurePadesSignerProvider(Provider);
  try
    Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
  finally
    ConfigurePadesSignerProvider(nil);
  end;
end;

远程或 HSM 签名者从哪里得知摘要

远程签名把工作一分为二。PreparePadesRemoteSignature 写出一个带空 /Contents 槽位的 PDF 加一份 TPadesRemoteSigningRequest,密钥持有者稍后——常在另一个进程或另一台机器上——经供 HSM 与云密钥使用的准备/完成会话交回分离式 CMS。准备侧你设置 TPadesRemoteSignOptions.DigestAlgorithm,比如设成 cdaSha3_384,请求把这个选择连同 DocumentDigest——CMS messageDigest 必须携带的值——记进 TPadesRemoteSigningRequest.DigestAlgorithm。从这刻起请求就是权威。CompletePadesRemoteSignature 用请求里存的算法重算 /ByteRange 摘要,绝不再理会选项;否则准备与完成之间的配置变更,会让旧请求被新算法审判。构建在该组件上的签名服务应当从同一处取算法:

uses
  System.SysUtils, System.Classes,
  FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
  FPdfPadesRemote;   // 链接 Enterprise 实现

function SignRemoteRequest(const RequestFile: string;
  Padding: TPadesPaddingScheme): TBytes;
var
  Stream: TFileStream;
  Request: TPadesRemoteSigningRequest;
  SignerOpts: TPadesSignerOptions;
  Resolved: TPadesResolvedSigner;
begin
  Stream := TFileStream.Create(RequestFile, fmOpenRead or fmShareDenyWrite);
  try
    Request := LoadPadesRemoteSigningRequest(Stream);
  finally
    Stream.Free;
  end;

  SignerOpts := TPadesSignerOptions.Default;   // OpenSSL 用空指纹
  SignerOpts.PaddingScheme := Padding;
  // 摘要由请求决定,本服务的配置说了不算
  Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
  Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
    Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;

在请求内部,摘要长度是算法的唯一证据,兼职指纹。读任何流之前,CompletePadesRemoteSignature 先比较 Length(DocumentDigest) 与 CmsDigestByteLength(DigestAlgorithm),被截断的摘要或被改标签的算法当场失败,目标流分毫未动。光看长度分不出 SHA3-256 与 SHA-256——都是 32 字节——所以摘要值还会与返回 CMS 内的 messageDigest 再比一次。相比之下,准备好的 PDF 对 SHA-256 与 SHA3-384 是逐字节相同的:占位符里只有 /ByteRange 数字,没有摘要。文件一模一样,很容易被误读成设置没生效。远程签名随 Enterprise 版提供,单元是 FPdfPadesRemote

变体很少是最难的决定:RSA 密钥通常就是 cdaSha3_256;生产环境里翻车的是某台主机的 CNG 或 libcrypto 产不出它,正因如此,可用性检查才该放在任何 PDFium Component for Delphi 部署的应用启动时