Une signature, ce sont deux entiers
Quels que soient les octets qui circulent sur le réseau, une signature ECDSA n’est mathématiquement qu’un couple d’entiers positifs (r, s), chacun compris entre 1 et n − 1, où n est l’ordre de la courbe (un nombre proche de 2²⁵⁶ pour P-256 et secp256k1). Tout le reste relève de la sérialisation, et l’écosystème s’est fixé sur deux façons incompatibles de la réaliser. Aucune ne contient d’information absente de l’autre : la conversion de l’une à l’autre est sans perte et purement mécanique.
Format 1 : ASN.1 DER, dans la lignée de X.509
Le format qu’utilisent OpenSSL, les certificats X.509 et TLS vient du monde ASN.1 : le couple est encodé comme une SEQUENCE DER de deux INTEGER. Exemple annoté sur P-256 :
30 45 SEQUENCE, 0x45 = 69 bytes follow
02 21 INTEGER, 0x21 = 33 bytes (this is r)
00 c8 7f 3b ... 33 bytes: leading 00 because r's top bit is set
02 20 INTEGER, 0x20 = 32 bytes (this is s)
6a 12 09 ... 32 bytes: no padding needed, top bit is clear
Ce sont les règles de DER pour les entiers qui rendent le format un peu délicat :
- Les entiers sont en big-endian, en complément à deux et de longueur minimale : les octets nuls de tête doivent être supprimés.
- Mais les valeurs sont positives : si le bit de poids fort du premier octet est à 1 (premier octet ≥
0x80), un unique0x00doit être ajouté en tête pour que le nombre ne soit pas lu comme négatif.retspeuvent chacun, indépendamment l’un de l’autre, porter ou non cet octet de remplissage. - Le résultat est un encodage de longueur variable : pour P-256, généralement 70, 71 ou 72 octets (et parfois moins, quand
rouscommence par des octets nuls). Un code qui suppose une longueur de signature fixe est un bogue qui n’attend que la mauvaise valeur aléatoire. - DER est l’encodage distinctif (le D de DER) : une seule chaîne d’octets valide par valeur. Les analyseurs stricts rejettent les entiers non minimaux, un remplissage hors spécification ou des octets parasites en fin de chaîne ; Bitcoin a fait du DER strict une règle de consensus (BIP-66) précisément pour éliminer les divergences entre analyseurs.
Format 2 : r‖s brut, IEEE P1363, à longueur fixe
L’autre convention se passe complètement d’ASN.1 : on complète r et s chacun jusqu’à la taille exacte d’une coordonnée de la courbe, en big-endian, puis on les concatène. On parle souvent de format IEEE P1363 (d’après la norme qui l’utilisait), de format « brut » (raw) ou de r‖s « à plat » (flat) :
| Courbe | Taille d’un élément du corps | Taille de la signature brute |
|---|---|---|
| P-256 / secp256k1 | 32 octets | 64 octets |
| P-384 | 48 octets | 96 octets |
| P-521 | 66 octets | 132 octets |
Longueur fixe, aucune étiquette, aucune règle de remplissage : coupez le bloc en deux et vous avez vos entiers. C’est le format qu’émet WebCrypto, celui qu’impose la RFC 7518 §3.4 pour JWS (et donc pour les JWT ; les conséquences pratiques sont traitées dans ES256 vs RS256 pour les JWT), et la forme que manipulent en interne la plupart des piles blockchain : les signatures Ethereum sont le r‖s‖v de 65 octets (le couple brut plus un octet de récupération), tandis que Bitcoin, comme indiqué plus haut, encode les signatures de transaction en DER strict.
Le mode d’échec est symétrique et extrêmement courant : une signature brute de 64 octets passée à une API qui attend du DER échoue dans l’analyseur ASN.1 (0x30 attendu), tandis qu’un bloc DER d’environ 71 octets passé à WebCrypto ou à un vérificateur JWS n’a tout simplement pas la bonne longueur et échoue proprement avec un message générique « signature invalide ». Quand deux piles divergent, convertissez explicitement : le convertisseur DER ⇄ brut le fait dans les deux sens dans le navigateur et affiche les valeurs r et s décodées.
High-S, low-S, et pourquoi une signature valide est rejetée
Même avec des encodages identiques, une autre subtilité casse la vérification d’une pile à l’autre. ECDSA présente une symétrie intrinsèque : si (r, s) est une signature valide, alors (r, n − s) est aussi une signature valide du même message avec la même clé. Quiconque détient une signature peut la faire basculer vers l’autre forme sans la clé privée : les octets de la signature sont malléables, même si le contenu signé ne l’est pas.
Pour les protocoles qui identifient des données en hachant les octets des signatures (les identifiants de transaction Bitcoin en sont l’exemple type), cette malléabilité par des tiers pose un vrai problème. La solution est la forme canonique low-S : exiger s ≤ n/2 et, sinon, normaliser en remplaçant s par n − s. Bitcoin a adopté low-S comme règle de relais (proposée dans BIP-62 et spécifiée dans BIP-146), et la vérification de libsecp256k1 n’accepte que les signatures low-S, sauf si l’appelant les normalise d’abord. Certaines bibliothèques axées sur la sécurité, dans d’autres écosystèmes, produisent aussi du low-S par défaut et peuvent être configurées pour rejeter le high-S à la vérification.
Qui parle quoi : les choix par défaut des bibliothèques
| Bibliothèque / API | Format par défaut | L’autre format |
|---|---|---|
| OpenSSL (CLI et API EVP) | DER | Pas de mode brut intégré pour ECDSA ; convertir à l’extérieur ou assembler à partir des r et s d’ECDSA_SIG |
Node.js node:crypto | DER | Option dsaEncoding: "ieee-p1363" de sign/verify (disponible depuis la branche LTS Node 12) |
WebCrypto crypto.subtle | Brut (P1363) uniquement | Pas de prise en charge de DER : convertir avant/après |
Go crypto/ecdsa | Aucun des deux : Sign/Verify prennent r, s sous forme de *big.Int | SignASN1 / VerifyASN1 pour DER (Go 1.15+) ; construire le format brut avec FillBytes |
Python cryptography (pyca) | DER | Les fonctions utilitaires decode_dss_signature / encode_dss_signature exposent r et s |
Paquet Python ecdsa | Brut par défaut | Arguments sigencode_der / sigdecode_der |
| Java (JCA Signature) | DER (SHA256withECDSA) | SHA256withECDSAinP1363Format dans les JDK récents (9+) |
Quelques-uns de ces cas en code. Node.js peut émettre l’un ou l’autre encodage avec la même clé :
import { generateKeyPairSync, sign } from "node:crypto";
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const msg = Buffer.from("same signature, two encodings");
const der = sign("sha256", msg, privateKey);
const raw = sign("sha256", msg, { key: privateKey, dsaEncoding: "ieee-p1363" });
console.log(der.length); // 70..72 (varies per signature)
console.log(raw.length); // always 64
Go rend explicites les entiers sous-jacents, ce qui rend aussi la conversion évidente ; notez FillBytes pour les moitiés brutes de largeur fixe, complétées par des zéros :
der, err := ecdsa.SignASN1(rand.Reader, priv, digest) // ASN.1 DER
if err != nil { return err }
r, s, err := ecdsa.Sign(rand.Reader, priv, digest) // the integers themselves
if err != nil { return err }
raw := make([]byte, 64)
r.FillBytes(raw[:32]) // zero-pads on the left — critical for short values
s.FillBytes(raw[32:])
En Python, la bibliothèque cryptography de pyca signe en DER et fournit des fonctions utilitaires pour accéder aux entiers :
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
der_sig = private_key.sign(message, ec.ECDSA(hashes.SHA256())) # DER bytes
r, s = decode_dss_signature(der_sig)
raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") # 64-byte P1363
Liste de contrôle pour le débogage
Quand une signature ECDSA refuse de se vérifier entre deux systèmes, parcourez cette liste dans l’ordre :
- Test de longueur. Exactement 64/96/132 octets → brut. Commence par
0x30avec un octet de longueur plausible → DER. Tout le reste : mauvais décodage d’une couche externe (base64, hex) ou copier-coller tronqué. - Concordance des formats. L’API de vérification attend-elle ce que le signataire a produit ? Convertissez avec le convertisseur DER ⇄ brut plutôt que de signer à nouveau.
- Remplissage par des zéros. Si des signatures brutes échouent par intermittence (environ 1 fois sur 128 :
retscommencent chacun par un octet nul environ 1 fois sur 256), soupçonnez une conversion qui a supprimé les octets nuls de tête au lieu de compléterretsà gauche jusqu’à la largeur fixe. - High-S. Si les échecs sont intermittents, à environ 50 %, et que l’un des deux côtés fait intervenir une blockchain ou une pile renforcée en sécurité, comparez
sàn/2et normalisez. - Tout le reste (mauvais condensat, mauvaise courbe, hachage du condensat) relève de la couche de l’algorithme, pas de celle du format ; pour ces notions, commencez par ECDSA vs RSA.
Le bilan est encourageant : DER ou brut, c’est de la pure plomberie. La conversion ne fait intervenir aucune cryptographie, rien ne change dans la clé, et dès que les deux côtés s’accordent sur la disposition des octets (et sur low-S, là où cela compte), la « signature invalide » qui vous a coûté un après-midi se révèle généralement avoir été valide depuis le début.