Skip to content

feat: add post-quantum hybrid Noise handshake#665

Draft
paschal533 wants to merge 1 commit intoChainSafe:masterfrom
paschal533:feat/pqc-xxhfs-noise
Draft

feat: add post-quantum hybrid Noise handshake#665
paschal533 wants to merge 1 commit 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
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