feat: add post-quantum hybrid Noise handshake - #665
Conversation
Adds Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256 as a second connection
encrypter alongside the existing classical noise(). Both can coexist in
the same libp2p node via protocol negotiation.
The new noiseHFS() factory implements the Noise HFS pattern which adds
two KEM tokens (e1 and ekem1) to the classical XX handshake, giving
quantum-safe forward secrecy against Store-Now-Decrypt-Later attacks.
Forward secrecy is secure if either X25519 or ML-KEM-768 (the two
underlying KEMs in X-Wing) is unbroken, so classical security is fully
preserved.
KEM: X-Wing (ML-KEM-768 + X25519 via SHA3-256 combiner)
- IETF draft-connolly-cfrg-xwing-kem
- Implemented via @noble/post-quantum v0.6.0 (pure JS)
New exports:
noiseHFS(), NoiseHFS, NoiseHFSInit
pqcKem, pqcCrypto, IKem, KemKeyPair, KemEncapsulateResult
XXhfsHandshakeState, NOISE_HFS_PROTOCOL_NAME
HfsHandshakeStateInit, HfsHandshakeParams
Wire sizes (empty payload):
Classical XX : 192 bytes
XXhfs : 2,544 bytes (+2,352 bytes)
Benchmark (Node.js v22.17.1, pure JS):
Classical XX handshake : 114 ops/s (8.75 ms)
XXhfs handshake : 23 ops/s (44.18 ms)
Tests: 99 new tests across 4 spec files + 5 deterministic test vectors
test/pqc-kem.spec.ts (17 tests - IKem / pqcKem)
test/pqc-protocol.spec.ts (18 tests - XXhfsHandshakeState)
test/pqc-noise.spec.ts (12 tests - integration with libp2p)
test/pqc-vectors.spec.ts (52 tests - test vector verification)
Also includes:
NOISE_HFS_SPEC.md - full wire format and protocol spec
benchmarks/benchmark-pqc.js - benchmark runner
benchmarks/results.md - measured results + PR #3432 analysis
scripts/generate-pqc-vectors.js - deterministic vector generator
src/crypto/pqc.node.ts - Node.js native KEM backend slot (TODO
when Node.js adds ML-KEM-768 support)
PR #3432 note: when js-libp2p adds ML-DSA identity (MLDSA65), NoiseHFS
will support it automatically. privateKey.sign() is key-type aware so
no changes are needed in this layer. The full-PQ wire size would be
approximately 9,400 bytes total (KEM overhead + MLDSA65 identity on
both sides).
|
Quick update: I've put together a standalone example repo that shows the XXhfs handshake working end-to-end with real libp2p nodes. It's at https://github.com/paschal533/pqc-libp2p-example The repo has two layers. The first is a pure-crypto walkthrough you can run with node scripts/handshake-walkthrough.js. It steps through the full XXhfs handshake in process with annotated output showing each message, the X-Wing encapsulate/decapsulate, and a KEM match confirmation. No libp2p involved, just the raw crypto. The second is a full libp2p setup: a Node.js listener (npm run listener) and a browser client (npm run browser:dev) both negotiating I'm also finalising a research paper covering the design rationale, security analysis, and benchmark numbers. I'll link it here once it's ready. If anyone wants to try it out and has feedback, especially on the API surface or anything that looks off in the handshake walkthrough, I'd really appreciate it. |
- scripts/node-listener.mjs: standalone TCP listener for interop testing with the Python py-libp2p implementation. Accepts one connection, performs the NoiseHFS (XXhfs) responder handshake, exchanges a greeting message, and reports success. Referenced in py-libp2p PR #1310. - package.json: add prepare script (aegir build) so the package builds automatically when installed from GitHub via npm/yarn/pnpm. - benchmarks/results.md: updated with April 2026 benchmark run showing current X-Wing and full handshake latency figures.
|
Ran the live cross-language interop test today, JS listener, Python dialer, real TCP connection. Setup:
JS listener output: Python dialer output: Both runtimes independently derived the same session keys and successfully exchanged encrypted messages. The Node.js v22, Python 3.13, Windows 11. |
WASM backend results + Amdahl's Law findingFollowing up on the performance numbers in the PR description. I built a Rust WASM module for the X-Wing KEM to see how much headroom there is on the KEM side, and the results turned up something interesting worth documenting here. What was builtA standalone Rust crate ( KEM micro-benchmark results (Node.js v22.17.1, x64)
The WASM numbers are stable across runs (less than 10% variance). The pure-JS numbers show more variance because of V8 JIT tier transitions on a busy dev machine. The surprising part: full handshake barely moves
The WASM KEM is 3.2x faster but the full handshake only improves by about 2%. This is Amdahl's Law in action. The KEM is not the only thing happening in a handshake. A complete
All of those are still pure-JS on What this means for the ~35-44 ms overheadTo actually get the full handshake close to classical, you need one of:
For now the WASM module is a useful reference showing what the KEM-only ceiling looks like, and the 58 KB binary is small enough to ship without concern. But the big win on handshake latency is going to come from the Node.js native path, not from this. The updated benchmark table and this analysis are now in the research write-up. Happy to add a note to |
Replace XWing with ml_kem768 from @noble/post-quantum. X-Wing bundled X25519 + ML-KEM-768 inside the KEM, but Noise XXhfs already provides the classical DH via ee/es/se tokens — the X25519 in X-Wing was redundant. Raw ML-KEM-768 in the ekem1 slot is the intended design. Protocol: Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 Protocol ID: /noise-mlkem768-hfs/0.1.0 Key sizes: pk=1184B ct=1088B sk=2400B ss=32B - Rewrite src/crypto/pqc.ts to use ml_kem768 (FIPS 203) - Stub src/crypto/pqc.wasm.ts to re-export pure-JS backend until src-wasm/src/lib.rs is updated for ML-KEM-768 functions - Update NOISE_HFS_PROTOCOL_NAME constant and all byte-size comments - Update protocol ID in NoiseHFS.protocol and header comments - Fix all test assertions for new key sizes (1184/1088/2400) - Regenerate test vectors with ML-KEM-768 seeded keys - Update generate-pqc-vectors.js to use ml_kem768 (64-byte keygen seed)
Update JSDoc and comment strings in src/index.ts, src/kem.ts, src/crypto/pool.ts, src/crypto/pqc.node.ts, and src/protocol-pqc.ts to reflect ML-KEM-768 instead of X-Wing: - Protocol name: Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 - Protocol ID: /noise-mlkem768-hfs/0.1.0 - Key sizes: PUBKEY_LEN=1184, CT_LEN=1088, SS_LEN=32, SK_LEN=2400 - Remove X-Wing architecture note from pqc.node.ts; fix TODO stub sizes - Fix readMessageA comment: "1184 bytes" (was "1216 bytes") No functional code changes — comments and JSDoc only. All 125 tests pass.
Standalone script that dials a Noise HFS listener at localhost:<port>, runs the Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 handshake as initiator, and prints PEER <peer_id> on success. Used in the triangle test: Rust listener + JS dialer, Python listener + JS dialer.
Post-quantum hybrid Noise handshake (XXhfs) for js-libp2p-noise
Implements
Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256— the hybrid Noise handshake combining X25519 (classical) with ML-KEM-768 (post-quantum) in the KEM slot, as proposed in libp2p/specs#723.Status: Research/WIP — not ready for merge, intended to demonstrate the integration point and validate cross-language interop.
What this adds
src/crypto/pqc.ts— ML-KEM-768 IKem backend via@noble/post-quantumsrc/crypto/pqc.wasm.ts— WASM-accelerated backend (pure-JS stub until WASM is rebuilt for ML-KEM-768)src/protocol-pqc.ts— XXhfs handshake state machine wrapping the existing Noise XX machinerysrc/noise-hfs.ts—NoiseHFSConnectionEncrypter, protocol ID/noise-mlkem768-hfs/0.1.0test/pqc-kem.spec.ts,test/pqc-protocol.spec.ts,test/pqc-noise.spec.ts,test/pqc-vectors.spec.ts— test suitescripts/noise-hfs-dial.mjs— standalone TCP dialer for cross-language interop testingCross-language interop
Tested against royzah/rust-libp2p PR #1 (Rust) and py-libp2p PR #1310 (Python):
All three runtimes speak
Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256on/noise-mlkem768-hfs/0.1.0.Key design decision
X-Wing (which bundles X25519 + ML-KEM-768 as a single KEM) was considered but rejected: the Noise XXhfs pattern already provides classical security through its own DH tokens (ee, es, se), making the X25519 inside X-Wing redundant. Raw ML-KEM-768 in the
ekem1slot is the architecturally correct choice.ML-KEM-768 sizes