Technischer Artikel

CMS-signedAttrs-DER-SET-OF-Sortierung im PDFium Component

PDFium Component baut den CMS-signedAttrs-Block über TDerWriter.SetOfSorted, denn X.690-Klausel 11.6 verlangt, dass die Mitglieder eines SET OF in aufsteigender Reihenfolge ihrer eigenen DER-Encodings emittiert werden. Bauen Sie sie stattdessen in Konstruktionsreihenfolge, und Sie bekommen Signaturen, die Ihr eigener Verifier akzeptiert und jeder Dritt-PAdES-Validator ablehnt

Dieser letzte Satz ist der ganze Grund, warum dieser Defekt eine vollständige Regressions-Suite überlebte. Der Signaturpfad war abgedeckt, der Verifikationspfad war abgedeckt, der Round Trip war auf jeder Plattform grün, und die Ausgabe war trotzdem kein gültiges DER. Ein Test, der mit Ihrem Writer signiert und mit Ihrem Reader verifiziert, kann eine Abweichung von den Encoding-Regeln nicht erkennen, denn beide Hälften teilen die Abweichung

Was X.690-Klausel 11.6 tatsächlich verlangt

Die Mitglieder eines SET-OF-Werts müssen sortiert sein, und der Sortierschlüssel ist das vollständige DER-Encoding jedes Mitglieds, verglichen als unsigned Octet-String, wobei das kürzere Encoding zuerst kommt, wenn eines ein Präfix des anderen ist. Das ist eine kanonische Einschränkung statt einer Empfehlung: DER admitiert exakt ein Encoding eines gegebenen Werts, also ist ein unsortiertes SET OF kein anders geordnetes Encoding desselben Werts, es ist überhaupt kein DER-Encoding. Weil ASN.1-Tags in einem TLV zuerst kommen, entscheidet sich der Vergleich meist in den ersten zwei Octets, und da jedes Mitglied einer CMS-Attributliste mit 30 beginnt, dem SEQUENCE-Tag, entscheidet das Längen-Octet. Die geforderte Ordnung sieht daher fast so aus, als würde man die Attribute nach Größe ordnen, was man wissen sollte, wenn man auf einen Hex-Dump starrt und sich fragt, warum die Ordnung willkürlich erscheint

Diagramm für den PDFium-Component-Artikel, das zeigt, wie X.690-Klausel 11.6 das CMS-signedAttrs-SET-OF sortiert: BuildSignedAttrs hängt content-type, message-digest, signing-certificate-v2 und cms-algorithm-protection in Konstruktionsreihenfolge an, während konformes DER die vier Mitglieder nach ihren vollständigen Encodings ordnet, 30 18, 30 2D, 30 2F, 30 37
Konstruktionsreihenfolge und Encoded-Reihenfolge widersprechen sich ab der allerersten Signatur, denn alle vier Mitglieder tragen das SEQUENCE-Tag 30, und das Längen-Octet entscheidet den Vergleich

Warum ist schon die minimale Attributmenge aus der Reihe?

Weil die natürliche Konstruktionsreihenfolge und die Encoded-Reihenfolge ab der allerersten Signatur, die Sie erzeugen, widersprechen, ohne dass optionale Attribute beteiligt sind. BuildSignedAttrs in FPdfCms.pas hängt content-type, dann message-digest, dann signing-certificate-v2, dann cms-algorithm-protection an. Mit SHA-256-Digests und einem RSA-Signaturalgorithmus beginnen die vier Attribut-TLVs mit diesen Bytes

// Von BuildSignedAttrs erzeugte Konstruktionsreihenfolge
//   content-type              30 18   (24 Inhalts-Octets)
//   message-digest            30 2F   (47)
//   signing-certificate-v2    30 37   (55)
//   cms-algorithm-protection  30 2D   (45)
//
// Von X.690 11.6 verlangte DER-Reihenfolge
//   30 18  content-type
//   30 2D  cms-algorithm-protection
//   30 2F  message-digest
//   30 37  signing-certificate-v2

Zwei der vier Attribute bewegen sich. cms-algorithm-protection bei 30 2D muss vor signing-certificate-v2 bei 30 37 kommen, und es muss auch vor message-digest bei 30 2F kommen. Also war die kleinste konforme PAdES-B-B-Attributmenge, die PDFium Component emittieren kann, unter dem alten Code schon falsch geordnet, bevor irgendjemand signing-time, commitment-type-indication oder einen signature-policy-identifier hinzufügt. Es gab nie eine Konfiguration, in der der Bug nicht feuerte

Warum bestehen unsere eigenen Tests, während jeder externe Validator scheitert?

Weil die zwei Familien von Verifiern den Message Digest über verschiedene Bytes berechnen. RFC 5652-Klausel 5.4 sagt, die Eingabe zur Signaturoperation sei das DER-Encoding des SignedAttributes-Werts, wobei das IMPLICIT-[0]-Tag durch das SET-Tag 31 ersetzt wird, und der Satz, der alles entscheidet, ist das DER-Encoding, nicht die Octets, die Sie empfangen haben. Der über CryptVerifyDetachedMessageSignature erreichte Windows-Plattform-Verifier nimmt die empfangenen Octets, tauscht das führende Tag und hasht sie, wie sie ankamen, also geht Ordnung nie ein, und ein falsch geordnetes SET OF digesteert exakt zu dem, was der Signierer berechnet hat. OpenSSL und BouncyCastle, worauf die weit verbreiteten europäischen Validatoren gebaut sind, parsen die Attributmenge in Strukturen und re-encoden vor dem Digesten. Re-Encoding wendet die Sortierung an, der Digest matcht nicht mehr den signierten, und die Signatur wird als kaputt gemeldet statt als falsch encodiert, was Sie auf die Jagd durch Key-Material und Zertifikatsketten schickt nach einem Defekt, der in einer Vergleichsfunktion lebt

PDFium-Component-Diagramm der Verifier-Divergenz hinter dem Sortiert-SET-OF-Bug: der Signierer hasht das DER-Encoding mit dem SET-Tag 31, der Windows-Pfad CryptVerifyDetachedMessageSignature digesteert die empfangenen Octets, also ist Ordnung egal und eine falsch geordnete Signatur besteht, während OpenSSL und BouncyCastle re-encoden, die X.690-Sortierung anwenden und die Signatur als kaputt melden
CryptVerifyDetachedMessageSignature digesteert die Octets, wie sie ankamen, also verifiziert eine falsch geordnete Menge, während OpenSSL und BouncyCastle vor dem Digesten re-encoden und die Signatur als kaputt melden statt als falsch encodiert

Verallgemeinern Sie über diesen einen Bug hinaus, denn das ist der teure Teil. Ein Same-Origin-Round-Trip ist immun gegen jede Abweichung von einer Interop-Spezifikation, die beide Hälften Ihres Codes teilen; er beweist, dass Ihr Writer und Ihr Reader miteinander übereinstimmen, was nicht die Eigenschaft ist, die irgendjemanden interessiert. Für alles, was den Prozess als Wire-Format verlässt, ist die einzige bedeutsame Regression eine, die Bytes gegen die Spezifikation pinnt oder eine fremde Implementierung über Ihre Ausgabe laufen lässt. Das OpenSSL-CMS-Verifikations-Backend verdient seinen Platz hier aus einem zweiten Grund neben der Portabilität: Es ist ein wirklich unabhängiger Leser Ihres eigenen DER

Der Fix, und wo er schon existierte

Der DER-Writer hatte die sortierte Variante die ganze Zeit. TDerWriter in FPdfAsn1.pas legt sowohl SetOf offen, das einen vor-konkatenierten Content-Puffer in ein SET-Tag wickelt und die Ordnung erhält, in der es übergeben wurde, als auch SetOfSorted, das die Mitglieder einzeln als TDerByteArrays nimmt und sie vor dem Wickeln per Merge-Sort nach ihren Encodings sortiert. Der PDF-MAC-Code in FPdfMac.pas baute seine authentifizierte Attributmenge bereits mit SetOfSorted; nur der CMS-Signer nicht. Die Mitglieder bis unmittelbar vor den Wickel-Aufruf als Array zu halten, ist die strukturelle Hälfte des Fixes, denn sobald Attribute zu einem einzigen Puffer konkateniert sind, ist die Ordnungsinformation weg, und keine Sorgfalt flussabwärts kann sie wiederherstellen. Eine Writer-API, die für SET OF nur SetOf(Content: TBytes) anbietet, ist eine API, die diesen Bug einlädt; die Array-nehmende Überladung ist, was das Richtige ausdrückbar macht

Diagramm des TDerWriter-API-Paars im PDFium Component: SetOf wickelt einen vor-konkatenierten TBytes-Puffer und erhält die übergebene Mitgliederordnung, was Ausgabe erzeugt, die kein DER ist, während SetOfSorted die Mitglieder als TDerByteArrays-Array hält, sie per Merge-Sort nach ihren vollständigen Encodings sortiert und ein konformes SET OF wickelt
Sobald Attribute zu einem einzigen Puffer konkateniert sind, ist die Ordnungsinformation weg, also ist die Array-nehmende SetOfSorted-Überladung, was das Richtige am Punkt des Encodings ausdrückbar macht
// Vorher: Mitglieder behalten die Reihenfolge, in der AddAttr sie angehängt hat
Result:= W.SetOf(ConcatMany(AttrList));

// Nachher: X.690-11.6-Ordnung am Punkt des Encodings angewendet
Result:= W.SetOfSorted(AttrList);

SET OF wird sortiert, SEQUENCE OF nicht

Das ist die am häufigsten verwechselte Unterscheidung, und sie rückwärts zu bekommen ist genauso schädlich wie sie zu ignorieren. Ein SEQUENCE OF ist eine geordnete Sammlung: Seine Ordnung ist Teil des Werts, und Sie dürfen sie nie sortieren. Ein SET OF ist als Wert ungeordnet, also erlegt DER eine kanonische Ordnung auf, um das Encoding eindeutig zu machen. Ein SEQUENCE OF zu sortieren korrumpiert Daten; ein SET OF unsortiert zu lassen erzeugt etwas, das kein DER ist

  • SET OF, muss sortiert werden: SignedAttributes und UnsignedAttributes in SignerInfo, die digestAlgorithms-Menge, die certificates- und crls-Mengen in SignedData, die signerInfos-Menge, die Wertmenge in jedem einzelnen Attribute und die Attributmenge in jedem RelativeDistinguishedName eines X.501-Name
  • SEQUENCE OF, nie sortieren: die RDN-Liste, die einen Name ausmacht, das certs-Feld von SigningCertificateV2, Erweiterungslisten und die General-Name-Listen darin

Ein RDN ist ein SET OF, das eine Ebene höher in einem SEQUENCE OF sitzt, also enthält ein Distinguished Name beide Regeln innerhalb von zwei Verschachtelungsebenen. Dieses Layout erklärt auch, warum Ein-Mitglied-Mengen so verzeihend sind: Eine Menge mit einem Element ist trivial sortiert, weshalb die Attributwertmengen, die mit W.SetOf in BuildSignedAttrs gebaut wurden, wie geschrieben korrekt waren und keine Änderung brauchten. Der Bug lebt nur, wo eine Menge wirklich zwei oder mehr Mitglieder hält, und mehrwertige RDNs sind selten genug, dass die meisten Name-Encoder jahrelang mit demselben Fehler davonkommen

Wie verhindern Sie, dass das wiederkommt?

Asserten Sie auf Bytes, nicht auf Urteile. Der billigste dauerhafte Check ist ein Selbst-Audit, das die eigene Ausgabe durchläuft: Parsen Sie das emittierte signedAttrs, vergleichen Sie jedes Mitglied mit dem vorherigen mit demselben Unsigned-Octet-String-Vergleich, den der Writer verwendet, und lassen Sie den Build scheitern, wenn ein Paar aus der Reihe ist. Es läuft in Mikrosekunden, braucht keine externe Bibliothek, und anders als ein Verifikations-Round-Trip testet es die Eigenschaft, die die Spezifikation tatsächlich behauptet

// Die Mitglieder eines SET OF durchlaufen und die X.690-11.6-Ordnung bestätigen.
// Members ist die an SetOfSorted übergebene Liste oder das Ergebnis des
// Neu-Parsens eines emittierten 31 xx / A0 xx-Blocks
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;

// Im Signiertest nicht bei »unser Verifier sagt gültig« aufhören.
// Die Bytes pinnen, die der Signierer gehasht hat
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');

Zwei Grenzen lohnen die klare Ansage. Das Sortieren von signedAttrs macht eine Signatur nicht von selbst interoperabel, denn alles andere im Container muss auch halten, und die Gründe, aus denen Validatoren sonst einwandfrei klingende PAdES-Signaturen ablehnen, sind vielfältig genug für eine eigene Diskussion. Und ein sortiertes SET OF ist eine Eigenschaft dessen, was Sie schreiben, nicht dessen, was Sie lesen: PDFium Component muss weiterhin eine eingehende Signatur akzeptieren, deren signedAttrs unsortiert ankommen, denn solche Dateien existieren in freier Wildbahn, und RFC 5652-Klausel 5.4 sagt einem Verifier, zu re-encoden statt abzulehnen. Streng am Output, tolerant am Input, wie üblich. Der DER-Writer, der CMS-Builder und der in dem PAdES-B-B-Walkthrough beschriebene Signaturpfad fahren alle als lesbarer Pascal-Quelltext mit dem PDFium Delphi component, was überhaupt der Punkt ist, Quelltext für eine Signaturbibliothek auszuliefern: Wenn ein Validator irgendwo in Europa mit Ihrem Dokument uneins ist, können Sie die exakten Bytes lesen, die Ihr Code emittiert hat, und die exakte Klausel, die sie verletzen, statt ein Ticket einzureichen und zu warten