DEV Community

Cover image for Your WebCrypto key exchange is one string away from post-quantum
Jim
Jim

Posted on

Your WebCrypto key exchange is one string away from post-quantum

NIST finalized the post-quantum standards two years ago: ML-KEM (FIPS 203) for key exchange, ML-DSA (FIPS 204) for signatures. The WICG Modern Algorithms draft adds both to crypto.subtle. Node.js ships them natively since 24.7. Browsers: not yet.

So today you can't write post-quantum WebCrypto code that runs where your users actually are. And when browsers do ship it, you'll hit a second, sneakier problem: even with ML-KEM available, your existing ECDH code can't use it, because a KEM is a different shape of primitive.

subtlepq is a small Apache-2.0 library that solves both: a true polyfill of the WICG draft (75 KB of wasm, delegates everything it can to the platform, self-retires where native support exists), plus a migration adapter that lets you move your ECDH code to the KEM calling convention now, while it's still classical — so that going post-quantum later is literally a one-string change.

npm install subtlepq
Enter fullscreen mode Exit fullscreen mode

The shape problem nobody warns you about

ECDH and KEMs don't have compatible APIs, and no amount of polyfilling can hide that.

ECDH is symmetric in structure: both sides have a keypair, each combines their private key with the other's public key, and the same secret falls out. In WebCrypto that's deriveKey/deriveBits.

A KEM is asymmetric in structure: the sender encapsulates against the recipient's public key and gets back two things — a shared secret and a ciphertext that must travel to the recipient, who decapsulates it with their private key to get the same secret. There's no deriveBits here: encapsulation is randomized, there's an extra artifact on the wire, and there's no static-static mode.

That means "swap ECDH for ML-KEM" is not a find-and-replace. It's a protocol change: a new message field for the ciphertext, a new call sequence, new key lifecycle. If you wait for browsers to ship ML-KEM before starting, you'll be doing the API migration and the algorithm migration at the same time, under deadline.

RFC 9180 already solved this

HPKE (RFC 9180) had exactly this problem — it wanted one KEM interface over both classical and post-quantum algorithms — and solved it with a construction called DHKEM: run an ephemeral-static ECDH and call the ephemeral public key the ciphertext.

Encapsulate = generate a throwaway keypair, ECDH its private half against the recipient's public key, run the result through HKDF with RFC 9180's labeled derivation. The "ciphertext" you send is just the ephemeral public key — 32 bytes for X25519, 65 for P-256.

Decapsulate = ECDH the received ephemeral public key against your static private key, apply the same HKDF. Same secret, KEM-shaped API, interoperable with any RFC 9180 implementation.

subtlepq exposes this as DHKEM-X25519-HKDF-SHA256 and DHKEM-P256-HKDF-SHA256 through the same encapsulateKey/decapsulateKey calls as ML-KEM. It's 100% native crypto underneath — no wasm involved, the keys are genuine platform CryptoKeys.

Before and after

Classical WebCrypto ECDH — the code you probably have today:

// Alice derives an AES key from her private key + Bob's public key
const aesKey = await crypto.subtle.deriveKey(
    { name: "X25519", public: bobPublicKey },
    alicePrivateKey,
    { name: "AES-GCM", length: 256 }, false, ["encrypt"]);
Enter fullscreen mode Exit fullscreen mode

The same exchange in KEM shape, still 100% classical X25519 — and note it's the patched crypto.subtle:

import { install } from "subtlepq";
install(true);   // patch crypto.subtle; true = include the DHKEM extension

// Sender: encapsulate against Bob's public key
const { sharedKey, ciphertext } = await crypto.subtle.encapsulateKey(
    "DHKEM-X25519-HKDF-SHA256", bobPublicKey,
    { name: "AES-GCM", length: 256 }, false, ["encrypt"]);
// ... send ciphertext (32 bytes) along with your encrypted payload ...

// Bob: decapsulate with his private key
const aesKey = await crypto.subtle.decapsulateKey(
    "DHKEM-X25519-HKDF-SHA256", bobPrivateKey, ciphertext,
    { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
Enter fullscreen mode Exit fullscreen mode

The ciphertext is an encoded X25519 ephemeral public key, instead of Alice's pre-shared key. It needs to be transmitted along with the encrypted data.

One DHKEM-specific nuance: RFC 9180 mixes the recipient's public key into the derivation, so decapsulation needs the public half too. subtlepq remembers it automatically for keys created through its own generateKey/importKey — so bobPrivateKey above just works if Bob generated his keypair under the DHKEM name. For a private key from elsewhere — an existing native ECDH key, or one structured-cloned into another worker — pass the public half explicitly, { name: "DHKEM-X25519-HKDF-SHA256", publicKey } as the algorithm, or make sure the private key is extractable. ML-KEM has no such requirement, so this nuance also disappears with the migration.

And the post-quantum version of that code:

// Sender: encapsulate against Bob's public key
const { sharedKey, ciphertext } = await crypto.subtle.encapsulateKey(
    "ML-KEM-768", bobPublicKey,
    { name: "AES-GCM", length: 256 }, false, ["encrypt"]);

// Bob: decapsulate with his private key
const aesKey = await crypto.subtle.decapsulateKey(
    "ML-KEM-768", bobPrivateKey, ciphertext,
    { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
Enter fullscreen mode Exit fullscreen mode

That's the whole migration. The ciphertext gets bigger for ML-KEM (1088 bytes for ML-KEM-768) and the keys change, but the calling convention, the message flow, and the key lifecycle are already right. You can ship the DHKEM version today, run it in production while the PQ ecosystem settles, and flip the string when you're ready — or negotiate per peer.

sharedKey in all of these is a real native CryptoKey: subtlepq produces the raw secret, imports it through the platform's own importKey, and hands you back a platform key. Everything downstream — AES-GCM, HKDF, HMAC — is native crypto at native speed.

Signatures, by the way, don't have a shape problem: ML-DSA is already ECDSA-shaped, so sign/verify just work with the new names.

const kp = await crypto.subtle.generateKey("ML-DSA-65", false, ["sign", "verify"]);
const sig = await crypto.subtle.sign("ML-DSA-65", kp.privateKey, data);
const valid = await crypto.subtle.verify("ML-DSA-65", kp.publicKey, sig, data);
Enter fullscreen mode Exit fullscreen mode

What "true polyfill" means here

Everything above ran on the real crypto.subtle: install() patches it in place, so existing code and third-party libraries pick up ML-KEM/ML-DSA without knowing subtlepq exists — non-ML calls reach the untouched native methods. install(true) additionally registers the DHKEM extension names. And it self-retires per algorithm: install probes native support for each algorithm it would handle and delegates the ones the platform already does — on Node ≥ 24.7, ML-KEM keys come from the native implementation (genuine platform keys) while only DHKEM is polyfilled, and on a platform that covers everything nothing is patched at all.

Prefer not to touch globals? import { subtle } from "subtlepq" is the same thing as a ponyfill.

The engine is liboqs compiled to wasm with a minimal config — all six parameter sets (ML-KEM-512/768/1024, ML-DSA-44/65/87) in 75 KB. The build is reproducible from a pinned toolchain: clone the repo, run wasm/build.sh, and you get byte-identical artifacts to what's on npm. Correctness is anchored to NIST ACVP known-answer vectors, RFC 9180 Appendix A vectors for DHKEM, and a parity suite that checks byte-identical key formats and cross-operation against Node's native implementation.

The polyfilled keys support the full format surface of the draft: raw public keys, 32–64-byte seeds, SPKI, PKCS#8, JWK (the new AKP key type), plus wrapKey/unwrapKey and getPublicKey.

The honest limitations

A script polyfill can't fully impersonate platform keys, and subtlepq fails loud rather than pretending:

Polyfilled ML-* keys are not structured-cloneable — postMessage or IndexedDB throws DataCloneError instead of silently producing a dead key. The supported pattern is exporting the seed (32–64 bytes) and re-importing on the other side.

extractable: false on polyfilled keys is API-level enforcement. The key material lives in wasm linear memory, not in platform-isolated storage. If your threat model needs hardware-backed non-extractability, that has to wait for native support — which is exactly why the polyfill self-retires.

The DHKEM names are a documented subtlepq extension, not part of the WICG draft. If the working group adopts DHKEM identifiers, subtlepq will adopt theirs and alias these.

Try it

Everything above runs today in any modern browser and Node 20+:

subtlepq is built by the team behind libVES — end-to-end encryption between users, with post-quantum vaults and VESrecovery™ key recovery. subtlepq gives you the primitives; libVES is what goes on top: user-to-user key exchange, shared vaults, and recovery from a lost key without trusting the server with your secrets.

Questions and threat-model scrutiny welcome — that's what the Discussions are for.

Top comments (0)