Appearance
Hybrid KEM+AES encryption, explained
pqc.encrypt looks like a black box. It isn't — this is exactly what happens inside.
The problem it solves
ML-KEM is not a data cipher: it is a key encapsulation mechanism (KEM). It cannot encrypt your 2 MB JSON; the only thing it knows how to do is agree on a 32-byte secret between two parties in a way that resists quantum computers.
The standard pattern (the same one TLS uses) is hybrid: the KEM agrees on the secret, and a fast symmetric cipher — AES-256-GCM — encrypts the data with that secret.
Step by step: ml-kem-768 (envelope v1)
encrypt(data, publicKey):
1. ML-KEM-768.encapsulate(publicKey)
→ cipherText (1088 bytes) what travels
→ sharedSecret (32 bytes) NEVER travels
2. nonce = random(12 bytes)
3. sealed = AES-256-GCM(key = sharedSecret, nonce).encrypt(data)
→ includes the authentication tag (16 bytes)
4. result = [version|alg|cipherText|nonce|sealed]The recipient reverses the process: decapsulate(cipherText, secretKey) reconstructs the same 32-byte sharedSecret, and AES-GCM decrypts and verifies integrity. A single flipped bit and the tag fails to validate: decrypt throws PqcError('DECRYPTION_FAILED').
Step by step: x-wing (envelope v2)
Same shape, one extra combining step because two KEMs run instead of one:
encrypt(data, publicKey):
1. XWing.encapsulate(publicKey) # runs X25519 AND ML-KEM-768 internally
→ cipherText = ct_M(1088) ‖ ct_X(32) → 1120 bytes, what travels
→ sharedSecret (32 bytes) → SHA3-256(ss_M ‖ ss_X ‖ ct_X ‖ pk_X ‖ label)
NEVER travels
2. nonce = random(12 bytes)
3. sealed = AES-256-GCM(key = sharedSecret, nonce).encrypt(data)
→ includes the authentication tag (16 bytes)
4. result = [version|alg|cipherText|nonce|sealed]The sharedSecret X-Wing returns is already the draft's combiner output — a SHA3-256 hash over both component secrets, the ciphertext, and the public key, with a fixed domain-separation label. The SDK uses it directly as the AES-256 key, exactly like ml-kem-768's secret, because the combiner already is the key derivation (see "Why these decisions").
Why these decisions
- The KEM's shared secret is used directly as the AES-256 key, for both algorithms. FIPS 203 guarantees ML-KEM-768's shared secret is uniformly random; X-Wing's shared secret is its own combiner output (SHA3-256 with domain separation) — either way, no additional KDF layer is added on top.
- GCM provides confidentiality and authentication in one pass — no manual encrypt-then-sign, no padding oracles.
- Random nonce per message: encrypting the same thing twice produces different ciphertexts.
- Fixed overhead: 1118 bytes per message for
ml-kem-768(2 header + 1088 KEM + 12 nonce + 16 tag), 1150 bytes forx-wing(2 header + 1120 hybrid ciphertext + 12 nonce + 16 tag) — 32 bytes more, the size of the X25519 ephemeral share (ct_X) — whether the payload is 1 byte or 100 MB.
Two senses of "hybrid"
"Hybrid encryption" on this page means the classic KEM-DEM construction: a public-key KEM agrees on a symmetric key, and a symmetric cipher (AES-256-GCM) encrypts the data. This is the long-standing meaning of the term — public-key to establish a key, symmetric to move the bytes — and the one TLS uses. Both algorithms below follow it.
That is a different axis from classical+post-quantum hybrid: running two key-establishment algorithms and combining their secrets, so the result stays secure as long as either one holds. The SDK offers this as a second, opt-in KEM:
x-wing(the default since 0.8.0): X25519 + ML-KEM-768, combined per draft-connolly-cfrg-xwing-kem, an established construction with a formal security analysis — not a homegrown combiner. Envelopepqcenc.v2.ml-kem-768: ML-KEM-768 (FIPS 203) alone as the KEM. Envelopepqcenc.v1. Choose it deliberately when FIPS certification scope matters (X-Wing is a CFRG draft, not covered by FIPS 203) or when size and speed dominate.
Both are selected purely by which key you pass — same pqc.encrypt/ pqc.decrypt signature, no mode flag:
ts
import { pqc } from '@pqc-sdk/core';
const hybrid = await pqc.keys.generate(); // x-wing (default) → pqcenc.v2
const kemOnly = await pqc.keys.generate({ algorithm: 'ml-kem-768' }); // → pqcenc.v1
await pqc.encrypt('data', hybrid.publicKey); // v2 envelope
await pqc.encrypt('data', kemOnly.publicKey); // v1 envelope
// pqc.decrypt reads the leading version byte and picks the matching path —
// no need to track which algorithm produced a given ciphertext.Choosing an algorithm
x-wing (default) | ml-kem-768 | |
|---|---|---|
| Security if ML-KEM-768 alone were ever broken | Still confidential (X25519 holds) | Confidentiality lost |
| Overhead per message | 1150 bytes + plaintext (+32 B) | 1118 bytes + plaintext |
| Public key | 1216 bytes | 1184 bytes |
| Secret key | 32 bytes (a seed) | 2400 bytes |
| Relative speed (this SDK's bench) | ~3–6× slower per operation | baseline |
| FIPS 203 certification scope | No (CFRG draft) | Yes |
Use x-wing for data that must stay confidential for years — archives, backups, anything with a long secrecy requirement — where the extra 32 bytes and the slower KEM operations are a small price for not depending on a single, relatively new algorithm. This mirrors the industry's current default for new protocols: TLS 1.3's X25519MLKEM768, Signal's PQXDH, and Apple's PQ3 all combine a classical KEM with a post-quantum one rather than betting on either alone.
x-wing is the SDK default since 0.8.0 (pqc.keys.generate() with no arguments). The flip was originally deferred to v1.0, on the grounds that changing the no-arg return type breaks TypeScript consumers; at 0.x that break is cheap and expected, and shipping the weaker default for longer was the worse trade. See migrating to 0.8.0.
ml-kem-768 stays a first-class choice, one line away (pqc.keys.generate({ algorithm: 'ml-kem-768' })), and is the right call when FIPS certification scope matters — X-Wing is a CFRG draft, not covered by FIPS 203 — or when size and speed dominate. One deployment caveat survives the flip: a peer on ≤0.4.x cannot decrypt a pqcenc.v2 envelope or parse an x-wing key token, so a mixed fleet upgrades its readers before its writers.
In code
ts
import { pqc } from '@pqc-sdk/core';
const pair = await pqc.keys.generate();
const a = await pqc.encrypt('same message', pair.publicKey);
const b = await pqc.encrypt('same message', pair.publicKey);
console.log(a.length === b.length); // true — fixed overhead
// but a ≠ b: fresh encapsulation and nonce on every callWhat it does NOT do
- It does not authenticate the sender. Anyone with your public key can encrypt messages to you. If you need to know who sent it, combine with ML-DSA signatures.
- It does not protect the secret key. Store it like any secret (KMS, encrypted env vars — and out of git).