Technický článek

Řazení signedAttrs jako DER SET OF v PDFium Component

PDFium Component staví blok CMS signedAttrs přes TDerWriter.SetOfSorted, protože klauzule 11.6 normy X.690 vyžaduje, aby se členové SET OF emitovali ve vzestupném pořadí podle vlastních DER kódování. Když je místo toho poskládáte v pořadí konstrukce, dostanete podpisy, které váš vlastní ověřovač přijme a každý cizí PAdES validátor odmítne

Ta poslední věta je celý důvod, proč tahle chyba přežila kompletní regresní sadu. Cesta podepisování byla pokrytá, cesta ověřování byla pokrytá, round trip prošel na každé platformě a výstup stejně nebyl platné DER. Test, který podepíše vaším writerem a ověří vaším readerem, odchylku od kódovacích pravidel neodhalí, protože obě poloviny tu odchylku sdílejí

Co vlastně vyžaduje klauzule 11.6 normy X.690

Členové hodnoty SET OF se musí řadit a klíčem řazení je kompletní DER kódování každého člena, porovnávané jako bezznaménkový oktetový řetězec, přičemž kratší kódování jde první, když je prefixem toho delšího. Je to kanonické omezení, ne doporučení: DER připouští pro danou hodnotu přesně jedno kódování, takže nesetříděný SET OF není jinak uspořádaným kódováním téže hodnoty – není to DER vůbec. Protože tagy ASN.1 přicházejí v TLV první, porovnání se obvykle rozhodne v prvních dvou oktetech, a protože každý člen seznamu atributů CMS začíná 30, tagem SEQUENCE, rozhoduje oktet délky. Požadované pořadí tak vypadá skoro jako řazení atributů podle velikosti, což se hodí vědět, když zíráte na hex dump a přemýšlíte, proč je pořadí zdánlivě náhodné

Diagram k článku o PDFium Component ukazující, jak klauzule 11.6 normy X.690 řadí SET OF signedAttrs v CMS: BuildSignedAttrs připojuje content-type, message-digest, signing-certificate-v2 a cms-algorithm-protection v pořadí konstrukce, zatímco konformní DER řadí čtyři členy podle kompletních kódování 30 18, 30 2D, 30 2F, 30 37
Pořadí konstrukce a kódované pořadí se rozcházejí už u prvního podpisu, protože všichni čtyři členové nesou tag SEQUENCE 30 a o porovnání rozhoduje oktet délky

Proč je už minimální sada atributů mimo pořadí?

Protože přirozené pořadí konstrukce a kódované pořadí se rozcházejí už u prvního podpisu, který vyrobíte, a to bez jediného volitelného atributu. BuildSignedAttrs v FPdfCms.pas připojuje content-type, pak message-digest, pak signing-certificate-v2 a nakonec cms-algorithm-protection. Se SHA-256 digesty a podpisovým algoritmem RSA začínají čtyři atributové TLV těmito bajty

// Pořadí sestavení vytvořené metodou BuildSignedAttrs
//   content-type              30 18   (24 content octets)
//   message-digest            30 2F   (47)
//   signing-certificate-v2    30 37   (55)
//   cms-algorithm-protection  30 2D   (45)
//
// Pořadí DER vyžadované normou X.690 11.6
//   30 18  content-type
//   30 2D  cms-algorithm-protection
//   30 2F  message-digest
//   30 37  signing-certificate-v2

Dva ze čtyř atributů se přesouvají. cms-algorithm-protection na 30 2D musí předcházet signing-certificate-v2 na 30 37 a předcházet musí i message-digest na 30 2F. Nejmenší konformní sada atributů PAdES B-B, kterou PDFium Component umí vyrobit, tak byla už za starého kódu mimo pořadí, dřív než někdo přidá signing-time, commitment-type-indication nebo signature-policy-identifier. Nikdy neexistovala konfigurace, ve které by se chyba nespustila

Proč naše vlastní testy procházejí a každý externí validátor selhává?

Protože ty dvě rodiny ověřovačů počítají message digest z jiných bajtů. Klauzule 5.4 normy RFC 5652 říká, že vstupem podpisové operace je DER kódování hodnoty SignedAttributes s implicitním tagem [0] nahrazeným tagem SET 31, a všechno rozhoduje věta DER kódování, ne oktety, které jste dostali. Windows ověřovač dosažitelný přes CryptVerifyDetachedMessageSignature vezme přijaté oktety, prohodí úvodní tag a zahashuje je tak, jak dorazily, takže pořadí do hry nikdy nevstoupí a nesetříděný SET OF se zahashuje přesně na to, co spočítal podepisující. OpenSSL a BouncyCastle, na kterých stojí široce nasazené evropské validátory, naparsují sadu atributů do struktur a před digestem ji znovu zakódují. Re-encoding aplikuje řazení, digest už nesouhlasí s tím podepsaným a podpis se hlásí jako rozbitý, ne jako špatně zakódovaný, což vás pošle pátrat v klíčovém materiálu a řetězcích certifikátů po chybě, která žije v porovnávací funkci

Diagram PDFium Component k divergenci ověřovačů za bugem se setříděným SET OF: podepisující hashuje DER kódování s tagem SET 31, cesta CryptVerifyDetachedMessageSignature digestuje přijaté oktety, takže na pořadí nikdy nezáleží a špatně setříděný podpis projde, zatímco OpenSSL a BouncyCastle znovu zakódují, aplikují řazení podle X.690 a hlásí podpis jako rozbitý
CryptVerifyDetachedMessageSignature digestuje oktety tak, jak dorazily, takže nesetříděná sada projde ověřením, zatímco OpenSSL a BouncyCastle před digestem znovu zakódují a hlásí podpis jako rozbitý, ne jako špatně zakódovaný

Zobecněte to za tenhle jeden bug, protože to je ta drahá část. Round trip stejného původu je imunní vůči každé odchylce od interop specifikace, kterou sdílejí obě poloviny vašeho kódu; dokazuje, že váš writer a váš reader spolu souhlasí, což není vlastnost, na které někomu záleží. Pro cokoli, co opouští proces jako wire formát, je jediná smysluplná regrese ta, která připne bajty proti specifikaci nebo prožene váš výstup cizí implementací. Backend ověřování CMS přes OpenSSL si tu zaslouží své místo i z druhého důvodu kromě přenositelnosti: je to skutečně nezávislý čtenář vašeho vlastního DER

Oprava a kde už existovala

Writer DER měl setříděnou variantu celou dobu. TDerWriter v FPdfAsn1.pas vystavuje jak SetOf, který obalí předem zkonkatenovaný buffer obsahu tagem SET a zachová pořadí, v jakém ho dostal, tak SetOfSorted, který vezme členy jednotlivě jako TDerByteArrays a sloučí je setříděné podle kódování, než je obalí. Kód PDF MAC v FPdfMac.pas už svou sadu autentizovaných atributů stavěl přes SetOfSorted; jen CMS podepisující ne. Držet členy jako pole až do chvíle obalení je strukturální polovina opravy, protože jakmile se atributy zkonkatenují do jediného bufferu, informace o pořadí je pryč a žádná následná péče ji nevrátí. Writer API, které pro SET OF nabízí jen SetOf(Content: TBytes), je API, které tenhle bug přímo zve; přetížení přijímající pole je to, co dělá správnou věc vyjádřitelnou

Diagram dvojice API TDerWriter v PDFium Component: SetOf obalí předem zkonkatenovaný buffer TBytes a zachová pořadí členů, jaké dostal, takže výstup není DER, zatímco SetOfSorted drží členy jako pole TDerByteArrays, sloučí je setříděné podle kompletních kódování a obalí konformní SET OF
Jakmile se atributy zkonkatenují do jediného bufferu, informace o pořadí je pryč, takže přetížení SetOfSorted přijímající pole dělá správnou věc vyjádřitelnou v místě kódování
// Před: členové zachovávají pořadí, v jakém je AddAttr připojil
Result:= W.SetOf(ConcatMany(AttrList));

// Po: řazení podle X.690 11.6 aplikované v okamžiku kódování
Result:= W.SetOfSorted(AttrList);

SET OF se řadí, SEQUENCE OF ne

To je rozdíl, ve kterém se lidé pletou nejčastěji, a splést si ho obráceně škodí stejně jako ho ignorovat. SEQUENCE OF je uspořádaná kolekce: její pořadí je součástí hodnoty a nikdy se nesmí řadit. SET OF je jako hodnota neuspořádaný, takže DER vnucuje kanonické pořadí, aby kódování bylo jednoznačné. Setřídit SEQUENCE OF kazí data; nechat SET OF nesetříděný produkuje něco, co není DER

  • SET OF, musí se řadit: SignedAttributes a UnsignedAttributes v SignerInfo, sada digestAlgorithms, sady certificates a crls v SignedData, sada signerInfos, sada hodnot uvnitř každého jednotlivého Attribute a sada atributů uvnitř každého RelativeDistinguishedName jména X.501
  • SEQUENCE OF, nikdy se neřadí: seznam RDN, který tvoří Name, pole certs v SigningCertificateV2, seznamy rozšíření a seznamy general-name uvnitř nich

RDN je SET OF sedící uvnitř SEQUENCE OF o úroveň výš, takže rozlišené jméno obsahuje obě pravidla ve dvou úrovních zanoření. Tohle rozložení také vysvětluje, proč jsou jednoprvkové sady tak shovívavé: sada s jedním prvkem je triviálně setříděná, a proto sady hodnot atributů postavené přes W.SetOf uvnitř BuildSignedAttrs byly správné, jak byly, a nepotřebovaly žádnou změnu. Bug žije jen tam, kde sada skutečně drží dva a více členů, a vícehodnotové RDN jsou vzácné natolik, že si většina encoderů jmen odpustí stejnou chybu celé roky

Jak zajistit, aby se to nevrátilo?

Sázejte na bajty, ne na verdikty. Nejlevnější trvalá kontrola je self-audit, který prochází vlastní výstup: naparsujte vyemitované signedAttrs, porovnejte každého člena s předchozím stejným porovnáním bezznaménkových oktetových řetězců, které používá writer, a při jakékoli dvojici mimo pořadí nechte build selhat. Trvá to mikrosekundy, nepotřebuje žádnou externí knihovnu a na rozdíl od ověřovacího round tripu testuje vlastnost, kterou specifikace skutečně uvádí

// Projdi členy SET OF a ověř řazení podle X.690 11.6.
// Members je seznam předaný funkci SetOfSorted, nebo výsledek
// při opětovném parsování emitovaného bloku 31 xx / A0 xx
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
  I: Integer;
begin
  for I:= 1 to Length(Members)- 1 do
    if CompareDerBytes(Members[I- 1], Members[I])> 0 then
      Exit(False);
  Result:= True;
end;

// V testu podepisování se nezastavuj u "our verifier said valid".
// Ukotvi bajty, které signer zahashoval
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');

Dvě meze stojí za to říct na rovinu. Setřídění signedAttrs samo o sobě nedělá podpis interoperabilním, protože všechno ostatní v kontejneru musí taky držet a důvodů, proč validátory odmítají jinak zdravé PAdES podpisy, je dost na vlastní diskusi. A setříděný SET OF je vlastnost toho, co píšete, ne toho, co čtete: PDFium Component musí pořád přijmout příchozí podpis, jehož signedAttrs dorazí nesetříděné, protože takové soubory ve světě existují a klauzule 5.4 normy RFC 5652 říká ověřovači, aby znovu kódoval, ne odmítal. Přísní na výstupu, tolerantní na vstupu, jako obvykle. Writer DER, builder CMS a cesta podepisování popsaná v průchodu PAdES B-B se dodávají jako čitelný zdrojový kód v Pascalu s PDFium Delphi component, což je ostatně celý smysl dodávání zdrojáků u podpisové knihovny: když se validátor někde v Evropě rozejde s vaším dokumentem, můžete si přečíst přesné bajty, které váš kód vyemitoval, i přesnou klauzuli, kterou porušují, místo abyste zakládali ticket a čekali