Articolo tecnico

Firmare PAdES con un'identità Keychain di macOS in Delphi

PDFium VCL firma documenti PAdES con una chiave privata custodita nel Keychain di macOS attraverso un backend che risolve ogni simbolo Security e CoreFoundation a runtime con dlopen e dlsym. Niente è legato al linking, il che significa che un nome di simbolo sbagliato emerge come KeychainAvailable che restituisce False e KeychainMissingSymbols che nomina il colpevole, invece che come un errore del linker o un crash

Quella scelta è stata forzata da un vincolo scomodo, e il modo in cui è stata gestita si generalizza. L'unit è stata scritta su una macchina senza SDK macOS, quindi ogni nome di simbolo e ogni costante del framework viene dalla documentazione e nessuno poteva essere controllato contro un header. La risposta sbagliata a quella situazione è scrivere il codice con cura e sperare. Quella giusta è organizzare le cose perché gli inevitabili errori si annuncino nella forma più localizzabile possibile

Perché il binding dinamico è la scelta giusta anche sulla piattaforma target

Perché converte una classe di guasto che ferma il programma in una classe di guasto che si auto-riporta. Un riferimento staticamente linkato a un framework che è sbagliato fallisce al linking sulla target e non linka mai altrove. Uno legato dinamicamente che è sbagliato produce un backend non disponibile e una lista di nomi non risolti, e la prima esecuzione su un Mac trasforma la domanda da perché questo non è disponibile in una singola riga che nomina un refuso

C'è un secondo beneficio che paga ogni giorno invece che una volta. Poiché l'unit non linka framework, compila su ogni piattaforma, così la build Windows ordinaria continua a controllarne sintassi, tipi e uses clause. Un'unit che compila solo su una piattaforma che nessuno nel team possiede è un'unit senza alcun compilatore che la guardi, e decade in silenzio a ogni refactor di un tipo condiviso

uses
  FPdfCrypto, FPdfCryptoMac;

var
  Options: TPadesSignerOptions;
begin
  if not KeychainAvailable then
    raise Exception.Create('Keychain backend unavailable, unresolved: ' +
      KeychainMissingSymbols);

  ConfigureKeychainSignerProvider;   // installare come backend signer PAdES
  ConfigureKeychainCmsVerifier;      // e come backend di verifica

  Writeln('signer backend  : ', PadesCryptoBackendName);
  Writeln('verify backend  : ', PadesCmsVerificationBackendName);

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1, maiuscole o minuscole
  Options.PaddingScheme := psRsaPss;
end;

Due tipi di simbolo esportato, due modi di leggerli

Questo è il dettaglio singolarmente più confondente di tutto il binding, e prenderlo al contrario compila pulito e fallisce a runtime. CoreFoundation e Security esportano due cose categoricamente diverse attraverso la stessa chiamata dlsym, e il codice deve sapere qual è quale

Le costanti nominate come le chiavi di classe degli item del keychain e i singleton booleani CoreFoundation sono variabili esportate il cui contenuto è il CFStringRef o CFBooleanRef che vuoi. dlsym restituisce l'indirizzo di quella variabile, quindi devi dereferenziare una volta per ottenere il valore. Le strutture a tabella di callback come le callback di chiave e valore del dizionario sono strutture esportate, e dlsym restituisce l'indirizzo della struttura, che è precisamente il puntatore che la funzione di creazione del dizionario si aspetta. Dereferenziare quella e passi la prima parola macchina della struttura come se fosse un puntatore

Nessuno dei due errori produce un errore di compilazione, e nessuno produce un errore runtime chiaro. Ottieni un puntatore spazzatura che fallisce da qualche parte a valle. Il modo per rendere la distinzione impossibile da sbagliare è smettere di contare sul ricordarsela: due funzioni helper, una che lega e dereferenzia e una che lega e non dereferenzia, così il call site dichiara quale tipo di simbolo sta chiedendo e l'helper impone il resto

Diagramma del backend Keychain macOS di PDFium VCL che risolve i simboli Security e CoreFoundation attraverso dlsym: kSecClass è una variabile esportata che BindConstant dereferenzia una volta per ottenere il valore CFStringRef, mentre kCFTypeDictionaryKeyCallBacks è una struttura esportata che BindStruct passa per indirizzo, e mescolare le due regole produce puntatori spazzatura a valle
Una sola chiamata dlsym restituisce due cose categoricamente diverse: l'indirizzo di una variabile che contiene un CFTypeRef e l'indirizzo di una struttura di callback. Due helper prendono la decisione dereferenziare-o-no al sito di binding invece che in memoria
// Variabile esportata: dlsym dà l'indirizzo di una variabile che contiene
// il CFTypeRef, quindi dereferenziare una volta
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');

// Struttura esportata: dlsym dà l'indirizzo DELLA struttura, che è ciò
// che l'API vuole. Non dereferenziare
FKeyCallbacks := BindStruct(CoreFoundationLib,
  'kCFTypeDictionaryKeyCallBacks');

Perché una firma RSA-PSS ha bisogno di due fallback separati?

Perché l'algoritmo può mancare in due modi indipendenti, e solo uno di loro è una questione di versione. La costante dell'algoritmo di firma del digest PSS è apparsa in macOS 10.13, quindi su un sistema più vecchio il simbolo semplicemente non c'è e il binding ottiene nil. Questo è il controllo di versione. Separatamente, su un sistema dove la costante esiste, una specifica chiave può comunque rifiutarla, e il framework risponde a quella domanda tramite SecKeyIsAlgorithmSupported per quella chiave. Una chiave supportata da hardware o una chiave con attributi restrittivi può declinare PSS mentre una chiave software sulla stessa macchina lo accetta

Entrambi i percorsi devono portare allo stesso fallback: passare a PKCS#1 v1.5. E la parte critica è che il fallback deve cambiare anche l'identificatore di algoritmo scritto nella struttura CMS, non solo la chiamata di firma. Emettere un identificatore di algoritmo PSS producendo davvero una firma v1.5 genera un documento che ogni verificatore respinge a occhi chiusi, il che è strettamente peggiore che riportare che PSS non è supportato. Un downgrade è accettabile, un disallineamento tra ciò che dichiari e ciò che hai fatto non lo è, ed è una regola generale per il codice di firma e non una bizzarria macOS. Le implicazioni a livello di firma sono esposte in firmare PDF con PAdES B-B

Catena decisionale che mostra perché la firma RSA-PSS nel backend Keychain di PDFium VCL richiede due fallback indipendenti: dlsym restituisce nil per la costante di firma del digest sulle versioni macOS precedenti alla 10.13, SecKeyIsAlgorithmSupported può declinare una chiave supportata da hardware, e entrambe le barriere si immettono nello stesso downgrade PKCS#1 v1.5 il cui identificatore di algoritmo CMS deve cambiare con esso
PSS può non essere disponibile due volte, una per versione di macOS e una per chiave, e solo la barriera di versione è una domanda di sistema. Entrambe le barriere si immettono nello stesso downgrade v1.5, e l'identificatore CMS segue

Codifica delle firme ECDSA, e un'inversione da notare

Il percorso a curve ellittiche non richiede alcuna conversione su macOS, e questo è l'opposto di ciò che richiede un binding PKCS#11. L'algoritmo di firma del digest del framework Security per ECDSA restituisce la firma già in forma X9.62 DER, che è esattamente ciò che CMS vuole. Un token PKCS#11 restituisce invece la coppia grezza a larghezza fissa P1363, che va ricodificata prima di entrare in una struttura di firma

Così due backend che implementano la stessa interfaccia hanno bisogno di trattamenti opposti per lo stesso algoritmo, e nessuno dei due è sbagliato. Questo è precisamente il tipo di differenza che un'astrazione deve assorbire invece di esporre: il layer PAdES chiede a un provider di firmare, e le convenzioni di codifica restano dentro il provider. Se trapelano verso l'alto, ogni chiamante finisce per portarsi dietro un condizionale per backend. La stessa forma compare nella storia della firma remota descritta in sessioni di firma PAdES remota contro un HSM

Confronto della codifica delle firme ECDSA tra due backend del signer PAdES di PDFium VCL: il framework Security Keychain di macOS restituisce X9.62 DER che CMS accetta con zero conversioni, mentre un token PKCS#11 restituisce la coppia grezza a larghezza fissa P1363 che va ricodificata, così ResolvePadesSigner tiene le convenzioni di codifica dentro il provider
La stessa interfaccia ECDSA richiede trattamenti opposti per backend: Security consegna DER finito mentre un token PKCS#11 consegna P1363 grezzo, quindi la conversione sta dentro il provider e i chiamanti non vedono mai un condizionale per backend
// L'interfaccia provider è la stessa su ogni piattaforma, quindi la
// selezione è una decisione di avvio e non per chiamata
{$IFDEF DARWIN}
  if KeychainAvailable then
    ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
  // Il provider CNG di Windows è installato dall'unit di piattaforma
{$ENDIF}

if not PadesCryptoAvailable then
  raise Exception.Create('no signing backend on this platform');

// Da qui il codice di firma è platform-neutral
Signer := ResolvePadesSigner(Options);

Regole di reference counting che stanno a tre righe di distanza

La gestione della memoria di Core Foundation segue convenzioni di nomenclatura, e la trappola qui è che funzioni con convenzioni diverse appaiono una accanto all'altra nello stesso breve blocco. Una funzione che ottiene un certificato da un trust object restituisce un riferimento prestato che non va rilasciato. Le funzioni che copiano un certificato del signer o copiano i suoi dati restituiscono riferimenti posseduti che vanno rilasciati. Tre chiamate in sequenza, due regole di proprietà, e rilasciare quello prestato non fallisce a quella riga. Corrompe un retain count e butta giù qualcosa di non correlato più tardi

La mitigazione è leggere il verbo in ogni nome di funzione del framework prima di scrivere la pulizia, ogni volta, senza eccezioni. È l'equivalente CoreFoundation del controllare se un'API restituisce una copia o una vista, e il costo di sbagliare è un crash intermittente invece di un errore

Cosa non dichiara questo backend

Non è mai girato su macOS al momento della scrittura, e dirlo senza giri di parole è più utile di una rassicurazione implicita. Ciò che è dimostrabilmente vero è più ristretto e resta comunque prezioso: l'unit compila su Windows come parte della build quotidiana, ogni simbolo del framework è legato per nome a runtime con i fallimenti enumerati, e la logica di selezione degli algoritmi compresi entrambi i fallback PSS è Pascal ordinario che si può revisionare e su cui ragionare. La prima esecuzione su un Mac funzionerà o produrrà una lista di nomi da correggere

La controparte di verifica, che usa il decoder CMS di livello più alto invece di assemblare a mano la struttura CMS, è trattata in verificare firme PDF su macOS con SecTrust, e condivide la stessa infrastruttura di binding e lo stesso approccio diagnostico

L'idea trasferibile qui riguarda il piazzamento del rischio piuttosto che macOS. Quando devi scrivere codice contro un'interfaccia che non puoi verificare, scegli la costruzione dove gli errori sono più economici da localizzare. Il binding dinamico con una lista esplicita di nomi non risolti trasforma venti assunzioni non verificabili in una riga diagnostica. Entrambi i backend vengono spediti come sorgente con il PDFium Delphi component, quindi se un nome di simbolo deve davvero essere corretto, è una modifica di una riga nel tuo albero invece che un ticket di supporto