import { generateKeyPairSync, sign, verify } from "node:crypto"; const { publicKey, privateKey } = generateKeyPairSync("ml-dsa-65");const message = Buffer.from("hello, post-quantum world"); // algorithm must be null: ML-DSA hashes the message itselfconst signature = sign(null, message, privateKey);console.log("public key", publicKey.export({ type: "spki", format: "der" }).length, "bytes SPKI,", "signature", signature.length, "bytes");console.log("verified:", verify(null, message, publicKey, signature));console.log("tampered message:", verify(null, Buffer.from("hello, post-quantum world!"), publicKey, signature)); // A context string binds the signature to one purposeconst context = Buffer.from("invoice-v1");const invoiceSignature = sign(null, message, { key: privateKey, context });console.log("with the context:", verify(null, message, { key: publicKey, context }, invoiceSignature), "without:", verify(null, message, publicKey, invoiceSignature)); How it works
generateKeyPairSync("ml-dsa-65")returns twoKeyObjects; the public key exports as a 1974-byte SPKI, the signature is 3309 bytes.- The algorithm argument of
signandverifymust benull: ML-DSA hashes the message itself. - A context string goes in the key argument:
{ key, context }on both sides. createPublicKeyreads OpenSSL's SPKI PEM andcreatePrivateKeyreads PKCS#8 files in both forms OpenSSL writes (seed only, or seed plus expanded key); the run verifies OpenSSL's signature and derives OpenSSL's public key from the shared seed file.
Gotchas
- Passing a digest name fails:
sign("sha256", message, key)throwsERR_OSSL_INVALID_DIGEST. - Signing is randomized: two signatures of the same message differ.
export({ format: "jwk" })returns the memberskty,alg,pubandpriv— nocrv,xordas in EC and OKP keys — so code that expects those fields cannot handle an ML-DSA JWK.- The WebCrypto variant (
crypto.subtlewith{ name: "ML-DSA-65" }) also works in Node.js 24, but prints anExperimentalWarning;node:cryptodoes not.