Le module crypto de Node.js 22 prend en charge ECDSA et Ed25519 nativement, sans aucune dépendance externe. Une clé ECDSA P-256 offre le même niveau de sécurité qu’une clé RSA de 3 072 bits, avec une taille 12 fois plus petite. Ed25519 génère environ 120 000 signatures par seconde contre 2 000 pour RSA-2048, soit un facteur 60. Ce tutoriel construit pas à pas un service de signature numérique complet, de la génération des clés jusqu’au déploiement d’une API REST sécurisée.
Pourquoi ECDSA et Ed25519 supplantent RSA en 2026
RSA a dominé la cryptographie asymétrique pendant 45 ans. En 2026, il reste présent dans les systèmes legacy, mais trois tendances l’ont contraint à reculer. TLS 1.3 (RFC 8446) a supprimé les suites de chiffrement RSA sans forward secrecy. GitHub exige depuis 2022 les clés Ed25519 pour les nouveaux comptes SSH. Le standard FIDO2/WebAuthn utilise ECDSA P-256 comme algorithme primaire pour l’authentification sans mot de passe.
La cryptographie sur courbes elliptiques (ECC) repose sur un problème mathématique distinct du factoring RSA : le problème du logarithme discret sur une courbe elliptique (ECDLP). Aucun algorithme classique connu ne résout ce problème en temps polynomial pour les courbes standardisées. Une clé ECC de 256 bits offre 128 bits de sécurité, identique à RSA-3072 avec une taille 12 fois inférieure. Selon le rapport NIST SP 800-57 mis à jour en 2025, RSA-2048 fournit seulement 112 bits de sécurité et devra être retiré des systèmes fédéraux américains d’ici 2030.
| Algorithme | Taille de clé | Sécurité (bits) | Signatures/s (Intel Xeon) | Cas d’usage typique |
|---|---|---|---|---|
| RSA-2048 | 2 048 bits | 112 bits | ~2 000 | Legacy TLS, PGP ancien |
| RSA-4096 | 4 096 bits | 140 bits | ~400 | Certificats CA racine |
| ECDSA P-256 | 256 bits | 128 bits | ~35 000 | TLS 1.3, JWT, FIDO2, code signing |
| ECDSA P-384 | 384 bits | 192 bits | ~18 000 | Secteur défense, gouvernement |
| Ed25519 | 256 bits | 128 bits | ~120 000 | SSH, WireGuard, GPG, npm provenance |
ECDSA (Elliptic Curve Digital Signature Algorithm) et EdDSA (Edwards-curve Digital Signature Algorithm) sont deux familles distinctes. ECDSA P-256 utilise la courbe NIST P-256 (aussi appelée secp256r1 ou prime256v1) et produit des signatures DER encodées. Ed25519 utilise la courbe Edwards Curve 25519 conçue par Daniel Bernstein, avec une résistance prouvée contre les attaques par canaux auxiliaires. Le standard RFC 8032 définit Ed25519 comme une implémentation EdDSA sur Curve25519 avec SHA-512. Ces deux algorithmes sont désormais recommandés par l’ANSSI pour les applications civiles françaises, tandis que P-384 reste requis pour les informations classifiées.
Prérequis
Ce tutoriel requiert un environnement à jour. Aucune bibliothèque externe n’est nécessaire pour les 10 premières étapes : Node.js intègre OpenSSL 3.x depuis la version 18. Express est utilisé uniquement pour le service REST de l’étape 11.
| Composant | Version minimale | Version recommandée | Vérification |
|---|---|---|---|
| Node.js | 18.0.0 | 22.14.x LTS | node --version |
| npm | 9.0.0 | 10.9.x | npm --version |
| OpenSSL (inclus) | 1.1.1 | 3.0.x | node -e "console.log(process.versions.openssl)" |
| Express (optionnel) | 4.18.x | 5.x | npm list express |
| Connaissances | Hash cryptographique | Bases crypto asymétrique | Voir articles prérequis ci-dessous |
Pour consolider les bases théoriques avant de commencer, lire les articles Signatures numériques : comment le hachage et les clés garantissent l’authenticité et HMAC-SHA256 en Node.js : signer une API en 12 étapes [2026].
Étape 1 : Initialiser le projet
Créer la structure de répertoires et initialiser le projet npm. L’ensemble de l’étapes 1 à 10 n’utilise que le module crypto intégré à Node.js. Express est ajouté en option pour le service REST de l’étape 11.
mkdir ecdsa-ed25519-tutorial
cd ecdsa-ed25519-tutorial
npm init -y
mkdir keys scripts
# Optionnel : installer Express pour le service REST (étape 11)
npm install express
# Vérifier la version d'OpenSSL et les courbes disponibles
node -e "const c = require('crypto'); console.log('OpenSSL:', process.versions.openssl); console.log('Courbes P-256:', c.getCurves().filter(x => x.includes('256')))"
Sortie attendue :
OpenSSL: 3.0.15
Courbes P-256: [ 'prime256v1', 'secp256r1' ]
Point de contrôle : Si la sortie ne contient pas prime256v1, Node.js a été compilé sans support ECC complet. Mettre à jour vers Node.js 22 LTS via nvm install 22.
Étape 2 : Comprendre ECDSA et EdDSA avant d’écrire du code
Deux familles de courbes elliptiques sont utilisées dans ce tutoriel, et leurs différences techniques ont des conséquences directes sur l’API Node.js.
NIST P-256 (secp256r1 / prime256v1) : Courbe de Weierstrass approuvée dans FIPS 186-5. Les signatures ECDSA produites sur P-256 sont encodées au format DER (ASN.1 binaire), de taille variable entre 70 et 72 octets. Chaque signature ECDSA nécessite un nonce aléatoire k unique. Si k est réutilisé pour deux signatures différentes avec la même clé privée, la clé privée peut être reconstituée mathématiquement (la PlayStation 3 de Sony a été compromise de cette façon en 2010). Node.js utilise OpenSSL pour générer des k sécurisés.
Curve25519 / Ed25519 : Courbe tordue d’Edwards conçue par Daniel Bernstein en 2006. Ed25519 est l’algorithme de signature EdDSA sur cette courbe, avec SHA-512 intégré. Les signatures font toujours exactement 64 octets. Ed25519 est résistant par construction aux attaques par canaux auxiliaires (timing attacks) car les opérations ne dépendent pas de données secrètes. Différence critique dans l’API Node.js : ECDSA requiert de spécifier l’algorithme de hachage ('SHA256'), tandis qu’Ed25519 le gère en interne et reçoit null comme algorithme.
Note importante : Ne pas confondre P-256 (secp256r1, NIST) avec secp256k1 (courbe Koblitz utilisée par Bitcoin et Ethereum). Ce sont deux courbes distinctes avec des paramètres différents. namedCurve: 'P-256' et namedCurve: 'prime256v1' désignent la même courbe NIST. namedCurve: 'secp256k1' est différente et ne convient pas pour TLS ou JWT.
Étape 3 : Générer une paire de clés ECDSA P-256
La génération utilise crypto.generateKeyPairSync() avec le type 'ec'. La clé privée doit toujours être chiffrée avec AES-256-CBC et une passphrase robuste avant d’être persistée sur disque.
// scripts/generate-ecdsa.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
function generateECDSAKeyPair(passphrase) {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256', // NIST P-256 = secp256r1 = prime256v1
publicKeyEncoding: {
type: 'spki', // SubjectPublicKeyInfo (standard X.509)
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8', // PKCS#8 PrivateKeyInfo
format: 'pem',
cipher: 'aes-256-cbc', // Chiffrement de la clé privée
passphrase: passphrase
}
});
return { privateKey, publicKey };
}
const PASSPHRASE = process.env.KEY_PASSPHRASE;
if (!PASSPHRASE) {
console.error('Erreur : définir la variable KEY_PASSPHRASE avant de générer les clés');
process.exit(1);
}
const { privateKey, publicKey } = generateECDSAKeyPair(PASSPHRASE);
const keysDir = path.join(__dirname, '..', 'keys');
fs.mkdirSync(keysDir, { recursive: true });
fs.writeFileSync(path.join(keysDir, 'ecdsa-private.pem'), privateKey, { mode: 0o600 });
fs.writeFileSync(path.join(keysDir, 'ecdsa-public.pem'), publicKey, { mode: 0o644 });
console.log('Clés ECDSA P-256 générées avec succès');
console.log('\nClé publique (à distribuer librement) :');
console.log(publicKey);
console.log('Taille clé publique PEM :', publicKey.length, 'caractères (~119 octets encodés)');
Exécuter avec :
KEY_PASSPHRASE=mon-secret-fort node scripts/generate-ecdsa.js
# Sortie attendue :
Clés ECDSA P-256 générées avec succès
Clé publique (à distribuer librement) :
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3h7U...
-----END PUBLIC KEY-----
Taille clé publique PEM : 178 caractères (~119 octets encodés)
Une clé publique ECDSA P-256 en PEM représente 65 octets de données brutes (1 octet de type + 32 octets x + 32 octets y), contre 294 octets pour RSA-2048. La clé privée chiffrée AES-256-CBC fait environ 365 octets.
Étape 4 : Signer des données avec ECDSA
La signature utilise crypto.sign(), disponible depuis Node.js 12. La fonction prend l’algorithme de hachage, les données en Buffer, et la clé privée. Elle retourne la signature au format DER binaire.
// scripts/sign-ecdsa.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
function signWithECDSA(data, privateKeyPem, passphrase) {
// Déchiffrer la clé privée protégée
const privateKeyObject = crypto.createPrivateKey({
key: privateKeyPem,
format: 'pem',
passphrase: passphrase
});
// Signer : SHA-256 + ECDSA P-256 = algorithme ES256 (nomenclature JWT)
const signature = crypto.sign('SHA256', Buffer.from(data, 'utf8'), privateKeyObject);
// Retourner en Base64 pour un transport JSON-friendly
return signature.toString('base64');
}
const keysDir = path.join(__dirname, '..', 'keys');
const privateKeyPem = fs.readFileSync(path.join(keysDir, 'ecdsa-private.pem'), 'utf8');
const PASSPHRASE = process.env.KEY_PASSPHRASE;
const message = 'Contrat signé électroniquement le 20 juin 2026';
const signature = signWithECDSA(message, privateKeyPem, PASSPHRASE);
console.log('Message :', message);
console.log('Signature ECDSA (Base64) :', signature);
console.log('Longueur brute :', Buffer.from(signature, 'base64').length, 'octets (DER, variable 70-72)');
Sortie attendue :
Message : Contrat signé électroniquement le 20 juin 2026
Signature ECDSA (Base64) : MEYCIQDy8n...+3A==
Longueur brute : 72 octets (DER, variable 70-72)
Particularité ECDSA : La signature DER encode les entiers r et s avec un préfixe de longueur variable. La taille oscille entre 70 et 72 octets selon si r ou s ont leur bit de poids fort à 1 (nécessitant un octet de padding 0x00). Cette variabilité pose problème pour les JWT, qui exigent le format P1363 (r||s concaténés, 64 octets fixes, voir étape 8).
Étape 5 : Vérifier une signature ECDSA
La vérification utilise crypto.verify() avec la clé publique. Contrairement à la clé privée, la clé publique peut être distribuée librement et sans passphrase. Elle ne donne aucune capacité à signer, uniquement à vérifier.
// scripts/verify-ecdsa.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
function verifyECDSA(data, signatureBase64, publicKeyPem) {
try {
return crypto.verify(
'SHA256',
Buffer.from(data, 'utf8'),
publicKeyPem,
Buffer.from(signatureBase64, 'base64')
);
} catch {
return false; // Signature malformée ou clé incompatible
}
}
const keysDir = path.join(__dirname, '..', 'keys');
const privateKeyPem = fs.readFileSync(path.join(keysDir, 'ecdsa-private.pem'), 'utf8');
const publicKeyPem = fs.readFileSync(path.join(keysDir, 'ecdsa-public.pem'), 'utf8');
const PASSPHRASE = process.env.KEY_PASSPHRASE;
const message = 'Contrat signé électroniquement le 20 juin 2026';
const privateKey = crypto.createPrivateKey({ key: privateKeyPem, passphrase: PASSPHRASE });
const signature = crypto.sign('SHA256', Buffer.from(message), privateKey).toString('base64');
// Test 1 : message non altéré
console.log('Vérification (original) :', verifyECDSA(message, signature, publicKeyPem)); // true
// Test 2 : message altéré d'un seul caractère
console.log('Vérification (altéré) :', verifyECDSA(message + '.', signature, publicKeyPem)); // false
// Test 3 : signature corrompue
const corruptedSig = signature.slice(0, -4) + 'AAAA';
console.log('Vérification (signature corrompue) :', verifyECDSA(message, corruptedSig, publicKeyPem)); // false
Sortie attendue :
Vérification (original) : true
Vérification (altéré) : false
Vérification (signature corrompue) : false
Étape 6 : Générer une paire de clés Ed25519
Ed25519 utilise le type 'ed25519' dans generateKeyPairSync(). La clé privée Ed25519 fait 32 octets (encodée en 64 octets en PKCS#8 PEM). La clé publique fait 32 octets. Une limitation importante : Node.js ne supporte pas le chiffrement natif de la clé privée Ed25519 via l’option cipher, contrairement à ECDSA.
// scripts/generate-ed25519.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
function generateEd25519KeyPair() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
// Pas de cipher pour Ed25519 en Node.js (limitation OpenSSL)
// Utiliser HashiCorp Vault, AWS Secrets Manager, ou GPG pour protéger ce fichier
}
});
return { privateKey, publicKey };
}
const { privateKey, publicKey } = generateEd25519KeyPair();
const keysDir = path.join(__dirname, '..', 'keys');
fs.writeFileSync(path.join(keysDir, 'ed25519-private.pem'), privateKey, { mode: 0o600 });
fs.writeFileSync(path.join(keysDir, 'ed25519-public.pem'), publicKey, { mode: 0o644 });
console.log('Clés Ed25519 générées avec succès');
console.log('Taille clé publique PEM :', publicKey.length, 'caractères (32 octets bruts)');
console.log('\nClé publique Ed25519 :');
console.log(publicKey);
Sortie attendue :
Clés Ed25519 générées avec succès
Taille clé publique PEM : 119 caractères (32 octets bruts)
Clé publique Ed25519 :
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----
La clé publique Ed25519 fait 32 octets bruts contre 65 pour ECDSA P-256 et 256 pour RSA-2048. Cette compacité a un impact mesurable sur la taille des tokens JWT et des paquets réseau dans les protocoles haute fréquence.
Étape 7 : Signer et vérifier avec Ed25519
Différence critique par rapport à ECDSA : Ed25519 intègre le hachage dans l’algorithme lui-même (SHA-512 en interne). Passer un algorithme de hachage explicite à crypto.sign() avec une clé Ed25519 génère une erreur en Node.js 22. L’argument algorithme doit être null.
// scripts/sign-verify-ed25519.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
function signWithEd25519(data, privateKeyPem) {
// null = Ed25519 gère le hachage en interne (SHA-512 + dérivation Curve25519)
// ERREUR si on passe 'SHA256' ici : TypeError: Invalid key type
const signature = crypto.sign(null, Buffer.from(data, 'utf8'), privateKeyPem);
return signature.toString('base64');
}
function verifyEd25519(data, signatureBase64, publicKeyPem) {
try {
return crypto.verify(
null, // null obligatoire pour Ed25519
Buffer.from(data, 'utf8'),
publicKeyPem,
Buffer.from(signatureBase64, 'base64')
);
} catch {
return false;
}
}
const keysDir = path.join(__dirname, '..', 'keys');
const privateKeyPem = fs.readFileSync(path.join(keysDir, 'ed25519-private.pem'), 'utf8');
const publicKeyPem = fs.readFileSync(path.join(keysDir, 'ed25519-public.pem'), 'utf8');
const message = 'Transaction validée par Ed25519 - 20 juin 2026 10:30:00 UTC';
const signature = signWithEd25519(message, privateKeyPem);
console.log('Signature Ed25519 (Base64) :', signature);
console.log('Taille fixe :', Buffer.from(signature, 'base64').length, 'octets (toujours 64)');
console.log('Vérification (original) :', verifyEd25519(message, signature, publicKeyPem)); // true
console.log('Vérification (altéré) :', verifyEd25519(message + '!', signature, publicKeyPem)); // false
Sortie attendue :
Signature Ed25519 (Base64) : dGhpcyBpcyBhIG...==
Taille fixe : 64 octets (toujours 64)
Vérification (original) : true
Vérification (altéré) : false
La taille fixe de 64 octets d’Ed25519 simplifie les protocoles réseau : pas besoin de parser un encodage DER à longueur variable, les buffers peuvent être pré-alloués de taille fixe.
Étape 8 : Implémenter JWT signé avec ES256 (ECDSA + SHA-256)
Le standard JWT (RFC 7519) supporte ECDSA via l’algorithme ES256 (ECDSA P-256 + SHA-256). ES256 est le deuxième algorithme JWT le plus répandu après RS256. L’implémentation ci-dessous n’utilise aucune bibliothèque externe, uniquement le module crypto. Un détail critique : Node.js retourne les signatures ECDSA au format DER, mais RFC 7518 impose le format P1363 (r et s concaténés, 64 octets fixes). La conversion DER vers P1363 est obligatoire.
// scripts/jwt-es256.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
// Base64url : pas de padding, + devient -, / devient _
function toBase64url(buffer) {
return Buffer.from(buffer).toString('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function jsonBase64url(obj) {
return toBase64url(Buffer.from(JSON.stringify(obj)));
}
// Convertir signature DER (ASN.1) vers IEEE P1363 (r||s, 64 octets) pour JWT
function derToP1363(der) {
// Structure DER : 0x30 [longueur-totale] 0x02 [len-r] [r] 0x02 [len-s] [s]
let offset = 2;
const rLen = der[offset + 1];
const rRaw = der.slice(offset + 2, offset + 2 + rLen);
offset += 2 + rLen;
const sLen = der[offset + 1];
const sRaw = der.slice(offset + 2, offset + 2 + sLen);
// Normaliser r et s à 32 octets (supprimer le 0x00 de padding si bit de signe)
const pad32 = (buf) => {
if (buf.length > 32) return buf.slice(buf.length - 32);
if (buf.length < 32) return Buffer.concat([Buffer.alloc(32 - buf.length), buf]);
return buf;
};
return Buffer.concat([pad32(rRaw), pad32(sRaw)]);
}
function createES256JWT(payload, privateKeyPem, passphrase) {
const header = { alg: 'ES256', typ: 'JWT' };
const encodedHeader = jsonBase64url(header);
const encodedPayload = jsonBase64url({
...payload,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600
});
const signingInput = `${encodedHeader}.${encodedPayload}`;
const privateKey = passphrase
? crypto.createPrivateKey({ key: privateKeyPem, passphrase })
: privateKeyPem;
const signatureDER = crypto.sign('SHA256', Buffer.from(signingInput), privateKey);
const signatureP1363 = derToP1363(signatureDER);
return `${signingInput}.${toBase64url(signatureP1363)}`;
}
function verifyES256JWT(token, publicKeyPem) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('JWT malformé');
const [headerB64, payloadB64, sigB64] = parts;
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('JWT expiré');
}
// Reconvertir P1363 vers DER pour la vérification Node.js
const sigP1363 = Buffer.from(sigB64, 'base64url');
const r = sigP1363.slice(0, 32);
const s = sigP1363.slice(32, 64);
const rPad = r[0] & 0x80 ? Buffer.concat([Buffer.from([0x00]), r]) : r;
const sPad = s[0] & 0x80 ? Buffer.concat([Buffer.from([0x00]), s]) : s;
const der = Buffer.concat([
Buffer.from([0x30, 4 + rPad.length + sPad.length]),
Buffer.from([0x02, rPad.length]), rPad,
Buffer.from([0x02, sPad.length]), sPad
]);
const valid = crypto.verify('SHA256', Buffer.from(`${headerB64}.${payloadB64}`), publicKeyPem, der);
return { valid, payload };
}
// Démonstration
const keysDir = path.join(__dirname, '..', 'keys');
const privateKeyPem = fs.readFileSync(path.join(keysDir, 'ecdsa-private.pem'), 'utf8');
const publicKeyPem = fs.readFileSync(path.join(keysDir, 'ecdsa-public.pem'), 'utf8');
const PASSPHRASE = process.env.KEY_PASSPHRASE;
const token = createES256JWT(
{ sub: 'utilisateur-42', email: '[email protected]', role: 'admin' },
privateKeyPem,
PASSPHRASE
);
console.log('JWT ES256 :', token.substring(0, 80) + '...');
const { valid, payload } = verifyES256JWT(token, publicKeyPem);
console.log('JWT valide :', valid);
console.log('Payload :', JSON.stringify(payload, null, 2));
Étape 9 : Signer des fichiers avec streaming
Pour les fichiers volumineux, charger l'intégralité du contenu en mémoire avant de signer est inefficace. crypto.createSign() supporte l'API Writable stream et traite les données par morceaux. Cette approche fonctionne sur des fichiers de plusieurs Go sans contrainte de mémoire.
// scripts/sign-file.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
async function signFile(filePath, privateKeyPem, passphrase) {
const privateKey = crypto.createPrivateKey({ key: privateKeyPem, passphrase });
return new Promise((resolve, reject) => {
const signer = crypto.createSign('SHA256');
fs.createReadStream(filePath)
.on('data', chunk => signer.update(chunk))
.on('end', () => resolve(signer.sign(privateKey, 'hex')))
.on('error', reject);
});
}
async function verifyFile(filePath, signatureHex, publicKeyPem) {
return new Promise((resolve, reject) => {
const verifier = crypto.createVerify('SHA256');
fs.createReadStream(filePath)
.on('data', chunk => verifier.update(chunk))
.on('end', () => {
try { resolve(verifier.verify(publicKeyPem, signatureHex, 'hex')); }
catch { resolve(false); }
})
.on('error', reject);
});
}
async function main() {
const keysDir = path.join(__dirname, '..', 'keys');
const privateKeyPem = fs.readFileSync(path.join(keysDir, 'ecdsa-private.pem'), 'utf8');
const publicKeyPem = fs.readFileSync(path.join(keysDir, 'ecdsa-public.pem'), 'utf8');
const PASSPHRASE = process.env.KEY_PASSPHRASE;
// Créer un fichier de test
fs.writeFileSync('document.pdf.test', 'Contenu binaire simulé pour la signature de fichier.');
const sig = await signFile('document.pdf.test', privateKeyPem, PASSPHRASE);
fs.writeFileSync('document.pdf.test.sig', sig);
console.log('Signature sauvegardée dans document.pdf.test.sig');
const isValid = await verifyFile('document.pdf.test', sig, publicKeyPem);
console.log('Intégrité vérifiée :', isValid); // true
// Simuler une altération
fs.appendFileSync('document.pdf.test', ' (modifié)');
const isValidAfterMod = await verifyFile('document.pdf.test', sig, publicKeyPem);
console.log('Intégrité après altération :', isValidAfterMod); // false
}
main().catch(console.error);
Étape 10 : Benchmark ECDSA vs Ed25519
Avant de choisir l'algorithme pour un système en production, mesurer les performances sur le matériel cible. Les résultats varient significativement selon le CPU (présence d'instructions AES-NI, fréquence, nombre de cœurs).
// scripts/benchmark.js
const crypto = require('crypto');
const ITERATIONS = 10000;
const data = Buffer.from('benchmark: performance des signatures numériques Node.js 2026');
// Générer les clés pour le test
const { privateKey: ecPriv, publicKey: ecPub } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
const { privateKey: ed25519Priv, publicKey: ed25519Pub } = crypto.generateKeyPairSync('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
// Pré-calculer les signatures pour les benchmarks de vérification
const ecSig = crypto.sign('SHA256', data, ecPriv);
const edSig = crypto.sign(null, data, ed25519Priv);
function bench(label, fn) {
const start = process.hrtime.bigint();
for (let i = 0; i < ITERATIONS; i++) fn();
const ms = Number(process.hrtime.bigint() - start) / 1e6;
const ops = Math.round(ITERATIONS / (ms / 1000));
console.log(`${label.padEnd(32)}: ${ops.toLocaleString('fr-FR').padStart(10)} ops/s`);
return ops;
}
console.log(`\nBenchmark Node.js 22 / ${ITERATIONS.toLocaleString('fr-FR')} itérations\n`);
const ecSignOps = bench('ECDSA P-256 signature', () => crypto.sign('SHA256', data, ecPriv));
const ecVerOps = bench('ECDSA P-256 vérification', () => crypto.verify('SHA256', data, ecPub, ecSig));
const edSignOps = bench('Ed25519 signature', () => crypto.sign(null, data, ed25519Priv));
const edVerOps = bench('Ed25519 vérification', () => crypto.verify(null, data, ed25519Pub, edSig));
console.log(`\nEd25519 est ${(edSignOps / ecSignOps).toFixed(1)}x plus rapide que ECDSA pour la signature`);
console.log(`Ed25519 est ${(edVerOps / ecVerOps).toFixed(1)}x plus rapide que ECDSA pour la vérification`);
Résultats typiques sur un serveur Linux Node.js 22 (Intel Xeon Platinum 8375C, 2 vCPU) :
| Opération | Résultat mesuré | Ratio vs ECDSA sign |
|---|---|---|
| ECDSA P-256 signature | ~33 000 ops/s | 1x (référence) |
| ECDSA P-256 vérification | ~11 000 ops/s | 0.33x |
| Ed25519 signature | ~115 000 ops/s | 3.5x plus rapide |
| Ed25519 vérification | ~44 000 ops/s | 4x plus rapide |
Pour une API REST qui valide 1 000 JWT par seconde, passer d'ES256 (ECDSA P-256) à EdDSA (Ed25519) libère environ 75% de la charge CPU consacrée aux vérifications cryptographiques. Sur un microservice à fort débit, c'est la différence entre 1 instance et 4 instances.
Étape 11 : Construire un service REST de signature
Le service Express 5 expose trois endpoints : POST /sign, POST /verify, et GET /public-key. Les clés sont chargées une fois au démarrage et réutilisées pour toutes les requêtes, évitant l'overhead de déchiffrement à chaque requête.
// server.js
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const app = express();
app.use(express.json({ limit: '64kb' }));
const PASSPHRASE = process.env.KEY_PASSPHRASE;
if (!PASSPHRASE) { console.error('KEY_PASSPHRASE manquante'); process.exit(1); }
// Charger et déchiffrer les clés au démarrage (une seule fois)
const KEYS_DIR = path.join(__dirname, 'keys');
let privateKeyObj, publicKeyPem;
try {
const rawPrivate = fs.readFileSync(path.join(KEYS_DIR, 'ecdsa-private.pem'), 'utf8');
publicKeyPem = fs.readFileSync(path.join(KEYS_DIR, 'ecdsa-public.pem'), 'utf8');
privateKeyObj = crypto.createPrivateKey({ key: rawPrivate, passphrase: PASSPHRASE });
console.log('Clés ECDSA chargées');
} catch (err) {
console.error('Chargement des clés échoué :', err.message); process.exit(1);
}
// POST /sign — Signer un message
app.post('/sign', (req, res) => {
const { data } = req.body;
if (!data || typeof data !== 'string') return res.status(400).json({ error: '"data" requis (string)' });
if (data.length > 65536) return res.status(413).json({ error: 'Données trop volumineuses (max 64 Ko)' });
try {
const sig = crypto.sign('SHA256', Buffer.from(data, 'utf8'), privateKeyObj);
res.json({ data, signature: sig.toString('base64'), algorithm: 'ECDSA-P256-SHA256', ts: new Date().toISOString() });
} catch { res.status(500).json({ error: 'Erreur de signature' }); }
});
// POST /verify — Vérifier une signature
app.post('/verify', (req, res) => {
const { data, signature } = req.body;
if (!data || !signature) return res.status(400).json({ error: '"data" et "signature" requis' });
try {
const valid = crypto.verify('SHA256', Buffer.from(data, 'utf8'), publicKeyPem,
Buffer.from(signature, 'base64'));
res.json({ valid, data });
} catch { res.status(400).json({ valid: false, error: 'Signature invalide ou malformée' }); }
});
// GET /public-key — Distribuer la clé publique
app.get('/public-key', (_req, res) => res.type('text').send(publicKeyPem));
app.listen(3000, () => console.log('Service de signature sur http://localhost:3000'));
Tester avec curl :
# Démarrer le service
KEY_PASSPHRASE=mon-secret-fort node server.js
# Signer un document
SIGNATURE=$(curl -s -X POST http://localhost:3000/sign \
-H 'Content-Type: application/json' \
-d '{"data":"Bon de commande #FR-2026-042"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['signature'])")
echo "Signature : $SIGNATURE"
# Vérifier la signature
curl -s -X POST http://localhost:3000/verify \
-H 'Content-Type: application/json' \
-d "{\"data\":\"Bon de commande #FR-2026-042\",\"signature\":\"$SIGNATURE\"}" | python3 -m json.tool
# Sortie :
# { "valid": true, "data": "Bon de commande #FR-2026-042" }
Étape 12 : Sécuriser les clés et déployer en production
Le stockage des clés privées est le point le plus critique de l'architecture. Une clé privée compromise invalide rétrospectivement toutes les signatures émises depuis cette clé. Cinq règles non négociables pour la production :
- Git exclusion : Ajouter
keys/et*.pemau.gitignoreavant le premier commit. Une clé privée poussée accidentellement doit être considérée comme compromise, même si le commit est supprimé de l'historique (les forks et les caches peuvent la conserver). - Gestionnaire de secrets : Utiliser HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, ou GCP Secret Manager. Ces services journalisent chaque accès, supportent la rotation automatique, et permettent de révoquer l'accès sans redeployer l'application.
- Permissions fichier Linux : La clé privée doit avoir les permissions
600(lecture/écriture pour le propriétaire uniquement). Vérifier avecls -la keys/ecdsa-private.pem. Corriger avecchmod 600 keys/ecdsa-private.pem. - Passphrase via variable d'environnement : Ne jamais passer la passphrase en argument de ligne de commande (visible dans
ps auxet les logs système). Utiliserprocess.env.KEY_PASSPHRASEexclusivement. - Rotation planifiée : Mettre en place une rotation des clés tous les 12 à 24 mois. Publier la nouvelle clé publique avec un délai de 48 heures avant de retirer l'ancienne pour permettre l'expiration des tokens existants.
# Audit de sécurité des clés (à inclure dans la CI/CD)
# Vérifier les permissions des clés privées
find keys/ -name "*private*" -not -perm 600 && echo "ALERTE : permissions incorrectes" || echo "Permissions OK"
# Vérifier l'empreinte SHA-256 de la clé publique (pour audit)
openssl pkey -in keys/ecdsa-public.pem -pubin -outform DER 2>/dev/null | openssl dgst -sha256
# SHA2-256(stdin)= a3b5c7... (documenter dans le registre d'audit)
# Vérifier que les clés ne sont pas dans l'historique Git
git log --all --full-history -- "keys/*.pem" && echo "ALERTE : clés dans Git" || echo "Git propre"
# Calculer la date d'expiration recommandée
node -e "const d = new Date(); d.setFullYear(d.getFullYear() + 1); console.log('Rotation recommandée avant :', d.toISOString().split('T')[0])"
Tableau récapitulatif : choisir entre ECDSA et Ed25519
| Critère | ECDSA P-256 | ECDSA P-384 | Ed25519 |
|---|---|---|---|
| Conformité FIPS 186-5 | Oui | Oui | Oui (depuis 2023) |
| Recommandation ANSSI | Oui (applications civiles) | Requis (classifié) | Non officiel |
| Performance signature | ~33 000 ops/s | ~18 000 ops/s | ~115 000 ops/s |
| Taille de signature | 70-72 octets (DER variable) | 102-104 octets | 64 octets (fixe) |
| JWT supporté | ES256 (universel) | ES384 | EdDSA (RFC 8037) |
| SSH (OpenSSH) | Oui | Oui | Recommandé (défaut) |
| Résistance timing attacks | Dépend implémentation | Dépend implémentation | Oui (par construction) |
| Chiffrement clé privée Node.js | Oui (AES-256-CBC) | Oui | Non (limitation OpenSSL) |
7 pièges courants à éviter
Piège 1 : Confondre secp256k1 (Bitcoin) et P-256 (NIST). Ces deux courbes ont des paramètres différents et ne sont pas interchangeables. namedCurve: 'P-256' et namedCurve: 'prime256v1' désignent la même courbe NIST P-256. namedCurve: 'secp256k1' est la courbe Bitcoin, non recommandée pour TLS ou JWT.
Piège 2 : Passer un algorithme de hachage à Ed25519. crypto.sign('SHA256', data, ed25519Key) génère une TypeError: Invalid key type en Node.js 22. Ed25519 gère le hachage en interne. Toujours utiliser crypto.sign(null, data, key) pour les clés Ed25519.
Piège 3 : Signature DER dans les JWT sans conversion. Node.js retourne les signatures ECDSA au format DER (longueur variable, 70-72 octets). RFC 7518 impose le format P1363 (r||s, 64 octets fixes). Sans la conversion derToP1363() de l'étape 8, le JWT échoue la vérification dans toute bibliothèque conforme (jsonwebtoken, jose, python-jwt, etc.).
Piège 4 : Stocker la clé privée ECDSA sans passphrase. Un fichier PEM non chiffré est lisible en clair. Toute vulnérabilité path traversal, tout snapshot S3 mal configuré, ou tout backup compromis expose immédiatement la clé. Toujours chiffrer avec cipher: 'aes-256-cbc' et une passphrase d'au moins 32 caractères.
Piège 5 : Charger et déchiffrer la clé privée à chaque requête. crypto.createPrivateKey() est coûteux car il déchiffre AES-256-CBC à chaque appel. Sur 10 000 requêtes/seconde, cela représente 10 000 opérations de déchiffrement inutiles. Charger l'objet KeyObject une seule fois au démarrage du serveur et le réutiliser.
Piège 6 : Utiliser SHA-1 comme algorithme de hachage avec ECDSA. crypto.sign('SHA1', data, ecKey) compile et s'exécute sans erreur, mais SHA-1 est cryptographiquement cassé depuis la démonstration SHAttered en 2017. Toujours utiliser SHA-256 minimum, SHA-384 pour P-384.
Piège 7 : Signer sans valider les données d'entrée. Un endpoint /sign qui accepte n'importe quelle chaîne peut être exploité pour forger des assertions arbitraires (tokens admin, faux contrats). Valider le format, la longueur, le schéma des données, et journaliser chaque signature avec l'identité de l'appelant avant de signer.
Dépannage : 8 erreurs et leurs solutions
| Message d'erreur | Cause | Solution |
|---|---|---|
error:0909006C:PEM routines:no start line | Fichier PEM tronqué ou corrompu | Vérifier que le fichier commence par -----BEGIN et se termine par -----END avec un retour à la ligne |
error:06065064:bad decrypt | Passphrase incorrecte | Vérifier KEY_PASSPHRASE. Attention aux caractères spéciaux interprétés par le shell |
TypeError: Invalid key type | Algorithme de hachage passé à Ed25519 | Remplacer 'SHA256' par null dans crypto.sign() |
ERR_OSSL_UNSUPPORTED | Algorithme legacy désactivé en Node.js 17+ | Mettre à jour vers des algorithmes modernes. Ne pas utiliser --openssl-legacy-provider en production |
| JWT rejeté par bibliothèques tierces | Signature DER au lieu de P1363 | Appliquer derToP1363(). Vérifier : Buffer.from(sig,'base64').length doit être 64 |
EACCES: permission denied | Permissions insuffisantes sur le fichier de clé | chmod 600 keys/ecdsa-private.pem. Le processus Node.js doit être l'utilisateur propriétaire |
error:100000F7: unknown group | Nom de courbe non reconnu | Vérifier avec crypto.getCurves(). Utiliser 'P-256' ou 'prime256v1' (synonymes) |
| Performances 100x inférieures à l'attendu | Clé rechargée et déchiffrée à chaque requête | Appeler crypto.createPrivateKey() une seule fois au démarrage et stocker le KeyObject |
Conseils avancés : rotation, HSM et Sigstore
Rotation des clés sans downtime : Le pattern JWKS (JSON Web Key Set) permet de publier plusieurs clés publiques simultanément via GET /.well-known/jwks.json. Chaque clé porte un identifiant kid. Pour une rotation : (1) générer la nouvelle paire, (2) ajouter la nouvelle clé publique au JWKS avec un nouveau kid, (3) attendre l'expiration des tokens existants (TTL + 15 min de marge), (4) basculer les nouvelles signatures vers la nouvelle clé privée, (5) retirer l'ancienne clé publique du JWKS 24 heures plus tard.
Intégration HSM (Hardware Security Module) : Les HSM (AWS CloudHSM, Thales Luna, YubiHSM 2) génèrent et stockent les clés privées dans un module matériel certifié FIPS 140-3 niveau 3. La clé privée ne quitte jamais le HSM : seules les opérations de signature sont déléguées. Node.js interagit avec les HSM via PKCS#11 (bibliothèque pkcs11js sur npm) ou les SDK propriétaires. Le YubiHSM 2, à 650 EUR, supporte Ed25519 et ECDSA P-256 nativement.
Sigstore et npm provenance : Depuis npm 9.5, la commande npm publish --provenance génère une attestation de provenance signée ECDSA via Sigstore (Fulcio CA + Rekor transparency log). Cette attestation lie le package à son dépôt GitHub via OpenID Connect, sans gestion de clé privée. C'est le modèle "keyless signing" adopté par Python (PyPI Trusted Publishers) et Ruby (RubyGems OIDC). La vérification s'effectue avec npm audit signatures.
Empreinte de clé publique pour audit : Dans les protocoles Trust On First Use (TOFU) et pour les registres d'audit, calculer l'empreinte SHA-256 de la clé publique SPKI :
// Calculer l'empreinte SHA-256 de la clé publique (pour audit et TOFU)
const crypto = require('crypto');
const fs = require('fs');
const pubKeyPem = fs.readFileSync('keys/ecdsa-public.pem', 'utf8');
const pubKeyDer = crypto.createPublicKey(pubKeyPem).export({ type: 'spki', format: 'der' });
const fingerprint = crypto.createHash('SHA256').update(pubKeyDer).digest('base64');
console.log('Empreinte SHA-256 :', fingerprint);
// Format : SHA256:a3b5c7d9... (48 caractères en Base64)
// À documenter dans le registre d'audit et à afficher dans l'interface de gestion des clés
Récapitulatif des fichiers du projet
| Fichier | Description | Étape |
|---|---|---|
scripts/generate-ecdsa.js | Génération clés ECDSA P-256 + AES chiffrement | 3 |
scripts/sign-ecdsa.js | Signature ECDSA avec passphrase | 4 |
scripts/verify-ecdsa.js | Vérification ECDSA | 5 |
scripts/generate-ed25519.js | Génération clés Ed25519 | 6 |
scripts/sign-verify-ed25519.js | Signature et vérification Ed25519 | 7 |
scripts/jwt-es256.js | JWT ES256 avec conversion DER/P1363 | 8 |
scripts/sign-file.js | Signature de fichiers via stream | 9 |
scripts/benchmark.js | Benchmark comparatif ECDSA vs Ed25519 | 10 |
server.js | Service REST Express : /sign /verify /public-key | 11 |
keys/ | Clés PEM (exclu de Git via .gitignore) | 12 |
Couverture connexe
Articles liés sur shattered.io
- Signatures numériques : comment le hachage et les clés garantissent l'authenticité - fondations théoriques des signatures numériques ECDSA et EdDSA
- HMAC-SHA256 en Node.js : signer une API en 12 étapes [2026] - alternative symétrique pour l'authentification d'API interne
- Authentification JWT en Node.js : 12 étapes [2026] - implémenter RS256 et HS256 avec la bibliothèque jsonwebtoken
- bcrypt Node.js : hacher un mot de passe, 12 étapes [2026] - sécuriser les mots de passe au repos
- GPG : chiffrer fichiers et emails, 12 étapes [2026] - Ed25519 via GnuPG pour la signature et le chiffrement de fichiers
- En-têtes de sécurité HTTP en Node.js : 12 étapes, 30 Min [2026] - compléter la sécurité de l'API avec Helmet.js
- Cryptographie : pilier de la confiance numérique - panorama de tous les sujets cryptographiques du cluster
Ressources officielles
- Documentation officielle module crypto Node.js - référence complète de l'API crypto avec exemples
- RFC 8032 : Edwards-Curve Digital Signature Algorithm (EdDSA) - standard IETF définissant Ed25519
- FIPS 186-5 : Digital Signature Standard - standard NIST pour ECDSA P-256 et P-384
- MDN : SubtleCrypto.sign() - API Web Crypto côté navigateur, complémentaire à Node.js
- ed25519.cr.yp.to - site officiel de Daniel Bernstein sur Ed25519 et Curve25519
FAQ
Quelle est la différence entre ECDSA et EdDSA ?
ECDSA (Elliptic Curve DSA) et EdDSA (Edwards-curve DSA) sont deux familles d'algorithmes de signature sur courbes elliptiques. ECDSA utilise les courbes de Weierstrass (P-256, P-384, secp256k1) et génère des signatures DER de taille variable. EdDSA utilise les courbes d'Edwards tordues (Ed25519, Ed448) et produit des signatures de taille fixe. EdDSA est plus rapide (3 à 4 fois), résistant par construction aux attaques par canaux auxiliaires, et ne requiert pas de nonce aléatoire externe (la valeur est dérivée déterministiquement de la clé privée et du message). Dans Node.js, la différence pratique principale est que Ed25519 prend null comme algorithme de hachage, tandis qu'ECDSA prend 'SHA256'.
Peut-on utiliser Ed25519 dans les JWT ?
Oui. Le RFC 8037 définit l'algorithme EdDSA pour les JWT avec Ed25519. La bibliothèque jose (npm) supporte EdDSA depuis 2021. La valeur alg: "EdDSA" avec crv: "Ed25519" dans la clé JWK identifie cet algorithme. Attention : jsonwebtoken ne supporte EdDSA qu'à partir de la version 9.0.0 (sortie en 2023). Pour les systèmes existants utilisant des versions antérieures, rester sur ES256 ou migrer vers jose.
ECDSA P-256 résiste-t-il aux ordinateurs quantiques ?
Non. L'algorithme de Shor résout le problème du logarithme discret sur courbes elliptiques en temps polynomial sur un ordinateur quantique suffisamment puissant. Cela compromettrait ECDSA, Ed25519, et RSA simultanément. Le NIST a finalisé les premiers standards post-quantiques en 2024 : FIPS 204 (CRYSTALS-Dilithium pour les signatures) et FIPS 205 (SPHINCS+ en option). Les applications critiques sur 10 ans devraient planifier une migration vers ces nouveaux standards. Pour la plupart des applications, les courbes elliptiques restent sécurisées pour les 10 à 20 prochaines années selon les estimations du NIST.
Quelle est la différence entre secp256k1 et P-256 ?
Ce sont deux courbes elliptiques distinctes. P-256 (prime256v1, secp256r1) est une courbe NIST avec des paramètres définis par le gouvernement américain, utilisée dans TLS, FIDO2, et les certificats X.509. secp256k1 est une courbe Koblitz choisie pour ses propriétés arithmétiques optimisées, adoptée par Bitcoin en 2008 et Ethereum. secp256k1 n'est pas recommandée pour TLS ou JWT (absence d'approbation FIPS, usage cantonné à la blockchain). Ne jamais les interchanger dans une application.
Comment faire pivoter les clés ECDSA sans interruption de service ?
Le protocole de rotation zéro-downtime avec JWKS suit 5 étapes : (1) Générer une nouvelle paire de clés ; (2) Publier la nouvelle clé publique dans le JWKS avec un nouveau kid, en conservant l'ancienne ; (3) Attendre l'expiration de tous les tokens existants (TTL maximal + 15 minutes de marge) ; (4) Basculer la signature des nouveaux tokens vers la nouvelle clé privée ; (5) Retirer l'ancienne clé publique du JWKS 24 heures après l'étape 4. Journaliser chaque étape avec horodatage pour l'audit. Ne jamais retirer la clé publique avant que tous les tokens signés avec la clé correspondante soient expirés.
Node.js crypto est-il conforme FIPS 140-3 ?
Node.js peut activer le mode FIPS OpenSSL avec node --enable-fips ou via crypto.setFips(1). En mode FIPS, les algorithmes non conformes (MD5, SHA-1, DES, RC4) sont désactivés et toute tentative de les utiliser génère une erreur. ECDSA P-256 et P-384 sont conformes FIPS 186-5. Ed25519 est approuvé dans FIPS 186-5 depuis 2023, mais la conformité effective dépend de la version d'OpenSSL-FIPS utilisée. Pour les applications soumises à des exigences FIPS formelles, vérifier avec le validateur NIST CMVP.
Peut-on utiliser les mêmes clés pour signer et chiffrer ?
Non, c'est une mauvaise pratique cryptographique. Les clés de signature (ECDSA, Ed25519) et les clés de chiffrement (ECDH avec X25519 pour l'échange de clés, puis AES pour le chiffrement symétrique) ont des usages distincts. Mélanger les deux crée des vulnérabilités théoriques et complique la révocation : révoquer la clé de signature invalide aussi le déchiffrement des messages archivés. Générer des paires de clés distinctes pour chaque usage, avec des cycles de vie indépendants.




