PDFium Component versiunea 3.114.20 repară codificarea RSASSA-PSS-params în toate cele trei backend-uri de semnare PAdES, Windows CNG, macOS Keychain și PKCS#11. RFC 4055 §3.1 dă fiecărui câmp din RSASSA-PSS-params o etichetă explicită specifică contextului, [0] până la [3], iar backend-urile emiteau saltLength ca INTEGER universal simplu și scriau în același timp un trailerField egal cu valoarea lui implicită. Octeții semnăturii au fost corecți tot timpul. AlgorithmIdentifier-ul care îi descrie nu a fost, iar asta singură este suficient pentru ca un verificator să respingă semnătura
Partea frustrantă este unde se ascunde bug-ul. O semnătură CMS are două jumătăți: operația criptografică și ASN.1-ul care îi spune verificatorului cum a fost efectuată operația. Faceți prima corect și a doua greșit, iar rezultatul este un document pe care nicio unealtă care respectă specificațiile nu îl poate distinge de o falsificare. Acest articol este doar despre a doua jumătate: cum trebuie etichetate RSASSA-PSS-params, cum au greșit trei backend-uri în același fel și cum arată DER-ul corectat în termeni de TDerWriter
De ce respinge un verificator o semnătură RSASSA-PSS ai cărei octeți sunt corecți?
Pentru că RSASSA-PSS este singura schemă RSA în care verificatorul nu poate recupera parametrii din semnătura însăși. Umplerea PKCS#1 v1.5 este determinată în întregime de OID-ul sha256WithRSAEncryption, așa că parametrii ei sunt un NULL simplu și nu există nimic de greșit. PSS este parametrizat de o funcție de hash, de o funcție de generare de mască cu propriul hash și de o lungime de salt, iar RFC 8017 §A.2.3 le lasă pe toate trei deschise. Semnatarul le alege, AlgorithmIdentifier-ul le cară, iar verificatorul trebuie să le reproducă exact înainte ca EMSA-PSS-VERIFY să poată începe măcar
Așa că atunci când PDFium Component semnează cu SHA-256, MGF1 peste SHA-256 și un salt de 32 de octeți, acele trei fapte trebuie să supraviețuiască codificării DER de aici și decodificării DER de pe altă implementare. Un bloc de parametri pe care verificatorul nu îl poate parsa încheie verificarea înainte de orice exponențiere modulară. Un bloc pe care îl parsează altfel este mai rău, pentru că RFC 4055 §3.1 dă lui saltLength o valoare implicită de 20. Un decodor care sare peste un câmp pe care nu îl recunoaște aterizează pe acea valoare implicită, rulează EMSA-PSS-VERIFY cu un salt de 20 de octeți împotriva unei semnături calculate cu 32 și raportează o semnătură rea, fără nicio indicație că problema este metadate, nu cheia. Ambele rezultate sunt ce producea codificarea din 3.114.19, în funcție de cât de strict era verificatorul, și niciunul nu indică spre AlgorithmIdentifier
Ce cere de fapt RFC 4055 §3.1 de la RSASSA-PSS-params
RFC 4055 §3.1 definește RSASSA-PSS-params ca un SEQUENCE de patru câmpuri, fiecare purtând o etichetă explicită specifică contextului și o valoare 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
// }
Etichetarea explicită în DER înseamnă că fiecare câmp este învelit într-un TLV construit, specific contextului: A0 pentru [0], A1 pentru [1], A2 pentru [2] și A3 pentru [3], cu codificarea universală a valorii imbricată înăuntru. Fiecare câmp este etichetat exact pentru că fiecare câmp este opțional prin valoarea lui implicită. Fără etichete, un decodor nu ar putea spune dacă un SEQUENCE care ține un singur AlgorithmIdentifier cară hashAlgorithm sau maskGenAlgorithm, fiindcă ambele sunt tipuri SEQUENCE; cu ele, numărul etichetei identifică câmpul indiferent de care vecini sunt prezenți. Valorile pe care le emite PDFium Component urmează profilul din ETSI TS 119 312 §7, SHA-256, MGF1 cu SHA-256 și un salt egal cu lungimea digestului, și oglindesc exact ce i se spune fiecărui apel de semnare al platformei: o BCRYPT_PSS_PADDING_INFO cu cbSalt 32 pentru NCryptSignHash, un CK_RSA_PKCS_PSS_PARAMS cu sLen 32 pentru mecanismul PKCS#11 și algoritmul PSS de semnare a digestului SHA-256 din Security framework
Cum au făcut trei backend-uri aceeași greșeală
Codificarea din 3.114.19 eticheta primele două câmpuri și le lăsa neetichetate pe ultimele două, identic în TWinCmsSigner, TKeychainCmsSigner și TPkcs11CmsSigner. Simetria nu este o coincidență: toate trei implementează interfața ICmsSigner din FPdfCms.pas, iar corpurile lor GetSignatureAlgorithmParams au fost scrise după un singur șablon. Șablonul arăta așa:
// Înainte de 3.114.20: [0] și [1] etichetate, [2] și [3] nu
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 simplu unde se cerea [2] EXPLICIT
W.IntegerOf(1))); // trailerField, egal cu DEFAULT, trebuie să lipsească
Un decodor care parcurge acel SEQUENCE vede A0, citește algoritmul de hash, vede A1, citește funcția de generare de mască și apoi întâlnește 02 01 20. Acela este un INTEGER universal, iar RSASSA-PSS-params nu are nicăieri vreun membru INTEGER fără etichetă. Un decodor strict se oprește acolo. Unul îngăduitor sare peste elementul nerecunoscut, nu găsește niciodată un A2, atribuie lui saltLength valoarea implicită 20, apoi dă peste un al doilea INTEGER rătăcit, 02 01 01, și are din nou aceeași problemă. Niciuna dintre căi nu ajunge la un salt de 32 de octeți. Un șablon comun este eficient când este corect și o cale la fel de eficientă de a greși de trei ori când nu este, motiv pentru care reparația a intrat în toate trei unitățile într-un singur commit și pentru care cele trei corpuri de metodă rămân structural identice după aceea. Un backend viitor ar trebui să copieze blocul din unul dintre acestea, nu să îl derive din nou, pentru că derivarea este exact locul unde s-a făcut greșeala
De ce este omis trailerField în loc să fie etichetat ca [3]?
Pentru că X.690 §11.5 spune că un codificator DER nu trebuie să codeze o componentă a cărei valoare este egală cu DEFAULT, iar trailerField are DEFAULT trailerFieldBC, adică întregul 1. Corecția evidentă a codului vechi, înlocuirea lui W.IntegerOf(1) simplu cu W.ContextSpecific(3, W.IntegerOf(1), True), dă un bloc pe care un decodor BER permisiv îl acceptă și un decodor DER strict are dreptul să îl respingă. Valoarea nu este greșită. Prezența ei este. Aceeași regulă explică de ce celelalte trei câmpuri sunt prezente: SHA-256 nu este sha1 implicit, MGF1 cu SHA-256 nu este mgf1SHA1 implicit, iar 32 nu este 20-ul implicit. Dacă backend-ul ar fi semnat cu SHA-1 și un salt de 20 de octeți, RFC 4055 §3.1 ar colapsa parametrii la un SEQUENCE gol, 30 00, iar acel SEQUENCE gol, nu NULL, este ce așteaptă un verificator. PDFium Component nu emite niciodată acea formă pentru că nu semnează niciodată cu acele valori, dar este cazul care îi prinde pe toți cei ce presupun că „fără parametri” se scrie mereu 05 00
Aceasta este distincția dintre DER și BER care contează în mod special pentru semnături. BER permite unui codificator să includă o componentă cu valoare implicită; DER interzice asta, pentru că DER există ca o valoare să aibă exact o codificare, iar o semnătură peste o structură cu două codificări legale este o semnătură care poate fi contestată. Tot ce se află în signedAttrs din CMS este DER din acest motiv, iar blocul de parametri călătorește în signedAttrs atât prin atributul cmsAlgorithmProtection, cât și în signatureAlgorithm exterior, așa că nu primește nicio scutire
Codificarea corectată în TDerWriter
PDFium Component construiește acum parametrii cu trei apeluri la TDerWriter.ContextSpecific din FPdfAsn1.pas, unul per câmp care nu este implicit, fiecare cu argumentul Constructed setat la True pentru a produce învelișul de etichetă explicită, și nicio linie pentru câmpul trailer. Acesta este corpul lui TWinCmsSigner.GetSignatureAlgorithmParams cu OID-urile scrise de-a lungul; unitățile Keychain și PKCS#11 scriu aceleași valori ca OID_SHA256, OID_MGF1 și OID_RSASSA_PSS:
function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if FPaddingScheme = psRsaPss then
begin
W := TDerWriter.Create;
try
// RFC 4055 3.1 etichetează toate cele patru câmpuri. saltLength este [2]; un
// INTEGER simplu aici este citit ca începutul unui alt câmp. trailerField
// este [3] cu DEFAULT 1, iar X.690 11.5 interzice codificarea unei valori
// egale cu cea implicită, așa că este omis complet
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 și ECDSA: AlgIdWithParams scrie NULL
end;
Două detalii ale mecanismului din jur contează. TDerWriter.AlgId produce un AlgorithmIdentifier cu parametri NULL, ceea ce RFC 4055 §2.1 le spune codificatorilor să genereze pentru hashAlgorithm imbricat și pentru hash-ul interior al MGF1. Iar constructorul de CMS din FPdfCms.pas împerechează OID-ul de semnătură cu acești octeți prin TDerWriter.AlgIdWithParams, care înlocuiește cu NULL când parametrii sunt nil; de aceea psRsaPkcs1v15 și psEcdsa întorc pur și simplu nil și nu au fost niciodată afectate și de aceea 1.2.840.113549.1.1.10, id-RSASSA-PSS, este singurul dintre cele trei OID-uri de semnătură care cară un bloc real de parametri. Octeții rezultați pentru profilul SHA-256 sunt ficși și destul de scurți ca să fie verificați cu ochiul: un SEQUENCE exterior 30 34 care ține A0 0F în jurul AlgorithmIdentifier-ului SHA-256 de 15 octeți, A1 1C în jurul AlgorithmIdentifier-ului MGF1 de 28 de octeți ale cărui proprii parametri sunt același AlgorithmIdentifier SHA-256 și A2 03 02 01 20 pentru salt. Dacă un dump al signatureAlgorithm-ului vostru arată 02 01 20 la nivelul de sus al SEQUENCE-ului de parametri, în loc să fie în interiorul unui A2, vă uitați la codificarea din 3.114.19
De ce nu a prins suita de teste un AlgorithmIdentifier malformat?
Pentru că testele PAdES conduc constructorul de CMS printr-un semnatar fals care raportează sha256WithRSAEncryption și întoarce nil din GetSignatureAlgorithmParams, așa că blocul de parametri PSS nu a fost construit niciodată într-un test. Este un design rezonabil pentru teste care trebuie să ruleze fără un depozit de certificate, fără Keychain sau fără token, și are un punct orb cu o formă precisă: orice produce doar un backend real este exercitat doar de un backend real. Al doilea strat este mai interesant. PDFium Component pune și AlgorithmIdentifier-ul de semnătură, cu parametri cu tot, în interiorul atributului semnat cmsAlgorithmProtection din RFC 6211, iar un verificator compară acea copie cu signatureAlgorithm exterior. Ambele copii veneau din același apel, așa că se potriveau perfect și fiecare verificare internă de consistență trecea. Codificarea era auto-consistentă și greșită, categoria de bug pe care nicio comparare a unei structuri cu ea însăși nu o poate scoate la iveală, iar aceeași lecție cu altă structură este spusă în signedAttrs din CMS și sortarea DER SET OF, unde un SET cu hash-ul calculat într-o ordine și emis în alta părea în regulă până când un verificator străin a recalculat hash-ul
Ce prinde într-adevăr această clasă de bug-uri este un decodor care nu a fost scris de autorul codificatorului, rulat pe ieșirea efectivă a backend-ului efectiv. Calea de verificare Windows din PDFium Component trece prin CryptoAPI, nu prin reader-ul propriu al librăriei, iar o semnătură PSS respinsă acolo este ce a dus înapoi la parametri. Orice ASN.1 pe care o implementare îl emite pentru ca alte implementări să îl citească merită cel puțin un round-trip printr-un decodor pe care nu îl controlează, iar cu cât structura are mai multe valori implicite și etichete, cu atât acel round-trip valorează mai mult
Unde se încadrează asta în restul poveștii PSS
Această reparație este independentă de celelalte două locuri în care PSS poate merge rău într-o semnătură PAdES, iar ținerea lor separate scurtează depanarea. Backend-ul macOS poate descoperi că o anumită cheie sau un sistem mai vechi refuză PSS și poate coborî la PKCS#1 v1.5, iar AlgorithmIdentifier-ul trebuie să urmeze coborârea; aceea este o întrebare de capabilitate, acoperită în semnarea PAdES cu o identitate din macOS Keychain. Backend-ul PKCS#11 poate da tokenului un CK_RSA_PKCS_PSS_PARAMS a cărui structură tokenul o citește altfel din cauza unei nepotriviri de lățime a întregului; aceea este o întrebare de ABI, acoperită în CK_ULONG și capcana de packing din PKCS#11. Acest articol este despre al treilea eșec, în care cheia a fost dispusă, tokenul a calculat octeții corecți, iar DER-ul care descrie rezultatul nu respecta RFC 4055 §3.1
Un semnatar care declară PSS își asumă o obligație pe care semnatarul v1.5 nu a avut-o niciodată: să își descrie propriii parametri într-o formă pe care altă implementare o decodează la aceleași trei valori. RFC 4055 §3.1 fixează etichetele, X.690 §11.5 fixează ce câmpuri pot apărea, iar ETSI TS 119 312 §7 fixează valorile care merită alese. Toate cele trei backend-uri ale componentei PDFium pentru Delphi sunt livrate ca sursă, așa că corpul GetSignatureAlgorithmParams de mai sus este cel pe care îl puteți citi, dumpa și compara cu propriul verificator, în loc să îl luați pe încredere