Articolo tecnico

Verificare firme PDF su macOS con SecTrust in Delphi

Il PDFium Delphi Component verifica le firme PDF su macOS tramite TPdfKeychainCmsVerifier, un backend di verifica CMS costruito su Apple CMSDecoder e SecTrust invece che su un parsing manuale del CMS. ConfigureKeychainCmsVerifier lo installa e una sola chiamata a CMSDecoderCopySignerStatus restituisce il verdetto della firma, un handle SecTrust e un codice risultato del certificato, esattamente la coppia di colonne che TPdfCmsVerifyResult portava già su Windows

Lo scenario che ha imposto il lavoro è banale e comune. Una build Lazarus di un archivio documentale gira su un Mac, apre un contratto firmato e ogni firma torna pcsUnsupported. Non c'è nulla di sbagliato nel file. La verifica delle firme semplicemente non aveva un backend fuori da Windows e il validator PAdES rifiutava di indovinare in assenza di uno. La versione 3.111.0 di PDFiumPas ha aperto il seam con IPdfCmsVerifier e ConfigurePadesCmsVerifier; la versione 3.113.0 lo ha riempito su macOS. La parte interessante del porting non è il plumbing, ma i tre punti in cui l'API Apple non ha la stessa forma di quella Windows

Perché una firma PDF copre due byte range?

Perché una firma non può coprire i byte che la contengono. ISO 32000-1 §12.8.1 mette il blob CMS SignedData nella stringa /Contents del signature dictionary e descrive l'estensione firmata con /ByteRange, un insieme di coppie offset e length che coprono tutto ciò che si trova ai lati di quel buco. Due segmenti e uno spazio vuoto al centro, su ogni piattaforma

Le piattaforme non concordano su come quei segmenti arrivino al layer crittografico, e il disaccordo costa memoria. Su Windows CryptVerifyDetachedMessageSignature accetta un array di puntatori e lunghezze, quindi entrambi gli span entrano come sono nel buffer e non viene duplicato nulla. Apple CMSDecoderSetDetachedContent accetta un solo CFData e non ha una forma multi-segmento, quindi il backend macOS concatena i due intervalli in un buffer contiguo prima del decode. È una seconda copia completa dei byte firmati. Su un archivio scansionato da 400 MB è un picco di memoria reale, scala con il documento invece che con la firma e non esiste un'API alternativa da usare. Dimensionate il batch worker di conseguenza invece di scoprirlo sulla macchina di un cliente

Una chiamata riempie due colonne di TPdfCmsVerifyResult

CMSDecoderCopySignerStatus è insolitamente generosa per un entry point di Security.framework: una chiamata restituisce lo status del signer, un SecTrustRef per la catena costruita e un OSStatus per la valutazione del certificato. Questi valori finiscono direttamente nel record che il validator PAdES già consuma, con lo status del signer che diventa SignatureStatus, il risultato del certificato che diventa TrustStatus e i valori raw conservati in SignatureError e TrustError, così un ticket di supporto può citare un numero invece di un aggettivo. I caller non toccano mai IPdfCmsVerifier: ValidatePadesCompliance e ValidatePadesTrust instradano ogni verifica attraverso il backend installato, quindi il codice che legge TPadesSignatureValidation è byte per byte lo stesso su entrambe le piattaforme, come descritto nella guida su ispezionare signature dictionary PDF e livelli PAdES in Delphi

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // Firma e verifica risolvono simboli framework diversi, quindi uno
  // può essere presente mentre l'altro non lo è
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

  // PadesCmsVerificationBackendName ora restituisce 'macOS Security.framework'
  if not PadesCmsVerificationAvailable then
    raise Exception.Create('No CMS verification backend is installed');
end;

Perché kCMSSignerInvalidCert segnala una firma valida?

Perché Apple assegna a quel valore un significato più stretto di quanto suggerisca il nome: la firma ha superato la verifica e solo la catena dei certificati non ha potuto essere stabilita. TPdfKeychainCmsVerifier mappa quindi kCMSSignerInvalidCert a pcvsValid nella colonna SignatureStatus e lascia emergere il problema del certificato tramite TrustStatus, dove appartiene un problema di catena. Fonderlo nel verdetto della firma farebbe dire al componente a un operatore che un documento integro è stato modificato, il peggior falso allarme che un validator di firme possa produrre

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // La firma è stata verificata e solo la catena non lo è stata:
  // lo status di trust lo segnala separatamente
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

Leggete i due status come una coppia ordinata e la logica di reporting si scrive da sola. SignatureStatus = pcvsValid insieme a TrustStatus = pcvsInvalid descrive un documento i cui byte sono intatti ma il cui issuer non è considerato trusted da quel Mac: un anchor assente dal Keychain, un intermediate scaduto o una catena che non può essere completata offline. È una domanda di policy dell'operatore, non di integrità del documento, e la distinzione è esattamente quella alla base della maggior parte dei casi nella nota su perché i validator rifiutano firme PAdES crittograficamente valide

Dove controlla davvero macOS la revoca?

Dentro la valutazione della trust, motivo per cui TPdfCmsVerifyResult.RevocationStatus segue TrustStatus invece di portare un proprio verdetto. SecPolicyCreateRevocation produce una policy, quella policy si unisce a SecPolicyCreateBasicX509 nell'array passato a CMSDecoderCopySignerStatus e il lavoro OCSP o CRL avviene durante la costruzione della catena. Non torna una risposta separata, quindi fornirne una significherebbe inventarla. L'array porta anche una piccola regola di ownership che vale la pena nominare: CFArrayCreate conserva entrambe le policy, quindi i due riferimenti locali vengono rilasciati subito dopo, mentre il caso a policy singola salta l'array e passa direttamente la policy, forma accettata anch'essa dall'API

Il funzionamento offline è un flag esplicito, non un incidente della connettività. Quando TPdfCmsVerifyOptions.OnlineRetrieval è False, il backend aggiunge kSecRevocationNetworkAccessDisabled, confinando la valutazione alle risposte già in cache sulla macchina, e il checkpoint callback continua a scattare per pcvstCryptographicSignature, pcvstChainBuild e pcvstRevocationCheck nello stesso ordine riportato dal backend Windows. Il codice applicativo imposta tutto attraverso il record di opzioni di livello superiore

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // solo risposte in cache
  Options.CheckTimeStamps:= True;

  Stream:= TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    Report:= ValidatePadesTrust(Stream, Options);
  finally
    Stream.Free;
  end;

  if Report.SignatureCount= 0 then
    Log('No signature dictionary in this document')
  else if Report.Signatures[0].CmsSignatureStatus <> pcsValid then
    Log('Document integrity failed')
  else if Report.Signatures[0].CertificateTrustStatus <> pcsValid then
    Log('Bytes intact, chain not trusted on this Mac');
end;

Get contro copy: la release che fallisce da un'altra parte

SecTrustGetCertificateAtIndex ha semantica get e il riferimento che restituisce non deve mai essere rilasciato, mentre CMSDecoderCopySignerCert e SecCertificateCopyData, a poche righe di distanza nella stessa routine, hanno semantica copy e devono esserlo. Core Foundation codifica l'intera regola in una sola parola del nome della funzione e il type system non ne applica nessuna parte. Rilasciate il riferimento borrowed e nulla si rompe nel punto di chiamata: l'oggetto trust diventa semplicemente unsound e il crash arriva più tardi, in un punto senza connessione visibile con le certificate chain

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Semantica get: questo riferimento è borrowed e qui non va rilasciato
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Semantica copy: questo riferimento è owned e deve essere restituito
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

Cosa garantisce il verifier quando nessun backend risponde?

Che la risposta sarà unsupported, mai un pass silenzioso. Quando ConfigurePadesCmsVerifier non ha installato nulla e il default della piattaforma non può aiutare, TPdfCmsVerifyResult torna con ogni colonna impostata su unavailable e il validator PAdES lo mappa su pcsUnsupported, così una build senza backend crittografico segnala onestamente di non poter dire nulla sulla firma invece di rivendicare qualcosa. Anche il binding macOS è conservativo nella stessa direzione: Security.framework e CoreFoundation vengono raggiunti tramite dlopen e dlsym, quindi un framework assente o un nome di simbolo sbagliato nel binding emerge come KeychainVerificationAvailable che restituisce False con KeychainMissingSymbols che nomina il colpevole, non come link failure e non come verdetto errato. È la stessa postura fail-closed adottata dal componente quando cerca la libreria nativa, descritta nell'articolo su caricare la libreria nativa PDFium su qualsiasi target

La verifica delle firme è il punto di uno stack PDF in cui essere sbagliati in silenzio è peggio che essere indisponibili in modo esplicito, e macOS offre un'API abbastanza generosa da rendere facili entrambe le situazioni. Concatenate i byte range e accettate la copia, mantenete separati il verdetto della firma e quello della catena, rispettate i verbi get e copy e lasciate che un backend mancante lo dica. Se state spostando un workflow documentale Delphi o Free Pascal su Mac e vi servono firma e validazione PAdES su entrambi i lati, il componente PDFium Delphi distribuisce il backend Keychain accanto a quello Windows dietro un'unica interfaccia