Skip to content
ecdsa.com

ML-DSA-65 sign/verify · Node.js

How to sign and verify with ML-DSA-65 in Node.js

To sign and verify with ML-DSA-65 in Node.js, generate the key with crypto.generateKeyPairSync("ml-dsa-65") and call crypto.sign(null, message, privateKey) and crypto.verify(null, message, publicKey, signature). Node.js 24.22 bundles OpenSSL 3.5, so nothing else is needed. This recipe also signs with a context string and verifies a signature OpenSSL made.

Tested with Node.js 24.22.0 (node:crypto, bundled OpenSSL 3.5.8) and OpenSSL 3.5.7 (shared test vector and the default key file).

sign.mjs — key pair, signature, verification and a context string
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 itself
const 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 purpose
const 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

  1. generateKeyPairSync("ml-dsa-65") returns two KeyObjects; the public key exports as a 1974-byte SPKI, the signature is 3309 bytes.
  2. The algorithm argument of sign and verify must be null: ML-DSA hashes the message itself.
  3. A context string goes in the key argument: { key, context } on both sides.
  4. createPublicKey reads OpenSSL's SPKI PEM and createPrivateKey reads 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) throws ERR_OSSL_INVALID_DIGEST.
  • Signing is randomized: two signatures of the same message differ.
  • export({ format: "jwk" }) returns the members kty, alg, pub and priv — no crv, x or d as in EC and OKP keys — so code that expects those fields cannot handle an ML-DSA JWK.
  • The WebCrypto variant (crypto.subtle with { name: "ML-DSA-65" }) also works in Node.js 24, but prints an ExperimentalWarning; node:crypto does not.

Related recipes