Le 13 août 2024, le NIST a publié la norme FIPS 204, qui fixe pour la première fois un algorithme de signature numérique résistant aux ordinateurs quantiques prêt pour la production : ML-DSA, dérivé de CRYSTALS-Dilithium. Deux ans plus tard, la plupart des tutoriels francophones sur la cryptographie post-quantique s’arrêtent à l’échange de clés (ML-KEM) et laissent de côté la signature. Pourtant, signer un document, un firmware ou un jeton d’authentification est un besoin tout aussi courant, et tout aussi exposé à la menace “harvest now, decrypt later”.

Ce tutoriel comble ce vide. Vous allez installer une bibliothèque ML-DSA-87 réelle publiée sur npm il y a quelques jours à peine, générer des paires de clés, signer des messages en mode “hedged” et déterministe, vérifier des signatures détachées, valider des clés reçues d’un tiers, puis assembler un petit service de signature de documents complet avec ses tests. À la fin, vous aurez un projet Node.js fonctionnel, pas seulement des extraits de code isolés.

La menace “harvest now, decrypt later” appliquée aux signatures

La plupart des discussions sur l’urgence post-quantique se concentrent sur le chiffrement : un attaquant qui enregistre aujourd’hui du trafic chiffré avec RSA ou ECDH pourra le déchiffrer plus tard, une fois un ordinateur quantique suffisamment puissant disponible. Pour la signature, le risque est différent mais tout aussi réel. Une signature ECDSA ou Ed25519 posée aujourd’hui sur un contrat, un firmware ou un certificat reste vérifiable pendant des années ; si l’algorithme sous-jacent tombe avant l’expiration de ce document, un tiers pourra forger de nouvelles signatures valides au nom de la clé compromise, y compris rétroactivement sur des documents jamais réellement signés par le titulaire.

C’est pour cette raison que les signatures méritent leur propre feuille de route de migration, distincte de celle du chiffrement. Un firmware signé aujourd’hui pour un équipement industriel censé fonctionner quinze ans, un acte notarié numérique, ou une clé racine d’autorité de certification sont des cas typiques où la durée de vie de la signature dépasse largement l’horizon “confortable” avant l’arrivée d’un ordinateur quantique cryptographiquement pertinent. Passer par ML-DSA dès maintenant sur ces cas d’usage à longue durée de vie, même en parallèle d’un algorithme classique, réduit l’exposition sans attendre une date de bascule imposée dans l’urgence.

Prérequis techniques et versions à utiliser

Avant de commencer, vérifiez que votre environnement correspond à ces versions. La bibliothèque que nous utilisons impose des contraintes précises sur le runtime, notamment la disponibilité de globalThis.crypto.getRandomValues.

  • Node.js 24.x LTS (nom de code “Krypton”, version 24.21.0 au moment de la rédaction) ou à défaut Node.js 22.x, ou 20.19 minimum. Vérifiez avec node -v.
  • npm 10 ou supérieur, livré avec Node.js 24.
  • @theqrl/mldsa87 en version 2.2.0, publiée le 17 septembre 2026 sur le registre npm. C’est l’implémentation ML-DSA-87 (niveau de sécurité NIST 5, équivalent AES-256) que nous utiliserons du début à la fin.
  • @noble/hashes en version 2.4.0, seule dépendance de la bibliothèque ci-dessus, installée automatiquement.
  • Un éditeur de code (VS Code ou équivalent) et des bases en JavaScript asynchrone.
  • Environ 90 minutes pour suivre l’ensemble des 12 étapes, y compris les tests.

Aucune connaissance préalable des réseaux de polynômes ou des treillis n’est nécessaire : la bibliothèque encapsule toute la mathématique de FIPS 204 derrière une API de haut niveau. Vous manipulerez des Uint8Array, pas des polynômes.

ML-DSA et FIPS 204 : ce qu’il faut savoir avant de coder

ML-DSA (Module-Lattice-Based Digital Signature Algorithm) est la version normalisée de CRYSTALS-Dilithium. La distinction compte : un paquet qui se présente simplement comme “Dilithium” peut implémenter une version antérieure à la normalisation, avec un encodage différent et sans paramètre de contexte. Pour un usage en production en 2026, exigez explicitement une implémentation FIPS 204, pas seulement “Dilithium”.

FIPS 204 définit trois jeux de paramètres : ML-DSA-44, ML-DSA-65 et ML-DSA-87, du niveau de sécurité le plus bas au plus élevé. Ce tutoriel utilise ML-DSA-87 (catégorie 5, équivalent à AES-256), le choix recommandé quand la durée de vie des données signées dépasse plusieurs années, ce qui est précisément le scénario où la menace quantique future compte.

AlgorithmeClé publiqueClé privéeSignatureRésistance quantique
Ed2551932 octets32 octets (seed)64 octetsNon
ECDSA P-256 (DER)~33 octets (compressée)32 octets~70-72 octetsNon
ML-DSA-441 312 octets2 560 octets2 420 octetsOui
ML-DSA-651 952 octets4 032 octets3 309 octetsOui
ML-DSA-872 592 octets4 896 octets4 627 octetsOui

La contrepartie du niveau de sécurité 5 est la taille : une signature ML-DSA-87 pèse 4 627 octets, contre 64 pour Ed25519, soit environ 72 fois plus. Si votre protocole a des contraintes strictes de bande passante (en-têtes HTTP, paquets UDP, QR codes), gardez ce ratio en tête avant de migrer un système entier. Nous avons déjà détaillé les écarts de taille entre familles post-quantiques dans notre comparatif ML-DSA vs SLH-DSA vs FN-DSA, utile si vous hésitez encore sur l’algorithme à adopter.

Pourquoi pas simplement augmenter la taille des clés RSA ou ECDSA ?

Une question revient souvent : pourquoi ne pas simplement utiliser des clés RSA plus longues plutôt que d’adopter un algorithme entièrement nouveau ? La réponse tient à la nature du problème mathématique cassé par un ordinateur quantique suffisamment puissant. L’algorithme de Shor, exécuté sur un tel ordinateur, résout en temps polynomial la factorisation d’entiers (RSA) et le logarithme discret sur courbe elliptique (ECDSA, Ed25519), quelle que soit la taille de la clé. Doubler ou tripler la longueur d’une clé RSA ne change rien à la nature du problème résolu, cela ne fait que retarder marginalement l’attaque une fois le matériel disponible. ML-DSA repose au contraire sur un problème de réseaux euclidiens (apprentissage avec erreurs sur un module, ou Module-LWE), pour lequel aucun algorithme quantique connu en 2026 n’offre d’accélération comparable à celle de Shor.

ML-DSA-87 face à Dilithium5 : la comparaison exacte

Le paquet npm que nous utilisons dans ce tutoriel a une bibliothèque sœur, @theqrl/dilithium5, qui implémente la version antérieure à la normalisation FIPS 204. Les deux ne sont pas interchangeables, comme le résume le tableau ci-dessous, tiré de la documentation officielle du paquet.

CaractéristiqueML-DSA-87Dilithium5
Norme suivieFIPS 204CRYSTALS Round 3
Taille de signature4 627 octets4 595 octets
Paramètre de contextePris en chargeNon pris en charge
Cas d’usage recommandéNouvelles implémentationsCompatibilité avec une infrastructure go-qrllib existante

Sauf contrainte d’interopérabilité explicite avec un système existant basé sur Dilithium5, choisissez ML-DSA-87 : c’est la version alignée sur la norme fédérale américaine actuellement en vigueur, et la seule des deux à exposer le paramètre de contexte utilisé dans les étapes suivantes de ce tutoriel. Les autorités de certification suivent la même logique : la documentation technique publiée par DigiCert sur ML-DSA confirme que les nouveaux déploiements de signature post-quantique s’orientent vers FIPS 204 plutôt que vers les implémentations pré-normalisation.

Étape 1 : initialiser le projet Node.js

Créez un dossier de projet et initialisez-le en module ES pour profiter d’une syntaxe import propre.

mkdir mldsa-signatures-demo
cd mldsa-signatures-demo
npm init -y
npm pkg set type="module"
node -v   # doit afficher v20.19+, v22.x ou v24.x

Le passage en module ES n’est pas obligatoire (la bibliothèque expose aussi une variante CommonJS), mais il simplifie les exemples qui suivent et correspond à l’usage recommandé pour un nouveau projet Node.js 24.

Étape 2 : installer et vérifier @theqrl/mldsa87

npm install @theqrl/[email protected]

Épinglez la version exacte (2.2.0) plutôt que d’accepter un intervalle semver large : pour une bibliothèque de sécurité aussi récente, verrouiller la version évite qu’une mise à jour automatique change silencieusement le comportement de signature en production. Le code source complet est publié sur le dépôt GitHub theQRL/qrypto.js, utile pour auditer l’implémentation avant un usage en production. Vérifiez ensuite que les constantes exposées correspondent bien à ce que documente le paquet.

import {
  CryptoPublicKeyBytes,
  CryptoSecretKeyBytes,
  CryptoBytes,
} from '@theqrl/mldsa87';

console.log('Taille clé publique :', CryptoPublicKeyBytes); // 2592
console.log('Taille clé privée   :', CryptoSecretKeyBytes); // 4896
console.log('Taille signature    :', CryptoBytes);          // 4627

Si ces trois valeurs s’affichent, votre installation est correcte et vous pouvez passer à la génération de clés.

Étape 3 : générer votre première paire de clés ML-DSA-87

L’API de génération de clés attend des buffers pré-alloués à la bonne taille, plutôt que de retourner un objet : c’est un choix de conception qui évite les allocations cachées dans une bibliothèque cryptographique.

import {
  cryptoSignKeypair,
  CryptoPublicKeyBytes,
  CryptoSecretKeyBytes,
} from '@theqrl/mldsa87';

const pk = new Uint8Array(CryptoPublicKeyBytes); // 2592 octets
const sk = new Uint8Array(CryptoSecretKeyBytes); // 4896 octets

// null = graine aléatoire générée automatiquement via
// globalThis.crypto.getRandomValues
const seedUtilise = cryptoSignKeypair(null, pk, sk);

console.log('Clé publique (hex) :', Buffer.from(pk).toString('hex').slice(0, 32) + '...');
console.log('Graine utilisée (hex) :', Buffer.from(seedUtilise).toString('hex'));

La fonction retourne la graine (seed) de 32 octets utilisée pour dériver la paire. Conservez-la uniquement si vous avez besoin de régénérer une clé de façon reproductible : dans la majorité des cas, ne stockez que sk et détruisez la graine après usage, car quiconque la possède peut recalculer la clé privée.

Étape 4 : comprendre le paramètre de contexte (ctx)

Contrairement à Ed25519 ou ECDSA, FIPS 204 introduit un paramètre de contexte obligatoire dans chaque opération de signature et de vérification. Il s’agit d’une chaîne d’octets de 0 à 255 octets qui sert à séparer les domaines d’usage : la même paire de clés peut ainsi signer des messages pour deux applications différentes sans qu’une signature valide dans l’une soit rejouable dans l’autre.

// Contexte spécifique à votre application : change selon le cas d'usage
const ctxDocuments = new TextEncoder().encode('mon-app-signature-documents-v1');
const ctxAuthentification = new TextEncoder().encode('mon-app-auth-tokens-v1');

// Un contexte vide est autorisé si aucune séparation n'est nécessaire
const ctxVide = new Uint8Array(0);

Le contexte doit être identique côté signature et côté vérification. C’est l’un des pièges les plus fréquents pour qui migre depuis Ed25519 : oublier ce paramètre, ou en utiliser un différent entre la signature et la vérification, fait échouer silencieusement toute la chaîne (la fonction retourne undefined ou false plutôt que de lever une exception explicite sur ce point précis).

Étape 5 : signer un message, mode hedged ou déterministe

FIPS 204 (section 3.4) recommande le mode “hedged” (aléatoire) pour la signature générale, car il ajoute une composante aléatoire supplémentaire qui renforce la résistance aux attaques par canal auxiliaire. Le mode déterministe (section 3.5) ne doit être réservé qu’aux protocoles qui l’exigent explicitement, par exemple des schémas de tirage vérifiable de type RANDAO.

import { cryptoSign, cryptoSignDeterministic } from '@theqrl/mldsa87';

const message = new TextEncoder().encode('Contrat signé le 23 septembre 2026');
const ctx = new TextEncoder().encode('mon-app-signature-documents-v1');

// Mode recommandé : hedged (randomizedSigning = true)
const messageSigneHedged = cryptoSign(message, sk, true, ctx);

// Mode déterministe : uniquement si votre protocole l'exige
const messageSigneDeterministe = cryptoSignDeterministic(message, sk, ctx);

console.log('Taille du message signé (hedged) :', messageSigneHedged.length);
// 4627 (signature) + longueur du message

cryptoSign retourne un unique Uint8Array au format “signature concaténée au message” : les 4 627 premiers octets sont la signature, le reste est le message d’origine. Si vous préférez conserver la signature et le message séparément (utile pour l’archivage ou l’API), l’étape 7 couvre la version détachée.

Étape 6 : vérifier une signature avec cryptoSignOpen

import { cryptoSignOpen } from '@theqrl/mldsa87';

const messageExtrait = cryptoSignOpen(messageSigneHedged, pk, ctx);

if (messageExtrait === undefined) {
  throw new Error('Signature invalide ou contexte incorrect');
}

console.log('Message vérifié :', new TextDecoder().decode(messageExtrait));
// "Contrat signé le 23 septembre 2026"

cryptoSignOpen renvoie soit le message d’origine si la signature est valide, soit undefined. Ne testez jamais uniquement la véracité du retour avec un simple if (!messageExtrait) sans vérifier explicitement === undefined : un message d’origine vide (Uint8Array(0)) est une valeur “falsy” en apparence mais parfaitement valide.

Étape 7 : signatures détachées avec cryptoSignSignature et cryptoSignVerify

Dans une API REST ou un format de fichier existant, on préfère souvent stocker la signature séparément du message plutôt que de les concaténer. La bibliothèque expose pour cela une paire de fonctions dédiées.

import { cryptoSignSignature, cryptoSignVerify, CryptoBytes } from '@theqrl/mldsa87';

const sig = new Uint8Array(CryptoBytes); // buffer de sortie, 4627 octets
cryptoSignSignature(sig, message, sk, true, ctx);

const estValide = cryptoSignVerify(sig, message, pk, ctx);
console.log('Signature détachée valide :', estValide); // true

// Falsification : modifier un seul octet du message invalide la signature
const messageAltere = new TextEncoder().encode('Contrat signé le 24 septembre 2026');
console.log('Après altération :', cryptoSignVerify(sig, messageAltere, pk, ctx)); // false

Cette forme détachée est celle à privilégier pour un format de type “document.json + document.sig”, où le vérificateur charge les deux fichiers indépendamment.

Étape 8 : valider les clés publiques et secrètes avant usage

FIPS 204 ne prévoit pas de contrôle de validité obligatoire des clés reçues d’un tiers, ce qui laisse la porte ouverte à des clés publiques “faibles” spécialement construites pour accepter n’importe quelle signature. La bibliothèque ajoute deux fonctions de contrôle non prévues par la norme mais recommandées avant toute vérification en environnement où les clés proviennent de l’extérieur.

import { validatePublicKey, validateSecretKey } from '@theqrl/mldsa87';

const controlePk = validatePublicKey(pkRecuDunTiers);
if (!controlePk.ok) {
  // reason : 'invalid-pk-type' | 'invalid-pk-length' | 'weak-public-key'
  throw new Error(`Clé publique rejetée : ${controlePk.reason}`);
}

const controleSk = validateSecretKey(sk);
if (!controleSk.ok) {
  // reason : 'invalid-sk-type' | 'invalid-sk-length' | 'invalid-sk-encoding'
  throw new Error(`Clé secrète rejetée : ${controleSk.reason}`);
}

Ces deux fonctions ne lèvent jamais d’exception : elles retournent toujours un objet { ok, reason }, ce qui les rend sûres à appeler même sur une entrée totalement malformée provenant d’un réseau non fiable. Toute fonction de signature de la bibliothèque applique déjà en interne le contrôle sur la clé secrète ; validateSecretKey permet simplement de l’exécuter en amont, par exemple juste après un import de clé.

Étape 9 : sérialiser, stocker et effacer les clés en toute sécurité

Les clés ML-DSA sont des tableaux d’octets bruts : pour les persister sur disque ou les transmettre en JSON, encodez-les en hexadécimal ou en base64. Une fois la clé secrète utilisée, effacez-la de la mémoire avec zeroize.

import { writeFile, readFile } from 'node:fs/promises';
import { zeroize } from '@theqrl/mldsa87';

// Sauvegarde
await writeFile('cle_publique.hex', Buffer.from(pk).toString('hex'), 'utf8');
await writeFile('cle_secrete.hex', Buffer.from(sk).toString('hex'), 'utf8');

// Relecture
const pkHex = await readFile('cle_publique.hex', 'utf8');
const pkRelue = new Uint8Array(Buffer.from(pkHex, 'hex'));

// Une fois la clé secrète chargée en mémoire et utilisée,
// effacez-la si vous n'en avez plus besoin dans ce processus
zeroize(sk);

Le README de la bibliothèque est explicite sur ce point : l’effacement en mémoire est “best-effort” en JavaScript, le ramasse-miettes du moteur pouvant avoir déjà copié les données ailleurs. Pour des clés à très forte valeur (autorité de certification, signature de firmware), stockez la clé secrète dans un HSM plutôt qu’en mémoire applicative.

Entre hexadécimal et base64 pour l’encodage, le choix dépend du contexte de transport. L’hexadécimal double la taille d’origine mais reste lisible tel quel dans un terminal ou un fichier de log, pratique en phase de debug. Le base64 n’augmente la taille que d’environ 33 %, ce qui compte davantage une fois que vous transportez des signatures de 4 627 octets dans des en-têtes HTTP ou des champs JSON à répétition. Pour ce tutoriel, nous gardons l’hexadécimal pour les clés stockées sur disque (plus simple à inspecter) et le base64 pour les signatures transportées dans le service de l’étape suivante.

Étape 10 : construire un service complet de signature de documents

Assemblons maintenant tout ce qui précède dans un module unique et réutilisable : génération de paire de clés, signature d’un document JSON, vérification, avec gestion d’erreurs.

// service-signature.js
import {
  cryptoSignKeypair,
  cryptoSignSignature,
  cryptoSignVerify,
  validatePublicKey,
  CryptoPublicKeyBytes,
  CryptoSecretKeyBytes,
  CryptoBytes,
} from '@theqrl/mldsa87';

const CTX = new TextEncoder().encode('service-signature-documents-v1');

export function genererPaireDeCles() {
  const pk = new Uint8Array(CryptoPublicKeyBytes);
  const sk = new Uint8Array(CryptoSecretKeyBytes);
  cryptoSignKeypair(null, pk, sk);
  return { pk, sk };
}

export function signerDocument(document, sk) {
  const messageBytes = new TextEncoder().encode(JSON.stringify(document));
  const sig = new Uint8Array(CryptoBytes);
  cryptoSignSignature(sig, messageBytes, sk, true, CTX);
  return {
    document,
    signature: Buffer.from(sig).toString('base64'),
    algorithme: 'ML-DSA-87',
  };
}

export function verifierDocument(paquetSigne, pk) {
  const controle = validatePublicKey(pk);
  if (!controle.ok) {
    return { valide: false, raison: `cle_publique_rejetee:${controle.reason}` };
  }
  const messageBytes = new TextEncoder().encode(JSON.stringify(paquetSigne.document));
  const sig = new Uint8Array(Buffer.from(paquetSigne.signature, 'base64'));
  const valide = cryptoSignVerify(sig, messageBytes, pk, CTX);
  return { valide, raison: valide ? null : 'signature_invalide' };
}

Ce module de moins de 40 lignes couvre l’essentiel d’un flux de signature de documents en production : génération, signature au format base64 transportable en JSON, et vérification avec contrôle préalable de la clé publique. Le point d’attention : sérialiser le document avec JSON.stringify avant de le signer suppose un ordre de clés stable. Pour un usage réel, préférez une sérialisation canonique (JSON Canonicalization Scheme, RFC 8785) plutôt qu’un JSON.stringify natif, dont l’ordre des clés n’est pas garanti entre deux moteurs JavaScript différents.

Étape 11 : écrire des tests automatisés de bout en bout

Un test de signature doit couvrir au minimum quatre scénarios : signature valide, message altéré, signature altérée, et mauvaise clé publique. Voici un test avec le module natif node:test, disponible sans dépendance supplémentaire depuis Node.js 18.

// service-signature.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { genererPaireDeCles, signerDocument, verifierDocument } from './service-signature.js';

test('signature valide accepte le document original', () => {
  const { pk, sk } = genererPaireDeCles();
  const paquet = signerDocument({ montant: 4200, devise: 'EUR' }, sk);
  const resultat = verifierDocument(paquet, pk);
  assert.equal(resultat.valide, true);
});

test('document modifié après signature est rejeté', () => {
  const { pk, sk } = genererPaireDeCles();
  const paquet = signerDocument({ montant: 4200, devise: 'EUR' }, sk);
  paquet.document.montant = 999999; // falsification
  const resultat = verifierDocument(paquet, pk);
  assert.equal(resultat.valide, false);
});

test('mauvaise clé publique rejette une signature pourtant valide', () => {
  const { sk } = genererPaireDeCles();
  const { pk: mauvaisePk } = genererPaireDeCles();
  const paquet = signerDocument({ montant: 4200, devise: 'EUR' }, sk);
  const resultat = verifierDocument(paquet, mauvaisePk);
  assert.equal(resultat.valide, false);
});

Lancez la suite avec node --test. Les trois tests doivent passer en quelques millisecondes : la vérification ML-DSA-87 reste rapide malgré la taille des clés, contrairement à l’opération de signature qui est plus coûteuse à cause de la boucle de rejet propre à l’algorithme (voir la section performances ci-dessous).

Étape 12 : mesurer les performances face à Ed25519 et ECDSA

La sécurité post-quantique a un coût mesurable en CPU et en bande passante. Voici les ordres de grandeur à retenir avant de dimensionner une migration.

CritèreEd25519ECDSA P-256ML-DSA-87
Taille de signature64 octets~70-72 octets (DER)4 627 octets
Taille clé publique32 octets~33 octets2 592 octets
Signature constant-timeOuiOui (implémentations modernes)Non garanti (boucle de rejet)
Vérification constant-timeOuiOuiOui
Résistance à l’ordinateur quantiqueNonNonOui (NIST catégorie 5)

Le point le plus souvent négligé : la documentation officielle du paquet signale explicitement que l’opération de signature (pas la vérification) n’est pas garantie constant-time, à cause de la boucle de rejet imposée par FIPS 204. Pour un usage serveur classique (signer des jetons, des documents), l’impact reste marginal. Pour un usage où le temps de signature pourrait fuiter une information sensible face à un attaquant colocalisé, consultez la documentation de sécurité du paquet avant déploiement.

Pour obtenir vos propres chiffres sur votre matériel plutôt que de vous fier à des ordres de grandeur génériques, encadrez simplement un bloc de signatures répétées avec console.time ou l’API performance native de Node.js, en isolant bien la phase de génération de clé (rare, coûteuse) de la phase de signature (fréquente). Exécutez le test plusieurs centaines de fois avant de tirer une moyenne : la boucle de rejet de FIPS 204 fait qu’une signature individuelle peut nécessiter plusieurs tentatives internes selon la clé et le message, ce qui introduit une variance que quelques exécutions isolées ne révèlent pas correctement.

Pièges courants à éviter

  • Confondre Dilithium et ML-DSA. Un paquet nommé “dilithium” sans référence à FIPS 204 peut utiliser un encodage de la 3e phase du concours NIST, incompatible byte à byte avec ML-DSA. Vérifiez toujours la mention explicite “FIPS 204” dans la documentation.
  • Contexte (ctx) incohérent entre signature et vérification. C’est l’erreur numéro un lors d’une migration depuis Ed25519, qui n’a pas ce paramètre. Une différence d’un seul octet dans le contexte fait échouer silencieusement la vérification.
  • Traiter les clés comme du texte. Les clés et signatures ML-DSA sont des données binaires. Utilisez toujours Uint8Array ou Buffer, jamais une manipulation de chaîne de caractères UTF-8 qui corromprait les octets.
  • Ignorer la validation des clés publiques externes. cryptoSignVerify ne rejette pas nativement les clés publiques “faibles” : FIPS 204 ne l’exige pas au niveau de l’algorithme. Appelez systématiquement validatePublicKey sur toute clé reçue d’un tiers avant de vérifier une signature avec elle.
  • Sérialisation JSON non canonique avant signature. Signer le résultat d’un JSON.stringify classique fonctionne tant que le même moteur sérialise des deux côtés, mais casse dès qu’un ordre de clés diffère entre client et serveur. Adoptez une sérialisation canonique pour tout usage inter-systèmes.
  • Sous-dimensionner le stockage. Une base de données ou un en-tête HTTP conçu pour des signatures Ed25519 de 64 octets déborde immédiatement avec des signatures ML-DSA-87 de 4 627 octets. Revoyez les schémas de colonnes et les limites de taille d’en-tête avant la mise en production.
  • Oublier d’épingler la version du paquet. À quelques jours de sa publication, la version 2.2.0 est encore jeune : verrouillez-la dans package.json et testez chaque montée de version avant déploiement, plutôt que d’accepter des mises à jour automatiques sur une dépendance de sécurité aussi récente.

Dépannage : résoudre les erreurs les plus fréquentes

La plupart des erreurs rencontrées avec ML-DSA en Node.js se classent en trois familles : des buffers de mauvaise taille, un contexte incohérent entre signature et vérification, ou une confusion entre les variantes de l’algorithme. Le tableau ci-dessous couvre les cas les plus fréquemment remontés lors de l’intégration de ce type de bibliothèque.

SymptômeCause probableSolution
Error: invalid pk lengthLe buffer de clé publique n’a pas exactement 2592 octetsAllouez toujours avec new Uint8Array(CryptoPublicKeyBytes), ne codez pas la taille en dur
Error: invalid sk lengthLe buffer de clé secrète n’a pas exactement 4896 octetsUtilisez CryptoSecretKeyBytes pour l’allocation
TypeError: ctx is required and must be a Uint8ArrayLe paramètre de contexte a été omis ou passé en chaîne de caractèresEncodez toujours le contexte avec new TextEncoder().encode(...), même pour un contexte vide utilisez new Uint8Array(0)
cryptoSignOpen retourne undefined alors que la signature semble correcteContexte différent entre signature et vérificationComparez octet par octet les deux valeurs de ctx utilisées
cryptoSignVerify retourne false sur un message pourtant identiqueLe message a été re-sérialisé différemment (ordre JSON, encodage) avant vérificationVérifiez que l’encodage binaire exact du message signé est reproduit, idéalement via une sérialisation canonique
Error: sk coefficient outside [-2, 2] lors de la signatureLa clé secrète est corrompue ou a été tronquée pendant le stockage/transportAppelez validateSecretKey immédiatement après le chargement de la clé pour détecter le problème avant de signer
Performances de signature très inférieures aux attentesLa boucle de rejet FIPS 204 nécessite plusieurs tentatives internes par signatureC’est un comportement normal ; ne signez pas en boucle serrée sur le thread principal d’un serveur à fort trafic, déportez sur un worker si nécessaire
Incompatibilité avec une signature générée par go-qrllibUtilisation de @theqrl/dilithium5 au lieu de @theqrl/mldsa87, ou inversementLes deux bibliothèques ne sont pas interchangeables : Dilithium5 suit CRYSTALS Round 3, ML-DSA-87 suit FIPS 204 avec un format différent
npm install échoue sur globalThis.cryptoVersion de Node.js antérieure à 20.19Mettez à jour vers Node.js 20.19+, 22.x ou 24.x LTS

Conseils avancés : signatures hybrides et migration progressive

Une bascule brutale vers ML-DSA seul est rarement le bon choix pour un système déjà en production. La pratique recommandée par la majorité des guides de migration post-quantique, y compris ceux évoqués par l’ENISA, consiste à déployer des signatures hybrides : signer chaque message à la fois avec un algorithme classique (Ed25519) et avec ML-DSA, puis exiger que les deux signatures soient valides pour accepter le message. Cette double vérification protège contre une faille non découverte dans l’un ou l’autre des deux algorithmes, au prix d’un surcoût de calcul et de taille de message.

Attention à un piège classique de l’hybridation : une politique de vérification de type “l’une des deux signatures suffit” annule tout l’intérêt de la démarche, puisqu’un attaquant capable de casser le plus faible des deux algorithmes retrouve alors un accès complet. La politique doit toujours être “les deux signatures sont exigées”, jamais “au moins une”. Pour la partie classique du schéma hybride, notre tutoriel sur ECDSA et Ed25519 en Node.js détaille l’implémentation du volet non post-quantique.

Sur l’inventaire de vos systèmes, distinguez bien la signature numérique (ML-DSA, objet de cet article) de l’échange de clés (ML-KEM), qui répond à un besoin différent et suit un calendrier de migration distinct ; notre tutoriel ML-KEM couvre ce second volet. Les deux briques sont complémentaires dans un déploiement TLS post-quantique complet, mais elles se mettent en œuvre séparément dans le code.

Enfin, gardez un œil sur les correctifs à venir : le NIST a publié le 31 juillet 2026 une note de planification signalant plusieurs corrections mineures attendues dans une future révision de FIPS 204, recensées dans une feuille d’erratum. Aucune de ces corrections ne remet en cause le format actuel des clés ou des signatures, mais elles justifient de suivre les annonces officielles du NIST avant de figer une implémentation dans un protocole difficile à faire évoluer ensuite. Le secteur bancaire teste déjà des déploiements ML-DSA-65 en conditions réelles, comme nous l’avions couvert dans notre article sur les premiers tests de signature post-quantique bancaire, un signal que l’écosystème d’outillage autour de ML-DSA va continuer à mûrir rapidement. Pour la posture réglementaire française, notre suivi sur les recommandations ANSSI reste la référence à consulter avant de fixer un calendrier interne.

Où stocker la clé secrète en production

Le code de ce tutoriel garde volontairement la clé secrète en mémoire applicative ou dans un fichier hexadécimal, pour rester lisible pédagogiquement. En production, ce choix ne convient qu’à des usages de test. Pour une clé racine ou une clé de signature de firmware, la pratique du secteur consiste à s’appuyer sur un module matériel de sécurité (HSM) qui ne laisse jamais la clé secrète sortir en clair du boîtier. Plusieurs fabricants de HSM ont déjà annoncé un support ML-DSA au niveau firmware ; nous avions couvert cette tendance dans notre article sur le Thales Luna 8. Pour une application web ou un microservice sans budget HSM, une alternative raisonnable est un gestionnaire de secrets managé (chiffrement au repos, rotation, journalisation des accès), en gardant à l’esprit que cela protège le stockage, pas l’exécution en mémoire au moment de signer.

Sur le plan réglementaire européen, le Cyber Resilience Act impose désormais une exigence de signature numérique pour le code logiciel mis sur le marché de l’UE, ce qui inclut potentiellement les firmwares et les mises à jour signées par les briques que nous venons de construire. Anticiper une compatibilité post-quantique dès la conception de ce type de pipeline de signature évite une réécriture complète le jour où un algorithme purement classique ne suffira plus à répondre aux exigences d’un client ou d’un régulateur. Côté français, l’ANSSI publie ses recommandations de transition post-quantique directement sur cyber.gouv.fr, la source à surveiller pour toute mise à jour de calendrier.

Le projet complet : structure de fichiers finale

À l’issue de ce tutoriel, votre projet doit contenir les fichiers suivants, tous fonctionnels et testés :

mldsa-signatures-demo/
├── package.json              # type: "module", dépendance @theqrl/[email protected]
├── service-signature.js      # génération, signature, vérification (étape 10)
├── service-signature.test.js # 3 tests node:test (étape 11)
├── cle_publique.hex          # clé publique encodée en hexadécimal (étape 9)
└── cle_secrete.hex           # clé secrète encodée en hexadécimal (étape 9, à protéger)

Exécutez node --test une dernière fois pour confirmer que l’ensemble fonctionne de bout en bout, puis intégrez service-signature.js dans votre application. Pensez à retirer cle_secrete.hex du contrôle de version (ajoutez-le à .gitignore) : cette clé ne doit jamais transiter par un dépôt Git, quelle que soit sa taille.

Foire aux questions

ML-DSA remplace-t-il complètement Ed25519 et ECDSA ?
Pas dans l’immédiat. La recommandation actuelle pour la majorité des organisations est un déploiement hybride, où ML-DSA s’ajoute à un algorithme classique plutôt que de le remplacer, le temps que l’écosystème (bibliothèques, formats de certificats, matériel) mature davantage.

Pourquoi utiliser ML-DSA-87 plutôt que ML-DSA-44 ou ML-DSA-65 ?
ML-DSA-87 correspond au niveau de sécurité NIST catégorie 5, recommandé pour les données dont la confidentialité ou l’intégrité doit tenir sur le très long terme. ML-DSA-44 ou ML-DSA-65 conviennent à des usages moins critiques où la taille de signature réduite compte davantage.

Le paquet @theqrl/mldsa87 est-il audité ?
Sa documentation indique des tests contre les vecteurs de référence pq-crystals et Wycheproof, ainsi qu’une interopérabilité vérifiée avec l’implémentation Go go-qrllib. Avant tout usage en production critique, vérifiez l’état d’audit le plus récent du paquet directement sur son dépôt.

Quelle est la différence entre le mode hedged et le mode déterministe ?
Le mode hedged ajoute un aléa supplémentaire à chaque signature, ce qui est le mode recommandé par FIPS 204 pour la majorité des usages. Le mode déterministe produit toujours la même signature pour un même message et une même clé, utile uniquement pour des protocoles qui exigent explicitement cette propriété.

Puis-je utiliser ML-DSA dans un navigateur, pas seulement en Node.js ?
Oui, la bibliothèque cible aussi les navigateurs disposant du Web Crypto API et du support BigInt (ES2020), ce qui couvre Chrome, Firefox, Safari et Edge dans leurs versions récentes.

Les signatures ML-DSA sont-elles compatibles avec les certificats X.509 classiques ?
L’intégration dans les chaînes de certificats X.509 existe mais reste en cours de standardisation et d’adoption par les autorités de certification. Pour la signature de documents ou de messages applicatifs comme dans ce tutoriel, aucune dépendance à X.509 n’est nécessaire.

Que se passe-t-il si je perds la clé secrète ML-DSA ?
Comme pour toute cryptographie asymétrique, la perte de la clé secrète rend impossible toute nouvelle signature avec cette identité : il faut générer une nouvelle paire de clés et republier la nouvelle clé publique auprès de vos vérificateurs. Il n’existe aucun mécanisme de récupération intégré à l’algorithme lui-même.

Faut-il déjà migrer en 2026 ou puis-je attendre ?
Cela dépend de la durée de vie de vos données signées. Pour des signatures dont la validité doit être vérifiable dans dix ou vingt ans, commencer les tests dès maintenant, en mode hybride, limite le risque de devoir migrer dans l’urgence plus tard.

Comment migrer un système qui signe déjà en Ed25519 sans tout casser ?
Ne remplacez pas le champ de signature existant : ajoutez un second champ dédié à la signature ML-DSA à côté du champ Ed25519 existant, avec un indicateur de version de schéma. Les anciens vérificateurs continuent de valider uniquement la signature Ed25519 tant qu’ils n’ont pas été mis à jour, et les nouveaux vérificateurs peuvent progressivement exiger la présence des deux champs. Cette approche additive évite une rupture de compatibilité brutale entre les composants migrés et ceux qui ne le sont pas encore.