Skip to content

feat: add post-quantum hybrid Noise handshake - #665

Draft
paschal533 wants to merge 6 commits into
ChainSafe:masterfrom
paschal533:feat/pqc-xxhfs-noise
Draft

feat: add post-quantum hybrid Noise handshake#665
paschal533 wants to merge 6 commits into
ChainSafe:masterfrom
paschal533:feat/pqc-xxhfs-noise

Conversation

@paschal533

@paschal533 paschal533 commented Apr 4, 2026

Copy link
Copy Markdown

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-quantum
  • src/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 machinery
  • src/noise-hfs.tsNoiseHFS ConnectionEncrypter, protocol ID /noise-mlkem768-hfs/0.1.0
  • test/pqc-kem.spec.ts, test/pqc-protocol.spec.ts, test/pqc-noise.spec.ts, test/pqc-vectors.spec.ts — test suite
  • scripts/noise-hfs-dial.mjs — standalone TCP dialer for cross-language interop testing

Cross-language interop

Tested against royzah/rust-libp2p PR #1 (Rust) and py-libp2p PR #1310 (Python):

Pair Result
Rust listener + Python dialer ✅ PASS
Rust listener + JS dialer ✅ PASS
Python listener + JS dialer ✅ PASS

All three runtimes speak Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 on /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 ekem1 slot is the architecturally correct choice.

ML-KEM-768 sizes

Field Size
Public key (encapsulation key) 1184 B
Secret key (decapsulation key) 2400 B
Ciphertext 1088 B
Shared secret 32 B
Message A wire size 1216 B (e=32 + e1=1184)
Encrypted ciphertext 1104 B (1088 + 16 AEAD)

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).
@paschal533
paschal533 requested a review from a team as a code owner April 4, 2026 22:35
@paschal533
paschal533 marked this pull request as draft April 4, 2026 22:36
@paschal533 paschal533 changed the title feat: add post-quantum hybrid Noise handshake (Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256) feat: add post-quantum hybrid Noise handshake Apr 4, 2026
@paschal533

paschal533 commented Apr 10, 2026

Copy link
Copy Markdown
Author

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 /noise-pq/1.0.0 over WebSockets. You paste the multiaddr into the browser UI, hit Connect, and it shows the remote peer ID, the protocol, and the handshake latency.

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.
@paschal533

paschal533 commented Apr 17, 2026

Copy link
Copy Markdown
Author

Ran the live cross-language interop test today, JS listener, Python dialer, real TCP connection.

Setup:

JS listener output:

Listener peer ID: 12D3KooWHFFVYwAnVQHZTqZhcq1woX11xKsMnujNDPbDyh77kLBa
Protocol: /noise-pq/1.0.0

Listening on tcp://127.0.0.1:8000
Waiting for Python dialer...

Incoming TCP connection from 127.0.0.1:PORT
Starting NoiseHFS responder handshake...
Handshake complete! Remote peer: 12D3KooWRPBkhDbfQmRJjVpR7hkEAmu8FP6mEaTBW61BAryeNjAH
Sent: "hello from JS"
Received: "hello from Python"

✅ INTEROP SUCCESS: Both sides exchanged messages through NoiseHFS!

Python dialer output:

[interop_dial] TCP connection established
[interop_dial] Starting XXhfs handshake (Python = initiator)...
[interop_dial] Handshake complete! Remote peer: 12D3KooWHFFVYwAnVQHZTqZhcq1woX11xKsMnujNDPbDyh77kLBa
[interop_dial] Received from JS: "hello from JS"
[interop_dial] Sent to JS: "hello from Python"

INTEROP SUCCESS
Python <-> JavaScript NoiseHFS handshake complete.
Protocol: Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256

Both runtimes independently derived the same session keys and successfully exchanged encrypted messages. The scripts/node-listener.mjs added in the latest commit is all you need on the JS side to reproduce this.

Node.js v22, Python 3.13, Windows 11.

@paschal533

Copy link
Copy Markdown
Author

WASM backend results + Amdahl's Law finding

Following 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 built

A standalone Rust crate (src-wasm/) compiled with wasm-pack to a 58 KB .wasm binary. Stack: ml-kem 0.3.0-rc.2 (RustCrypto FIPS 203), x25519-dalek 2.0, sha3 0.10, wasm-bindgen 0.2. Three exported functions: xwing_keygen, xwing_encapsulate, xwing_decapsulate. The secret key is stored as a 64-byte ML-KEM seed rather than the expanded 2400-byte decapsulation key, so the full keypair output is 1312 bytes instead of 3648.

KEM micro-benchmark results (Node.js v22.17.1, x64)

Operation Pure-JS ms/op WASM ms/op Speedup
generateKemKeyPair 3.42 1.40 2.4x
encapsulate 8.32 2.19 3.8x
decapsulate 7.33 3.10 2.4x
Full KEM round-trip 21.43 6.72 3.2x

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

Protocol ms/handshake
Noise_XX classical ~19 ms
Noise_XXhfs pure-JS KEM ~93 ms
Noise_XXhfs WASM KEM ~91 ms

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 XXhfs exchange also does roughly:

  • 8 SHA-256 invocations
  • 4 HKDF derivations
  • 4 ChaCha20-Poly1305 AEAD operations
  • 2 X25519 DH computations
  • 2 Ed25519 signature verifications
  • 2 Protobuf encode/decode passes
  • 6 async task boundaries (Promise.all, stream reads/writes)

All of those are still pure-JS on pureJsCrypto / @noble/*. The KEM accounts for maybe 25-30% of the total handshake time. Speeding up just that part can only recover a limited fraction of the overhead.

What this means for the ~35-44 ms overhead

To actually get the full handshake close to classical, you need one of:

  1. Native Node.js ML-KEM support (planned for v24+) - this would also help the SHA-256/ChaCha20 path since those are already native via defaultCrypto. Best case outcome.
  2. A full WASM crypto backend covering all Noise primitives, not just the KEM. Significantly more work but achievable.
  3. WebCrypto ML-KEM in browsers when it eventually lands.

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 benchmarks/results.md here too if that would be useful for reviewers.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant