Articolo tecnico

Verificare firme PDF con OpenSSL in PDFium VCL

PDFium VCL tratta la verifica CMS come un backend sostituibile dietro l'interfaccia IPdfCmsVerifier, così il validatore PAdES può girare su Windows tramite CryptoAPI, su macOS tramite il Keychain, e ovunque sia presente OpenSSL tramite ConfigureSslCmsVerifier. L'interfaccia è piccola. Tre comportamenti OpenSSL sotto di essa producono risposte sicure di sé e sbagliate se la implementi ingenuamente

La motivazione è abbastanza chiara appena un'applicazione Delphi lascia Windows. La validazione delle firme è una delle poche aree in cui lo stack crittografico di piattaforma non è un dettaglio implementativo: decide quali certificati sono fidati, quali algoritmi esistono, e cosa significa revoca. Hard-codarne uno e il codice non si porta altrove. Astrarlo male e ogni piattaforma riporta una risposta di forma diversa che il chiamante non può confrontare

Cosa deve davvero portare l'astrazione

Due forme di verifica e tre verdetti indipendenti. Una firma PDF è detached: il contenuto firmato sono i due intervalli di byte ai lati del buco /Contents, quindi VerifyDetached prende due segmenti invece di un buffer. Un token di timestamp è attached, porta il proprio contenuto, quindi VerifyAttached prende solo il DER

Il risultato si divide in tre status perché rispondono a tre domande diverse e possono essere in disaccordo. SignatureStatus dice se i byte sono stati firmati dalla chiave nel certificato del signer. TrustStatus dice se quel certificato forma una catena fino a qualcosa in cui hai fiducia. RevocationStatus dice se il certificato era ancora valido al momento rilevante. Un documento con una firma matematicamente perfetta da un certificato di cui non hai mai sentito parlare è valido, non fidato e sconosciuto, e collassare ciò in un solo booleano è il modo in cui i validatori finiscono per mentire agli utenti

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER, può essere vuoto
  ConfigureSslCrls(LoadFreshCrls);                // DER, può essere vuoto
  ConfigureSslCmsVerifier;                        // installa il backend

  Writeln('backend  : ', PadesCmsVerificationBackendName);
  Writeln('library  : ', SslLibraryPath, ' ', SslLibraryVersion);
  Writeln('ABI      : ', SslAbiLayout);           // ulong=<n> long=<n>

  Options := TPdfCmsVerifyOptions.Default;
  Options.CheckRevocation := True;
  Options.CollectChainCertificates := True;
end;

SslAbiLayout sembra una curiosità e non lo è. Ogni codice errore OpenSSL e ogni flag di store attraversa il confine come un unsigned long C, che è quattro byte su Windows e otto su Linux e macOS. Dichiaraelo come tipo fisso a 32 bit e il codice funziona su Windows, per poi leggere in silenzio metà valore su LP64. Riportare le larghezze assunte come una stringa su cui asserire in un test trasforma un'intera classe di deriva ABI di piattaforma in un controllo di una riga. Chi ha affrontato lo stesso problema con CK_ULONG in un binding PKCS#11 lo riconoscerà all'istante; quella storia è in packing delle struct PKCS#11 e larghezza CK_ULONG

Perché la seconda passata di verifica vede contenuto vuoto?

Perché CMS_verify legge il BIO del contenuto detached fino a fine file, e un BIO già letto non viene riavvolto per te. Verificare in due passate è un design ragionevole, prima la sola firma crittografica con la valutazione della catena soppressa, poi la valutazione completa, e fallisce in un modo insolitamente ingannevole se entrambe le passate condividono un BIO

La seconda passata ottiene zero byte di contenuto. In modalità detached non è un errore, perché un buffer di contenuto vuoto è un input legale. Il digest semplicemente non combacia, e il fallimento emerge come un fallimento di costruzione della catena invece che un fallimento di contenuto, il che ti manda a ispezionare certificati e trust store mentre il problema vero è una posizione di stream. Ricostruisci il memory BIO con BIO_new_mem_buf a ogni passata. Costa un'allocazione e elimina del tutto la possibilità

Cosa sopprime e cosa no il flag no-verify

CMS_NO_SIGNER_CERT_VERIFY sopprime la valutazione della catena, non la ricerca del certificato del signer. Internamente OpenSSL risolve e allega i certificati del signer prima di consultare il flag, quindi dopo una prima passata che porta quel flag il signer è già disponibile e i suoi identificatori di algoritmo si possono leggere subito. Non serve eseguire una seconda verifica completa solo per ottenere il certificato del signer, che è ciò che il nome del flag ti tenta di presupporre

Una regola di proprietà va con questo. Il riferimento al signer appartiene alla struttura CMS e non va liberato indipendentemente. È valido finché lo è la struttura, e liberarlo produce una corruzione il cui sintomo compare da tutt'altra parte, di solito durante la pulizia di un oggetto non correlato

Perché attivare il controllo CRL respinge ogni firma?

Perché OpenSSL controlla le CRL solo contro ciò che lo store già custodisce e non va a prelevare nulla da sé. Non segue i CRL distribution point e non parla OCSP. Imposta X509_V_FLAG_CRL_CHECK su uno store senza CRL al suo interno e ogni catena fallisce con l'incapacità di ottenere una CRL del certificato. Il risultato sembra un controllo di revoca che funziona e trova problemi. È un controllo di revoca che non gira mai

Il backend quindi imposta il flag solo quando ConfigureSslCrls ha davvero fornito almeno una CRL. Senza, RevocationStatus torna come pcvsUnsupported, che è un'affermazione onesta che la domanda non ha ricevuto risposta. Per lo stesso motivo OnlineRetrieval non ha effetto su questo backend e nessun checkpoint pcvstOnlineRetrieval viene emesso: non c'è un percorso di fetch da cui riportare progresso

Diagramma del verificatore CMS OpenSSL di PDFium VCL di tre trappole: un BIO di contenuto condiviso letto fino a fine file lascia la seconda passata di verifica con zero byte, CMS_NO_SIGNER_CERT_VERIFY sopprime la valutazione della catena ma non la ricerca del signer, e il controllo CRL su uno store vuoto respinge ogni catena senza che la revoca sia mai girata
Ogni trappola produce un verdetto sicuro di sé e sbagliato: una posizione di stream si spaccia per un fallimento di fiducia, il flag no-verify sopprime meno di quanto il nome suggerisca, e una revoca mai girata sembra una revoca che ha trovato problemi

Questa è una posizione di design che vale la pena difendere in generale. Un validatore che non può controllare la revoca dovrebbe dirlo. Riportare un certificato non controllato come non revocato è il modo più comune in assoluto con cui gli strumenti di validazione delle firme disinformano i propri utenti, ed è esattamente la classe di confusione esplorata in perché i validatori respingono le firme PAdES

// I checkpoint permettono a una UI di mostrare quale stadio sta girando,
// e dicono quali stadi un backend esegue davvero
type
  TSignatureProbe = class
    procedure Checkpoint(Stage: TPdfCmsVerifyStage);
  end;

procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
  case Stage of
    pcvstCryptographicSignature: Status('checking the signature');
    pcvstChainBuild:             Status('building the certificate chain');
    pcvstOnlineRetrieval:        Status('fetching validation data');
    pcvstRevocationCheck:        Status('checking revocation');
  end;
end;

// Leggi i tre verdetti separatamente; è permesso che siano in disaccordo
if Result.SignatureStatus = pcvsValid then
  case Result.TrustStatus of
    pcvsValid:         Report('signed and trusted');
    pcvsInvalid:       Report('signed, chain rejected');
    pcvsUnsupported,
    pcvsIndeterminate: Report('signed, trust not established');
  end;
if Result.RevocationStatus = pcvsUnsupported then
  Report('revocation was not checked on this backend');

Legarsi a una libreria che non puoi bloccare

OpenSSL ha rinominato i propri accessor dello stack tra 1.0 e 1.1, quindi la stessa funzione logica ha due possibili nomi di export a seconda della build che l'host si trova ad avere. Il binding risolve prima il nome più recente e ricade su quello più vecchio, e registra un simbolo mancante solo quando nessuno dei due si risolve. Quella è la forma giusta per qualunque binding dinamico verso una libreria che non spedisci: preferisci i nomi attuali, tollera quelli storici, e riporta solo l'assenza genuina

SslMissingSymbols è ciò che trasforma un caricamento fallito in un evento diagnosticabile. Un risultato non vuoto su un host che chiaramente ha libcrypto installato significa che la versione installata è più vecchia dell'API a cui questa build punta, che è una conversazione di supporto completamente diversa da una libreria assente. ConfigureSslLibraryPath copre l'altro caso comune, un host con diverse build OpenSSL dove quella sul percorso di ricerca predefinito non è quella che vuoi

Scegliere un backend per piattaforma

L'assetto pratico è selezionare all'avvio e registrare quale ha risposto. Su Windows, il backend di piattaforma si integra con gli archivi certificati che un'enterprise gestisce già, che è di solito ciò che vuoi. Su macOS il backend Keychain segue lo stesso ragionamento ed è descritto in verificare firme con SecTrust su macOS. OpenSSL è l'opzione portabile, ed è anche la scelta giusta quando ti serve una policy di validazione identica tra piattaforme invece che una che segua il trust store di ciascuna piattaforma

Diagramma PDFium VCL dell'astrazione IPdfCmsVerifier che porta VerifyDetached sui due intervalli di byte attorno al buco Contents e VerifyAttached per i token di timestamp, i tre verdetti indipendenti SignatureStatus, TrustStatus e RevocationStatus, e backend per piattaforma selezionati all'avvio tramite CryptoAPI, SecTrust o ConfigureSslCmsVerifier
L'interfaccia porta due forme di verifica e tre verdetti perché rispondono a domande diverse e possono essere in disaccordo, e il backend installato viene registrato accanto a ogni verdetto così i risultati memorizzati si possono riprodurre

Qualunque tu installi, logga PadesCmsVerificationBackendName accanto a ogni verdetto che registri. Un risultato di validazione memorizzato senza il backend che lo ha prodotto non si può riprodurre dopo, perché i tre valori di status significano cose sottilmente diverse a seconda di quale stack ha risposto. Il layer di ispezione delle firme sopra tutto questo, compreso come vengono riportati i livelli PAdES, è trattato in ispezionare firme digitali PDF e livelli PAdES

Tutto ciò viene spedito come sorgente con il PDFium Delphi component, il che qui conta più del solito: per un validatore di firme, poter leggere esattamente quali flag imposta un backend e quali controlli salta non è un avere-di-più, è l'unico modo di sapere cosa dichiara davvero una spunta verde nella tua applicazione