PDFium Component versie 3.114.20 herstelt de codering van RSASSA-PSS-params in alle drie de PAdES-ondertekeningsbackends: Windows CNG, macOS Keychain en PKCS#11. RFC 4055 §3.1 geeft elk veld van RSASSA-PSS-params een expliciete context-specifieke tag, [0] tot en met [3], en de backends schreven saltLength als een kale universele INTEGER uit terwijl ze een trailerField wegschreven die gelijk was aan zijn standaardwaarde. De handtekeningbytes waren al die tijd correct. De AlgorithmIdentifier die ze beschreef niet, en dat alleen al is genoeg voor een verifier om de handtekening te weigeren
Het frustrerende deel is waar de bug zich verstopt. Een CMS-handtekening heeft twee helften, de cryptografische bewerking en de ASN.1 die de verifier vertelt hoe de bewerking is uitgevoerd. Krijg de eerste goed en de tweede fout, en het resultaat is een document dat geen enkel spec-volgend hulpmiddel kan onderscheiden van een vervalsing. Dit artikel gaat alleen over die tweede helft: hoe RSASSA-PSS-params getagd moeten zijn, hoe drie backends het op dezelfde manier fout deden, en hoe de gecorrigeerde DER er in termen van TDerWriter uitziet
Waarom weigert een verifier een RSASSA-PSS-handtekening waarvan de bytes correct zijn?
Omdat RSASSA-PSS het enige RSA-schema is waarbij de verifier de parameters niet uit de handtekening zelf kan halen. PKCS#1 v1.5-padding ligt volledig vast met de OID sha256WithRSAEncryption, dus zijn parameters zijn een kale NULL en er valt niets fout te doen. PSS is geparametreerd met een hashfunctie, een maskergeneratiefunctie met zijn eigen hash, en een saltlengte, en RFC 8017 §A.2.3 laat alle drie open. De ondertekenaar kiest ze, de AlgorithmIdentifier draagt ze, en de verifier moet ze exact reproduceren voordat EMSA-PSS-VERIFY zelfs maar kan beginnen
Dus wanneer PDFium Component ondertekent met SHA-256, MGF1 over SHA-256 en een salt van 32 bytes, moeten die drie feiten hier de DER-codering overleven en bij een andere implementatie de DER-decodering. Een parametersblok dat de verifier niet kan parsen beëindigt de verificatie voordat er enige modulaire machtsverheffing plaatsvindt. Een blok dat hij anders parset is erger, want RFC 4055 §3.1 geeft saltLength een standaardwaarde van 20. Een decoder die een veld overslaat dat hij niet herkent komt op die standaardwaarde uit, draait EMSA-PSS-VERIFY met een salt van 20 bytes tegen een handtekening die met 32 is berekend, en meldt een slechte handtekening zonder enige hint dat het probleem metadata is en niet de sleutel. Beide uitkomsten zijn wat de codering van 3.114.19 opleverde, afhankelijk van hoe strikt de verifier was, en geen van beide wijst naar de AlgorithmIdentifier
Wat RFC 4055 §3.1 werkelijk van RSASSA-PSS-params eist
RFC 4055 §3.1 definieert RSASSA-PSS-params als een SEQUENCE van vier velden, elk met een expliciete context-specifieke tag en een DEFAULT-waarde:
// RSASSA-PSS-params ::= SEQUENCE {
// hashAlgorithm [0] HashAlgorithm DEFAULT sha1,
// maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1,
// saltLength [2] INTEGER DEFAULT 20,
// trailerField [3] TrailerField DEFAULT trailerFieldBC
// }
Expliciet taggen betekent in DER dat elk veld in een geconstrueerde context-specifieke TLV wordt gewikkeld, A0 voor [0], A1 voor [1], A2 voor [2] en A3 voor [3], met de universele codering van de waarde erbinnen genest. Elk veld is juist getagd omdat elk veld optioneel is via zijn standaardwaarde. Zonder tags zou een decoder niet kunnen zien of een SEQUENCE met één AlgorithmIdentifier hashAlgorithm of maskGenAlgorithm droeg, want beide zijn SEQUENCE-types; met tags identificeert het tagnummer het veld ongeacht welke buren aanwezig zijn. De waarden die PDFium Component uitschrijft volgen het profiel in ETSI TS 119 312 §7, SHA-256, MGF1 met SHA-256 en een salt gelijk aan de digestlengte, en ze spiegelen precies wat elke platform-ondertekeningsaanroep te horen krijgt: een BCRYPT_PSS_PADDING_INFO met cbSalt 32 voor NCryptSignHash, een CK_RSA_PKCS_PSS_PARAMS met sLen 32 voor het PKCS#11-mechanisme, en het SHA-256 digest-signing PSS-algoritme in het Security-framework
Hoe drie backends dezelfde fout maakten
De codering van 3.114.19 tagde de eerste twee velden en liet de laatste twee kaal, identiek in TWinCmsSigner, TKeychainCmsSigner en TPkcs11CmsSigner. Die symmetrie is geen toeval: alle drie implementeren de interface ICmsSigner uit FPdfCms.pas, en hun bodies van GetSignatureAlgorithmParams zijn naar één sjabloon geschreven. Het sjabloon las zo:
// Vóór 3.114.20: [0] en [1] getagd, [2] en [3] niet
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), // kale INTEGER waar [2] EXPLICIT vereist was
W.IntegerOf(1))); // trailerField, gelijk aan DEFAULT, moet afwezig zijn
Een decoder die die SEQUENCE doorloopt ziet A0, leest het hash-algoritme, ziet A1, leest de maskergeneratiefunctie, en komt dan 02 01 20 tegen. Dat is een universele INTEGER, en RSASSA-PSS-params heeft nergens een ongetagd INTEGER-lid. Een strikte decoder stopt daar. Een toegeeflijke slaat het onherkende element over, vindt nooit een A2, kent saltLength zijn standaardwaarde van 20 toe, stuit dan op een tweede verdwaalde INTEGER, 02 01 01, en heeft hetzelfde probleem weer. Geen van beide paden komt uit op een salt van 32 bytes. Een gedeeld sjabloon is efficiënt als het klopt en een even efficiënte manier om het drie keer fout te doen als het niet klopt, en daarom landde de fix in alle drie de units in één commit en blijven de drie methodebodies daarna structureel identiek. Een toekomstige backend kan het blok beter uit een van deze kopiëren dan het opnieuw af te leiden, want het afleiden is precies waar de fout is gemaakt
Waarom wordt trailerField weggelaten in plaats van als [3] getagd?
Omdat X.690 §11.5 zegt dat een DER-encoder geen component mag coderen waarvan de waarde gelijk is aan zijn DEFAULT, en trailerField heeft DEFAULT trailerFieldBC, wat het integer 1 is. De voor de hand liggende correctie op de oude code, het kale W.IntegerOf(1) vervangen door W.ContextSpecific(3, W.IntegerOf(1), True), levert een blok op dat een permissieve BER-decoder accepteert en een strikte DER-decoder mag weigeren. De waarde is niet fout. Zijn aanwezigheid is dat. Dezelfde regel is waarom de andere drie velden er wel staan: SHA-256 is niet de standaard sha1, MGF1 met SHA-256 is niet de standaard mgf1SHA1, en 32 is niet de standaard 20. Had de backend met SHA-1 en een salt van 20 bytes ondertekend, dan zou RFC 4055 §3.1 de parameters samenvouwen tot een lege SEQUENCE, 30 00, en die lege SEQUENCE in plaats van NULL is wat een verifier verwacht. PDFium Component schrijft die vorm nooit uit omdat het nooit met die waarden ondertekent, maar het is het geval dat iedereen eruit haalt die aanneemt dat "geen parameters" altijd 05 00 spelt
Dit is het onderscheid tussen DER en BER dat specifiek voor handtekeningen uitmaakt. BER laat een encoder een component met de standaardwaarde opnemen; DER verbiedt het, omdat DER bestaat opdat één waarde precies één codering heeft, en een handtekening over een structuur met twee legale coderingen is een handtekening waarover te twisten valt. Alles binnen CMS-signedAttrs is om die reden DER, en het parametersblok reist binnen signedAttrs mee via het attribuut cmsAlgorithmProtection én in de buitenste signatureAlgorithm, dus het krijgt geen vrijstelling
De gecorrigeerde TDerWriter-codering
PDFium Component bouwt de parameters nu met drie aanroepen van TDerWriter.ContextSpecific uit FPdfAsn1.pas, één per niet-standaardveld, elk met het argument Constructed op True om de expliciete-tag-wrapper te produceren, en geen regel voor het trailerveld. Dit is de body van TWinCmsSigner.GetSignatureAlgorithmParams met de OID's uitgeschreven; de units voor Keychain en PKCS#11 spellen dezelfde waarden als OID_SHA256, OID_MGF1 en OID_RSASSA_PSS:
function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if FPaddingScheme = psRsaPss then
begin
W := TDerWriter.Create;
try
// RFC 4055 3.1 tagt alle vier velden. saltLength is [2]; een kale
// INTEGER hier wordt gelezen als het begin van een ander veld. trailerField
// is [3] met DEFAULT 1, en X.690 11.5 verbiedt het coderen van een waarde
// gelijk aan de standaard, dus het wordt volledig weggelaten
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 en ECDSA: AlgIdWithParams schrijft NULL
end;
Twee details van de omringende machinerie doen ertoe. TDerWriter.AlgId produceert een AlgorithmIdentifier met NULL-parameters, wat is wat RFC 4055 §2.1 encoders voorschrijft voor de geneste hashAlgorithm en voor de binnenste MGF1-hash. En de CMS-builder in FPdfCms.pas koppelt de handtekening-OID aan deze bytes via TDerWriter.AlgIdWithParams, dat NULL invult wanneer de parameters nil zijn; daarom geven psRsaPkcs1v15 en psEcdsa simpelweg nil terug en zijn ze nooit geraakt, en daarom is 1.2.840.113549.1.1.10, id-RSASSA-PSS, de enige van de drie handtekening-OID's die een echt parametersblok draagt. De resulterende bytes voor het SHA-256-profiel liggen vast en zijn kort genoeg om met het oog te controleren: een buitenste 30 34-SEQUENCE met A0 0F rond de AlgorithmIdentifier van 15 bytes voor SHA-256, A1 1C rond de AlgorithmIdentifier van 28 bytes voor MGF1 waarvan de eigen parameters diezelfde SHA-256-AlgorithmIdentifier zijn, en A2 03 02 01 20 voor het salt. Laat een dump van je signatureAlgorithm 02 01 20 zien op het hoogste niveau van de params-SEQUENCE in plaats van binnen een A2, dan kijk je naar de codering van 3.114.19
Waarom ving de testsuite een misvormde AlgorithmIdentifier niet?
Omdat de PAdES-tests de CMS-builder aansturen via een nepondertekenaar die sha256WithRSAEncryption meldt en nil teruggeeft uit GetSignatureAlgorithmParams, dus het PSS-parametersblok werd in geen enkele test opgebouwd. Dat is een redelijk ontwerp voor tests die zonder certificaatopslag, Keychain of token moeten draaien, en het heeft een blinde vlek met een precieze vorm: alles wat alleen een echte backend produceert wordt alleen door een echte backend geoefend. De tweede laag is interessanter. PDFium Component plaatst de handtekening-AlgorithmIdentifier, parameters inbegrepen, ook in het signed attribuut cmsAlgorithmProtection uit RFC 6211, en een verifier vergelijkt die kopie met de buitenste signatureAlgorithm. Beide kopieën kwamen uit dezelfde aanroep, dus ze kwamen perfect overeen en elke interne consistentiecontrole slaagde. De codering was met zichzelf consistent en fout, de categorie bug die geen enkele hoeveelheid vergelijken van een structuur met zichzelf kan blootleggen, en dezelfde les met een andere structuur wordt verteld in CMS signedAttrs en DER SET OF-sortering, waar een SET die in de ene orde werd gehasht en in een andere uitgeschreven er prima uitzag tot een vreemde verifier de hash opnieuw berekende
Wat deze klasse van bugs wel vangt is een decoder die niet door de auteur van de encoder is geschreven, gedraaid tegen de werkelijke uitvoer van de werkelijke backend. Het Windows-verificatiepad in PDFium Component loopt via CryptoAPI in plaats van via de eigen lezer van de library, en een daar geweigerde PSS-handtekening is wat terugleidde naar de parameters. Elke ASN.1 die een implementatie uitschrijft voor andere implementaties om te lezen verdient minstens één round trip door een decoder die hij niet zelf bestuurt, en hoe meer standaardwaarden en tags de structuur heeft, hoe meer die round trip waard is
Waar dit past in de rest van het PSS-verhaal
Deze fix staat los van de twee andere plekken waar PSS in een PAdES-handtekening fout kan gaan, en ze gescheiden houden verkort het debuggen. De macOS-backend kan merken dat een bepaalde sleutel of een ouder systeem PSS weigert en terugvallen op PKCS#1 v1.5, en de AlgorithmIdentifier moet die terugval volgen; dat is een capability-vraag, behandeld in PAdES ondertekenen met een macOS Keychain-identiteit. De PKCS#11-backend kan het token een CK_RSA_PKCS_PSS_PARAMS geven waarvan het token de layout anders leest door een verschil in integerbreedte; dat is een ABI-vraag, behandeld in CK_ULONG en de PKCS#11-packing-valkuil. Dit artikel gaat over de derde fout, waarbij de sleutel gewillig was, het token de juiste bytes berekende, en de DER die het resultaat beschreef niet voldeed aan RFC 4055 §3.1
Een ondertekenaar die PSS verklaart neemt een verplichting op zich die de v1.5-ondertekenaar nooit had: zijn eigen parameters beschrijven in een vorm die een andere implementatie tot dezelfde drie waarden decodeert. RFC 4055 §3.1 legt de tags vast, X.690 §11.5 legt vast welke velden mogen voorkomen, en ETSI TS 119 312 §7 legt de waarden vast die het kiezen waard zijn. Alle drie de backends van de PDFium Delphi component worden als source meegeleverd, dus de body van GetSignatureAlgorithmParams hierboven is degene die je kunt lezen, dumpen en tegen je eigen verifier kunt vergelijken in plaats van op vertrouwen aan te nemen