feat: add post-quantum hybrid Noise handshake#665
feat: add post-quantum hybrid Noise handshake#665paschal533 wants to merge 2 commits intoChainSafe:masterfrom
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 |
What this PR does
This adds a second connection encrypter to the package alongside the existing
noise(): a post-quantum hybrid handshake callednoiseHFS()that implements theNoise_XXhfs_25519+XWing_ChaChaPoly_SHA256pattern.The idea is that you can swap
noise()fornoiseHFS()in your libp2p config and get quantum-safe forward secrecy without giving up any classical security. The handshake is secure if either X25519 or ML-KEM-768 is unbroken.Background
Store-Now-Decrypt-Later is the main near-term quantum threat for libp2p connections. An adversary capturing encrypted traffic today could decrypt it once a large enough quantum computer exists. Upgrading forward secrecy now (before quantum computers arrive) is the practical defense.
The Noise HFS spec (https://github.com/noiseprotocol/noise_hfs_spec) defines how to add a KEM alongside the existing DH operations in any Noise pattern. This PR applies that to XX, using X-Wing as the KEM.
X-Wing (IETF draft-connolly-cfrg-xwing-kem) is a hybrid of ML-KEM-768 (FIPS 203) and X25519, combined via SHA3-256. Using a hybrid means neither primitive has to be trusted alone -- the combined secret is secure as long as at least one of them is.
Protocol details
Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256/noise-pq/1.0.0@noble/post-quantumv0.6.0 (pure JS, works in browsers and Node.js without WASM or native bindings)The handshake pattern is:
The two new tokens are
e1(initiator sends KEM ephemeral public key in Message A) andekem1(responder encapsulates and sends back the encrypted KEM ciphertext in Message B, then both sides mix the KEM shared secret into the chaining key).Message C is unchanged from classical XX.
Wire sizes (empty payload)
With a real
NoiseHandshakePayload(Ed25519 identity + extensions): approximately 500 B classical XX vs approximately 2,852 B for XXhfs.Performance (Node.js v22.17.1, pure JS)
The ~5x slowdown over classical XX is dominated by X-Wing (about 21 ms per round-trip). Native WASM or Node.js native ML-KEM support (when it lands) would improve this by roughly 3-10x. See
benchmarks/results.mdfor the full breakdown.New files
src/kem.tsIKeminterfacesrc/crypto/pqc.tssrc/crypto/pqc.node.tssrc/protocol-pqc.tsXXhfsHandshakeStatestate machinesrc/performHandshake-hfs.tssrc/noise-hfs.tsNoiseHFSencrypter +noiseHFS()factoryNOISE_HFS_SPEC.mdbenchmarks/benchmark-pqc.jsbenchmarks/results.mdscripts/generate-pqc-vectors.jstest/fixtures/pqc-test-vectors.jsontest/pqc-kem.spec.tstest/pqc-protocol.spec.tstest/pqc-noise.spec.tstest/pqc-vectors.spec.tsUsage
Both peers must use
noiseHFS(). It is not backward-compatible with the classical/noiseprotocol because the handshake message layout differs.Notes on PR #3432
js-libp2p PR #3432 (feat: Post quantum identities with ML-DSA by @dozyio) adds ML-DSA identity support. When that lands,
NoiseHFSwill support ML-DSA identity keys automatically becauseprivateKey.sign()is key-type aware. No changes needed in this layer.With ML-DSA identity on both sides, the full-PQ wire size would be approximately 9,400 bytes total.
benchmarks/results.mdhas the full breakdown. For most use cases,noiseHFS()with Ed25519 identity is a reasonable first step that covers the main Store-Now-Decrypt-Later threat without waiting for the identity layer migration.Test plan
pnpm buildpasses with no TypeScript errorspnpm test:nodepasses all 99 new tests (plus existing suite)node benchmarks/benchmark-pqc.jsruns and produces output matchingbenchmarks/results.mdnode scripts/generate-pqc-vectors.jsregenerates identical vectors (deterministic)NOISE_HFS_SPEC.mdfor protocol correctness