Articolo tecnico

Firme PAdES con SHA-3 e ISO/TS 32001 in Delphi

Spostate una routine di firma Delphi su SHA3-512 e OpenSSL respinge il risultato: il CMS dichiara SHA3-512, eppure il suo attributo messageDigest contiene 32 byte dove quell'algoritmo ne produce 64. Sì, il codice Delphi può firmare con SHA-3: SignPadesForDigest di PDFium Component accetta SHA3-256, SHA3-384 o SHA3-512 e scrive ogni campo CMS che dipende dal digest per quella sola scelta, come si attende ISO/TS 32001

Cosa aggiunge ISO/TS 32001 a una firma PDF 2.0

ISO/TS 32001 è una breve estensione di ISO 32000-2. Allarga l'insieme delle funzioni hash che una firma PDF può dichiarare. La lista di base siede nella Tabella 260 di ISO 32000-2 §12.8.3.1, e per i subfilter basati su CMS si ferma a SHA-256, SHA-384, SHA-512 e RIPEMD-160, con SHA-1 deprecata. La specifica tecnica aggiunge la famiglia SHA-3 di FIPS 202. SHA-3 è una costruzione a spugna che non condivide nessuna struttura interna con SHA-2, ed è tutto il fascino per una politica di firma che vuole una seconda famiglia di hash sullo scaffale nel caso SHA-2 si indebolisca un giorno. In CMS ogni variante è un object identifier sotto l'arco NIST. Gli identificatori degli algoritmi di firma combinati siedono proprio accanto:

TCmsDigestAlgorithmOID del digestOutputRSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132 byte1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832 byte2.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 byte2.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 byte2.16.840.1.101.3.4.3.162.16.840.1.101.3.4.3.12

In PDFium Component l'intera scelta è una enumerazione, TCmsDigestAlgorithm dall'unit FPdfCms. Lasciate che sia la chiave a scegliere il membro. Una curva NIST si attende un digest della sua stessa larghezza, così P-256 si abbina a SHA3-256, P-384 a SHA3-384 e P-521 a SHA3-512, e un validatore che applica la politica di curve e digest di ISO/TS 32002 marca ogni altro abbinamento come invalido. RSA non ha una regola del genere. L'argomento della robustezza è comunque breve: una chiave RSA-2048 eroga circa 112 bit di sicurezza e una RSA-3072 circa 128, così SHA3-256 corre già più veloce della chiave, mentre SHA3-512 su una chiave RSA-2048 aggiunge 32 byte al CMS e nulla alla sicurezza. Raccomandiamo SHA3-256 per RSA, a meno che una politica scritta non nomini una variante più grande

Aggiunte SHA-3 di ISO/TS 32001 per firme PDF da Delphi: le chiavi EC si abbinano al digest della stessa larghezza e RSA riceve SHA3-256 per impostazione predefinita in PDFium Component
La famiglia SHA-3 entra nella Tabella 260 come seconda famiglia di hash, le curve EC si abbinano alla larghezza corrispondente, e SHA3-256 corre già più veloce di una chiave RSA-2048

Cinque campi CMS che cambiano con il digest

Sostituire la funzione hash è la parte più piccola del lavoro. Una firma CAdES detached nomina il suo digest in cinque posti, e un verificatore severo li ricontrolla a incrocio. Se anche uno solo dice ancora SHA-256, la firma fallisce anche se l'aritmetica RSA o ECDSA è corretta:

  1. digestAlgorithm nel SignerInfo, riflesso nell'insieme digestAlgorithms del SignedData, porta l'OID del digest, per esempio 2.16.840.1.101.3.4.2.9 per SHA3-384
  2. L'attributo firmato messageDigest (RFC 5652) contiene l'hash dei due intervalli /ByteRange, così il suo OCTET STRING è lungo 32, 48 o 64 byte
  3. signatureAlgorithm nomina lo schema combinato della tabella qui sopra. RSASSA-PSS è l'eccezione: il suo OID resta id-RSASSA-PSS (1.2.840.113549.1.1.10) e il digest si sposta nei parametri
  4. I parametri RSASSA-PSS ripetono il digest due volte, come hashAlgorithm e come hash MGF1, e portano saltLength alla lunghezza del digest. Se li omettete, un verificatore presuppone SHA-1, MGF1 con SHA-1 e un salt di 20 byte, che è un altro algoritmo; anche le regole di tagging dei RSASSA-PSS-params di RFC 4055 contano qui
  5. L'attributo firmato CMSAlgorithmProtection (RFC 6211) copia gli identificatori del digest e dell'algoritmo di firma negli attributi firmati, dove nessuno può scambiarli senza che si noti. Deve coincidere coi campi 1 e 3 byte per byte

Un campo non cambia affatto. L'ESSCertIDv2 dentro l'attributo signing-certificate-v2 (RFC 5035) hasha il certificato del firmatario con il suo hashAlgorithm, che per impostazione predefinita vale SHA-256 e è indipendente dal digest del SignedData, così PDFium Component lo mantiene su SHA-256 nella codifica predefinita. Lo sbaglio che compare più spesso nel CMS scritto a mano è la lunghezza del messageDigest. Il codice scritto per SHA-256 tende a tenere l'hash in un record fisso di 32 byte e a copiare quel record nell'attributo. Passateci dentro un risultato SHA3-512 e l'attributo riceve i primi 32 byte mentre digestAlgorithm dice .10: è il rifiuto di OpenSSL dell'apertura. Lo stesso sbaglio vive anche uno strato più in basso, dove l'EVP_PKEY_sign di OpenSSL prende la lunghezza del digest come tbslen e la controlla contro il message digest impostato sul contesto, così un firmatario che passa sempre 32 si prende un errore di lunghezza che non nomina mai SHA-3. PDFium Component calcola il digest del documento dall'algoritmo che il firmatario risolto dichiara e deriva gli attributi, i parametri PSS e l'attributo di protezione dell'algoritmo dalla stessa dichiarazione, così nessun campo può allontanarsi dagli altri

I cinque campi CMS che cambiano con un digest SHA-3 in una firma PAdES da Delphi, ricontrollati a incrocio dai verificatori severi così come PDFium Component li scrive
digestAlgorithm, messageDigest, signatureAlgorithm, i parametri PSS e CMSAlgorithmProtection devono nominare un solo digest, mentre ESSCertIDv2 conserva il suo SHA-256

Firmare un PDF locale con SignPadesForDigest

Sul componente TPdf i punti d'ingresso SHA-3 sono SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean e il suo gemello su stream SignPadesToStreamForDigest. Entrambi prendono lo stesso record TPadesSignOptions di SignPades, e cdaSha256 produce la stessa firma dell'overload esistente, così un solo percorso di codice serve entrambe le famiglie. Su Windows un thumbprint SHA-1 seleziona un certificato nello store Current User MY. Il backend OpenSSL sulle altre piattaforme firma con chiave e certificato dati a ConfigureSslSigningCredentials e rifiuta un thumbprint. Controllate anche la versione del documento. ISO/TS 32001 estende il PDF 2.0, e la firma entra come aggiornamento incrementale che conserva l'intestazione originale, così una relying party che valida secondo le regole PDF 2.0 vuole un file 2.0 prima che firmiate

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;   // provider SHA-3 di CNG, Windows 11 24H2 e oltre
{$ELSE}
  Result := SslSha3SigningAvailable;  // False finché le credenziali non sono configurate
{$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 firma con materiale configurato esplicitamente; nessuna catena in questo abbozzo
  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;   // certificato 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;

Quando il backend non sa produrre SHA-3, la chiamata solleva EPadesCryptoUnavailable invece di ripiegare su SHA-256. È il comportamento giusto da volere. Qualcuno ha chiesto SHA3-256 per un motivo, di solito un documento di politica della relying party, e una firma SHA-256 prodotta al posto suo passerebbe ogni controllo dal vostro lato e verrebbe respinta dal loro, settimane dopo, quando nessuno nel vostro team ricorda quale macchina abbia firmato. Catturate l'eccezione dove potete ancora dire all'operatore quale host non ha l'algoritmo

Dove SHA-3 è disponibile: CNG di Windows e OpenSSL

La disponibilità appartiene alla macchina. Su Windows il backend predefinito è CNG, e CNG spedisce provider di algoritmi SHA-3 da Windows 11 24H2 in avanti; sulle build precedenti BCryptOpenAlgorithmProvider per "SHA3-256" fallisce con STATUS_NOT_FOUND. WinSha3DigestAvailable apre una volta sola i provider SHA3-256, SHA3-384 e SHA3-512 e mette in cache la risposta, visto che non può cambiare mentre il processo gira. Su tutte le altre piattaforme firma il backend OpenSSL, e cosa sa fare dipende dalla libcrypto che trova:

  • OpenSSL 3.x esporta sempre EVP_sha3_256, EVP_sha3_384 ed EVP_sha3_512, così la firma SHA-3 funziona su ogni piattaforma dove il backend si carica
  • Il loader accetta anche una libreria di serie 1.1 (libcrypto-1_1.dll, libcrypto.so.1.1). SHA-3 è arrivato in OpenSSL 1.1.1 e manca nella 1.1.0, così sulle immagini Linux più vecchie decide SslSha3SigningAvailable
  • SslSha3SigningAvailable esige anche credenziali di firma configurate. Chiesto prima di ConfigureSslSigningCredentials, risponde False anche quando SslLibraryVersion segnala una libreria 3.x perfettamente in forma. Sembra un backend mancante. È solo un problema di ordine
  • Un eseguibile a 32 bit non può caricare libcrypto-3-x64.dll. Quando questo backend è stato messo in piedi, una build Win32 puntata sulla DLL x64 riportava SslAvailable = False mentre SslMissingSymbols restituiva nient'altro che il percorso della libreria, un sintomo che sembra un binding di simboli rotto; la causa era la bitness, e una DLL compatibile con l'eseguibile l'ha sistemata

Un parco macchine Windows che include ancora macchine pre-24H2 può tenere CNG dove ha SHA-3 e instradare il resto attraverso OpenSSL con un signer provider. IPadesSignerProvider ha due metodi. ResolveSslSignerForDigest in FPdfCryptoSsl restituisce un signer il cui digest dichiarato, algoritmo di firma, lunghezza del digest e salt PSS seguono tutti l'algoritmo richiesto, e con un provider configurato ResolvePadesSignerForDigest controlla che il signer restituito dichiari il digest chiesto dalla chiamata, sollevando EPadesCrypto in caso negativo. Un provider costruito per SHA3-256 non può timbrare una richiesta SHA3-384. Il provider però vale per l'intero processo, così un server che firma in concorrenza dovrebbe configurarlo una volta sola all'avvio anziché per chiamata come fa questo abbozzo; ConfigureSslLibraryPath punta il loader sulla libcrypto quando la DLL non è nel percorso di ricerca

Flusso di disponibilità della firma SHA-3 in Delphi: provider CNG di Windows dal 24H2, guardie su versione e bitness di OpenSSL, e il percorso di fallimento EPadesCryptoUnavailable in PDFium Component
La disponibilità appartiene alla macchina, quindi il controllo sta all'avvio: CNG dove ha SHA-3, OpenSSL altrove, e nessun ripiego silenzioso su 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);   // percorso 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 firma con il certificato configurato
  Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
  ConfigurePadesSignerProvider(Provider);
  try
    Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
  finally
    ConfigurePadesSignerProvider(nil);
  end;
end;

Come scopre il digest un signer remoto o HSM?

La firma remota divide il lavoro in due. PreparePadesRemoteSignature scrive un PDF con uno slot /Contents vuoto più un TPadesRemoteSigningRequest, e il detentore della chiave restituisce più tardi un CMS detached, spesso da un altro processo o macchina, attraverso la sessione di prepare e complete usata per chiavi HSM e cloud. Sul lato prepare impostate TPadesRemoteSignOptions.DigestAlgorithm, diciamo a cdaSha3_384, e la richiesta registra la scelta in TPadesRemoteSigningRequest.DigestAlgorithm accanto a DocumentDigest, il valore che il messageDigest del CMS deve portare. Da lì in poi la richiesta è l'autorità. CompletePadesRemoteSignature ricalcola il digest /ByteRange con l'algoritmo conservato nella richiesta e non consulta più mai le opzioni; altrimenti un cambio di configurazione tra prepare e complete giudicherebbe una richiesta vecchia con un algoritmo nuovo. Un servizio di firma costruito sul componente dovrebbe prendere l'algoritmo dallo stesso posto:

uses
  System.SysUtils, System.Classes,
  FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
  FPdfPadesRemote;   // collega il modulo 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;   // thumbprint vuoto per OpenSSL
  SignerOpts.PaddingScheme := Padding;
  // Il digest lo decide la richiesta, non la configurazione di questo servizio
  Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
  Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
    Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;

Dentro la richiesta, la lunghezza del digest è l'unica prova dell'algoritmo. Fa anche da impronta digitale. Prima di leggere qualunque stream, CompletePadesRemoteSignature confronta Length(DocumentDigest) con CmsDigestByteLength(DigestAlgorithm), così un digest troncato o un algoritmo rinominato fallisce in quel punto e lo stream di destinazione non viene mai toccato. La lunghezza da sola non distingue SHA3-256 da SHA-256, entrambi 32 byte, ed è per questo che il valore del digest viene confrontato anche con il messageDigest dentro il CMS restituito. Il PDF preparato, al contrario, è byte-identico per SHA-256 e SHA3-384: il segnaposto contiene solo numeri /ByteRange e nessun digest. I file identici si lasciano leggere facilmente come segno che l'impostazione venga ignorata. La firma remota viaggia nell'edizione Enterprise attraverso l'unit FPdfPadesRemote

La variante è raramente la decisione difficile; per una chiave RSA di solito è cdaSha3_256, e ciò che fallisce in produzione è un host il cui CNG o la cui libcrypto non sanno produrla, ed è per questo che il controllo di disponibilità sta all'avvio dell'applicazione in qualunque deployment del PDFium Component per Delphi