Choosing what to use
The library offers several ways to do each job. This page says which to pick for a given situation and, just as important, when not to use something. It is the answer the maintainers would give you in person.
The decision in one table
| You want to... | Use | Do not use |
|---|---|---|
| Encrypt data to someone's public key | Envelope.seal with a hybrid key (HybridKEM, default X25519+ML-KEM-768) | HybridKEM.encapsulate plus your own AES code |
| Encrypt under the CNSA 2.0 parameter sets | Envelope.seal with a pure ML-KEM-1024 key (envelope v2) via cnsa2.kem() | A hybrid key (reported partial); ML-KEM-768 |
| Encrypt data that other ecosystems (not Python) must decrypt | X-Wing | The default hybrid (it is its own construction: only Python and this library read it) |
| Encrypt a large file or an upload | sealStream / openStream | Envelope.seal on a multi-gigabyte buffer |
| Sign data that only quantum-safe libraries verify | HybridSign with a context | A signature without a context |
| Sign data that other ecosystems must verify | Sign.signRaw() (a standard FIPS 204 signature) | Sign.sign() and HybridSign signatures (a generic ML-DSA library cannot verify them) |
| Issue tokens your own services verify | JWTSigner / JWTVerifier | Hand-built tokens |
| Issue tokens that other JOSE libraries must verify | StandardJwt (RFC 9964) | JWTSigner (only quantum-safe libraries verify it) |
| Move existing keys to post-quantum safely | Upgrader plus MigrationStateManager | Swapping every key atomically in one deploy |
| Find classical cryptography in a codebase | quantum-safe-audit scan in CI | Grep |
| Check a configuration against the CNSA 2.0 parameter sets | cnsa2.report and cnsa2.enforce | A hybrid suite offered as a compliance claim |
The same decision as code:
import { HybridKEM, HybridSign, cnsa2, Envelope, utf8 } from 'quantum-safe-ts';
function encryptionKeys(cnsa2Profile: boolean) {
// Pure ML-KEM-1024 (envelope v2) for the CNSA 2.0 profile; otherwise the hybrid default.
return cnsa2Profile ? cnsa2.kem().generateKeyPair() : new HybridKEM().generateKeyPair();
}
function signer(everyVerifierIsPy031OrTs: boolean) {
// -v2 (the default) when every verifier can read it; the original format when an older Python verifier is involved.
return everyVerifierIsPy031OrTs ? new HybridSign() : new HybridSign('Ed25519+ML-DSA-65');
}
for (const profile of [false, true]) {
using keys = encryptionKeys(profile);
const sealed = Envelope.seal(utf8('data'), keys.publicKey);
if (sealed.version !== (profile ? 2 : 1)) throw new Error('the version is chosen by the key, not by you');
}
for (const modern of [false, true]) {
const s = signer(modern);
using kp = s.generateKeyPair();
const sm = s.sign(utf8('data'), kp.secretKey, { context: utf8('example-v1') });
s.verify(sm, kp.publicKey, { expectedContext: utf8('example-v1') });
}Key exchange and encryption
Hybrid or pure?
Use hybrid (the default) for new deployments during the transition. A hybrid such as X25519+ML-KEM-768 stays secure if either of its two components holds, which is the position NIST, CISA, BSI and NCSC take for the transition period. The price is a few tens of microseconds and 34 extra bytes of ciphertext and of public key (X25519's 32 bytes plus a 2-byte length).
Use pure ML-KEM when a standard or a contract requires it. CNSA 2.0 is the main case: NSA accepts standalone ML-KEM-1024 and does not require hybrids (see CNSA 2.0).
Do not use pure ML-KEM merely to save bytes. If bandwidth is tight, measure first; the hybrid overhead is small next to a network round trip.
Which security level?
| Parameter set | NIST category | Pick it when |
|---|---|---|
ML-KEM-512 | 1 | Almost never. Only to interoperate with something that fixes it. |
ML-KEM-768 | 3 | The default. Right for most applications. |
ML-KEM-1024 | 5 | CNSA 2.0, or long-lived secrets where you want margin. |
Hybrid, X-Wing, or the Python-compatible hybrid?
X25519+ML-KEM-768 (default) | X-Wing | |
|---|---|---|
| Readable by | quantum-safe-py and quantum-safe-ts | Other X-Wing implementations (for example @noble/post-quantum) and quantum-safe-ts; not quantum-safe-py |
| Specified by | quantum-safe-py's own HKDF combiner | draft-connolly-cfrg-xwing-kem (an individual Internet-Draft, not an RFC) |
| Reviewed against NIST SP 800-227's combiner guidance | No | Mentioned in SP 800-227 as an example; that is not an endorsement |
If Python is on the other end, take the default. If anyone else is, take X-Wing.
Envelope v1 or v2?
You do not choose a version: Envelope.seal picks it from the key. A hybrid key gives v1 (HKDF-SHA-256). A pure ML-KEM-1024 key gives v2 (HKDF-SHA-384, which is what CNSA 2.0 asks of key derivation). quantum-safe-py 0.3.2 or later reads and writes both.
Signatures
Hybrid or pure?
Use hybrid (HybridSign, Ed25519+ML-DSA-65) while classical signatures still carry trust in your ecosystem. Both signatures must verify.
Use pure ML-DSA (Sign) for CNSA 2.0 (ML-DSA-87) or when size matters more than belt-and-braces. Sizes are fixed by the algorithm:
import { Sign, HybridSign, utf8 } from 'quantum-safe-ts';
const message = utf8('size check');
const sizes: Record<string, number> = {};
for (const name of ['ML-DSA-65', 'ML-DSA-65-v2'] as const) {
const s = new Sign(name);
using kp = s.generateKeyPair();
sizes[name] = s.sign(message, kp.secretKey).signature.length;
}
const h = new HybridSign('Ed25519+ML-DSA-65-v2');
using hk = h.generateKeyPair();
sizes['Ed25519+ML-DSA-65-v2'] = h.sign(message, hk.secretKey).signature.length;
console.log(sizes);
// -v2: the ML-DSA-65 signature is 3309 bytes; the hybrid adds a 64-byte Ed25519 signature.
if (sizes['ML-DSA-65-v2'] !== 3309 || sizes['Ed25519+ML-DSA-65-v2'] !== 3309 + 64) throw new Error('unexpected sizes');
// The default format adds a 33-byte hedging prefix (1 length byte + 32 random bytes).
if (sizes['ML-DSA-65'] !== 3309 + 33) throw new Error('unexpected default-format size');Original format or -v2?
| Original (v1) | -v2 (the default) | |
|---|---|---|
| Who can verify | quantum-safe-py (all versions) and quantum-safe-ts | quantum-safe-py 0.3.2 or later and quantum-safe-ts. A standard FIPS 204 library can verify it only by rebuilding the wrapped message M2 and passing the context quantum-safe-sig-v2. |
| Structure | Hedging prefix and an unsigned length byte, wrapped in CBOR | No prefix; one fixed-length blob |
| Hedging | Your choice (hedged); the verifier must match | Always hedged inside ML-DSA; nothing to match |
| What is signed | Context yes; the algorithm no | Algorithm and context, for both halves |
| Available for | Every ML-DSA suite, the hybrids, SLH-DSA | ML-DSA-44/65/87 and the Ed25519 / P-256 hybrids (no SLH-DSA) |
Recommendation. -v2 is the default of easy.generateSigningKeys and HybridSign() from 1.0.0, so the defaults are right for new signatures wherever every verifier you control is quantum-safe-py 0.3.2+ or quantum-safe-ts. Name the original format (new HybridSign('Ed25519+ML-DSA-65')) while an older Python verifier is in the loop. new HybridSign(undefined, { hedged: false }) selects the original format, because -v2 is always hedged.
WARNING
Never use the same key material in both formats. The ML-DSA halves are separated by FIPS 204's context, but the classical halves are not. Generate separate keys for -v2.
Always give signatures a context
A context such as myapp-release-v1 ties a signature to one purpose. Sign with it and verify with the same value (expectedContext), and a signature made for one purpose cannot be replayed for another.
Tokens (JWT)
JWTSigner/JWTVerifier: tokens that only quantum-safe-py and quantum-safe-ts can verify. Supports hybrid keys. Pick it when every verifier is yours.StandardJwt(RFC 9964): tokens any compliant JOSE library can verify; pure ML-DSA only. Pick it when other ecosystems, gateways or third parties verify your tokens.
Do not put a JWTSigner token in front of a generic JWT gateway: it will be rejected.
Migration
Upgrader produces a hybrid key that contains your classical key, and MigrationStateManager tracks each key through classical_only → hybrid_transition → pqc_preferred → pqc_only. An upgraded hybrid key is not backward compatible: classical-only software cannot parse it, so keep publishing the original classical key to such clients during the transition. Give the manager a store with compareAndSet whenever more than one process shares it (Migration).
When this library is not the right tool
- You need XMSS, multi-level HSS signing, or LMS signing outside Node.js. LMS signing of a single tree is provided in Node.js (LMS signing); verification works everywhere. For hardware-module key custody, use the module's own signer.
- You need to run on constrained or embedded devices. The WebAssembly is about 455 KiB gzipped. Use liboqs, mlkem-native or a vendor SDK there.
- You only need a primitive and the platform already has it (recent Node.js and some browsers expose ML-KEM and ML-DSA in WebCrypto). A smaller dependency is better; see What quantum-safe-ts adds.