Articolo tecnico

SET OF signedAttrs CMS ordinata in DER con PDFium Component

La PDFium Component costruisce il blocco CMS signedAttrs tramite TDerWriter.SetOfSorted, perché la clausola 11.6 di X.690 impone che i membri di una SET OF siano emessi in ordine crescente delle loro codifiche DER. Assemblarli nell'ordine di costruzione e ottieni firme che il tuo stesso verificatore accetta e che ogni validatore PAdES di terze parti rifiuta

Quell'ultima frase è l'intero motivo per cui questo difetto è sopravvissuto a una suite di regressione completa. Il percorso di firma era coperto, il percorso di verifica era coperto, il round trip era verde su ogni piattaforma, e l'output non era comunque DER valido. Un test che firma con il tuo writer e verifica con il tuo reader non può rilevare una deviazione dalle regole di codifica, perché entrambe le metà condividono la deviazione

Cosa richiede davvero la clausola 11.6 di X.690

I membri di un valore SET OF vanno ordinati, e la chiave di ordinamento è la codifica DER completa di ogni membro, confrontata come stringa di octet senza segno, con la codifica più corta ordinata per prima quando una è prefisso dell'altra. È una restrizione canonica, non un suggerimento: la DER ammette esattamente una codifica per un dato valore, quindi una SET OF non ordinata non è una codifica diversamente ordinata dello stesso valore, non è affatto una codifica DER. Poiché in un TLV i tag ASN.1 vengono per primi, il confronto di solito si risolve dentro i primi due octet, e siccome ogni membro di una lista di attributi CMS inizia con 30, il tag SEQUENCE, il pareggio si rompe sull'octet di lunghezza. L'ordine richiesto quindi sembra quasi un ordinamento degli attributi per dimensione, cosa utile da sapere mentre fissi un hex dump chiedendoti perché l'ordine sembra arbitrario

Diagramma dell'articolo PDFium Component che mostra come la clausola 11.6 di X.690 ordina la SET OF signedAttrs CMS: BuildSignedAttrs append content-type, message-digest, signing-certificate-v2 e cms-algorithm-protection in ordine di costruzione, mentre la DER conforme ordina i quattro membri per codifiche complete, 30 18, 30 2D, 30 2F, 30 37
L'ordine di costruzione e l'ordine codificato divergono già dalla primissima firma, perché tutti e quattro i membri portano il tag SEQUENCE 30 e l'octet di lunghezza decide il confronto

Perché il set di attributi minimo è già fuori ordine?

Perché l'ordine di costruzione naturale e l'ordine codificato divergono già dalla primissima firma che produci, senza attributi opzionali in mezzo. BuildSignedAttrs in FPdfCms.pas append content-type, poi message-digest, poi signing-certificate-v2, poi cms-algorithm-protection. Con digest SHA-256 e un algoritmo di firma RSA, i quattro TLV degli attributi iniziano con questi byte

// Ordine di costruzione prodotto da BuildSignedAttrs
//   content-type              30 18   (24 content octets)
//   message-digest            30 2F   (47)
//   signing-certificate-v2    30 37   (55)
//   cms-algorithm-protection  30 2D   (45)
//
// Ordine DER richiesto da X.690 11.6
//   30 18  content-type
//   30 2D  cms-algorithm-protection
//   30 2F  message-digest
//   30 37  signing-certificate-v2

Due dei quattro attributi si spostano. cms-algorithm-protection a 30 2D deve precedere signing-certificate-v2 a 30 37, e deve precedere anche message-digest a 30 2F. Quindi il più piccolo set di attributi PAdES B-B conforme che la PDFium Component può emettere era già mal ordinato col vecchio codice, prima ancora che qualcuno aggiunga signing-time, commitment-type-indication o un signature-policy-identifier. Non è mai esistita una configurazione in cui il bug non scattasse

Perché i nostri test passano mentre ogni validatore esterno fallisce?

Perché le due famiglie di verificatori calcolano il message digest su byte diversi. La clausola 5.4 di RFC 5652 dice che l'input dell'operazione di firma è la codifica DER del valore SignedAttributes, con il tag IMPLICIT [0] sostituito dal tag SET 31, e la frase che decide tutto è la codifica DER, non gli octet che hai ricevuto. Il verificatore della piattaforma Windows raggiunto tramite CryptVerifyDetachedMessageSignature prende gli octet ricevuti, scambia il tag iniziale e li hasha così come sono arrivati, quindi l'ordine non entra mai in gioco e una SET OF mal ordinata produce esattamente il digest che il firmatario ha calcolato. OpenSSL e BouncyCastle, su cui sono costruiti i validatori europei più diffusi, fanno il parse del set di attributi in strutture e ricodificano prima di fare il digest. La ricodifica applica il sort, il digest non corrisponde più a quello firmato, e la firma viene segnalata come rotta invece che come mal codificata, il che ti manda a caccia tra materiale delle chiavi e catene di certificati per un difetto che vive in una funzione di confronto

Diagramma PDFium Component della divergenza tra verificatori dietro il bug della SET OF ordinata: il firmatario hasha la codifica DER col tag SET 31, il percorso Windows CryptVerifyDetachedMessageSignature digesta gli octet ricevuti quindi l'ordine non conta mai e una firma mal ordinata passa, mentre OpenSSL e BouncyCastle ricodificano, applicano il sort di X.690 e segnalano la firma come rotta
CryptVerifyDetachedMessageSignature digesta gli octet come sono arrivati, quindi un set mal ordinato verifica, mentre OpenSSL e BouncyCastle ricodificano prima del digest e segnalano la firma come rotta invece che come mal codificata

Generalizza oltre questo singolo bug, perché è lì che sta la parte costosa. Un round trip della stessa origine è immune a ogni deviazione da una specifica di interoperabilità che entrambe le metà del tuo codice condividono; dimostra che il tuo writer e il tuo reader vanno d'accordo tra loro, che non è la proprietà che interessa a nessuno. Per qualsiasi cosa che esca dal processo come wire format, l'unica regressione significativa è quella che inchioda i byte alla specifica o che fa girare un'implementazione estranea sul tuo output. Il backend di verifica CMS OpenSSL si guadagna il suo posto qui per un secondo motivo oltre alla portabilità: è un reader genuinamente indipendente della tua stessa DER

La correzione, e dove esisteva già

Il writer DER aveva la variante ordinata da sempre. TDerWriter in FPdfAsn1.pas espone sia SetOf, che avvolge un buffer di contenuto già concatenato in un tag SET e preserva l'ordine che gli è stato consegnato, sia SetOfSorted, che prende i membri singolarmente come TDerByteArrays e fa un merge sort delle loro codifiche prima di avvolgere. Il codice del MAC PDF in FPdfMac.pas costruiva già il suo set di attributi autenticati con SetOfSorted; solo il firmatario CMS no. Tenere i membri come array fino alla chiamata di avvolgimento è la metà strutturale della correzione, perché una volta che gli attributi sono stati concatenati in un unico buffer l'informazione sull'ordine è persa e nessuna cura a valle può ripristinarla. Un'API di writer che per la SET OF offre solo SetOf(Content: TBytes) è un'API che invita questo bug; l'overload che accetta l'array è ciò che rende esprimibile la cosa giusta

Diagramma della coppia di API TDerWriter nella PDFium Component: SetOf avvolge un buffer TBytes già concatenato e preserva l'ordine dei membri che gli è stato consegnato, producendo output che non è DER, mentre SetOfSorted tiene i membri come array TDerByteArrays, fa un merge sort delle loro codifiche complete e avvolge una SET OF conforme
Una volta che gli attributi sono stati concatenati in un unico buffer l'informazione sull'ordine è persa, quindi l'overload SetOfSorted che accetta l'array è ciò che rende esprimibile la cosa giusta nel punto della codifica
// Prima: i membri mantengono l'ordine in cui AddAttr li ha aggiunti
Result:= W.SetOf(ConcatMany(AttrList));

// Dopo: ordinamento X.690 11.6 applicato al momento della codifica
Result:= W.SetOfSorted(AttrList);

La SET OF si ordina, la SEQUENCE OF no

Questa è la distinzione più spesso confusa, e prenderla al contrario è dannoso quanto ignorarla. Una SEQUENCE OF è una collezione ordinata: il suo ordine fa parte del valore e non devi mai ordinarla. Una SET OF è non ordinata come valore, quindi la DER impone un ordine canonico per rendere unica la codifica. Ordinare una SEQUENCE OF corrompe i dati; lasciare una SET OF non ordinata produce qualcosa che non è DER

  • SET OF, da ordinare: SignedAttributes e UnsignedAttributes in SignerInfo, il set digestAlgorithms, i set certificates e crls in SignedData, il set signerInfos, il set di valori dentro ogni singolo Attribute, e il set di attributi dentro ogni RelativeDistinguishedName di un Name X.501
  • SEQUENCE OF, mai da ordinare: la lista di RDN che compone un Name, il campo certs di SigningCertificateV2, le liste di estensioni e le liste di general name al loro interno

Un RDN è una SET OF dentro una SEQUENCE OF di un livello sopra, quindi un distinguished name contiene entrambe le regole in due livelli di annidamento. Quel layout spiega anche perché i set a membro singolo sono così permissivi: un set con un elemento è banalmente ordinato, ed è per questo che i set di valori degli attributi costruiti con W.SetOf dentro BuildSignedAttrs erano corretti come scritti e non serviva toccarli. Il bug vive solo dove un set contiene davvero due o più membri, e gli RDN multi-valore sono abbastanza rari che la maggior parte degli encoder di name la scampa con lo stesso errore per anni

Come si evita che torni?

Fai assert sui byte, non sui verdetto. Il controllo durevole più economico è un self-audit che cammina sul proprio output: fai il parse dei signedAttrs emessi, confronta ogni membro col precedente usando lo stesso confronto a stringa di octet senza segno che usa il writer, e fallisci la build se una coppia è fuori ordine. Gira in microsecondi, non richiede librerie esterne e, a differenza di un round trip di verifica, testa la proprietà che la specifica dichiara davvero

// Scorri i membri di un SET OF e verifica l'ordinamento X.690 11.6.
// Members è la lista passata a SetOfSorted, oppure il risultato di
// riparsando un blocco emesso 31 xx / A0 xx
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
  I: Integer;
begin
  for I:= 1 to Length(Members)- 1 do
    if CompareDerBytes(Members[I- 1], Members[I])> 0 then
      Exit(False);
  Result:= True;
end;

// Nel test di firma, non fermarti a "il nostro verificatore ha detto valido".
// Fissa i byte che il firmatario ha sottoposto a hash
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');

Due limiti vale la pena dirli chiaro. Ordinare i signedAttrs non rende una firma interoperabile da solo, perché anche tutto il resto del contenitore deve reggere e i motivi per cui i validatori bocciano firme PAdES altrimenti sane sono abbastanza vari da riempire una discussione dedicata. E una SET OF ordinata è una proprietà di ciò che scrivi, non di ciò che leggi: la PDFium Component deve comunque accettare una firma in arrivo i cui signedAttrs arrivano non ordinati, perché file del genere esistono in giro e la clausola 5.4 di RFC 5652 dice al verificatore di ricodificare, non di rifiutare. Severi in output, tolleranti in input, come al solito. Il writer DER, il builder CMS e il percorso di firma descritti in il walkthrough PAdES B-B sono tutti distribuiti come sorgente Pascal leggibile con la PDFium Delphi component, che è il senso di distribuire il sorgente per una libreria di firma: quando un validatore da qualche parte in Europa non è d'accordo col tuo documento, puoi leggere i byte esatti emessi dal tuo codice e la clausola esatta che violano, invece di aprire un ticket e aspettare