Skip to content
ecdsa.com

ML-KEM-768 encapsulation · Node.js

How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Node.js

To encapsulate and decapsulate a shared secret with ML-KEM-768 in Node.js, generate the key with crypto.generateKeyPairSync("ml-kem-768"), call crypto.encapsulate(publicKey) on the sender's side and crypto.decapsulate(privateKey, ciphertext) on the receiver's. Node.js 24.22 bundles OpenSSL 3.5, so nothing else is needed; this recipe also exchanges ciphertexts with the OpenSSL command line.

Tested with Node.js 24.22.0 (node:crypto encapsulate/decapsulate, bundled OpenSSL 3.5.8) and OpenSSL 3.5.7 (shared test vector and decapsulation of the recipe's ciphertext).

kem.mjs — key pair, encapsulation, decapsulation
import { decapsulate, encapsulate, generateKeyPairSync, timingSafeEqual } from "node:crypto";
// Receiver: a key pair; only the encapsulation (public) key is handed out
const { publicKey, privateKey } = generateKeyPairSync("ml-kem-768");
// Sender: a fresh 32-byte shared secret and the ciphertext that carries it
const { sharedKey, ciphertext } = encapsulate(publicKey);
// Receiver: the same secret from the ciphertext
const received = decapsulate(privateKey, ciphertext);
console.log("encapsulation key", publicKey.export({ type: "spki", format: "der" }).length, "bytes SPKI,",
"ciphertext", ciphertext.length, "bytes, shared secret", sharedKey.length, "bytes");
console.log("shared secret agreed:", timingSafeEqual(received, sharedKey));

How it works

  1. generateKeyPairSync("ml-kem-768") returns two KeyObjects; the public key exports as a 1206-byte SPKI.
  2. encapsulate(publicKey) returns { sharedKey, ciphertext } — a 32-byte secret and a 1088-byte ciphertext.
  3. decapsulate(privateKey, ciphertext) returns the same 32 bytes; compare secrets with timingSafeEqual, never === on strings.
  4. createPublicKey and createPrivateKey read OpenSSL's files in every form (seed-only or seed plus expanded key); the run decapsulates OpenSSL's ciphertext to the shared secret dee35810…30f9, and OpenSSL decapsulates the ciphertext Node.js made.

Gotchas

  • A tampered ciphertext does not throw: decapsulate returns a different secret (implicit rejection). Only the next step — decrypting or authenticating with a key derived from the secret — reveals the mismatch.
  • A ciphertext of the wrong length throws ERR_CRYPTO_OPERATION_FAILED.
  • export({ type: "pkcs8", format: "der" }) writes the 86-byte seed-only form, which every library on this page reads — even when the key was loaded from OpenSSL's larger default file.
  • crypto.subtle has the same operation (encapsulateBits / decapsulateBits), but Node.js 24 marks it experimental and prints an ExperimentalWarning; node:crypto does not.

Related recipes