Skip to content
ecdsa.com

ML-KEM-768 encapsulation · WebCrypto

How to encapsulate and decapsulate a shared secret with ML-KEM-768 in WebCrypto

To encapsulate and decapsulate a shared secret with ML-KEM-768 in WebCrypto, generate the key with crypto.subtle.generateKey({ name: "ML-KEM-768" }, …), call crypto.subtle.encapsulateBits on the sender's side and crypto.subtle.decapsulateBits on the receiver's. These methods come from the WICG "Modern Algorithms in WebCrypto" proposal; the recipe runs the same page in Chromium, Firefox and WebKit and shows which support them today.

Tested against the WebCrypto API in Chromium 156.0.8078.4 (Playwright 1.64.1 headless, crypto.subtle), Firefox 157.0 (Playwright 1.64.1), and WebKit 27.2 (Playwright 1.64.1) — the same crypto.subtle interface browsers implement.

mlkem.mjs — key pair, encapsulation, decapsulation, and a PKCS#8 decapsulation key
const algorithm = { name: "ML-KEM-768" };
export async function agree() {
// Receiver: a key pair; only the encapsulation key is handed out
const { publicKey, privateKey } = await crypto.subtle.generateKey(algorithm, true, ["encapsulateBits", "decapsulateBits"]);
// Sender: a fresh 32-byte shared secret and the ciphertext that carries it
const { sharedKey, ciphertext } = await crypto.subtle.encapsulateBits(algorithm, publicKey);
// Receiver: the same secret from the ciphertext
const received = await crypto.subtle.decapsulateBits(algorithm, privateKey, ciphertext);
const raw = await crypto.subtle.exportKey("raw-public", publicKey);
console.log("encapsulation key", raw.byteLength, "bytes, ciphertext", ciphertext.byteLength, "bytes, shared secret", sharedKey.byteLength, "bytes");
const equal = new Uint8Array(received).every((byte, i) => byte === new Uint8Array(sharedKey)[i]);
console.log("shared secret agreed:", equal);
}
// Decapsulate with a PKCS#8 private key (the DER of a "BEGIN PRIVATE KEY" seed file, e.g. from OpenSSL 3.5)
export async function decapsulateWithPkcs8(pkcs8, ciphertext) {
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, algorithm, false, ["decapsulateBits"]);
return crypto.subtle.decapsulateBits(algorithm, privateKey, ciphertext);
}

How it works

  1. generateKey({ name: "ML-KEM-768" }, true, ["encapsulateBits", "decapsulateBits"]) creates the pair; exportKey("raw-public", publicKey) returns the 1184-byte encapsulation key.
  2. encapsulateBits(algorithm, publicKey) resolves to { sharedKey, ciphertext } — 32 and 1088 bytes as ArrayBuffers.
  3. decapsulateBits(algorithm, privateKey, ciphertext) resolves to the same 32 bytes. encapsulateKey / decapsulateKey return a ready CryptoKey (for example AES-GCM) instead of raw bits.
  4. importKey("pkcs8", …) takes the DER of OpenSSL's seed-only private key file and importKey("spki", …) its public key; Chromium decapsulates OpenSSL's ciphertext to dee35810…30f9.

Gotchas

  • Support is not universal yet: Chromium 156 runs every step, Firefox 157 rejects generateKey with NotSupportedError, and WebKit 27.2 fails with a bare TypeError: Type error. Feature-detect crypto.subtle.encapsulateBits before relying on it.
  • Like every ML-KEM implementation, decapsulateBits returns a different secret for a tampered ciphertext instead of rejecting it; authenticate what you protect with the derived key.
  • Node.js 24 implements the same methods in crypto.subtle, marked experimental.

Related recipes