Skip to content

feat: add post-quantum hybrid Noise handshake#665

Draft
paschal533 wants to merge 2 commits intoChainSafe:masterfrom
paschal533:feat/pqc-xxhfs-noise
Draft

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

Conversation

@paschal533
Copy link
Copy Markdown

What this PR does

This adds a second connection encrypter to the package alongside the existing noise(): a post-quantum hybrid handshake called noiseHFS() that implements the Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256 pattern.

The idea is that you can swap noise() for noiseHFS() 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

  • Protocol name: Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256
  • libp2p protocol ID: /noise-pq/1.0.0
  • KEM library: @noble/post-quantum v0.6.0 (pure JS, works in browsers and Node.js without WASM or native bindings)

The handshake pattern is:

-> e, e1
<- e, ee, ekem1, s, es
-> s, se

The two new tokens are e1 (initiator sends KEM ephemeral public key in Message A) and ekem1 (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)

Message Classical XX XXhfs Delta
Msg A (initiator to responder) 32 B 1,248 B +1,216 B
Msg B (responder to initiator) 96 B 1,232 B +1,136 B
Msg C (initiator to responder) 64 B 64 B 0 B
Total 192 B 2,544 B +2,352 B

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)

ops/s ms/op
X-Wing keygen 293 3.42
X-Wing encapsulate 120 8.32
X-Wing decapsulate 136 7.33
Classical XX handshake 114 8.75
XXhfs handshake 23 44.18

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.md for the full breakdown.

New files

File Description
src/kem.ts IKem interface
src/crypto/pqc.ts X-Wing KEM backend (pure JS)
src/crypto/pqc.node.ts Node.js backend slot with TODO for native ML-KEM-768
src/protocol-pqc.ts XXhfsHandshakeState state machine
src/performHandshake-hfs.ts Initiator and responder orchestration
src/noise-hfs.ts NoiseHFS encrypter + noiseHFS() factory
NOISE_HFS_SPEC.md Full wire format spec + security analysis
benchmarks/benchmark-pqc.js Benchmark runner
benchmarks/results.md Measured results
scripts/generate-pqc-vectors.js Deterministic test vector generator
test/fixtures/pqc-test-vectors.json 5 committed test vectors
test/pqc-kem.spec.ts IKem unit tests (17)
test/pqc-protocol.spec.ts Handshake state machine tests (18)
test/pqc-noise.spec.ts Integration tests (12)
test/pqc-vectors.spec.ts Test vector verification (52)

Usage

import { createLibp2p } from 'libp2p'
import { noiseHFS } from '@chainsafe/libp2p-noise'

const node = await createLibp2p({
  connectionEncrypters: [noiseHFS()],
  // ... other options
})

Both peers must use noiseHFS(). It is not backward-compatible with the classical /noise protocol 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, NoiseHFS will support ML-DSA identity keys automatically because privateKey.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.md has 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 build passes with no TypeScript errors
  • pnpm test:node passes all 99 new tests (plus existing suite)
  • node benchmarks/benchmark-pqc.js runs and produces output matching benchmarks/results.md
  • node scripts/generate-pqc-vectors.js regenerates identical vectors (deterministic)
  • Review NOISE_HFS_SPEC.md for protocol correctness

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
Copy link
Copy Markdown
Author

paschal533 commented Apr 10, 2026

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
Copy link
Copy Markdown
Author

paschal533 commented Apr 17, 2026

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.

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