La versione 3.114.20 di PDFium Component corregge la codifica di RSASSA-PSS-params in tutti e tre i backend di firma PAdES, Windows CNG, macOS Keychain e PKCS#11. RFC 4055 §3.1 assegna a ogni campo di RSASSA-PSS-params un tag esplicito context-specific, da [0] a [3], e i backend emettevano saltLength come INTEGER universale nudo scrivendo al contempo un trailerField uguale al suo default. I byte della firma erano corretti per tutto il tempo. L'AlgorithmIdentifier che li descriveva no, e questo da solo basta perché un verificatore rifiuti la firma
La parte frustrante è dove si nasconde il bug. Una firma CMS ha due metà, l'operazione crittografica e l'ASN.1 che dice al verificatore come l'operazione è stata eseguita. Azzecca la prima e sbaglia la seconda, e il risultato è un documento che nessuno strumento conforme alla specifica riesce a distinguere da un falso. Questo articolo parla solo di quella seconda metà: come va taggato RSASSA-PSS-params, come tre backend l'hanno sbagliato allo stesso modo e che aspetto ha il DER corretto in termini di TDerWriter
Perché un verificatore rifiuta una firma RSASSA-PSS i cui byte sono corretti?
Perché RSASSA-PSS è l'unico schema RSA in cui il verificatore non riesce a recuperare i parametri dalla firma stessa. Il padding PKCS#1 v1.5 è completamente determinato dall'OID sha256WithRSAEncryption, quindi i suoi parametri sono un NULL nudo e non c'è nulla da sbagliare. PSS è parametrizzato da una funzione di hash, da una mask generation function con il proprio hash e da una lunghezza del salt, e RFC 8017 §A.2.3 lascia aperti tutti e tre. Li sceglie il firmatario, li porta l'AlgorithmIdentifier, e il verificatore deve riprodurli esattamente prima che EMSA-PSS-VERIFY possa persino iniziare
Quindi quando PDFium Component firma con SHA-256, MGF1 su SHA-256 e un salt da 32 byte, quei tre fatti devono sopravvivere alla codifica DER qui e alla decodifica DER su un'altra implementazione. Un blocco di parametri che il verificatore non riesce ad analizzare termina la verifica prima che avvenga qualsiasi esponenziazione modulare. Un blocco che analizza in modo diverso è peggio, perché RFC 4055 §3.1 assegna a saltLength un default di 20. Un decoder che salta un campo che non riconosce atterra su quel default, esegue EMSA-PSS-VERIFY con un salt da 20 byte contro una firma calcolata con 32, e riporta una firma non valida senza alcun indizio che il problema siano i metadati e non la chiave. Entrambi gli esiti sono quelli prodotti dalla codifica della 3.114.19, a seconda di quanto fosse severo il verificatore, e nessuno dei due punta all'AlgorithmIdentifier
Che cosa richiede davvero RFC 4055 §3.1 a RSASSA-PSS-params
RFC 4055 §3.1 definisce RSASSA-PSS-params come una SEQUENCE di quattro campi, ognuno con un tag esplicito context-specific e un valore DEFAULT:
// RSASSA-PSS-params ::= SEQUENCE {
// hashAlgorithm [0] HashAlgorithm DEFAULT sha1,
// maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1,
// saltLength [2] INTEGER DEFAULT 20,
// trailerField [3] TrailerField DEFAULT trailerFieldBC
// }
Il tagging esplicito in DER significa che ogni campo è avvolto in un TLV context-specific constructed, A0 per [0], A1 per [1], A2 per [2] e A3 per [3], con la codifica universale del valore annidata dentro. Ogni campo è taggato proprio perché ogni campo è opzionale tramite il suo default. Senza tag un decoder non potrebbe dire se una SEQUENCE che contiene un solo AlgorithmIdentifier stia portando hashAlgorithm o maskGenAlgorithm, dato che entrambi sono tipi SEQUENCE; con i tag, il numero del tag identifica il campo indipendentemente da quali vicini siano presenti. I valori che PDFium Component emette seguono il profilo di ETSI TS 119 312 §7, SHA-256, MGF1 con SHA-256 e un salt pari alla lunghezza del digest, e rispecchiano esattamente ciò che viene detto a ogni chiamata di firma della piattaforma: un BCRYPT_PSS_PADDING_INFO con cbSalt 32 per NCryptSignHash, un CK_RSA_PKCS_PSS_PARAMS con sLen 32 per il meccanismo PKCS#11, e l'algoritmo PSS di firma con digest SHA-256 nel framework Security
Come tre backend hanno commesso lo stesso errore
La codifica della 3.114.19 taggava i primi due campi e lasciava nudi gli ultimi due, in modo identico in TWinCmsSigner, TKeychainCmsSigner e TPkcs11CmsSigner. Quella simmetria non è una coincidenza: tutti e tre implementano l'interfaccia ICmsSigner di FPdfCms.pas, e i loro corpi di GetSignatureAlgorithmParams erano scritti su un unico template. Il template diceva così:
// Prima della 3.114.20: [0] e [1] taggati, [2] e [3] no
Result := W.Sequence(Concat4(
W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
W.IntegerOf(32), // INTEGER nudo dove serviva il [2] EXPLICIT
W.IntegerOf(1))); // trailerField, uguale al DEFAULT, deve essere assente
Un decoder che percorre quella SEQUENCE vede A0, legge la funzione di hash, vede A1, legge la mask generation function, e poi incontra 02 01 20. Quello è un INTEGER universale, e RSASSA-PSS-params non ha alcun membro INTEGER non taggato da nessuna parte. Un decoder severo si ferma lì. Uno permissivo salta l'elemento non riconosciuto, non trova mai un A2, assegna a saltLength il suo default di 20, poi incappa in un secondo INTEGER estraneo, 02 01 01, e ha di nuovo lo stesso problema. Nessuno dei due percorsi arriva a un salt da 32 byte. Un template condiviso è efficiente quando è giusto ed è un modo altrettanto efficiente di sbagliare tre volte quando non lo è, ed è per questo che la correzione è finita in tutte e tre le unità in un unico commit e che i tre corpi dei metodi restano identici nella struttura dopo la modifica. Un backend futuro dovrebbe copiare il blocco da uno di questi invece di riderivarlo, perché la derivazione è esattamente il punto in cui l'errore è stato commesso
Perché trailerField viene omesso invece di essere taggato come [3]?
Perché X.690 §11.5 dice che un encoder DER non deve codificare un componente il cui valore sia uguale al suo DEFAULT, e trailerField ha DEFAULT trailerFieldBC, che è l'intero 1. La correzione ovvia del vecchio codice, sostituire il W.IntegerOf(1) nudo con W.ContextSpecific(3, W.IntegerOf(1), True), produce un blocco che un decoder BER permissivo accetta e che un decoder DER severo ha il diritto di rifiutare. Il valore non è sbagliato. Lo è la sua presenza. La stessa regola è il motivo per cui gli altri tre campi ci sono: SHA-256 non è il default sha1, MGF1 con SHA-256 non è il default mgf1SHA1, e 32 non è il default 20. Se il backend avesse firmato con SHA-1 e un salt da 20 byte, RFC 4055 §3.1 farebbe collassare i parametri in una SEQUENCE vuota, 30 00, e quella SEQUENCE vuota invece di NULL è ciò che un verificatore si aspetta. PDFium Component non emette mai quella forma perché non firma mai con quei valori, ma è il caso che coglie chiunque dia per scontato che "nessun parametro" si scriva sempre 05 00
Questa è la distinzione tra DER e BER che conta specificamente per le firme. BER permette a un encoder di includere un componente con valore di default; DER lo vieta, perché DER esiste perché un valore abbia esattamente una codifica, e una firma su una struttura con due codifiche legali è una firma su cui si può discutere. Tutto ciò che sta dentro CMS signedAttrs è DER per quel motivo, e il blocco dei parametri viaggia dentro signedAttrs attraverso l'attributo cmsAlgorithmProtection oltre che nel signatureAlgorithm esterno, quindi non gode di alcuna esenzione
La codifica TDerWriter corretta
Ora PDFium Component costruisce i parametri con tre chiamate a TDerWriter.ContextSpecific di FPdfAsn1.pas, una per ogni campo non default, ognuna con l'argomento Constructed impostato a True per produrre il wrapper del tag esplicito, e nessuna riga per il campo trailer. Questo è il corpo di TWinCmsSigner.GetSignatureAlgorithmParams con gli OID scritti per esteso; le unit Keychain e PKCS#11 scrivono gli stessi valori come OID_SHA256, OID_MGF1 e OID_RSASSA_PSS:
function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if FPaddingScheme = psRsaPss then
begin
W := TDerWriter.Create;
try
// RFC 4055 3.1 tagga tutti e quattro i campi. saltLength è [2]; un
// INTEGER nudo qui viene letto come inizio di un altro campo. trailerField
// è [3] con DEFAULT 1, e X.690 11.5 vieta di codificare un valore
// uguale al default, quindi viene omesso del tutto
Result := W.Sequence(Concat3(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'), // id-mgf1
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.ContextSpecific(2, W.IntegerOf(32), True)));
finally
W.Free;
end;
end
else
Result := nil; // PKCS#1 v1.5 ed ECDSA: AlgIdWithParams scrive NULL
end;
Due dettagli della macchina circostante contano. TDerWriter.AlgId produce un AlgorithmIdentifier con parametri NULL, che è ciò che RFC 4055 §2.1 dice agli encoder di generare per l'hashAlgorithm annidato e per l'hash interno di MGF1. E il costruttore CMS in FPdfCms.pas accoppia l'OID della firma con questi byte tramite TDerWriter.AlgIdWithParams, che sostituisce NULL quando i parametri sono nil; ed è per questo che psRsaPkcs1v15 e psEcdsa restituiscono semplicemente nil e non sono mai stati toccati, e che 1.2.840.113549.1.1.10, id-RSASSA-PSS, è l'unico dei tre OID di firma a portare un vero blocco di parametri. I byte risultanti per il profilo SHA-256 sono fissi e abbastanza corti da controllare a occhio: una SEQUENCE esterna 30 34 che contiene A0 0F attorno all'AlgorithmIdentifier SHA-256 da 15 byte, A1 1C attorno all'AlgorithmIdentifier MGF1 da 28 byte i cui parametri sono quello stesso AlgorithmIdentifier SHA-256, e A2 03 02 01 20 per il salt. Se un dump del tuo signatureAlgorithm mostra 02 01 20 al primo livello della SEQUENCE dei parametri invece che dentro un A2, stai guardando la codifica della 3.114.19
Perché la suite di test non ha intercettato un AlgorithmIdentifier malformato?
Perché i test PAdES guidano il costruttore CMS attraverso un finto firmatario che dichiara sha256WithRSAEncryption e restituisce nil da GetSignatureAlgorithmParams, quindi il blocco di parametri PSS non è mai stato costruito in un test. È un progetto ragionevole per test che devono girare senza un certificate store, un Keychain o un token, e ha un punto cieco dalla forma precisa: qualsiasi cosa che produca solo un backend reale viene esercitata solo da un backend reale. Il secondo strato è più interessante. PDFium Component mette l'AlgorithmIdentifier della firma, parametri compresi, anche dentro l'attributo firmato cmsAlgorithmProtection di RFC 6211, e un verificatore confronta quella copia con il signatureAlgorithm esterno. Entrambe le copie venivano dalla stessa chiamata, quindi combaciavano perfettamente e ogni controllo di coerenza interna passava. La codifica era coerente con se stessa e sbagliata, la categoria di bug che nessun confronto di una struttura con se stessa può rivelare, e la stessa lezione con una struttura diversa è raccontata in CMS signedAttrs e l'ordinamento DER SET OF, dove un SET con hash calcolato in un ordine ed emesso in un altro sembrava a posto finché un verificatore estraneo non ricalcolava l'hash
Ciò che intercetta questa classe di bug è un decoder non scritto dall'autore dell'encoder, fatto girare sull'output reale del backend reale. Il percorso di verifica Windows in PDFium Component passa da CryptoAPI e non dal reader della libreria, ed è una firma PSS rifiutata lì che ha portato indietro fino ai parametri. Qualsiasi ASN.1 che un'implementazione emette perché altre implementazioni lo leggano merita almeno un round trip attraverso un decoder che non controlla, e quanti più default e tag ha la struttura, tanto più quel round trip vale la pena
Dove questo si colloca nel resto della storia PSS
Questa correzione è indipendente dagli altri due punti in cui PSS può andare storto in una firma PAdES, e tenerli separati accorcia il debug. Il backend macOS può scoprire che una particolare chiave o un sistema più vecchio rifiuta PSS e degradare a PKCS#1 v1.5, e l'AlgorithmIdentifier deve seguire il downgrade; quella è una questione di capacità, trattata in firmare PAdES con un'identità del Keychain di macOS. Il backend PKCS#11 può consegnare al token un CK_RSA_PKCS_PSS_PARAMS il cui layout il token legge diversamente per un disallineamento nella larghezza degli interi; quella è una questione di ABI, trattata in CK_ULONG e la trappola del packing in PKCS#11. Questo articolo parla del terzo fallimento, quello in cui la chiave era disponibile, il token calcolava i byte giusti, e il DER che descriveva il risultato non era conforme a RFC 4055 §3.1
Un firmatario che dichiara PSS si assume un obbligo che il firmatario v1.5 non aveva mai avuto: descrivere i propri parametri in una forma che un'altra implementazione decodifichi negli stessi tre valori. RFC 4055 §3.1 fissa i tag, X.690 §11.5 fissa quali campi possono comparire, ed ETSI TS 119 312 §7 fissa i valori che vale la pena scegliere. Tutti e tre i backend del componente PDFium per Delphi sono distribuiti come sorgente, quindi il corpo di GetSignatureAlgorithmParams qui sopra è quello che puoi leggere, dumppare e confrontare con il tuo verificatore invece di prenderlo per buono