You switch a Delphi signing routine to SHA3-512 and OpenSSL rejects the result: the CMS declares SHA3-512, yet its messageDigest attribute holds 32 bytes where that algorithm produces 64. Yes, Delphi code can sign with SHA-3: SignPadesForDigest in PDFium Component accepts SHA3-256, SHA3-384 or SHA3-512 and writes every digest-dependent CMS field for that one choice, as ISO/TS 32001 expects
What ISO/TS 32001 adds to a PDF 2.0 signature
ISO/TS 32001 is a short extension to ISO 32000-2. It widens the set of hash functions a PDF signature may declare. The base list sits in Table 260 of ISO 32000-2 §12.8.3.1, and for the CMS-based subfilters it stops at SHA-256, SHA-384, SHA-512 and RIPEMD-160, with SHA-1 deprecated. The technical specification adds the SHA-3 family from FIPS 202. SHA-3 is a sponge construction that shares no internal structure with SHA-2, which is the whole appeal for a signature policy that wants a second hash family on the shelf in case SHA-2 ever weakens. In CMS each variant is an object identifier under the NIST arc. The combined signature algorithm identifiers sit right beside them:
TCmsDigestAlgorithm | Digest OID | Output | RSA PKCS#1 v1.5 | ECDSA |
|---|---|---|---|---|
cdaSha256 | 2.16.840.1.101.3.4.2.1 | 32 bytes | 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 bytes | 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 bytes | 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 bytes | 2.16.840.1.101.3.4.3.16 | 2.16.840.1.101.3.4.3.12 |
In PDFium Component the whole choice is one enumeration, TCmsDigestAlgorithm from the FPdfCms unit. Let the key pick the member. A NIST curve expects a digest of its own width, so P-256 pairs with SHA3-256, P-384 with SHA3-384 and P-521 with SHA3-512, and a validator applying the ISO/TS 32002 curve and digest policy marks any other pairing invalid. RSA has no such rule. The strength argument is short anyway: an RSA-2048 key delivers roughly 112 bits of security and RSA-3072 about 128, so SHA3-256 already outruns the key, while SHA3-512 on an RSA-2048 key adds 32 bytes to the CMS and nothing to the security. We recommend SHA3-256 for RSA unless a written policy names a larger variant
Five CMS fields that change with the digest
Swapping the hash function is the smallest part of the job. A detached CAdES signature names its digest in five places, and a strict verifier cross-checks them. If any one still says SHA-256, the signature fails even though the RSA or ECDSA arithmetic is correct:
digestAlgorithmin the SignerInfo, mirrored in the SignedDatadigestAlgorithmsset, carries the digest OID, for example 2.16.840.1.101.3.4.2.9 for SHA3-384- The
messageDigestsigned attribute (RFC 5652) holds the hash of the two/ByteRangespans, so its OCTET STRING is 32, 48 or 64 bytes long signatureAlgorithmnames the combined scheme from the table above. RSASSA-PSS is the exception: its OID stays id-RSASSA-PSS (1.2.840.113549.1.1.10) and the digest moves into the parameters- The RSASSA-PSS parameters repeat the digest twice, as
hashAlgorithmand as the MGF1 hash, and setsaltLengthto the digest length. Leave them out and a verifier assumes SHA-1, MGF1 with SHA-1 and a 20-byte salt, which is a different algorithm; the RSASSA-PSS-params tagging rules from RFC 4055 matter here too - The CMSAlgorithmProtection signed attribute (RFC 6211) copies the digest and signature algorithm identifiers into the signed attributes, where nobody can swap them unnoticed. It must match fields 1 and 3 byte for byte
One field does not change at all. The ESSCertIDv2 inside the signing-certificate-v2 attribute (RFC 5035) hashes the signer certificate with its own hashAlgorithm, which defaults to SHA-256 and is independent of the SignedData digest, so PDFium Component keeps it at SHA-256 in the default encoding. The mistake that shows up most in hand-written CMS code is the messageDigest length. Code written for SHA-256 tends to hold the hash in a fixed 32-byte record and copy that record into the attribute. Push a SHA3-512 result through it and the attribute receives the first 32 bytes while digestAlgorithm says .10: the OpenSSL rejection from the opening. The same mistake also lives one layer down, where OpenSSL's EVP_PKEY_sign takes the digest length as tbslen and checks it against the message digest set on the context, so a signer that always passes 32 gets a length error that never mentions SHA-3. PDFium Component computes the document digest from whatever algorithm the resolved signer declares and derives the attributes, the PSS parameters and the algorithm-protection attribute from that same declaration, so no field can drift from the others
Signing a local PDF with SignPadesForDigest
On the TPdf component the SHA-3 entry points are SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean and its stream twin SignPadesToStreamForDigest. Both take the same TPadesSignOptions record as SignPades, and cdaSha256 produces the same signature as the existing overload, so one code path serves both families. On Windows a SHA-1 thumbprint selects a certificate in the Current User MY store. The OpenSSL backend on other platforms signs with the key and certificate given to ConfigureSslSigningCredentials and refuses a thumbprint. Check the document version too. ISO/TS 32001 extends PDF 2.0, and the signature goes in as an incremental update that keeps the original header, so a relying party that validates against PDF 2.0 rules needs a 2.0 file before you sign it
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 providers, Windows 11 24H2+
{$ELSE}
Result := SslSha3SigningAvailable; // False until credentials are configured
{$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 signs with explicitly configured material; no chain in this sketch
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; // certificate in Current User 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;
When the backend cannot produce SHA-3, the call raises EPadesCryptoUnavailable instead of falling back to SHA-256. That is the behaviour to want. Somebody asked for SHA3-256 for a reason, usually a policy document from the relying party, and a SHA-256 signature produced in its place would pass every check on your side and be turned away on theirs, weeks later, when nobody on your team remembers which machine signed it. Catch the exception where you can still tell the operator which host lacks the algorithm
Where SHA-3 is available: Windows CNG and OpenSSL
Availability belongs to the machine. On Windows the default backend is CNG, and CNG ships SHA-3 algorithm providers from Windows 11 24H2 onward; on earlier builds BCryptOpenAlgorithmProvider for "SHA3-256" fails with STATUS_NOT_FOUND. WinSha3DigestAvailable opens the SHA3-256, SHA3-384 and SHA3-512 providers once and caches the answer, since it cannot change whilst the process runs. Everywhere else the OpenSSL backend signs, and what it can do depends on the libcrypto it finds:
- OpenSSL 3.x always exports
EVP_sha3_256,EVP_sha3_384andEVP_sha3_512, so SHA-3 signing works on every platform where the backend loads - The loader also accepts a 1.1-series library (
libcrypto-1_1.dll,libcrypto.so.1.1). SHA-3 arrived in OpenSSL 1.1.1 and is absent from 1.1.0, so on older Linux imagesSslSha3SigningAvailabledecides SslSha3SigningAvailablealso requires configured signing credentials. Asked beforeConfigureSslSigningCredentials, it answers False even whenSslLibraryVersionreports a perfectly good 3.x library. That looks like a missing backend. It is only an ordering problem- A 32-bit executable cannot load
libcrypto-3-x64.dll. When this backend was brought up, a Win32 build pointed at the x64 DLL reportedSslAvailable = FalsewhilstSslMissingSymbolsechoed nothing but the library path, a symptom that reads like a broken symbol binding; the cause was the bitness, and a DLL matching the executable fixed it
A Windows fleet that still includes pre-24H2 machines can keep CNG where it has SHA-3 and route the rest through OpenSSL with a signer provider. IPadesSignerProvider has two methods. ResolveSslSignerForDigest in FPdfCryptoSsl returns a signer whose declared digest, signature algorithm, digest length and PSS salt all follow the requested algorithm, and with a provider configured, ResolvePadesSignerForDigest checks that the returned signer declares the digest the call asked for, raising EPadesCrypto if it does not. A provider built for SHA3-256 cannot stamp a SHA3-384 request. The provider is process-wide, though, so a server that signs concurrently should configure it once at startup rather than per call as this sketch does; ConfigureSslLibraryPath points the loader at libcrypto when the DLL is not on the search path
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 path
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 signs with the configured cert
Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
ConfigurePadesSignerProvider(Provider);
try
Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
finally
ConfigurePadesSignerProvider(nil);
end;
end;
How a remote or HSM signer learns the digest
Remote signing splits the work in two. PreparePadesRemoteSignature writes a PDF with an empty /Contents slot plus a TPadesRemoteSigningRequest, and the key holder returns a detached CMS later, often from another process or machine, through the prepare and complete session used for HSM and cloud keys. On the prepare side you set TPadesRemoteSignOptions.DigestAlgorithm, say to cdaSha3_384, and the request records the choice in TPadesRemoteSigningRequest.DigestAlgorithm next to DocumentDigest, the value the CMS messageDigest must carry. From then on the request is the authority. CompletePadesRemoteSignature recomputes the /ByteRange digest with the algorithm stored in the request and never consults the options again; otherwise a configuration change between prepare and complete would judge an old request by a new algorithm. A signing service built on the component should take the algorithm from the same place:
uses
System.SysUtils, System.Classes,
FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
FPdfPadesRemote; // links the Enterprise implementation
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; // empty thumbprint for OpenSSL
SignerOpts.PaddingScheme := Padding;
// The request decides the digest; the service configuration does not
Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;
Inside the request, the digest length is the only evidence of the algorithm. It doubles as a fingerprint. Before reading any stream, CompletePadesRemoteSignature compares Length(DocumentDigest) with CmsDigestByteLength(DigestAlgorithm), so a truncated digest or a relabelled algorithm fails at that point and the destination stream is never touched. Length alone cannot tell SHA3-256 from SHA-256, both 32 bytes, which is why the digest value is also compared with the messageDigest inside the returned CMS. The prepared PDF, by contrast, is byte-identical for SHA-256 and SHA3-384: the placeholder holds only /ByteRange numbers and no digest. Identical files are easy to misread as the setting being ignored. Remote signing ships in the Enterprise edition through the FPdfPadesRemote unit
The variant is rarely the hard decision; for an RSA key it is usually cdaSha3_256, and what fails in production is a host whose CNG or libcrypto cannot produce it, which is why the availability check belongs at application startup in any deployment of the PDFium Component for Delphi