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.

AlgorithmeTaille de cléSécurité (bits)Signatures/s (Intel Xeon)Cas d’usage typique
RSA-20482 048 bits112 bits~2 000Legacy TLS, PGP ancien
RSA-40964 096 bits140 bits~400Certificats CA racine
ECDSA P-256256 bits128 bits~35 000TLS 1.3, JWT, FIDO2, code signing
ECDSA P-384384 bits192 bits~18 000Secteur défense, gouvernement
Ed25519256 bits128 bits~120 000SSH, 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.

ComposantVersion minimaleVersion recommandéeVérification
Node.js18.0.022.14.x LTSnode --version
npm9.0.010.9.xnpm --version
OpenSSL (inclus)1.1.13.0.xnode -e "console.log(process.versions.openssl)"
Express (optionnel)4.18.x5.xnpm list express
ConnaissancesHash cryptographiqueBases crypto asymétriqueVoir 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érationRésultat mesuréRatio vs ECDSA sign
ECDSA P-256 signature~33 000 ops/s1x (référence)
ECDSA P-256 vérification~11 000 ops/s0.33x
Ed25519 signature~115 000 ops/s3.5x plus rapide
Ed25519 vérification~44 000 ops/s4x 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 *.pem au .gitignore avant 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 avec ls -la keys/ecdsa-private.pem. Corriger avec chmod 600 keys/ecdsa-private.pem.
  • Passphrase via variable d'environnement : Ne jamais passer la passphrase en argument de ligne de commande (visible dans ps aux et les logs système). Utiliser process.env.KEY_PASSPHRASE exclusivement.
  • 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èreECDSA P-256ECDSA P-384Ed25519
Conformité FIPS 186-5OuiOuiOui (depuis 2023)
Recommandation ANSSIOui (applications civiles)Requis (classifié)Non officiel
Performance signature~33 000 ops/s~18 000 ops/s~115 000 ops/s
Taille de signature70-72 octets (DER variable)102-104 octets64 octets (fixe)
JWT supportéES256 (universel)ES384EdDSA (RFC 8037)
SSH (OpenSSH)OuiOuiRecommandé (défaut)
Résistance timing attacksDépend implémentationDépend implémentationOui (par construction)
Chiffrement clé privée Node.jsOui (AES-256-CBC)OuiNon (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'erreurCauseSolution
error:0909006C:PEM routines:no start lineFichier PEM tronqué ou corrompuVérifier que le fichier commence par -----BEGIN et se termine par -----END avec un retour à la ligne
error:06065064:bad decryptPassphrase incorrecteVérifier KEY_PASSPHRASE. Attention aux caractères spéciaux interprétés par le shell
TypeError: Invalid key typeAlgorithme de hachage passé à Ed25519Remplacer 'SHA256' par null dans crypto.sign()
ERR_OSSL_UNSUPPORTEDAlgorithme 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 tiercesSignature DER au lieu de P1363Appliquer derToP1363(). Vérifier : Buffer.from(sig,'base64').length doit être 64
EACCES: permission deniedPermissions 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 groupNom de courbe non reconnuVérifier avec crypto.getCurves(). Utiliser 'P-256' ou 'prime256v1' (synonymes)
Performances 100x inférieures à l'attenduClé rechargée et déchiffrée à chaque requêteAppeler 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

FichierDescriptionÉtape
scripts/generate-ecdsa.jsGénération clés ECDSA P-256 + AES chiffrement3
scripts/sign-ecdsa.jsSignature ECDSA avec passphrase4
scripts/verify-ecdsa.jsVérification ECDSA5
scripts/generate-ed25519.jsGénération clés Ed255196
scripts/sign-verify-ed25519.jsSignature et vérification Ed255197
scripts/jwt-es256.jsJWT ES256 avec conversion DER/P13638
scripts/sign-file.jsSignature de fichiers via stream9
scripts/benchmark.jsBenchmark comparatif ECDSA vs Ed2551910
server.jsService REST Express : /sign /verify /public-key11
keys/Clés PEM (exclu de Git via .gitignore)12

Couverture connexe

Articles liés sur shattered.io

Ressources officielles

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.