← All posts

Eine ES256-Migration, drei Wege, eine gültige Signatur zu verstümmeln

Authagonal·July 20, 2026
jwtecdsaes256cryptovaultjwsdotnet

Die Algorithmus-Migration selbst lief problemlos. Die drei Leben meines JWT-Signaturschlüssels erzählt diese Geschichte: RS256 zu ES256 auf einem laufenden Issuer, beide Schlüssel während der Übergangsphase im JWKS veröffentlicht, ein Active-Key-Picker, der sich selbst vom alten RSA-Schlüssel wegrotiert. Schlüssel, Rotation, Discovery, also alle Teile, die schwierig klingen, funktionierten auf Anhieb.

Dies ist die Geschichte des Teils, der das nicht tat. Dreimal während dieser Migration prägte unser Auth-Server ein Token, dessen Signatur kryptographisch gültig war (die Mathematik stimmte, der richtige Schlüssel hatte die richtigen Bytes signiert), und kein Validator der Welt hätte es akzeptiert. Drei verschiedene Schichten hatten die Bytes jeweils auf ihre eigene Weise verstümmelt, und jeder Fehler sah von außen identisch aus: invalid signature. Nicht "falsches Format", nicht "unerwartete Länge". Einfach nur: invalid.

Wenn Sie mitten in einer Migration auf genau diesen Fehler starren, springen Sie direkt zum Feldführer am Ende. Die Länge der dekodierten Signatur benennt die schuldige Schicht fast jedes Mal.

RSA hat uns verwöhnt: eine Ganzzahl, eine Kodierung

Hier ist, was einem vor einer ECDSA-Migration niemand sagt: RSA hatte dieses Problem nie, also lässt sich keine Ihrer Intuitionen übertragen.

Eine RSA-Signatur ist eine einzelne Ganzzahl. Das Wire-Format ist einfach diese Ganzzahl, Big-Endian, auf die Schlüsselgröße aufgefüllt: 256 Bytes für RSA-2048, und es gibt im Wesentlichen nur eine Art, sie aufzuschreiben. Signieren Sie mit einer beliebigen Bibliothek, verifizieren Sie mit einer beliebigen anderen, und die Bytes bedeuten dasselbe.

Eine ECDSA-Signatur ist ein Paar von Ganzzahlen, (r, s). Ein Paar braucht eine Kodierung, und das Ökosystem hat sich auf zwei inkompatible geeinigt:

  • ASN.1 DER: eine SEQUENCE aus zwei INTEGERn. Variable Länge (typischerweise 70 bis 72 Bytes für P-256), beginnt immer mit dem Byte 0x30. Das ist die Sprache von OpenSSL, X.509 und TLS.
  • Raw R‖S (IEEE P1363): r und s werden jeweils linksseitig auf genau 32 Bytes aufgefüllt und aneinandergehängt. Immer exakt 64 Bytes. Das schreibt JWS für ES256 vor (RFC 7518 §3.4).

Beide kodieren dieselbe Signatur. Beide sind "korrekt". Geben Sie einem Validator die Variante, die er nicht erwartet, und er sagt nicht "falscher Container": er dekodiert unsinnige Ganzzahlen und meldet das Einzige, was er melden kann: die Signatur verifiziert nicht.

Jede Schicht zwischen Ihrem Schlüssel und der Leitung hat eine Meinung dazu, welche der beiden sie spricht. Bei uns waren es drei Schichten, und wir haben alle drei falsch gemacht, nacheinander.

Footgun eins: das KMS antwortet in DER

Unser privater Schlüssel lebt in der Transit-Engine von HashiCorp Vault und verlässt sie nie: die Anwendung schickt Bytes zum Signieren und bekommt eine Signatur zurück. Als wir den Transit-Schlüssel von rsa-2048 auf ecdsa-p256 umstellten, trug der Sign-Request noch ein Überbleibsel aus der RSA-Ära: signature_algorithm=pkcs1v15.

Das ist ein RSA-Padding-Parameter. Für einen ECDSA-Schlüssel ist er bedeutungslos. Vault warf keinen Fehler. Es ignorierte den irrelevanten Parameter, signierte mit dem Kurvenschlüssel und antwortete mit 200 und einer vollkommen gültigen ECDSA-Signatur, in seinem Standard-Marshaling, also DER.

So blieb der Happy Path durchgehend grün: Request akzeptiert, Signatur zurückgeliefert, openssl dgst -verify hätte sie abgesegnet. Und jede JWT-Bibliothek lehnte jedes Token ab, denn nach dem base64url-Dekodieren des dritten Segments erwartete sie 64 Bytes R‖S und fand 71 Bytes, die mit 0x30 begannen.

Der Fix ist ein einziger Request-Parameter: der Transit-Sign-Endpunkt von Vault akzeptiert marshaling_algorithm=jws, was direkt rohes R‖S zurückgibt. Aber beachten Sie die Form der Falle: ein Parameter, der für den Schlüsseltyp falsch ist und stillschweigend ignoriert wird. Der Request sah falsch aus, war falsch und funktionierte trotzdem, nach der falschen Definition von funktionieren.

Footgun zwei: "einfach die Ganzzahlen aneinanderhängen" scheitert bei einem von 128 Tokens

Wenn Ihr KMS R‖S nicht nativ ausgeben kann, sind Sie versucht, DER selbst zu konvertieren: die beiden INTEGER herausparsen, aneinanderhängen, fertig. Das ist die fieseste Falle der drei, weil sie intermittierend korrekt ist.

DER-Ganzzahlen sind minimal lang und vorzeichenbehaftet. Das heißt:

  • ein r, das zufällig numerisch klein ist (höchstes Byte null), kodiert in 31 Bytes oder weniger, weil DER führende Nullen entfernt;
  • ein r, dessen höchstes Bit gesetzt ist, bekommt ein 0x00-Vorzeichenbyte dazu und kodiert in 33 Bytes.

Das JWS-Format will keins von beidem. Es will jeden Wert linksseitig auf genau 32 Bytes aufgefüllt, immer. Eine naive Konkatenation der DER-Ganzzahlen erzeugt je nach Zufall Signaturen von 63, 64, 65 oder 66 Bytes, und nur die 64-Byte-Fälle verifizieren. Behandeln Sie das Vorzeichenbyte, aber vergessen Sie das Padding, dann bleibt ein Konverter übrig, der immer dann versagt, wenn r oder s mit einem Nullbyte beginnt: jeweils ein 1-zu-256-Ereignis, also kommt etwa eines von 128 Tokens zu kurz heraus.

Diese Version besteht Ihren Smoke-Test, besteht das Code-Review, geht live und lässt dann in der Produktion einen Login pro Stunde fehlschlagen, ohne erkennbares Muster. Wir sind ihr ausgewichen, indem wir Vault die Konvertierung machen ließen (wieder marshaling_algorithm=jws: die Partei, der die Signieroperation gehört, soll auch das Format besitzen), aber dieselbe Falle hat eine rein lokale Variante in .NET: ECDsa.SignData/VerifyData nehmen ein DSASignatureFormat entgegen, und verschiedene Ecken des Frameworks haben unterschiedliche Defaults: die Welt von SignedXml und Zertifikaten denkt in Rfc3279DerSequence, während die rohen ECDsa-APIs standardmäßig P1363 verwenden. Unser lokaler Verifikationspfad sagt jetzt explizit DSASignatureFormat.IeeeP1363FixedFieldConcatenation. Seit dieser Migration lassen wir keine Signatur-API mehr irgendwo ihr Format per Default wählen.

Footgun drei: der Fix wechselte unter uns den base64-Dialekt

Vault verpackt jede Signatur in einen Umschlag: vault:v1:<encoded-bytes>. Mit dem Standard-DER-Marshaling ist der kodierte Teil Standard-base64, und unser Client rief darauf brav Convert.FromBase64String auf.

Wechseln Sie zu marshaling_algorithm=jws, und Vault kodiert (naheliegend, gemäß der Spezifikation, die es jetzt spricht) den Signaturteil in base64url: das Alphabet mit -/_, ohne Padding. Ein einziges Request-Flag hatte zwei Dinge auf einmal geändert: das Byte-Format, das wir angefordert hatten, und die Textkodierung, in der es ankam.

Convert.FromBase64String will Standard-base64 mit Padding. Eine 64-Byte-Signatur ergibt 86 base64url-Zeichen, kein Vielfaches von vier, also warf das Dekodieren bei jedem einzelnen Token eine FormatException. In gewisser Weise war das der gnädigste der drei Fehler: er crashte wenigstens, laut, genau an der Zeile, die falsch war, statt gültig aussehende Bytes zu erzeugen, die anderswo leise scheiterten. Der Fix ist, mit einem base64url-Codec zu dekodieren (und auf dem Verify-Rückweg wieder zu enkodieren).

Zählen Sie die Ironie mit: die Signatur war endlich im richtigen Byte-Format, und nun verstümmelte die Text-Schicht sie. Drei Kodierungen, übereinandergestapelt: die Kodierung des Ganzzahlpaars, das Fixed-Field-Padding und die Zeichenkodierung. Jede einzelne für sich in der Lage, eine gültige Signatur in ein abgelehntes Token zu verwandeln.

Der Feldführer: die Länge benennt den Schuldigen

Wenn ein JWT mitten in einer Algorithmus-Migration die Validierung nicht besteht, dekodieren Sie das dritte Segment des Tokens per base64url und schauen Sie, was Sie vor sich haben:

Dekodierte Signatur Welche Schicht sie verstümmelt hat
Genau 64 Bytes Das Wire-Format stimmt, suchen Sie woanders (kid-Mismatch, falscher Schlüssel im JWKS, alg-Pinning)
70 bis 72 Bytes, erstes Byte 0x30 DER ist durchgesickert: das KMS oder die Krypto-Bibliothek gibt ASN.1 aus
63 oder 65 Bytes, und nur manche Tokens scheitern Eine handgestrickte DER→Raw-Konvertierung ohne Fixed-Field-Padding
Das Dekodieren wirft eine Exception oder liefert Müll base64-vs.-base64url-Mismatch eine Schicht tiefer

Und ein Gegencheck, der Formatfehler in Sekunden von Schlüsselfehlern trennt: verifizieren Sie dieselbe Signatur mit OpenSSL, das DER spricht. Wenn OpenSSL "gültig" sagt, während jede JWT-Bibliothek "ungültig" sagt, haben Sie ein Kodierungsproblem. Sagen beide "ungültig", haben Sie ein Schlüsselproblem.

Die Lektion

"Kryptographisch gültig" ist eine Eigenschaft der Mathematik. "Verifizierbar" ist eine Eigenschaft jeder Kodierungsgrenze zwischen der Mathematik und dem Konsumenten, und unsere Signatur überquerte vier davon: das Marshaling des KMS, dessen Transport-Umschlag, das Dekodieren unseres Clients und das JWS-Wire-Format. Drei dieser Grenzen hatten Meinungen, und jede widersprach ihrem Nachbarn genau einmal.

Die Test-Moral ist die, bei der wir unseren Prozess tatsächlich geändert haben: beweisen Sie einen Signierpfad nie, indem Sie mit Ihrem eigenen Code verifizieren. Unser Signierpfad und unser Verifikationspfad teilten jede einzelne dieser Annahmen, also bestanden Sign-then-Verify-Roundtrips, während jeder echte Konsument uns ablehnte. Der Test, der schließlich alle drei Bugs festnagelte, prägt ein echtes Token, validiert es mit der Standard-JWT-Bibliothek der Plattform gegen das veröffentlichte JWKS (die Annahmen von jemand anderem, nicht unsere) und prüft dann obendrauf eine brutal spezifische Sache: die dekodierte Signatur ist exakt 64 Bytes lang. Die Mathematik kümmert sich um sich selbst. Auf die Bytes müssen Sie aufpassen.