PDFium Component vo verzii 3.114.20 opravuje kódovanie RSASSA-PSS-params vo všetkých troch PAdES signing backendoch: Windows CNG, macOS Keychain a PKCS#11. RFC 4055 §3.1 dáva každému poľu RSASSA-PSS-params explicitný context-specific tag, [0] až [3], a backendy emitovali saltLength ako holý univerzálny INTEGER, pričom zapisovali trailerField rovný svojmu defaultu. Bajty podpisu boli celý čas správne. AlgorithmIdentifier, ktorý ich opisoval, nie, a to samo o sebe stačí na to, aby verifikátor podpis odmietol
Frustrujúce je to, kde sa ten bug skrýva. CMS podpis má dve polovice: kryptografickú operáciu a ASN.1, ktorý verifikátorovi hovorí, ako bola tá operácia vykonaná. Spravte prvú správne a druhú zle a výsledkom je dokument, ktorý nástroj dodržiavajúci špecifikáciu nevie odlíšiť od falzifikátu. Tento článok je len o tej druhej polovici: ako sa RSASSA-PSS-params musí tagovať, ako to tri backendy pokazili tým istým spôsobom a ako vyzerá opravený DER v pojmoch TDerWriter
Prečo verifikátor odmietne RSASSA-PSS podpis, ktorého bajty sú správne?
Pretože RSASSA-PSS je jediná RSA schéma, v ktorej verifikátor nedokáže získať parametre zo samotného podpisu. PKCS#1 v1.5 padding je plne určený OID sha256WithRSAEncryption, takže jeho parametre sú holý NULL a nie je na čom urobiť chybu. PSS je parametrizovaný hash funkciou, mask generation funkciou s vlastným hashom a dĺžkou saltu, a RFC 8017 §A.2.3 necháva všetky tri otvorené. Podpisujúci ich vyberie, AlgorithmIdentifier ich nesie a verifikátor ich musí reprodukovať presne, skôr než EMSA-PSS-VERIFY vôbec môže začať
Takže keď PDFium Component podpisuje so SHA-256, MGF1 nad SHA-256 a 32-bajtovým saltom, tie tri fakty musia prežiť DER kódovanie tu a DER dekódovanie na inej implementácii. Blok parametrov, ktorý verifikátor nedokáže naparsovať, ukončí verifikáciu skôr, než dôjde k akejkoľvek modulárnej exponenciácii. Blok, ktorý naparsuje inak, je horší, pretože RFC 4055 §3.1 dáva saltLength default 20. Dekóder, ktorý preskočí pole, ktoré nepozná, pristane na tom defaulte, spustí EMSA-PSS-VERIFY s 20-bajtovým saltom proti podpisu vypočítanému s 32 a nahlási zlý podpis bez čo i len náznaku, že problém je v metadátach a nie v kľúči. Oba výsledky produkovalo kódovanie z 3.114.19 v závislosti od toho, aký prísny verifikátor bol, a ani jeden neukazuje na AlgorithmIdentifier
Čo RFC 4055 §3.1 od RSASSA-PSS-params naozaj vyžaduje
RFC 4055 §3.1 definuje RSASSA-PSS-params ako SEQUENCE štyroch polí, z ktorých každé nesie explicitný context-specific tag a DEFAULT hodnotu:
// RSASSA-PSS-params ::= SEQUENCE {
// hashAlgorithm [0] HashAlgorithm DEFAULT sha1,
// maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1,
// saltLength [2] INTEGER DEFAULT 20,
// trailerField [3] TrailerField DEFAULT trailerFieldBC
// }
Explicitné tagovanie v DER znamená, že každé pole je zabalené do konštruovaného context-specific TLV, A0 pre [0], A1 pre [1], A2 pre [2] a A3 pre [3], s univerzálnym kódovaním hodnoty vnoreným doň. Každé pole je otagované práve preto, že každé pole je voliteľné cez svoj default. Bez tagov by dekóder nedokázal povedať, či SEQUENCE držiaca jediný AlgorithmIdentifier nesie hashAlgorithm alebo maskGenAlgorithm, keďže oboje sú SEQUENCE typy; s nimi číslo tagu identifikuje pole bez ohľadu na to, ktorí susedia sú prítomní. Hodnoty, ktoré PDFium Component emituje, sledujú profil v ETSI TS 119 312 §7, SHA-256, MGF1 so SHA-256 a salt rovný dĺžke digestu, a zrkadlia presne to, čo sa hovorí platformovému signing volaniu: BCRYPT_PSS_PADDING_INFO s cbSalt 32 pre NCryptSignHash, CK_RSA_PKCS_PSS_PARAMS s sLen 32 pre PKCS#11 mechanizmus a PSS algoritmus pre podpisovanie SHA-256 digestu v Security frameworku
Ako tri backendy spravili tú istú chybu
Kódovanie z 3.114.19 otagovalo prvé dve polia a posledné dve nechalo holé, a to identicky v TWinCmsSigner, TKeychainCmsSigner aj TPkcs11CmsSigner. Tá symetria nie je náhoda: všetky tri implementujú rozhranie ICmsSigner z FPdfCms.pas a ich telá GetSignatureAlgorithmParams boli napísané podľa jednej šablóny. Tá šablóna vyzerala takto:
// Pred 3.114.20: [0] a [1] otagované, [2] a [3] nie
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), // holý INTEGER tam, kde sa vyžadoval [2] EXPLICIT
W.IntegerOf(1))); // trailerField rovný DEFAULTu musí chýbať
Dekóder, ktorý prechádza tú SEQUENCE, vidí A0, prečíta hash algoritmus, vidí A1, prečíta mask generation funkciu a potom narazí na 02 01 20. To je univerzálny INTEGER a RSASSA-PSS-params nemá nikde žiadny neotagovaný INTEGER člen. Prísny dekóder sa tam zastaví. Benevolentný preskočí nerozpoznaný element, nikdy nenájde A2, priradí saltLength jeho default 20, potom narazí na druhý zblúdilý INTEGER, 02 01 01, a má ten istý problém znova. Ani jedna cesta nedôjde k 32-bajtovému saltu. Zdieľaná šablóna je efektívna, keď je správna, a rovnako efektívny spôsob, ako sa pomýliť trikrát, keď nie je, a preto oprava pristála vo všetkých troch unitoch v jednom commite a preto tri telá metód zostávajú po nej štrukturálne identické. Budúci backend by ten blok mal skopírovať z jedného z nich a nie ho znova odvodzovať, pretože odvodzovanie je presne to miesto, kde sa chyba stala
Prečo je trailerField vynechaný a nie otagovaný ako [3]?
Pretože X.690 §11.5 hovorí, že DER encoder nemá kódovať komponent, ktorého hodnota sa rovná jeho DEFAULTu, a trailerField má DEFAULT trailerFieldBC, čo je integer 1. Očividná oprava starého kódu, nahradenie holého W.IntegerOf(1) výrazom W.ContextSpecific(3, W.IntegerOf(1), True), dá blok, ktorý permisívny BER dekóder prijme a prísny DER dekóder má právo odmietnuť. Hodnota nie je zlá. Zlá je jej prítomnosť. To isté pravidlo je dôvod, prečo tie ostatné tri polia sú prítomné: SHA-256 nie je defaultný sha1, MGF1 so SHA-256 nie je defaultný mgf1SHA1 a 32 nie je defaultných 20. Keby backend podpisoval so SHA-1 a 20-bajtovým saltom, RFC 4055 §3.1 by parametre zbalila na prázdnu SEQUENCE, 30 00, a práve tú prázdnu SEQUENCE a nie NULL verifikátor očakáva. PDFium Component nikdy ten tvar neemituje, pretože nikdy nepodpisuje s tými hodnotami, ale je to prípad, ktorý nachytá každého, kto predpokladá, že „žiadne parametre“ sa vždy píšu ako 05 00
To je rozdiel medzi DER a BER, ktorý je dôležitý špecificky pre podpisy. BER dovoľuje encoderu zahrnúť komponent s defaultnou hodnotou; DER to zakazuje, pretože DER existuje preto, aby jedna hodnota mala presne jedno kódovanie, a podpis nad štruktúrou s dvoma legálnymi kódovaniami je podpis, o ktorom sa dá viesť spor. Všetko vnútri CMS signedAttrs je z toho dôvodu DER, a blok parametrov cestuje vnútri signedAttrs cez atribút cmsAlgorithmProtection rovnako ako vo vonkajšom signatureAlgorithm, takže nedostane žiadnu výnimku
Opravené kódovanie v TDerWriter
PDFium Component teraz stavia parametre tromi volaniami TDerWriter.ContextSpecific z FPdfAsn1.pas, jedno na každé nedefaultné pole, každé s argumentom Constructed nastaveným na True, aby vznikol explicit-tag obal, a so žiadnym riadkom pre trailer pole. Toto je telo TWinCmsSigner.GetSignatureAlgorithmParams s vypísanými OID; unity pre Keychain a PKCS#11 píšu tie isté hodnoty ako OID_SHA256, OID_MGF1 a OID_RSASSA_PSS:
function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if FPaddingScheme = psRsaPss then
begin
W := TDerWriter.Create;
try
// RFC 4055 3.1 taguje všetky štyri polia. saltLength je [2]; holý
// INTEGER sa tu prečíta ako začiatok ďalšieho poľa. trailerField
// je [3] s DEFAULT 1 a X.690 11.5 zakazuje kódovať hodnotu
// rovnajúcu sa defaultu, takže sa vynecháva úplne
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 a ECDSA: AlgIdWithParams zapíše NULL
end;
Dva detaily okolitej mašinérie sú dôležité. TDerWriter.AlgId produkuje AlgorithmIdentifier s NULL parametrami, čo je to, čo RFC 4055 §2.1 hovorí encoderom generovať pre vnorený hashAlgorithm a pre vnútorný hash MGF1. A CMS builder v FPdfCms.pas páruje OID podpisu s týmito bajtmi cez TDerWriter.AlgIdWithParams, ktorý dosadí NULL, keď sú parametre nil; preto psRsaPkcs1v15 a psEcdsa jednoducho vracajú nil a nikdy neboli zasiahnuté, a preto 1.2.840.113549.1.1.10, id-RSASSA-PSS, je jediný z tých troch OID podpisov, ktorý nesie skutočný blok parametrov. Výsledné bajty pre SHA-256 profil sú pevné a dosť krátke na kontrolu okom: vonkajšia SEQUENCE 30 34 držiaca A0 0F okolo 15-bajtového SHA-256 AlgorithmIdentifier, A1 1C okolo 28-bajtového MGF1 AlgorithmIdentifier, ktorého vlastné parametre sú ten istý SHA-256 AlgorithmIdentifier, a A2 03 02 01 20 pre salt. Ak dump vášho signatureAlgorithm ukazuje 02 01 20 na najvyššej úrovni params SEQUENCE a nie vnútri A2, pozeráte sa na kódovanie z 3.114.19
Prečo test suite nechytila malformovaný AlgorithmIdentifier?
Pretože PAdES testy poháňajú CMS builder cez fake signer, ktorý hlási sha256WithRSAEncryption a vracia nil z GetSignatureAlgorithmParams, takže blok PSS parametrov sa v teste nikdy nepostavil. To je rozumný dizajn pre testy, ktoré musia bežať bez certificate store, Keychainu či tokenu, a má slepé miesto s presným tvarom: čokoľvek, čo produkuje len skutočný backend, precvičí len skutočný backend. Druhá vrstva je zaujímavejšia. PDFium Component umiestňuje AlgorithmIdentifier podpisu, vrátane parametrov, aj do signed atribútu cmsAlgorithmProtection z RFC 6211, a verifikátor porovnáva tú kópiu s vonkajším signatureAlgorithm. Obe kópie pochádzali z toho istého volania, takže dokonale sedeli a každá interná kontrola konzistencie prešla. Kódovanie bolo samo so sebou konzistentné a nesprávne, čo je kategória bugu, ktorú neodhalí žiadne porovnávanie štruktúry so sebou samou, a tú istú lekciu s inou štruktúrou rozpráva CMS signedAttrs a triedenie DER SET OF, kde SET hashovaná v jednom poradí a emitovaná v inom vyzerala dobre, kým cudzí verifikátor hash neprepočítal
Čo túto kategóriu bugov naozaj chytí, je dekóder, ktorý nenapísal autor encodera, spustený proti skutočnému výstupu skutočného backendu. Verifikačná cesta na Windows v PDFium Component ide cez CryptoAPI a nie cez vlastný reader knižnice, a práve odmietnutý PSS podpis tam viedol späť k parametrom. Každé ASN.1, ktoré implementácia emituje, aby ho čítali iné implementácie, si zaslúži aspoň jeden round trip cez dekóder, ktorý nekontroluje, a čím viac defaultov a tagov tá štruktúra má, tým viac sa ten round trip vyplatí
Kam to patrí v zvyšku PSS príbehu
Táto oprava je nezávislá od dvoch ďalších miest, kde sa PSS v PAdES podpise môže pokaziť, a držať ich oddelené skracuje debugging. macOS backend môže zistiť, že konkrétny kľúč alebo starší systém PSS odmieta, a downgradovať na PKCS#1 v1.5, a AlgorithmIdentifier musí ten downgrade sledovať; to je otázka capability, ktorú pokrýva podpisovanie PAdES s macOS Keychain identitou. PKCS#11 backend môže tokenu podať CK_RSA_PKCS_PSS_PARAMS, ktorého rozloženie token číta inak kvôli nesúladu šírky integeru; to je otázka ABI, ktorú pokrýva CK_ULONG a pasca s PKCS#11 packingom. Tento článok je o treťom zlyhaní, kde bol kľúč ochotný, token spočítal správne bajty a DER opisujúci výsledok nezodpovedal RFC 4055 §3.1
Signer, ktorý deklaruje PSS, berie na seba záväzok, aký v1.5 signer nikdy nemal: opísať svoje vlastné parametre vo forme, ktorú iná implementácia dekóduje na tie isté tri hodnoty. RFC 4055 §3.1 fixuje tagy, X.690 §11.5 fixuje, ktoré polia smú byť prítomné, a ETSI TS 119 312 §7 fixuje hodnoty, ktoré sa oplatí voliť. Všetky tri backendy PDFium Delphi komponentu vychádzajú ako zdroj, takže telo GetSignatureAlgorithmParams vyššie je to, ktoré si môžete prečítať, dumpnúť a porovnať so svojím vlastným verifikátorom, a nie brať ho na vieru