PDFium Component Version 3.114.20 korrigiert die RSASSA-PSS-params-Kodierung in allen drei PAdES-Signatur-Backends, Windows CNG, macOS Keychain und PKCS#11. RFC 4055 §3.1 gibt jedem Feld der RSASSA-PSS-params ein explizites kontextspezifisches Tag, [0] bis [3], und die Backends gaben saltLength als nackten universellen INTEGER aus, während sie ein trailerField schrieben, das seinem Default entsprach. Die Signatur-Bytes waren die ganze Zeit korrekt. Der AlgorithmIdentifier, der sie beschreibt, war es nicht, und das allein reicht einem Verifizierer, um die Signatur abzulehnen
Das Frustrierende ist, wo der Bug sich versteckt. Eine CMS-Signatur hat zwei Hälften, die kryptografische Operation und das ASN.1, das dem Verifizierer sagt, wie die Operation ausgeführt wurde. Die erste richtig und die zweite falsch, und das Ergebnis ist ein Dokument, das kein specificationsgetreues Werkzeug von einer Fälschung unterscheiden kann. Dieser Artikel handelt nur von dieser zweiten Hälfte: wie RSASSA-PSS-params getaggt werden müssen, wie drei Backends es auf dieselbe Weise falsch gemacht haben und wie das korrigierte DER in TDerWriter-Begriffen aussieht
Warum lehnt ein Verifizierer eine RSASSA-PSS-Signatur ab, deren Bytes korrekt sind?
Weil RSASSA-PSS das eine RSA-Schema ist, bei dem der Verifizierer die Parameter nicht aus der Signatur selbst zurückgewinnen kann. PKCS#1-v1.5-Padding ist vollständig durch die OID sha256WithRSAEncryption bestimmt, seine Parameter sind ein nacktes NULL, und es gibt nichts falsch zu machen. PSS ist parametrisiert über eine Hash-Funktion, eine Mask-Generation-Function mit eigenem Hash und eine Salt-Länge, und RFC 8017 §A.2.3 lässt alle drei offen. Der Signierer wählt sie, der AlgorithmIdentifier trägt sie, und der Verifizierer muss sie exakt reproduzieren, bevor EMSA-PSS-VERIFY überhaupt beginnen kann
Wenn PDFium Component also mit SHA-256, MGF1 über SHA-256 und einem 32-Byte-Salt signiert, müssen diese drei Fakten die DER-Kodierung hier und das DER-Decoding auf einer anderen Implementierung überleben. Ein Parameterblock, den der Verifizierer nicht parsen kann, beendet die Verifizierung, bevor irgendeine modulare Exponentiation passiert. Ein Block, den er anders parst, ist schlimmer, denn RFC 4055 §3.1 gibt saltLength einen Default von 20. Ein Decoder, der ein Feld überspringt, das er nicht erkennt, landet auf diesem Default, lässt EMSA-PSS-VERIFY mit einem 20-Byte-Salt gegen eine mit 32 berechnete Signatur laufen und meldet eine schlechte Signatur ohne jeden Hinweis, dass das Problem Metadaten sind und nicht der Schlüssel. Beide Ergebnisse produzierte die 3.114.19-Kodierung, je nachdem, wie streng der Verifizierer war, und keines davon zeigt auf den AlgorithmIdentifier
Was RFC 4055 §3.1 wirklich von RSASSA-PSS-params verlangt
RFC 4055 §3.1 definiert RSASSA-PSS-params als SEQUENCE aus vier Feldern, jedes mit einem expliziten kontextspezifischen Tag und einem DEFAULT-Wert:
// RSASSA-PSS-params ::= SEQUENCE {
// hashAlgorithm [0] HashAlgorithm DEFAULT sha1,
// maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1,
// saltLength [2] INTEGER DEFAULT 20,
// trailerField [3] TrailerField DEFAULT trailerFieldBC
// }
Explizites Tagging heißt in DER, dass jedes Feld in eine konstruierte kontextspezifische TLV gehüllt ist, A0 für [0], A1 für [1], A2 für [2] und A3 für [3], mit der universellen Kodierung des Werts darin verschachtelt. Jedes Feld ist getaggt, gerade weil jedes Feld über seinen Default optional ist. Ohne Tags könnte ein Decoder nicht unterscheiden, ob eine SEQUENCE mit einem einzigen AlgorithmIdentifier hashAlgorithm oder maskGenAlgorithm trägt, denn beide sind SEQUENCE-Typen; mit ihnen identifiziert die Tag-Nummer das Feld, ganz gleich, welche Nachbarn vorhanden sind. Die Werte, die PDFium Component ausgibt, folgen dem Profil in ETSI TS 119 312 §7 – SHA-256, MGF1 mit SHA-256 und ein Salt in Digest-Länge – und sie spiegeln exakt, was jeder Plattform-Signaturaufruf mitgeteilt bekommt: eine BCRYPT_PSS_PADDING_INFO mit cbSalt 32 für NCryptSignHash, eine CK_RSA_PKCS_PSS_PARAMS mit sLen 32 für den PKCS#11-Mechanismus und der SHA-256-Digest-Signing-PSS-Algorithmus im Security-Framework
Wie drei Backends denselben Fehler machten
Die 3.114.19-Kodierung taggte die ersten beiden Felder und ließ die letzten beiden nackt, identisch in TWinCmsSigner, TKeychainCmsSigner und TPkcs11CmsSigner. Diese Symmetrie ist kein Zufall: Alle drei implementieren das ICmsSigner-Interface aus FPdfCms.pas, und ihre GetSignatureAlgorithmParams-Bodies waren nach einer Vorlage geschrieben. Die Vorlage las sich so:
// Vor 3.114.20: [0] und [1] getaggt, [2] und [3] nicht
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), // nacktes INTEGER, wo [2] EXPLICIT gefordert war
W.IntegerOf(1))); // trailerField, dem DEFAULT gleich, muss fehlen
Ein Decoder, der diese SEQUENCE abläuft, sieht A0, liest den Hash-Algorithmus, sieht A1, liest die Mask-Generation-Function und trifft dann auf 02 01 20. Das ist ein universeller INTEGER, und RSASSA-PSS-params hat nirgends ein untagged INTEGER-Member. Ein strenger Decoder stoppt dort. Ein nachsichtiger überspringt das unerkannte Element, findet nie ein A2, weist saltLength seinen Default 20 zu, läuft dann in einen zweiten verirrten INTEGER, 02 01 01, und hat dasselbe Problem erneut. Kein Weg führt zu einem 32-Byte-Salt. Eine gemeinsame Vorlage ist effizient, solange sie richtig ist, und ein genauso effizienter Weg, sich dreimal zu irren, wenn nicht – deshalb landete der Fix in allen drei Units in einem Commit, und deshalb bleiben die drei Methodenbodies danach strukturell identisch. Ein künftiges Backend sollte den Block von einem dieser kopieren, statt ihn neu abzuleiten, denn die Ableitung ist genau die Stelle, an der der Fehler passierte
Warum wird trailerField weggelassen statt als [3] getaggt?
Weil X.690 §11.5 sagt, ein DER-Encoder solle keine Komponente kodieren, deren Wert ihrem DEFAULT entspricht, und trailerField hat DEFAULT trailerFieldBC, was die ganze Zahl 1 ist. Die naheliegende Korrektur des alten Codes, das nackte W.IntegerOf(1) durch W.ContextSpecific(3, W.IntegerOf(1), True) zu ersetzen, liefert einen Block, den ein nachsichtiger BER-Decoder akzeptiert und den ein strenger DER-Decoder berechtigt ist abzulehnen. Der Wert ist nicht falsch. Seine Anwesenheit ist es. Dieselbe Regel ist der Grund, warum die anderen drei Felder doch da sind: SHA-256 ist nicht der Default sha1, MGF1 mit SHA-256 ist nicht der Default mgf1SHA1, und 32 ist nicht der Default 20. Hätte das Backend mit SHA-1 und einem 20-Byte-Salt signiert, würde RFC 4055 §3.1 die Parameter auf eine leere SEQUENCE kollabieren lassen, 30 00, und diese leere SEQUENCE statt NULL erwartet ein Verifizierer. PDFium Component gibt diese Form nie aus, weil es nie mit diesen Werten signiert, aber es ist der Fall, der jeden erwischt, der annimmt, „keine Parameter“ schreibe sich stets 05 00
Das ist der Unterschied zwischen DER und BER, der gerade für Signaturen zählt. BER erlaubt dem Encoder, eine Komponente mit Default-Wert einzuschließen; DER verbietet es, denn DER existiert, damit ein Wert exakt eine Kodierung hat, und eine Signatur über eine Struktur mit zwei legalen Kodierungen ist eine Signatur, über die man streiten kann. Alles in CMS signedAttrs ist aus diesem Grund DER, und der Parameterblock reist innerhalb von signedAttrs über das cmsAlgorithmProtection-Attribut ebenso wie im äußeren signatureAlgorithm, bekommt also keine Ausnahme
Die korrigierte TDerWriter-Kodierung
PDFium Component baut die Parameter jetzt mit drei Aufrufen von TDerWriter.ContextSpecific aus FPdfAsn1.pas auf, einem pro Nicht-Default-Feld, jeweils mit dem Constructed-Argument auf True, um den Explizit-Tag-Wrapper zu erzeugen, und ohne jede Zeile für das Trailer-Feld. Dies ist der Body von TWinCmsSigner.GetSignatureAlgorithmParams mit ausgeschriebenen OIDs; die Keychain- und PKCS#11-Units schreiben dieselben Werte als OID_SHA256, OID_MGF1 und OID_RSASSA_PSS:
function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if FPaddingScheme = psRsaPss then
begin
W := TDerWriter.Create;
try
// RFC 4055 3.1 taggt alle vier Felder. saltLength ist [2]; ein
// nacktes INTEGER hier wird als Anfang eines weiteren Felds gelesen.
// trailerField ist [3] mit DEFAULT 1, und X.690 11.5 verbietet es,
// einen dem Default gleichen Wert zu kodieren, also bleibt er weg.
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 und ECDSA: AlgIdWithParams schreibt NULL
end;
Zwei Details der umgebenden Mechanik zählen. TDerWriter.AlgId erzeugt einen AlgorithmIdentifier mit NULL-Parametern, was RFC 4055 §2.1 Encoder für das verschachtelte hashAlgorithm und den MGF1-Innenhash generieren lässt. Und der CMS-Builder in FPdfCms.pas paart die Signatur-OID über TDerWriter.AlgIdWithParams mit diesen Bytes, das NULL einsetzt, wenn die Params nil sind; deshalb geben psRsaPkcs1v15 und psEcdsa schlicht nil zurück und waren nie betroffen, und deshalb ist 1.2.840.113549.1.1.10, id-RSASSA-PSS, die einzige der drei Signatur-OIDs mit einem echten Parameterblock. Die entstehenden Bytes für das SHA-256-Profil sind fix und kurz genug, um sie mit dem Auge zu prüfen: eine äußere 30 34-SEQUENCE, die A0 0F um den 15-Byte-SHA-256-AlgorithmIdentifier hält, A1 1C um den 28-Byte-MGF1-AlgorithmIdentifier, dessen eigene Parameter ebendieser SHA-256-AlgorithmIdentifier sind, und A2 03 02 01 20 für den Salt. Zeigt ein Dump Ihres signatureAlgorithm ein 02 01 20 auf der obersten Ebene der Params-SEQUENCE statt innerhalb eines A2, schauen Sie auf die 3.114.19-Kodierung
Warum erwischte die Testsuite keinen missgestalteten AlgorithmIdentifier?
Weil die PAdES-Tests den CMS-Builder über einen Fake-Signer antreiben, der sha256WithRSAEncryption meldet und aus GetSignatureAlgorithmParams nil zurückgibt, wurde der PSS-Parameterblock in einem Test nie konstruiert. Das ist ein vernünftiges Design für Tests, die ohne Zertifikatsspeicher, Keychain oder Token laufen müssen, und es hat einen toten Winkel mit exakter Kontur: Alles, was nur ein echtes Backend produziert, wird auch nur von einem echten Backend geübt. Die zweite Schicht ist interessanter. PDFium Component platziert den Signatur-AlgorithmIdentifier, Parameter eingeschlossen, auch innerhalb des cmsAlgorithmProtection-Signed-Attributs aus RFC 6211, und ein Verifizierer vergleicht diese Kopie gegen den äußeren signatureAlgorithm. Beide Kopien kamen vom selben Aufruf, passten also perfekt, und jede interne Konsistenzprüfung ging durch. Die Kodierung war selbstkonsistent und falsch – die Kategorie von Bug, die kein noch so häufiges Vergleichen einer Struktur mit sich selbst enthüllt. Dieselbe Lektion mit einer anderen Struktur erzählt der Artikel zu CMS signedAttrs und DER SET OF sorting, wo ein SET in einer Reihenfolge gehasht und in einer anderen ausgegeben wurde und solange gut aussah, bis ein fremder Verifizierer den Hash nachrechnete
Was diese Klasse von Bug erwischen kann, ist ein Decoder, der nicht vom Autor des Encoders geschrieben wurde, ausgeführt gegen die tatsächliche Ausgabe des tatsächlichen Backends. Der Windows-Verifizierungspfad in PDFium Component geht über CryptoAPI statt über den eigenen Reader der Bibliothek, und eine dort abgelehnte PSS-Signatur war es, die zu den Parametern zurückführte. Jedes ASN.1, das eine Implementierung für andere Implementierungen zum Lesen ausgibt, verdient mindestens einen Round Trip durch einen Decoder, den sie nicht kontrolliert, und je mehr Defaults und Tags die Struktur hat, desto mehr ist dieser Round Trip wert
Wo das in der übrigen PSS-Geschichte steht
Dieser Fix ist unabhängig von den zwei anderen Stellen, an denen PSS in einer PAdES-Signatur schiefgehen kann, und sie getrennt zu halten verkürzt die Fehlersuche. Das macOS-Backend kann feststellen, dass ein bestimmter Schlüssel oder ein älteres System PSS verweigert und auf PKCS#1 v1.5 zurückstuft, und der AlgorithmIdentifier muss dem Downgrade folgen; das ist eine Capability-Frage, behandelt in PAdES-Signierung mit einer macOS-Keychain-Identität. Das PKCS#11-Backend kann dem Token eine CK_RSA_PKCS_PSS_PARAMS geben, deren Layout das Token wegen einer Integer-Breiten-Diskrepanz anders liest; das ist eine ABI-Frage, behandelt in CK_ULONG und der PKCS#11-Packing-Falle. Dieser Artikel handelt vom dritten Fehler, bei dem der Schlüssel willens war, das Token die richtigen Bytes berechnete und das DER, das das Ergebnis beschreibt, nicht RFC 4055 §3.1 entsprach
Ein Signierer, der PSS deklariert, übernimmt eine Verpflichtung, die der v1.5-Signierer nie hatte: seine eigenen Parameter in einer Form zu beschreiben, die eine andere Implementierung zu denselben drei Werten dekodiert. RFC 4055 §3.1 legt die Tags fest, X.690 §11.5 legt fest, welche Felder erscheinen dürfen, und ETSI TS 119 312 §7 legt die Werte fest, die man wählen sollte. Alle drei Backends des PDFium Delphi component werden als Quelle ausgeliefert, der GetSignatureAlgorithmParams-Body oben lässt sich also lesen, dumpen und gegen den eigenen Verifizierer vergleichen, statt ihn auf Treu und Glauben zu nehmen