Sealed Boxes in Twenty Lines
Post-quantum public-key encryption with no nonce for you to get wrong
Audience: Rust and WASM developers who need to encrypt to a public key Version: 1.0 | September 2026
A KEM gives you 32 bytes, not encryption
A key encapsulation mechanism like ML-KEM does one thing: it lets two parties agree on a 32-byte shared secret. It does not encrypt your message.
To go from there to encrypted data, you have to:
- derive an encryption key from the shared secret,
- pick an AEAD cipher,
- generate a nonce and never reuse it,
- bind the KEM ciphertext to the encrypted payload, so the two halves can't be swapped between messages,
- decide what an attacker learns when decryption fails.
Every one of those is a place to get it subtly wrong, and most teams write it themselves.
pqc-kem 0.3 does it once, behind a feature called aead-wrap.
The whole thing
use pqc_kem::aead_wrap::{open_hybrid, seal_hybrid};
use pqc_kem::HybridKemKeypair;
use rand::rngs::OsRng;
let recipient = HybridKemKeypair::generate(&mut OsRng).unwrap();
let sealed = seal_hybrid(&mut OsRng, &recipient.public_key(), b"my-protocol/v1", b"header", b"payload").unwrap();
let plaintext = open_hybrid(&recipient, &sealed, b"my-protocol/v1", b"header").unwrap();
assert_eq!(&plaintext[..], b"payload");
[dependencies]
pqc-kem = { version = "0.3", features = ["aead-wrap"] }
That seals a payload to the recipient's X25519 + ML-KEM-768 hybrid public key. The recipient
opens it with their keypair. seal_ml_kem_768 / open_ml_kem_768 do the same for ML-KEM-768
alone.
What happens underneath
(kem_ct, ss) = Encapsulate(recipient_public_key)
key = HKDF-SHA256(ikm = ss, salt = none,
info = "pqc-kem-aead-wrap-v1" ‖ 0x00 ‖ kem_algorithm ‖ 0x00 ‖ context)
ciphertext = AEAD(key, random nonce, plaintext, aad = kem_ct ‖ aad)
The design choices, and why:
- No caller-supplied nonce, anywhere. Every box comes from a fresh encapsulation, so every box has its own key. A random nonce under a single-use key is safe, so the library generates it and no function accepts one. There is no API for nonce reuse.
- XChaCha20-Poly1305 by default. ChaCha20-Poly1305 is there for peers that need the IETF construction. Each box records its suite, and opening accepts either.
contextis domain separation. The same shared secret under two contexts gives two unrelated keys. Use a fixed, protocol-specific string, so a box sealed for one protocol can never be opened as a message in another.- The KEM ciphertext is authenticated along with your
aad. An attacker can't move the encrypted half of one box onto the key-exchange half of another. - One error for every failed open. A malformed box (wrong length, wrong algorithm) is
reported as malformed. After that, a wrong key, a tampered byte, and a wrong context or
aadall return the same error. A failed open tells an attacker nothing about which check failed. - Secrets clean up after themselves. The derived key and the opened plaintext are zeroized on drop.
How we know the derivation is right
The key derivation is pinned by five test vectors computed by an independent
implementation of HKDF-SHA256 (RFC 5869), written in plain Python and sharing no code with
the crate. Before producing any vector, that implementation checks itself against RFC 5869's
own published test case. If the derivation ever changes, it gets a new label
(pqc-kem-aead-wrap-v2); the values under v1 never change.
Seventeen tests cover round trips under both suites and both KEMs, tampering at every position, wrong keys, wrong contexts, and malformed boxes.
In the browser
The repository's pqc-kem-wasm crate builds JavaScript bindings with wasm-pack that
expose the same thing: aead_seal_hybrid, aead_seal_ml_kem_768, and aead_open on each
keypair. Sealing from JavaScript is XChaCha20-Poly1305 only, so a JavaScript caller never
handles a nonce either. (The bindings aren't on npm yet; build them from the repository.)
pqc-kem has not been independently audited and is not FIPS 140-3 validated. Known issues
are in its SECURITY.md. API docs for
sealed boxes: src/aead_wrap.rs.
These are the primitives 0x307 builds its own products on. More runnable programs, one per crate, are in the examples repository.