Tehnički članak

Kodiranje RSASSA-PSS-params po RFC 4055 u PDFium Delphiju

PDFium Component verzija 3.114.20 popravlja kodiranje RSASSA-PSS-params u sva tri PAdES backenda za potpisivanje, Windows CNG, macOS Keychain i PKCS#11. RFC 4055 §3.1 daje svakom polju RSASSA-PSS-params eksplicitni context-specific tag, [0] do [3], a backendovi su emitirali saltLength kao goli univerzalni INTEGER dok su ispisivali trailerField jednak njegovom defaultu. Bajtovi potpisa bili su cijelo vrijeme točni. AlgorithmIdentifier koji ih opisuje nije, a samo je to dovoljno da verifikator odbije potpis

Frustrirajući dio je gdje se bug skriva. CMS potpis ima dvije polovice, kriptografsku operaciju i ASN.1 koji verifikatoru govori kako je operacija izvedena. Pogodite prvu, a drugu promašite, i rezultat je dokument koji nijedan alat koji slijedi specifikaciju ne može razlikovati od krivotvorine. Ovaj je članak samo o toj drugoj polovici: kako RSASSA-PSS-params mora biti tagiran, kako su tri backenda pogriješila na isti način, i kako ispravljeni DER izgleda u terminima TDerWriter-a

Zašto verifikator odbija RSASSA-PSS potpis čiji su bajtovi točni?

Zato što je RSASSA-PSS jedina RSA shema gdje verifikator ne može izvući parametre iz samog potpisa. PKCS#1 v1.5 padding potpuno je određen OID-om sha256WithRSAEncryption, pa su njegovi parametri goli NULL i nema što pogriješiti. PSS je parametriziran hash funkcijom, mask generation funkcijom s vlastitim hashem i duljinom soli, a RFC 8017 §A.2.3 ostavlja sve tri otvorene. Potpisnik ih bira, AlgorithmIdentifier ih nosi, a verifikator ih mora reproducirati točno prije nego EMSA-PSS-VERIFY uopće može započeti

Pa kada PDFium Component potpisuje sa SHA-256, MGF1 nad SHA-256 i soli od 32 bajta, te tri činjenice moraju preživjeti DER kodiranje ovdje i DER dekodiranje na drugoj implementaciji. Blok parametara koji verifikator ne može parsirati završava verifikaciju prije nego se dogodi ijedno modularno potenciranje. Blok koji parsira drugačije još je gori, jer RFC 4055 §3.1 daje saltLength default 20. Dekoder koji preskoči polje koje ne prepoznaje slijeće na taj default, pokreće EMSA-PSS-VERIFY sa soli od 20 bajtova nad potpisom izračunatim s 32, i prijavljuje loš potpis bez ikakvog nagovještaja da je problem u metapodacima, a ne u ključu. Oba ishoda proizvodilo je kodiranje iz 3.114.19, ovisno o tome koliko je verifikator strog, i nijedan ne pokazuje na AlgorithmIdentifier

Što RFC 4055 §3.1 doista zahtijeva od RSASSA-PSS-params

RFC 4055 §3.1 definira RSASSA-PSS-params kao SEQUENCE od četiri polja, svako s eksplicitnim context-specific tagom i DEFAULT vrijednošću:

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

Eksplicitno tagiranje u DER-u znači da je svako polje zamotano u konstruirani context-specific TLV, A0 za [0], A1 za [1], A2 za [2] i A3 za [3], s univerzalnim kodiranjem vrijednosti ugniježđenim unutra. Svako je polje tagirano upravo zato što je svako polje opcionalno kroz svoj default. Bez tagova dekoder ne bi mogao znati nosi li SEQUENCE s jednim AlgorithmIdentifierom hashAlgorithm ili maskGenAlgorithm, jer su oba SEQUENCE tipovi; s njima, broj taga identificira polje bez obzira na to koji su susjedi prisutni. Vrijednosti koje PDFium Component emitira slijede profil iz ETSI TS 119 312 §7, SHA-256, MGF1 sa SHA-256 i sol jednaku duljini sažetka, i zrcale točno ono što se svakom platformskom pozivu za potpisivanje kaže: BCRYPT_PSS_PADDING_INFO s cbSalt 32 za NCryptSignHash, CK_RSA_PKCS_PSS_PARAMS sa sLen 32 za PKCS#11 mehanizam, i PSS algoritam za potpisivanje SHA-256 sažetka u Security frameworku

PDFium Component dijagram RSASSA-PSS-params po RFC 4055: hashAlgorithm A0, maskGenAlgorithm A1 i saltLength A2 nose eksplicitne context tagove s DEFAULT vrijednostima, ETSI profil emitira SHA-256, MGF1 sa SHA-256 i sol 32, a trailerField A3 jednak je trailerFieldBC pa ga DER u cijelosti izostavlja
Svako je polje tagirano upravo zato što je svako polje opcionalno kroz svoj default, pa broj taga identificira polje bez obzira na to koje susjede koder izostavi

Kako su tri backenda napravila istu grešku

Kodiranje iz 3.114.19 tagiralo je prva dva polja i ostavilo zadnja dva gola, jednako u TWinCmsSigner, TKeychainCmsSigner i TPkcs11CmsSigner. Ta simetrija nije slučajnost: sva tri implementiraju sučelje ICmsSigner iz FPdfCms.pas, a njihova tijela GetSignatureAlgorithmParams napisana su po jednom predlošku. Predložak je čitao ovako:

// Prije 3.114.20: [0] i [1] tagirani, [2] i [3] ne
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),      // goli INTEGER tamo gdje je [2] EXPLICIT bio obavezan
  W.IntegerOf(1)));     // trailerField, jednak DEFAULT-u, mora biti odsutan

Dekoder koji hoda po tom SEQUENCE-u vidi A0, čita hash algoritam, vidi A1, čita mask generation funkciju, i zatim susreće 02 01 20. To je univerzalni INTEGER, a RSASSA-PSS-params nigdje nema netagiranog INTEGER člana. Strogi dekoder tu staje. Blagi preskoči neprepoznati element, nikad ne nađe A2, dodijeli saltLength njegov default 20, zatim naleti na drugi zalutali INTEGER, 02 01 01, i ima isti problem ponovno. Nijedna putanja ne dolazi do soli od 32 bajta. Dijeljeni predložak učinkovit je kada je točan i jednako učinkovit način da se pogriješi tri puta kada nije, zato je popravak sletio u sve tri jedinice u jednom commitu i zato tri tijela metoda i nakon toga ostaju strukturno identična. Budući backend treba kopirati blok iz jednog od njih, a ne izvoditi ga ponovno, jer je izvođenje upravo mjesto gdje je greška i napravljena

PDFium Component dijagram DER buga iz 3.114.19: nakon A0 i A1 parametri susreću goli 02 01 20 gdje pripada eksplicitni tag A2, strogi dekoder staje, a blagi potpisuje s default soli od 20 bajtova, dok 3.114.20 zamata sol od 32 bajta u A2
RSASSA-PSS-params nema netagiranog INTEGER člana, pa su zalutali bajtovi bili ili pad parsiranja ili tiho vraćanje na zadanu duljinu soli, i nijedna putanja nije došla do potpisnikovih 32

Zašto je trailerField izostavljen umjesto tagiran kao [3]?

Zato što X.690 §11.5 kaže da DER koder ne smije kodirati komponentu čija je vrijednost jednaka njenom DEFAULT-u, a trailerField ima DEFAULT trailerFieldBC, što je integer 1. Očita ispravka starog koda, zamjena golog W.IntegerOf(1) s W.ContextSpecific(3, W.IntegerOf(1), True), daje blok koji permisivni BER dekoder prihvaća, a strogi DER dekoder ima pravo odbiti. Vrijednost nije pogrešna. Njena prisutnost jest. Isto pravilo objašnjava zašto su ostala tri polja prisutna: SHA-256 nije zadani sha1, MGF1 sa SHA-256 nije zadani mgf1SHA1, a 32 nije zadani 20. Da je backend potpisivao sa SHA-1 i soli od 20 bajtova, RFC 4055 §3.1 sveo bi parametre na prazni SEQUENCE, 30 00, i taj prazni SEQUENCE, a ne NULL, ono je što verifikator očekuje. PDFium Component nikad ne emitira taj oblik jer nikad ne potpisuje tim vrijednostima, ali to je slučaj koji hvata svakoga ko pretpostavi da se "nema parametara" uvijek piše 05 00

To je razlika između DER-a i BER-a koja je važna posebno za potpise. BER dopušta koderu da uključi komponentu s default vrijednošću; DER to zabranjuje, jer DER postoji zato da jedna vrijednost ima točno jedno kodiranje, a potpis nad strukturom s dva legalna kodiranja potpis je o kojem se može raspravljati. Sve unutar CMS signedAttrs je DER upravo zato, a blok parametara putuje unutar signedAttrs kroz atribut cmsAlgorithmProtection kao i u vanjskom signatureAlgorithm, pa ne dobiva nikakvo izuzeće

PDFium Component dijagram pravila X.690 11.5 u PSS popravku: trailerField jednak svom DEFAULT-u trailerFieldBC mora ostati odsutan jer je tagirani A3 wrapper kodiranje koje strogi DER odbija, dok saltLength 32 razlikuje se od zadanog 20 i mora biti prisutan kao A2
DER postoji zato da jedna vrijednost ima točno jedno kodiranje, a komponenta jednaka svom defaultu već ima najkraće kodiranje koje postoji: da se uopće ne pojavi

Ispravljeno TDerWriter kodiranje

PDFium Component sada gradi parametre s tri poziva TDerWriter.ContextSpecific iz FPdfAsn1.pas, po jedan za svako ne-default polje, svaki s argumentom Constructed postavljenim na True da proizvede wrapper eksplicitnog taga, i bez ijednog retka za trailer polje. Ovo je tijelo TWinCmsSigner.GetSignatureAlgorithmParams s ispisanim OID-ovima; Keychain i PKCS#11 jedinice ispisuju iste vrijednosti kao 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 tagira sva četiri polja. saltLength je [2]; goli
      // INTEGER ovdje se čita kao početak drugog polja. trailerField
      // je [3] s DEFAULT 1, a X.690 11.5 zabranjuje kodiranje vrijednosti
      // jednake defaultu, pa se u cijelosti izostavlja
      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 piše NULL
end;

Dva detalja okolne mehanike imaju značenje. TDerWriter.AlgId proizvodi AlgorithmIdentifier s NULL parametrima, što je ono što RFC 4055 §2.1 kaže koderima da generiraju za ugniježđeni hashAlgorithm i za MGF1 unutarnji hash. A CMS builder u FPdfCms.pas spaja OID potpisa s tim bajtovima kroz TDerWriter.AlgIdWithParams, koji zamjenjuje NULL kada su parametri nil; zato psRsaPkcs1v15 i psEcdsa jednostavno vraćaju nil i nikad nisu bili pogođeni, i zato je 1.2.840.113549.1.1.10, id-RSASSA-PSS, jedini OID potpisa od tih triju koji nosi pravi blok parametara. Dobiveni bajtovi za SHA-256 profil su fiksni i dovoljno kratki da se provjere okom: vanjski SEQUENCE 30 34 koji drži A0 0F oko 15-bajtnog AlgorithmIdentifiera za SHA-256, A1 1C oko 28-bajtnog MGF1 AlgorithmIdentifiera čiji su vlastiti parametri taj isti SHA-256 AlgorithmIdentifier, i A2 03 02 01 20 za sol. Ako dump vašeg signatureAlgorithm pokazuje 02 01 20 na vršnoj razini SEQUENCE-a parametara umjesto unutar A2, gledate kodiranje iz 3.114.19

Zašto test suite nije uhvatio neispravno oblikovan AlgorithmIdentifier?

Zato što PAdES testovi tjeraju CMS builder kroz lažni potpisnik koji prijavljuje sha256WithRSAEncryption i vraća nil iz GetSignatureAlgorithmParams, pa blok PSS parametara nikad nije bio konstruiran ni u jednom testu. To je razuman dizajn za testove koji moraju raditi bez certifikat storea, Keychaina ili tokena, i ima slijepu točku preciznog oblika: sve što proizvodi samo pravi backend vježba i samo pravi backend. Drugi je sloj zanimljiviji. PDFium Component također smješta AlgorithmIdentifier potpisa, uključujući parametre, unutar potpisanog atributa cmsAlgorithmProtection iz RFC 6211, a verifikator tu kopiju uspoređuje s vanjskim signatureAlgorithm. Obje kopije došle su iz istog poziva, pa su se savršeno poklopile i svaka interna provjera konzistentnosti je prošla. Kodiranje je bilo samokonzistentno i pogrešno, kategorija buga koju nikakvo uspoređivanje strukture sa sobom samom ne može razotkriti, a ista lekcija s drugom strukturom ispričana je u CMS signedAttrs i DER SET OF sortiranju, gdje je SET hashiran u jednom poretku i emitiran u drugom izgledao dobro dok strani verifikator nije ponovno izračunao hash

Ono što doista hvata ovu klasu bugova je dekoder koji nije napisao autor kodera, pokrenut nad stvarnim izlazom stvarnog backenda. Windows putanja verifikacije u PDFium Componentu prolazi kroz CryptoAPI, a ne kroz vlastiti reader biblioteke, i odbijen PSS potpis ondje ono je što je dovelo natrag do parametara. Svaki ASN.1 koji neka implementacija emitira da bi ga čitale druge implementacije zaslužuje barem jedan round trip kroz dekoder koji ne kontrolira, a što više defaulta i tagova struktura ima, to taj round trip više vrijedi

Kako se ovo uklapa u ostatak PSS priče

Ovaj popravak neovisan je o dvama drugim mjestima gdje PSS može poći po zlu u PAdES potpisu, a njihovo razdvajanje skraćuje debugging. macOS backend može otkriti da neki ključ ili stariji sustav odbija PSS i spustiti se na PKCS#1 v1.5, i AlgorithmIdentifier mora slijediti to spuštanje; to je pitanje sposobnosti, obrađeno u potpisivanju PAdES-a s macOS Keychain identitetom. PKCS#11 backend može tokenu predati CK_RSA_PKCS_PSS_PARAMS čiji layout token čita drugačije zbog nesklada širine integera; to je ABI pitanje, obrađeno u CK_ULONG i PKCS#11 zamci s pakiranjem struktura. Ovaj je članak o trećem padu, gdje je ključ bio voljan, token je izračunao točne bajtove, a DER koji opisuje rezultat nije bio sukladan s RFC 4055 §3.1

Potpisnik koji deklarira PSS preuzima obvezu koju v1.5 potpisnik nikad nije imao: opisati vlastite parametre u obliku koji druga implementacija dekodira u iste tri vrijednosti. RFC 4055 §3.1 fiksira tagove, X.690 §11.5 fiksira koja se polja smiju pojaviti, a ETSI TS 119 312 §7 fiksira vrijednosti koje vrijedi odabrati. Sva tri backenda PDFium Delphi komponente isporučuju se kao izvorni kod, pa je tijelo GetSignatureAlgorithmParams iznad ono koje možete pročitati, dumpati i usporediti s vlastitim verifikatorom, umjesto da ga uzimate na vjeru