Technical Article

SHA-3 PDF Signatures in Delphi: Five CMS Fields That Must Agree

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:

TCmsDigestAlgorithmDigest OIDOutputRSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132 bytes1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832 bytes2.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 bytes2.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 bytes2.16.840.1.101.3.4.3.162.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

ISO/TS 32001 SHA-3 additions for PDF signatures from Delphi: EC keys pair with the same-width digest and RSA gets SHA3-256 by default in PDFium Component
the SHA-3 family joins Table 260 as a second hash family, EC curves pair with the matching width, and SHA3-256 already outruns an RSA-2048 key

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:

  1. digestAlgorithm in the SignerInfo, mirrored in the SignedData digestAlgorithms set, carries the digest OID, for example 2.16.840.1.101.3.4.2.9 for SHA3-384
  2. The messageDigest signed attribute (RFC 5652) holds the hash of the two /ByteRange spans, so its OCTET STRING is 32, 48 or 64 bytes long
  3. signatureAlgorithm names 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
  4. The RSASSA-PSS parameters repeat the digest twice, as hashAlgorithm and as the MGF1 hash, and set saltLength to 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
  5. 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

The five CMS fields that change with a SHA-3 digest in a PAdES signature from Delphi, cross-checked by strict verifiers as PDFium Component writes them
digestAlgorithm, messageDigest, signatureAlgorithm, the PSS parameters and CMSAlgorithmProtection must name one digest, while ESSCertIDv2 keeps its own SHA-256

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 behavior 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 while 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_384 and EVP_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 images SslSha3SigningAvailable decides
  • SslSha3SigningAvailable also requires configured signing credentials. Asked before ConfigureSslSigningCredentials, it answers False even when SslLibraryVersion reports 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 reported SslAvailable = False while SslMissingSymbols echoed 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

SHA-3 signing availability flow in Delphi: Windows CNG providers from 24H2, OpenSSL version and bitness guards, and the EPadesCryptoUnavailable failure path in PDFium Component
availability belongs to the machine, so the check belongs at startup: CNG where it has SHA-3, OpenSSL elsewhere, and no silent fallback to 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 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 does a remote or HSM signer learn 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; this service's 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