Un signatureValue ECDSA dentro un contenitore CMS è una DER SEQUENCE { INTEGER r, INTEGER s }. La funzione CNG di Windows BCryptVerifySignature non accetta nessuna delle due: vuole la forma a larghezza fissa IEEE P1363 r || s, senza tag e senza lunghezze. HotPDF, il componente VCL PDF nativo per Delphi e C++Builder, converte tra le due secondo regole DER rigorose prima di importare una chiave
Il fallimento che questo previene è specifico e demoralizzante. Acrobat apre il documento e mostra un segno di spunta verde. Il tuo verificatore, percorrendo gli stessi byte, restituisce non valido, oppure CNG restituisce STATUS_INVALID_SIGNATURE senza ulteriore spiegazione. Non c'è nulla di sbagliato nella firma. Ciò che è sbagliato è che circa settanta byte di ASN.1 sono stati passati a una API che si aspettava sessantaquattro byte di intero grezzo, e il disallineamento è invisibile a meno che tu non sappia dove cercarlo
Perché BCryptVerifySignature rifiuta una firma ECDSA valida?
Perché i due lati della chiamata parlano codifiche di firma diverse, e nessuna delle due lo dichiara. ISO 32000-1 §12.8 dice che un dizionario di firma porta un blob CMS in /Contents; RFC 5652 §5.3 dice che il signatureValue in ogni SignerInfo è una OCTET STRING il cui contenuto è ciò che l'algoritmo di firma definisce. Per ECDSA quel contenuto è la struttura DER SEC 1: una SEQUENCE che contiene due INTEGER. È di lunghezza variabile per progetto, perché r e s sono interi e DER rimuove gli ottetti zero iniziali dagli interi
IEEE P1363 assume il punto di vista opposto. Definisce la firma come la concatenazione delle due coordinate, ciascuna riempita a sinistra con zeri fino esattamente alla larghezza in byte del campo della curva. Una firma P-256 è sempre di 64 byte. Una codifica DER della stessa firma è normalmente di 70 o 71 byte e può variare da circa 8 a 72. Passa la forma DER a BCryptVerifySignature e il solo controllo di lunghezza condanna la chiamata, motivo per cui HotPDF normalizza prima di verificare anziché dopo
uses
HPDFECDSA;
// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
out ARaw: TBytes): Boolean;
begin
Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;
Le regole DER che un parser di firme non deve allentare
Ogni rifiuto elencato qui è un rifiuto che HotPDF effettua deliberatamente, e ciascuno chiude un percorso che un parser permissivo lascerebbe aperto. La tentazione quando si scrive un convertitore è trovare i due nodi INTEGER, copiarne il contenuto, e proseguire. Funziona su input ben formati e accetta silenziosamente una famiglia di ricodifiche malleabili su input ostili. Quindi HPDFECDSANormalizeSignature rifiuta un intero negativo, cioè qualunque r o s il cui primo ottetto di contenuto ha il bit alto impostato, perché uno scalare ECDSA valido è positivo. Rifiuta un valore interamente zero, poiché r = 0 o s = 0 non è mai una firma legittima. Rifiuta un ottetto zero iniziale ridondante: X.690 §8.3 ne permette esattamente uno, e solo quando l'ottetto successivo si leggerebbe altrimenti come negativo, quindi un 00 seguito da un ottetto sotto 0x80 è una ricodifica, non una firma. Rifiuta un'intestazione di lunghezza non minimale, perché X.690 §10.1 richiede la forma definita codificata nel minor numero di ottetti, e una lunghezza in forma estesa che avrebbe potuto essere in forma breve è una stringa di byte diversa che porta lo stesso significato. Rifiuta un intero più largo della dimensione della coordinata della curva, poiché quel valore non può essere un elemento di campo. E rifiuta qualunque nodo finale dopo s, insieme a una SEQUENCE esterna la cui lunghezza totale non è uguale alla lunghezza dell'intero blob
Questi ultimi due contano più di quanto sembrino. I byte finali dopo la SEQUENCE sono il classico trucco di malleabilità della firma: aggiungi spazzatura, e un verificatore permissivo continua a dire valido mentre la stringa di byte che ha validato non è la stringa di byte che è stata firmata. Lo stesso istinto guida l'irrobustimento della lunghezza ASN.1 descritto nella nota sul parsing di PKCS#12, ed è lo stesso istinto qui. In un percorso di verifica, una struttura accettata che non è mai stata emessa da un firmatario conforme è un difetto, non una cortesia
La larghezza delle coordinate appartiene alla curva, non alla firma
HotPDF deriva la larghezza di output dall'OID della curva nominata, mai dalla lunghezza della DER appena analizzata. Questa è la seconda metà della conversione, e la metà in cui è facile sbagliare in modo sottile. RFC 5480 §2.1.1 identifica la curva nei parametri SubjectPublicKeyInfo del certificato, e HPDFECDSACurveFromOID mappa i tre OID che HotPDF supporta: 1.2.840.10045.3.1.7 per P-256, 1.3.132.0.34 per P-384, e 1.3.132.0.35 per P-521. HPDFECDSACoordinateSize restituisce quindi 32, 48, o 66 byte, e il buffer P1363 è il doppio: 64, 96, o 132. Ogni intero decodificato è allineato a destra nella propria metà, quindi un r corto viene riempito di zeri a sinistra anziché spostato. P-521 è quella che coglie in fallo le persone, perché 521 bit sono 65,125 byte e si arrotondano a 66, dando una firma di 132 byte che nessuna intuizione basata su potenze di due avrebbe previsto. La chiave pubblica viaggia accanto come punto EC non compresso secondo RFC 5480 §2.2, cioè 0x04 seguito da X e Y, quindi HotPDF verifica che sia esattamente 1 + 2 * CoordinateSize byte e che inizi con 0x04 prima di toccare CNG
var
Digest, SigDER, PublicPoint: TBytes;
Curve: THPDFECDSACurve;
Res: THPDFECDSAVerifyResult;
begin
// secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');
// PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);
case Res of
evrValid:
Memo1.Lines.Add('signature verifies');
evrInvalid:
Memo1.Lines.Add('signature does not match the digest');
evrMalformed:
Memo1.Lines.Add('DER encoding or public point rejected');
evrUnsupported:
Memo1.Lines.Add('curve or algorithm not supported here');
evrProviderUnavailable:
Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
evrProviderError:
Memo1.Lines.Add('CNG returned an unexpected status');
end;
end;
Nota l'ultimo parametro. HPDFECDSAVerifyDigest accetta anche eseP1363 per i chiamanti che possiedono già una firma a larghezza fissa, da un token hardware o da un servizio di firma remoto che restituisce r || s grezzo. Quel percorso applica comunque il controllo di lunghezza e di non-zero su entrambe le metà, così un buffer della dimensione giusta ma pieno di zeri viene rifiutato anziché passato al provider
Perché il nome generico dell'algoritmo ECDSA fallisce su Windows più vecchi?
Perché il nome generico è più recente della base di deployment su cui stai distribuendo. CNG espone un identificatore di algoritmo ECDSA che deduce la curva dalla chiave importata, ed è il modo pulito di scrivere questo codice, ma BCryptOpenAlgorithmProvider è garantito risolverlo solo sulle versioni più recenti di Windows. Su una macchina più vecchia la chiamata di apertura fallisce, l'handle del provider resta nil, e ogni verifica ECDSA nella tua applicazione riporta non supportato su una firma perfettamente valida. HotPDF evita il precipizio aprendo invece gli identificatori per singola curva. Risolve ECDSA_P256, ECDSA_P384, e ECDSA_P521 una volta, mette in cache un handle di provider per curva, e li chiude nella finalizzazione dell'unit. Ogni verifica fa quindi solo il lavoro economico: importa una chiave pubblica temporanea da un ECCPUBLICBLOB, chiama BCryptVerifySignature, distrugge la chiave. Nessun LoadLibrary ripetuto, nessun GetProcAddress ripetuto, nessuna apertura e chiusura di provider per firma. La verifica in batch di alcune centinaia di documenti sente la differenza, e così anche un processo di servizio che altrimenti consumerebbe handle di provider sotto carico
I codici di risultato restano onesti riguardo alla distinzione. evrProviderUnavailable significa che la macchina non ha potuto fornire un provider a HotPDF; evrInvalid significa che CNG ha risposto STATUS_INVALID_SIGNATURE. Comprimere queste due in un unico fallimento è come un problema di deployment viene erroneamente segnalato come documento contraffatto. La stessa separazione tra fallimento ambientale e fallimento crittografico attraversa la gestione CNG e CAPI sul lato firma, trattata in l'articolo sulla firma con certificate store e sull'ordine dei byte
Quale certificato ha firmato questo? SignerIdentifier è due cose diverse
RFC 5652 §5.3 rende SignerIdentifier una CHOICE, e un verificatore che gestisce un solo ramo verificherà silenziosamente contro la chiave sbagliata. Il primo ramo è issuerAndSerialNumber, una SEQUENCE che contiene il Name dell'issuer in DER grezzo e l'INTEGER seriale, e farne il match è un confronto di byte contro ogni certificato nell'insieme certificates del CMS. Il secondo ramo è [0] subjectKeyIdentifier, una OCTET STRING taggata implicitamente, e farne il match richiede di scavare nel certificato anziché confrontare i suoi campi di intestazione
Lo scavo ha un livello che sorprende le persone. L'identificatore di chiave vive in un'estensione X.509v3, quindi HotPDF percorre il campo extensions [3] del tbsCertificate, trova l'estensione il cui OID è 2.5.29.14, salta il BOOLEAN opzionale critical, e prende la OCTET STRING extnValue. Quella stringa di ottetti non è l'identificatore. Secondo RFC 5280 §4.2.1.2 il suo contenuto è a sua volta DER, e il tipo KeyIdentifier è un'altra OCTET STRING, quindi devi analizzare una seconda volta per raggiungere i byte reali. Fermati un livello troppo presto e confronti un wrapper di 22 byte contro un identificatore di 20 byte, nessun certificato corrisponde mai, e il verificatore ricade su qualunque euristica tu abbia scritto dopo, che è il vero pericolo. Prendere il primo certificato nell'insieme è una scorciatoia allettante ed è sbagliata ogni volta che il CMS porta una catena, il che è la maggior parte delle volte, perché il leaf non è tenuto a venire per primo. HotPDF accetta un certificato non corrispondente solo quando il contenitore ne contiene esattamente uno; con più certificati presenti, un match esatto di SignerIdentifier è obbligatorio. Verificare un digest contro la chiave pubblica di una CA intermedia non produce un errore amichevole, produce un non valido sicuro di sé su un documento che è a posto
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('signed.pdf') > 0 then
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
[String(Info.FieldName), String(Info.PublicKeyAlgorithm),
String(Info.CurveName), String(Info.HashAlgorithm),
String(Info.SignerName)]))
else
Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
finally
Pdf.Free;
end;
end;
THPDFSignatureInfo.CurveName riporta P-256, P-384, o P-521 così un log di audit registra quale curva è stata effettivamente usata anziché solo la parola ECDSA. L'infrastruttura a livello di documento attorno a questa chiamata, in particolare come vengono sottoposti a hash i segmenti /ByteRange e perché il digest deve essere calcolato sul file anziché sull'albero di oggetti analizzato, è l'argomento di l'articolo di accompagnamento sulla verifica delle firme PDF
Cosa questo non ti dà
Un risultato verde da HPDFECDSAVerifyDigest risponde a una sola domanda: questi byte sono stati firmati dalla chiave privata corrispondente a questa chiave pubblica. Non dice nulla su se quella chiave appartenga a qualcuno di cui dovresti fidarti. La costruzione della catena fino a un trust anchor, la revoca tramite CRL o OCSP, e i controlli di policy sono lavoro separato, e qualunque prodotto che riporta una firma valida senza di essi sta riportando meno di quanto l'utente presuma. Le date di validità del certificato sono esposte separatamente in THPDFSignatureInfo esattamente per questo motivo: una firma può verificare crittograficamente mentre il certificato che l'ha prodotta è scaduto da due anni. Anche il supporto alle curve è deliberatamente ristretto. Vengono gestite tre curve prime NIST, e una firma su qualunque altra curva restituisce non supportato anziché un'ipotesi. Il percorso CNG è solo Windows, il che è il compromesso giusto per un componente VCL ma vale la pena dirlo prima di pianificare un servizio cross-platform attorno ad esso. E la rigidità non è configurabile: non esiste una modalità permissiva che accetta una lunghezza DER non minimale perché qualche firmatario legacy ne ha emessa una. Se incontri un file del genere in produzione, la risposta onesta è registrarlo e rincorrere il produttore, non allargare il parser finché il file non passa
Il percorso di verifica ECDSA descritto qui viene fornito come parte del componente standard HotPDF Component per Delphi e C++Builder, insieme ai percorsi RSA PKCS#1 v1.5 e RSA-PSS e al record completo delle informazioni di firma; la pagina prodotto riporta il riferimento completo per le firme digitali