← All posts

Une migration ES256, trois façons de massacrer une signature valide

Authagonal·July 20, 2026
jwtecdsaes256cryptovaultjwsdotnet

La migration d'algorithme elle-même s'est bien passée. La triple vie de ma clé de signature JWT raconte cette histoire : passage de RS256 à ES256 sur un émetteur en production, les deux clés publiées dans le JWKS pendant la période de transition, un sélecteur de clé active qui se détache de lui-même de l'ancienne clé RSA. Les clés, la rotation, la découverte, toutes les parties qui semblent difficiles, ont fonctionné du premier coup.

Voici l'histoire de la partie qui n'a pas fonctionné. À trois reprises pendant cette migration, notre serveur d'authentification a émis un jeton dont la signature était cryptographiquement valide (le calcul était juste, la bonne clé avait signé les bons octets) et qu'aucun validateur au monde n'aurait accepté. Trois couches différentes avaient chacune massacré les octets à leur manière, et chaque échec paraissait identique vu de l'extérieur : invalid signature. Pas « mauvais format », pas « longueur inattendue ». Juste : invalide.

Si vous êtes en train de fixer cette erreur en pleine migration, sautez directement au guide de terrain à la fin. La longueur de la signature décodée désigne la couche coupable presque à chaque fois.

RSA nous avait gâtés : un entier, un encodage

Voici ce que personne ne vous dit avant une migration ECDSA : RSA n'a jamais eu ce problème, donc aucun de vos réflexes ne se transpose.

Une signature RSA est un entier unique. Le format de transmission est simplement cet entier, en big-endian, complété jusqu'à la taille de la clé : 256 octets pour RSA-2048, et il n'existe pour l'essentiel qu'une seule façon de l'écrire. Signez avec n'importe quelle bibliothèque, vérifiez avec n'importe quelle autre, et les octets signifient la même chose.

Une signature ECDSA est une paire d'entiers, (r, s). Une paire a besoin d'un encodage, et l'écosystème s'est fixé sur deux encodages incompatibles :

  • ASN.1 DER : une SEQUENCE de deux INTEGER. Longueur variable (typiquement 70 à 72 octets pour P-256), commençant toujours par l'octet 0x30. C'est ce que parlent OpenSSL, X.509 et TLS.
  • R‖S brut (IEEE P1363) : r et s complétés à gauche chacun à exactement 32 octets, puis concaténés. Toujours exactement 64 octets. C'est ce que JWS impose pour ES256 (RFC 7518 §3.4).

Les deux encodent la même signature. Les deux sont « corrects ». Donnez à un validateur celui qu'il n'attend pas et il ne dira pas « mauvais conteneur » : il décode des entiers absurdes et signale la seule chose qu'il peut signaler : la signature ne se vérifie pas.

Chaque couche entre votre clé et le réseau a un avis sur celui des deux qu'elle parle. Nous avions trois couches, et nous nous sommes trompés sur les trois, l'une après l'autre.

Premier piège : le KMS répond en DER

Notre clé privée vit dans le moteur transit de HashiCorp Vault et n'en sort jamais : l'application envoie des octets à signer et reçoit une signature en retour. Quand nous avons fait passer la clé transit de rsa-2048 à ecdsa-p256, la requête de signature portait encore un vestige de l'ère RSA : signature_algorithm=pkcs1v15.

C'est un paramètre de padding RSA. Il n'a aucun sens pour une clé ECDSA. Vault n'a pas renvoyé d'erreur. Il a ignoré le paramètre sans objet, signé avec la clé de courbe elliptique, et répondu 200 avec une signature ECDSA parfaitement valide, dans son marshaling par défaut, c'est-à-dire DER.

Le chemin nominal est donc resté au vert de bout en bout : requête acceptée, signature renvoyée, openssl dgst -verify l'aurait bénie. Et chaque bibliothèque JWT a rejeté chaque jeton, parce qu'après avoir décodé le troisième segment en base64url elle attendait 64 octets de R‖S et trouvait 71 octets commençant par 0x30.

La correction tient en un paramètre de requête : l'endpoint de signature transit de Vault accepte marshaling_algorithm=jws, qui renvoie directement du R‖S brut. Mais notez la forme du piège : un paramètre inadapté au type de clé et silencieusement ignoré. La requête avait l'air fausse, était fausse, et fonctionnait quand même, pour une mauvaise définition de « fonctionner ».

Deuxième piège : « il suffit de concaténer les entiers » échoue sur un jeton sur 128

Si votre KMS ne sait pas émettre du R‖S nativement, vous serez tenté de convertir le DER vous-même : extraire les deux INTEGER, concaténer, terminé. C'est le plus vicieux des trois pièges, parce qu'il est correct par intermittence.

Les entiers DER sont de longueur minimale et signés. Cela signifie :

  • un r qui se trouve être numériquement petit (octet de poids fort à zéro) s'encode en 31 octets ou moins, car DER supprime les zéros de tête ;
  • un r dont le bit de poids fort est à 1 récupère un octet de signe 0x00 et s'encode en 33 octets.

Le format JWS ne veut ni l'un ni l'autre. Il veut chaque valeur complétée à gauche à exactement 32 octets, toujours. Une concaténation naïve des entiers DER produit des signatures de 63, 64, 65 ou 66 octets selon le tirage, et seuls les tirages à 64 octets se vérifient. Gérez l'octet de signe mais oubliez le padding, et vous vous retrouvez avec un convertisseur qui échoue dès que r ou s commence par un octet nul : chacun est un événement à 1 sur 256, donc environ un jeton sur 128 sort trop court.

Cette version passe votre test de fumée, passe la revue de code, part en production, puis fait échouer une connexion par heure, sans motif discernable. Nous l'avons évitée en confiant la conversion à Vault (marshaling_algorithm=jws, encore lui : laissez la partie qui possède l'opération de signature posséder aussi le format), mais le même piège a une variante purement locale en .NET : ECDsa.SignData/VerifyData prennent un DSASignatureFormat, et différents recoins du framework n'ont pas le même défaut : le monde SignedXml/certificats pense en Rfc3279DerSequence tandis que les API ECDsa brutes utilisent P1363 par défaut. Notre chemin de vérification local indique désormais DSASignatureFormat.IeeeP1363FixedFieldConcatenation explicitement. Depuis cette migration, nous ne laissons plus aucune API de signature choisir son format par défaut, nulle part.

Troisième piège : le correctif a changé le dialecte base64 sous nos pieds

Vault enveloppe chaque signature dans une enveloppe : vault:v1:<encoded-bytes>. Avec le marshaling DER par défaut, la partie encodée est du base64 standard, et notre client appelait consciencieusement Convert.FromBase64String dessus.

Passez à marshaling_algorithm=jws et Vault (en toute logique, vu la spécification qu'il parle désormais) encode la portion signature en base64url : l'alphabet -/_, sans padding. Un seul drapeau de requête avait changé deux choses à la fois : le format d'octets demandé, et l'encodage texte dans lequel il arrivait.

Convert.FromBase64String exige du base64 standard avec padding. Une signature de 64 octets fait 86 caractères base64url, ce qui n'est pas un multiple de quatre, donc le décodage levait une FormatException sur absolument chaque jeton. D'une certaine manière, ce fut le plus clément des trois échecs : au moins il plantait, bruyamment, à la ligne exacte qui était fausse, au lieu de produire des octets d'apparence valide qui échouaient discrètement ailleurs. La correction consiste à décoder (et, sur le trajet retour de vérification, ré-encoder) avec un codec base64url.

Mesurez l'ironie : la signature était enfin dans le bon format d'octets, et voilà que la couche texte la massacrait. Trois encodages, empilés : l'encodage de la paire d'entiers, le padding à champ fixe, et l'encodage des caractères. Chacun capable, à lui seul, de transformer une signature valide en jeton rejeté.

Le guide de terrain : la longueur désigne le coupable

Quand un JWT échoue à la validation en pleine migration d'algorithme, décodez en base64url le troisième segment du jeton et regardez ce que vous obtenez :

Signature décodée Couche fautive
Exactement 64 octets Le format de transmission est bon : cherchez ailleurs (kid non concordant, mauvaise clé dans le JWKS, épinglage de alg)
70 à 72 octets, premier octet 0x30 Du DER a fui : le KMS ou la bibliothèque cryptographique émet de l'ASN.1
63 ou 65 octets, et seuls certains jetons échouent Une conversion DER→brut artisanale sans padding à champ fixe
Le décodage lève une exception ou produit du charabia Confusion base64 / base64url une couche plus bas

Et une contre-vérification qui distingue en quelques secondes un bug de format d'un bug de clé : vérifiez la même signature avec OpenSSL, qui parle DER. Si OpenSSL dit valide alors que toutes les bibliothèques JWT disent invalide, vous avez un problème d'encodage. Si les deux disent invalide, vous avez un problème de clé.

La leçon

« Cryptographiquement valide » est une propriété des mathématiques. « Vérifiable » est une propriété de chaque frontière d'encodage entre les mathématiques et le consommateur, et notre signature en a traversé quatre : le marshaling du KMS, son enveloppe de transport, le décodage de notre client, et le format de transmission JWS. Trois de ces frontières avaient un avis, et chacune a contredit sa voisine exactement une fois.

La morale côté tests est celle qui nous a réellement fait changer nos processus : ne prouvez jamais un chemin de signature en le vérifiant avec votre propre code. Notre chemin de signature et notre chemin de vérification partageaient chacune de ces hypothèses, donc les allers-retours signer-puis-vérifier passaient pendant que chaque consommateur réel nous rejetait. Le test qui a finalement épinglé les trois bugs émet un vrai jeton, le valide avec la bibliothèque JWT standard de la plateforme contre le JWKS publié (les hypothèses de quelqu'un d'autre, pas les nôtres), puis affirme par-dessus une chose brutalement précise : la signature décodée fait exactement 64 octets. Les mathématiques se débrouilleront toutes seules. Ce sont les octets qu'il faut surveiller.