Artykuł techniczny

Sortowanie signedAttrs CMS jako DER SET OF w PDFium

PDFium Component buduje blok CMS signedAttrs przez TDerWriter.SetOfSorted, bo klauzula 11.6 standardu X.690 wymaga, żeby elementy SET OF były emitowane w kolejności rosnącej swoich własnych kodowań DER. Złóż je w kolejności konstrukcji, a dostaniesz podpisy, które twój własny weryfikator akceptuje, a każdy zewnętrzny walidator PAdES odrzuca

To ostatnie zdanie to cały powód, dla którego ten defekt przetrwał pełny zestaw testów regresyjnych. Ścieżka podpisywania była pokryta, ścieżka weryfikacji była pokryta, round trip był zielony na każdej platformie, a wynik wciąż nie był poprawnym DER. Test, który podpisuje twoim pisarzem i weryfikuje twoim czytnikiem, nie potrafi wykryć odstępstwa od reguł kodowania, bo obie połówki współdzielą to odstępstwo

Czego faktycznie wymaga klauzula 11.6 standardu X.690

Elementy wartości SET OF muszą być posortowane, a kluczem sortowania jest kompletne kodowanie DER każdego elementu, porównywane jako niepodpisany ciąg oktetów, przy czym krótsze kodowanie jest pierwsze, gdy jedno jest prefiksem drugiego. To kanoniczne ograniczenie, a nie sugestia: DER dopuszcza dokładnie jedno kodowanie danej wartości, więc nieposortowane SET OF to nie inaczej uporządkowane kodowanie tej samej wartości — to w ogóle nie jest kodowanie DER. Ponieważ tagi ASN.1 są pierwsze w TLV, porównanie zwykle rozstrzyga się w pierwszych dwóch oktetach, a skoro każdy element listy atrybutów CMS zaczyna się od 30, taga SEQUENCE, remis rozbija oktet długości. Wymagany porządek wygląda więc niemal jak sortowanie atrybutów po rozmiarze, co warto wiedzieć, gdy wpatrujesz się w hex dump i zastanawiasz się, czemu kolejność wygląda na przypadkową

Diagram do artykułu o komponencie PDFium pokazujący, jak klauzula 11.6 standardu X.690 sortuje CMS signedAttrs jako SET OF: BuildSignedAttrs dopina content-type, message-digest, signing-certificate-v2 i cms-algorithm-protection w kolejności konstrukcji, podczas gdy zgodny DER porządkuje cztery elementy po ich kompletnych kodowaniach, 30 18, 30 2D, 30 2F, 30 37
Kolejność konstrukcji i kolejność zakodowana różnią się już od pierwszego podpisu, bo wszystkie cztery elementy niosą tag SEQUENCE 30, a o porównaniu decyduje oktet długości

Czemu minimalny zestaw atrybutów już jest w złej kolejności?

Bo naturalna kolejność konstrukcji i kolejność zakodowana różnią się od pierwszego podpisu, jaki produkujesz, bez udziału jakichkolwiek opcjonalnych atrybutów. BuildSignedAttrs w FPdfCms.pas dopina content-type, potem message-digest, potem signing-certificate-v2, a na końcu cms-algorithm-protection. Przy skrótach SHA-256 i algorytmie podpisu RSA cztery TLV atrybutów zaczynają się od tych bajtów

// Kolejność konstrukcji elementów z 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)
//
// Porządek DER wymagany przez X.690 11.6
//   30 18  content-type
//   30 2D  cms-algorithm-protection
//   30 2F  message-digest
//   30 37  signing-certificate-v2

Dwa z czterech atrybutów się przemieszczają. cms-algorithm-protection na 30 2D musi poprzedzać signing-certificate-v2 na 30 37 i musi też poprzedzać message-digest na 30 2F. A więc najmniejszy zgodny zestaw atrybutów PAdES B-B, jaki PDFium Component potrafi wyemitować, już był w złej kolejności pod starym kodem, zanim ktokolwiek doda signing-time, commitment-type-indication czy signature-policy-identifier. Nie istniała konfiguracja, w której ten bug by nie odpalił

Czemu nasze własne testy przechodzą, a każdy zewnętrzny walidator pada?

Bo te dwie rodziny walidatorów liczą skrót wiadomości z różnych bajtów. Klauzula 5.4 standardu RFC 5652 mówi, że wejściem operacji podpisu jest kodowanie DER wartości SignedAttributes z tagiem IMPLICIT [0] zastąpionym tagiem SET 31, a fraza, która decyduje o wszystkim, to kodowanie DER, nie oktety, które otrzymałeś. Walidator platformy Windows osiągany przez CryptVerifyDetachedMessageSignature bierze otrzymane oktety, podmienia tag wiodący i haszuje je tak, jak przyszły, więc kolejność w ogóle nie wchodzi w grę i nieposortowane SET OF digestuje dokładnie do tego, co policzył podpisujący. OpenSSL i BouncyCastle, na których zbudowane są powszechnie wdrażane europejskie walidatory, parsują zestaw atrybutów do struktur i re-kodują przed haszowaniem. Re-kodowanie stosuje sortowanie, skrót przestaje pasować do tego podpisanego i podpis jest raportowany jako zepsuty, a nie jako źle zakodowany, co wysyła cię na polowanie po materiale klucza i łańcuchach certyfikatów za defektem, który żyje w funkcji porównującej

Diagram komponentu PDFium pokazujący rozjazd walidatorów stojący za bugiem sortowania SET OF: podpisujący haszuje kodowanie DER z tagiem SET 31, ścieżka CryptVerifyDetachedMessageSignature w Windows digestuje otrzymane oktety, więc kolejność nigdy nie ma znaczenia i nieposortowany podpis przechodzi, podczas gdy OpenSSL i BouncyCastle re-kodują, stosują sortowanie z X.690 i raportują podpis jako zepsuty
CryptVerifyDetachedMessageSignature digestuje oktety tak, jak przyszły, więc nieposortowany zestaw się weryfikuje, a OpenSSL i BouncyCastle re-kodują przed haszowaniem i raportują podpis jako zepsuty, nie jako źle zakodowany

Uogólnij poza ten jeden bug, bo to jest droga część. Round trip tego samego pochodzenia jest odporny na każde odstępstwo od specyfikacji interoperacyjności, które obie połówki twojego kodu współdzielą; dowodzi, że twój zapis i twój odczyt zgadzają się ze sobą, co nie jest właściwością, na której komukolwiek zależy. Dla wszystkiego, co opuszcza proces jako format sieciowy, jedynym sensownym regresem jest taki, który przypina bajty do specyfikacji albo odpala obcą implementację na twoim wyniku. Zaplecze weryfikacji CMS oparte o OpenSSL zasługuje na swoje miejsce tutaj z drugiego powodu, nie tylko przenośności: to prawdziwie niezależny czytnik twojego własnego DER

Naprawa — i to, że już istniała

Pisarz DER miał posortowany wariant od początku. TDerWriter w FPdfAsn1.pas wystawia zarówno SetOf, który opakowuje wstępnie skonkatenowany bufor zawartości w tag SET i zachowuje każdy porządek, w jakim go dostał, jak i SetOfSorted, który bierze elementy osobno jako TDerByteArrays i merge-sortuje je po ich kodowaniach przed opakowaniem. Kod MAC dla PDF w FPdfMac.pas już budował swój zestaw uwierzytelnionych atrybutów przez SetOfSorted; tylko sygnujący CMS nie. Trzymanie elementów jako tablicy aż do samego wywołania opakowującego to strukturalna połowa naprawy, bo gdy atrybuty zostaną skonkatenowane do jednego bufora, informacja o kolejności przepada i żadna późniejsza ostrożność jej nie przywróci. API pisarza, które dla SET OF oferuje tylko SetOf(Content: TBytes), to API zapraszające ten bug; przeciążenie przyjmujące tablicę to właśnie to, co czyni poprawne rozwiązanie wyrażalnym

Diagram pary API TDerWriter w komponencie PDFium: SetOf opakowuje wstępnie skonkatenowany bufor TBytes i zachowuje każdy porządek elementów, jaki dostał, produkując wynik, który nie jest DER, podczas gdy SetOfSorted trzyma elementy jako tablicę TDerByteArrays, merge-sortuje je po kompletnych kodowaniach i opakowuje zgodne SET OF
Gdy atrybuty zostaną skonkatenowane do jednego bufora, informacja o kolejności przepada, więc przeciążenie SetOfSorted przyjmujące tablicę czyni poprawne rozwiązanie wyrażalnym w punkcie kodowania
// Przed: elementy zachowują kolejność, w jakiej dopisywał je AddAttr
Result:= W.SetOf(ConcatMany(AttrList));

// Po: porządek wg X.690 11.6 zastosowany w momencie kodowania
Result:= W.SetOfSorted(AttrList);

SET OF się sortuje, SEQUENCE OF nie

To najczęściej mylone rozróżnienie, a pomylenie go w drugą stronę jest równie szkodliwe jak zignorowanie. SEQUENCE OF to kolekcja uporządkowana: jej kolejność jest częścią wartości i nigdy nie wolno jej sortować. SET OF jako wartość jest nieuporządkowany, więc DER narzuca porządek kanoniczny, żeby uczynić kodowanie jednoznacznym. Posortowanie SEQUENCE OF psuje dane; zostawienie nieposortowanego SET OF daje coś, co nie jest DER

  • SET OF, musi być sortowany: SignedAttributes i UnsignedAttributes w SignerInfo, zestaw digestAlgorithms, zbiory certificates i crls w SignedData, zestaw signerInfos, zbiór wartości wewnątrz każdego pojedynczego Attribute oraz zbiór atrybutów w każdym RelativeDistinguishedName nazwy X.501
  • SEQUENCE OF, nigdy nie sortowany: lista RDN składająca się na Name, pole certs w SigningCertificateV2, listy rozszerzeń i listy general-name w ich wnętrzu

RDN to SET OF siedzący wewnątrz SEQUENCE OF jeden poziom wyżej, więc nazwa wyróżniająca zawiera obie reguły w dwóch poziomach zagnieżdżenia. Ten układ tłumaczy też, czemu zbiory jednoelementowe są tak wybaczające: zbiór z jednym elementem jest trywialnie posortowany, dlatego zbiory wartości atrybutów budowane przez W.SetOf wewnątrz BuildSignedAttrs były poprawne, jak stały, i nie wymagały zmian. Bug żyje tylko tam, gdzie zbiór faktycznie trzyma dwa lub więcej elementów, a wielowartościowe RDN są na tyle rzadkie, że większość koderów nazw uchodzi z tym samym błędem przez lata

Jak to powstrzymać przed powrotem?

Asertuj na bajtach, nie na werdyktach. Najtańszym trwałym sprawdzeniem jest samoaudyt, który przechodzi po własnym wyniku: sparsuj wyemitowany signedAttrs, porównaj każdy element z poprzednim tym samym porównaniem niepodpisanych ciągów oktetów, którego używa pisarz, i oblej build, jeśli jakakolwiek para jest w złej kolejności. Działa w mikrosekundach, nie potrzebuje zewnętrznej biblioteki i — w przeciwieństwie do round tripu weryfikacji — testuje właściwość, którą specyfikacja faktycznie stwierdza

// Przejdź po elementach SET OF i potwierdź porządek wg X.690 11.6.
// Members to lista przekazana do SetOfSorted albo wynik
// ponownego sparsowania wyemitowanego 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;

// W teście podpisu nie kończ na stwierdzeniu, że nasz walidator
// uznał podpis za ważny. Przypnij bajty, które podpisujący zahaszował
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');

Warto wprost powiedzieć o dwóch granicach. Sortowanie signedAttrs samo w sobie nie czyni podpisu interoperacyjnym, bo wszystko inne w kontenerze też musi się trzymać, a powodów, dla których walidatory odrzucają skądinąd poprawne podpisy PAdES, jest na tyle dużo, że starczy ich na osobną dyskusję. A posortowane SET OF to właściwość tego, co piszesz, nie tego, co czytasz: PDFium Component wciąż musi przyjąć przychodzący podpis, którego signedAttrs przychodzą nieposortowane, bo takie pliki istnieją w terenie, a klauzula 5.4 standardu RFC 5652 każe walidatorowi re-kodować, a nie odrzucać. Rygorystycznie na wyjściu, tolerancyjnie na wejściu, jak zwykle. Pisarz DER, budowniczy CMS i ścieżka podpisywania opisane w przewodniku po PAdES B-B są dostarczane jako czytelny kod źródłowy w Pascalu razem z komponentem PDFium dla Delphi, a to jest sens dostarczania źródła biblioteki podpisującej w ogóle: gdy jakiś walidator w Europie nie zgadza się z twoim dokumentem, możesz przeczytać dokładne bajty, które wyemitował twój kod, i dokładną klauzulę, którą naruszają, zamiast zakładać ticket i czekać