把 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.5 | ECDSA |
|---|---|---|---|---|
cdaSha256 | 2.16.840.1.101.3.4.2.1 | 32 字节 | 1.2.840.113549.1.1.11 | 1.2.840.10045.4.3.2 |
cdaSha3_256 | 2.16.840.1.101.3.4.2.8 | 32 字节 | 2.16.840.1.101.3.4.3.14 | 2.16.840.1.101.3.4.3.10 |
cdaSha3_384 | 2.16.840.1.101.3.4.2.9 | 48 字节 | 2.16.840.1.101.3.4.3.15 | 2.16.840.1.101.3.4.3.11 |
cdaSha3_512 | 2.16.840.1.101.3.4.2.10 | 64 字节 | 2.16.840.1.101.3.4.3.16 | 2.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
随摘要而变的五个 CMS 字段
换哈希函数只是这活里最小的部分。分离式 CAdES 签名在五处写下自己的摘要,严格的校验器会交叉核对这些地方。任何一处仍写着 SHA-256,签名就失败,哪怕 RSA 或 ECDSA 的算术全对:
- SignerInfo 里的
digestAlgorithm(与 SignedData 的digestAlgorithms集合互为镜像)携带摘要 OID,比如 SHA3-384 是 2.16.840.1.101.3.4.2.9 - 签名属性
messageDigest(RFC 5652)装着两个/ByteRange区段的哈希,其 OCTET STRING 长 32、48 或 64 字节 signatureAlgorithm指名上表中的组合方案。RSASSA-PSS 是例外:其 OID 仍是 id-RSASSA-PSS(1.2.840.113549.1.1.10),摘要挪进参数里- RSASSA-PSS 参数把摘要重复两遍,一次作
hashAlgorithm、一次作 MGF1 哈希,并把saltLength设为摘要长度。省掉它们,校验器就按 SHA-1、SHA-1 的 MGF1 和 20 字节盐理解——那是另一个算法;RFC 4055 的 RSASSA-PSS-params 标签规则在这里同样要紧 - 签名属性 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 参数和算法保护属性,任何字段都漂移不了
用 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 给加载器指路
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 部署的应用启动时