Ein Hex-Dump einer RSASSA-PSS-signatureAlgorithm, der mit A0 0F … A1 1C … 02 01 20 02 01 01 endet, sieht vollständig aus: SHA-256, MGF1, ein 32-Byte-Salt und ein Trailer von 1. Ein strenger Dekoder weist diesen Parameterblock rundherum zurück, ein nachgiebiger liest die Salt-Länge als 20. Wie auch immer: Eine Signatur, deren RSA-Arithmetik korrekt ist, kommt als ungültig zurück. RFC 4055 §3.1 erklärt warum: Jedes Feld der RSASSA-PSS-params trägt ein explizites Kontext-Tag, das Salt gehört also in A2, und unter DER darf ein trailerField, der seinem Default gleicht, gar nicht erst erscheinen
Das gibt einer CMS-Signatur zwei Hälften, die unabhängig voneinander scheitern können. Die eine ist die kryptografische Operation; die andere ist das ASN.1, das dem Verifizierer sagt, wie die Operation ausgeführt wurde. Die erste richtig und die zweite falsch — dann kann ein spezifikationstreuer Verifizierer Ihr Dokument nicht von einer Fälschung unterscheiden. Die ASN.1-Hälfte ist es, die dieser Artikel behandelt
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 Algorithmus-OID ableiten kann. PKCS#1 v1.5 ist durch sha256WithRSAEncryption vollends festgenagelt, seine Parameter sind ein nacktes NULL. PSS ist parametrisiert durch einen Nachrichten-Hash, eine Mask Generation Function mit eigenem Hash und eine Salt-Länge, und RFC 8017 §A.2.3 lässt alle drei dem Signer. Der AlgorithmIdentifier trägt die Entscheidungen des Signers, und der Verifizierer muss sie exakt reproduzieren, bevor EMSA-PSS-VERIFY laufen kann
Einen Block, den der Verifizierer nicht parsen kann, beendet die Verifikation, bevor irgendeine modulare Exponentiation passiert. Einen Block, den er anders parst, ist schlimmer. RFC 4055 §3.1 gibt saltLength einen Default von 20, ein Dekoder, der das Salt-Feld nie findet, fährt also EMSA-PSS-VERIFY mit einem 20-Byte-Salt gegen eine Signatur, die mit 32 gerechnet wurde. Gemeldet wird eine schlechte Signatur, ohne dass irgendetwas auf die Metadaten zeigt. Wie streng der Verifizierer ist, entscheidet, welcher der beiden Fehlschläge Sie sehen — keine der beiden Meldungen erwähnt den AlgorithmIdentifier
Die vier Felder der RSASSA-PSS-params und ihre Tags
RFC 4055 §3.1 definiert RSASSA-PSS-params als SEQUENCE aus vier Feldern, jedes mit einem expliziten kontextspezifischen Tag und einem DEFAULT-Wert. In DER wickelt explizites Tagging jedes Feld in einen konstruierten Kontext-TLV — A0 bis A3 — mit der universellen Kodierung des Felds selbst darin verschachtelt:
| Feld | Tag (DER-Byte) | DEFAULT | Wert nach ETSI TS 119 312 §7 | Kodiert? |
|---|---|---|---|---|
hashAlgorithm | [0] (A0) | sha1 | SHA-256 | Ja |
maskGenAlgorithm | [1] (A1) | mgf1SHA1 | MGF1 mit SHA-256 | Ja |
saltLength | [2] (A2) | 20 | 32 (die Digest-Länge) | Ja |
trailerField | [3] (A3) | trailerFieldBC (1) | 1 | Nein, er gleicht dem Default |
Die Tags sind da, weil jedes Feld über seinen Default optional ist. hashAlgorithm und maskGenAlgorithm sind beides AlgorithmIdentifier-SEQUENCEs; ohne Tag könnte ein Dekoder, der auf einen Parameterblock stößt, der eine einzelne SEQUENCE hält, nicht unterscheiden, welche der beiden er vor sich hat. Mit Tags benennt die Tag-Nummer das Feld, ganz gleich, welche Nachbarn der Encoder weggelassen hat
Wählen Sie ein Salt gleich der Digest-Länge und lassen Sie es dabei. Manche APIs bieten „maximales Salt“ oder „Salt-Länge auto“ an, was einen vom Schlüssel abhängigen Wert liefert; unter RFC 8017 legal, aber der AlgorithmIdentifier muss dann eine Zahl angeben, die sich von Schlüssel zu Schlüssel ändert — und ein 32-Byte-Salt für SHA-256 ist es, was ETSI TS 119 312 §7 ohnehin verlangt. PDFium Components eingebaute Signing-Backends nageln es alle auf 32 fest und reichen dieselbe Zahl an den Plattform-Aufruf durch: cbSalt im BCRYPT_PSS_PADDING_INFO für NCryptSignHash unter Windows, sLen im CK_RSA_PKCS_PSS_PARAMS für ein PKCS#11-Token, EVP_PKEY_CTX_set_rsa_pss_saltlen unter OpenSSL und der Digest-Längen-Salt, den kSecKeyAlgorithmRSASignatureDigestPSSSHA256 auf macOS impliziert
GetSignatureAlgorithmParams für den eigenen ICmsSigner schreiben
In PDFium Component implementiert jedes Signing-Backend das Interface ICmsSigner aus FPdfCms.pas, und der Parameterblock stammt aus einer Methode: GetSignatureAlgorithmParams. Der CMS-Builder paart ihr Ergebnis mit der OID aus GetSignatureAlgorithmOid über TDerWriter.AlgIdWithParams. Über diesen Weg stolpern Sie, wenn Sie einen eigenen Signer über ConfigurePadesSignerProvider anschließen — ein Netzwerk-HSM, ein Signing-Service, ein Smartcard-Treiber, den die eingebauten Backends nicht abdecken. Die Kodierung, die die meisten zuerst schreiben, taggt die zwei AlgorithmIdentifiers, weil die offensichtlich eine Umhüllung brauchen, und lässt die beiden Integer nackt:
// Ein verbreiteter erster Versuch: [0] und [1] getaggt, [2] und [3] nicht
Result := W.Sequence(Concat4(
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'),
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.IntegerOf(32), // nacktes INTEGER, wo [2] EXPLICIT verlangt ist
W.IntegerOf(1))); // trailerField gleich DEFAULT: muss fehlen
Gehen Sie ihn durch wie ein Dekoder. Er sieht A0 und liest den Hash, sieht A1 und liest die Mask Generation Function, dann begegnet er 02 01 20, einem universellen INTEGER. RSASSA-PSS-params hat nirgends ein ungetaggtes INTEGER-Member, ein strenger Dekoder stoppt also. Ein nachgiebiger überspringt das unbekannte Element, findet nie ein A2, veranschlagt den Default-Salt von 20 — und stolpert dann über einen zweiten herrenlosen INTEGER, 02 01 01. Kein Weg endet bei 32
Die konforme Variante nutzt drei Aufrufe von TDerWriter.ContextSpecific aus FPdfAsn1.pas, einen pro nicht-defaulteten Feld, jeweils mit Constructed auf True, sodass das Ergebnis eine explizite Umhüllung ist. Für den Trailer-Feld gibt es keine Zeile. Das ist derselbe Block, den die Backends Windows CNG, macOS Keychain, PKCS#11 und OpenSSL ausgeben, hier so hingeschrieben, wie ihn ein eigener Signer mitbringen würde:
uses
System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;
type
TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
private
FUsePss: Boolean;
public
function GetDigestAlgorithmOid: AnsiString;
function GetSignatureAlgorithmOid: AnsiString;
function GetSignatureAlgorithmParams: TBytes;
function GetSignerId: TCmsSignerId;
function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
end;
// Ihre eigene Klasse; die anderen vier Methoden sprechen mit Ihrem HSM und sind nicht gezeigt
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if not FUsePss then
Exit(nil); // v1.5 und ECDSA: AlgIdWithParams schreibt NULL für nil
W := TDerWriter.Create;
try
// RFC 4055 3.1: [0] Hash, [1] MGF1 mit seinem Hash, [2] Salt.
// [3] trailerField = 1 gleicht seinem DEFAULT, also lässt X.690 11.5 es 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;
TDerWriter.AlgId schreibt einen AlgorithmIdentifier mit NULL-Parametern, was RFC 4055 §2.1 den Encodern für die verschachtelten SHA-256-Identifier vorschreibt. Die entstehenden Bytes sind kurz genug, um sie per Auge zu prüfen: ein äußeres 30 34, dann A0 0F um den 15-Byte-SHA-256-AlgorithmIdentifier, A1 1C um den 28-Byte-MGF1-AlgorithmIdentifier und A2 03 02 01 20 für das Salt. Das Salt, das Sie in GetSignatureAlgorithmParams hinschreiben, muss zu dem passen, das Ihr HSM wirklich nutzt; meldet der Service nur „PSS mit SHA-256“ und verrät die Salt-Länge nicht, klären Sie das vor dem Ausliefern — der AlgorithmIdentifier ist eine Behauptung darüber
Warum wird trailerField weggelassen statt als [3] getaggt?
X.690 §11.5 sagt: Ein DER-Encoder soll keine Komponente kodieren, deren Wert ihrem DEFAULT gleicht. trailerField hat DEFAULT trailerFieldBC, die Integer 1, und PSS nutzt, wie RFC 8017 es definiert, nur diesen Trailer. Die naheliegende Reparatur des ersten Versuchs — W.IntegerOf(1) durch W.ContextSpecific(3, W.IntegerOf(1), True) zu ersetzen — erzeugt also A3 03 02 01 01, was ein nachsichtiger BER-Dekoder akzeptiert und ein strenger DER-Dekoder nach Kräften zurückweisen darf. Der Wert ist richtig. Seine Anwesenheit nicht
Dieselbe Regel erklärt, warum die anderen drei Felder auftauchen: SHA-256 ist nicht sha1, MGF1 mit SHA-256 ist nicht mgf1SHA1, und 32 ist nicht 20. Ein Signer mit SHA-1 und 20-Byte-Salt würde den ganzen Block auf eine leere SEQUENCE kollabieren lassen, 30 00. Diese Gestalt wirft jeden aus der Bahn, der davon ausgeht, „keine Parameter“ werde immer als 05 00 geschrieben; für id-RSASSA-PSS in CMS ist das Parameterfeld Pflicht, eine leere SEQUENCE heißt „alle Defaults“, und NULL ist kein gültiger RSASSA-PSS-params-Wert
BER erlaubt einem Encoder, eine Komponente mit Default-Wert einzuschließen; DER verbietet es, denn DER existiert, damit ein Wert exakt eine Kodierung hat — und über eine Signatur auf einer Struktur mit zwei legalen Kodierungen lässt sich streiten. Dem Parameterblock entkommt DER hier nicht. Er sitzt in der äußeren signatureAlgorithm des SignerInfo und wandert über das Attribut cmsAlgorithmProtection aus RFC 6211 in die signierten Attribute hinein, wo jedes Byte von der Signatur gedeckt ist
Eine CMS prüfen, die man nicht selbst gebaut hat
Diese Kopie in cmsAlgorithmProtection fängt einen falsch getaggten Block nicht. Das Attribut und die äußere signatureAlgorithm stammen aus demselben Aufruf, sie matchen also stets, und ein Strukturvergleich gegen sich selbst läuft durch. Was das Problem fängt, ist ein Dekoder, der das Parameterfeld Feld für Feld liest. PDFium Components InspectPadesSignatureAlgorithm in FPdfPades.pas tut das: Es akzeptiert nur A0 bis A3, jedes höchstens einmal und in aufsteigender Ordnung, ergänzt die RFC-4055-Defaults für alles Fehlende und meldet die effektiven Werte in TPadesRsaPssParameters. Ein nacktes INTEGER im Block lässt SignaturePss.Valid auf False stehen, und EvaluatePadesSignatureAlgorithm setzt darauf PolicyStatus auf pcsInvalid. Es weist zudem einen PSS-Hash zurück, der vom Digest-Algorithmus des SignerInfo abweicht — genau das, was ein nachgiebiger Rückfall auf sha1 produzierte
Am wichtigsten ist das beim Remote-Signing, wo ein externer Service ein fertiges CMS liefert und CompletePadesRemoteSignature es in die vorbereitete PDF einbettet. Prüfen Sie den Blob, bevor Sie ihn einbetten. Die Zurückweisung an dieser Stelle kostet einen API-Aufruf; der Kunde mit seinem Validator kostet eine erneute Signaturrunde mit dem, wer auch immer den Service betreibt:
uses
System.SysUtils, FPdfPades;
// Vorgeschlagener Check, den Sie selbst vor CompletePadesRemoteSignature ergänzen
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
Info: TPadesSignatureAlgorithmInfo;
Values: string;
begin
Result := False;
Why := '';
// nil: Der Inspektor soll das Signer-Zertifikat im CMS selbst finden
if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
begin
Why := 'CMS SignerInfo could not be parsed';
Exit;
end;
if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
begin
if not Info.SignaturePss.Valid then
begin
Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
Exit;
end;
// Effektive Werte, Defaults eingeschlossen: Ein fehlendes A2 erscheint hier als 20
Why := Format('hash %s, MGF hash %s, salt %d', [
string(Info.SignaturePss.HashAlgorithmOid),
string(Info.SignaturePss.MaskGenHashAlgorithmOid),
Info.SignaturePss.SaltLength]);
end;
if Info.PolicyStatus = pcsInvalid then
Exit; // Why hält fürs Log weiterhin die dekodierten Werte
Values := Why;
Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
if Result then
Why := Values; // Der Protection-Check löscht Why bei Erfolg
end;
Loggen Sie den Why-String auch bei Erfolg. Eine Salt-Länge von 20 neben einem SHA-256-Hash ist der Fingerabdruck eines Parameterblocks, dessen A2 verloren ging — was auch immer die Logs des Services sagen. Für einen flüchtigen Blick ohne Code druckt openssl asn1parse -inform DER -in signature.p7s den Baum; unter der id-RSASSA-PSS-OID wollen Sie cont [ 2 ] sehen, das einen INTEGER von 20 hex umwickelt, und kein cont [ 3 ] überhaupt. Ein Top-Level-INTEGER in dieser SEQUENCE ist die falsch getaggte Kodierung aus dem ersten Codeblock
Wo das in der übrigen PSS-Geschichte steht
Die Parameter-Kodierung ist einer von drei getrennten Wegen, an denen PSS in einer PAdES-Signatur scheitern kann, und sie auseinanderzuhalten verkürzt die Fehlersuche. Unter macOS bietet ein bestimmter Schlüssel oder ein älteres System PSS womöglich gar nicht an, und das Backend signiert dann mit PKCS#1 v1.5 und meldet passend sha256WithRSAEncryption; diese Fähigkeitsfrage behandelt PAdES-Signieren mit einer macOS-Keychain-Identität. Ein PKCS#11-Token kann CK_RSA_PKCS_PSS_PARAMS mit anderer Integer-Breite lesen, als der Aufrufer schrieb — eine ABI-Frage, behandelt in CK_ULONG und die PKCS#11-Packing-Falle. Der dritte ist der obige: Der Schlüssel will, das Token rechnet die richtigen Bytes, und das DER, das sie beschreibt, folgt nicht RFC 4055 §3.1
Das SET der signierten Attribute hat seine eigene Variante des Selbstkonsistenz-blinden Flecks, wo ein SET in einer Reihenfolge gehasht und in einer anderen ausgegeben bis zum fremden Verifizierer unauffällig bleibt, der den Hash nachrechnet; siehe CMS signedAttrs und DER SET OF sorting. Für den Zyklus aus Vorbereiten, remote signieren, Fertigstellen, in den der obige Check eingreift, behandelt Remote-PAdES-Signing mit einem HSM das Zwei-Phasen-Protokoll und was der Fertigstellungsschritt zurückweist
Die PDFium Delphi Component liefert ihre Signing-Backends als Quelltext aus — die GetSignatureAlgorithmParams-Leiber in den Units für Windows, macOS, PKCS#11 und OpenSSL stehen also zum Lesen und Dumpen bereit, Byte für Byte neben der Ausgabe Ihres eigenen Signers