Skip to content

feat(secure): add NoiseHFS - post-quantum hybrid Noise (Noise_XXhfs_25519+ML-KEM-768)#2811

Draft
paschal533 wants to merge 7 commits into
vacp2p:masterfrom
paschal533:feat/noise-hfs-pqc
Draft

feat(secure): add NoiseHFS - post-quantum hybrid Noise (Noise_XXhfs_25519+ML-KEM-768)#2811
paschal533 wants to merge 7 commits into
vacp2p:masterfrom
paschal533:feat/noise-hfs-pqc

Conversation

@paschal533

@paschal533 paschal533 commented Jul 11, 2026

Copy link
Copy Markdown

Draft: opened for early feedback rather than as something ready to merge - see "What's left" below.

What this adds

A post-quantum hybrid variant of the Noise handshake, NoiseHFS, implementing Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 under protocol id /noise-mlkem768-hfs/0.1.0. It applies the Noise Hybrid Forward Secrecy extension (the e1/ekem1 tokens) to the classical XX pattern, so a hybrid-capable node keeps the same three-message handshake structure as plain /noise, just with an extra ML-KEM-768 key encapsulation mixed into the chaining key alongside the existing X25519 DH operations:

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

The session stays confidential if X25519 is later broken by a quantum computer (the KEM covers that), and stays confidential if ML-KEM-768 turns out to have a classical weakness (the DH tokens cover that) - neither side's failure weakens the other's contribution to the final keys.

Full wire format, the reasoning behind picking raw ML-KEM-768 over a composite KEM like X-Wing, and the exact ekem1 token ordering (which matters - swapping two steps there silently produces divergent chaining keys) are written up in libp2p/protocols/secure/NOISE_HFS_SPEC.md.

Why raw ML-KEM-768 specifically

This wire format is meant to match Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 as published in a research paper I've been working on ("Post-Quantum Cryptography Integration into the Noise Protocol"), which already covers TypeScript and Python implementations of the same profile. The design rationale is that the XXhfs pattern's three DH tokens already provide classical security on their own, so wrapping the KEM slot in a composite primitive like X-Wing would just be a redundant extra X25519 operation.

Where the ML-KEM-768 implementation comes from

Rather than pulling in a separate PQC library, libp2p/crypto/mlkem768.nim binds directly to the MLKEM768_* C API that BoringSSL already ships (crypto/mlkem/mlkem.cc), which nim-libp2p already compiles in through its boringssl dependency for TLS. It's the same ML-KEM-768 implementation Chrome uses. No new C dependency, no new nimble dependency.

How it's wired in

NoiseHFS reuses noise.nim's SymmetricState/CipherState/KeyPair and message framing as-is (I exported what was needed rather than duplicating it - see the "export primitives" commit, which is purely additive visibility changes plus one new optional parameter with a default that keeps the classical Noise class's behaviour unchanged). Only the e1/ekem1 token handling and the top-level connection encrypter are new.

It's registered as a second SecureProtocol variant alongside the existing one, with a withNoiseHFS() builder method, meant to be mounted next to plain Noise so a hybrid-capable node still falls back to classical /noise transparently against peers that don't support it - no coordination needed, multistream-select handles the negotiation.

What's verified

I didn't have a Nim toolchain locally, so I set one up from scratch (portable Nim + mingw-w64 + a from-source BoringSSL build) specifically so I could actually run this rather than just eyeball it.

Unit and integration tests (tests/libp2p/protocols/test_noisehfs.nim, 9/9 pass locally): MLKEM768 encapsulate/decapsulate round-trips, malformed-length rejection, wrong-key implicit rejection (FIPS 203 6.4 - this must not crash), key uniqueness, and a full two-node NoiseHFS handshake over a real TCP connection with peer identity verification, plus a peer-id-mismatch rejection case.

Live cross-language interop against py-libp2p, confirmed working. py-libp2p has its own implementation of this same profile (libp2p/py-libp2p, branch feat/pqc-noise-xxhfs, migrated to raw ML-KEM-768). I added interop/noise-pq/interop_dial.nim and ran it against py-libp2p's scripts/interop_listen_mlkem768.py:

# py-libp2p side (responder)
READY 9999
Connection from 127.0.0.1:56421
PEER 12D3KooWLitocTge1Lm2TmS3THHfrMWfV3d6UJZpPZNweL3c6CFD

# nim-libp2p side (initiator)
DIALING port 9999
HANDSHAKE_OK remotePeer=12D3KooWJGqi39m6ykyVhs8K1c1z8eeb4nFEqVxHLPopHdm8rV9h

Both sides completed the full three-message handshake - real X25519 DH, real ML-KEM-768 encapsulate/decapsulate, real ChaCha20-Poly1305 AEAD, real Ed25519 peer identity signature verification - with no changes needed to either implementation's wire format. The differing peer ids are expected: each side is printing the other side's freshly-generated identity. Full details, including a note on why I used Ed25519 identity keys rather than the crypto module's default ECDSA (py-libp2p's key-type deserializer doesn't cover ECDSA yet - an unrelated gap on their side, hit after the actual Noise/KEM handshake had already succeeded), are in interop/noise-pq/README.md.

What's left

Files

  • libp2p/crypto/mlkem768.nim - raw ML-KEM-768 via BoringSSL
  • libp2p/protocols/secure/noise.nim - exports the primitives NoiseHFS reuses (no behaviour change)
  • libp2p/protocols/secure/noisehfs.nim - the NoiseHFS connection encrypter
  • libp2p/protocols/secure/NOISE_HFS_SPEC.md - wire format, design rationale, interop status
  • libp2p/builders.nim - SecureProtocol.NoiseHFS / withNoiseHFS()
  • tests/libp2p/protocols/test_noisehfs.nim - unit + integration tests
  • interop/noise-pq/ - standalone dial/listen scripts and the confirmed py-libp2p interop run

Wraps the MLKEM768_* C API that BoringSSL already ships
(crypto/mlkem/mlkem.cc), which nim-libp2p already compiles in through
its boringssl nimble dependency for TLS support. This is the same
ML-KEM-768 implementation shipped in Chrome's TLS stack, so it avoids
pulling in a second, separately-audited PQC library just for this.

generateKeyPair/encapsulate/decapsulate wrap MLKEM768_generate_key,
MLKEM768_parse_public_key + MLKEM768_encap, and MLKEM768_decap
respectively. Per FIPS 203 6.4 implicit rejection, decapsulate does
not fail for a well-formed ciphertext produced under a different key;
only a wrong-length ciphertext is rejected outright.
Purely additive visibility changes (adds `*` to existing types,
fields, and procs) plus one new optional parameter with a default
that preserves current behaviour - no behavioural change to the
classical Noise handshake.

Exports KeyPair, CipherState, SymmetricState, HandshakeResult,
NoiseConnection's readCs/writeCs, genKeyPair, dh, hasKey, mixKey,
mixHash, encryptAndHash, decryptAndHash, split, readFrame,
receiveHSMessage, sendHSMessage, PayloadString, and HandshakeTimeout.
SymmetricState.init gains an optional protocolName parameter
(defaulting to the existing classical XX name) so other handshake
patterns can initialize the same state machine under their own
protocol name.

This lets a sibling handshake pattern (NoiseHFS, added next) reuse
the existing cipher/symmetric state, DH, and message framing instead
of duplicating them.
Implements Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256, applying
the Noise Hybrid Forward Secrecy extension (e1/ekem1 tokens) to the
classical XX pattern, under protocol id /noise-mlkem768-hfs/0.1.0:

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

The three DH tokens (ee, es, se) provide the same classical security
as plain /noise. The e1/ekem1 tokens additionally mix an ML-KEM-768
shared secret into the chaining key, so the session stays confidential
even if X25519 is later broken by a quantum computer, and stays
confidential even if ML-KEM-768 is broken, since neither component's
failure weakens the other's contribution - matching the hybrid
security property of the Noise HFS specification.

Raw ML-KEM-768 is used rather than a composite KEM like X-Wing,
because the XXhfs pattern's three DH tokens already provide classical
security; embedding a second X25519 operation inside the KEM slot
would be redundant. See NOISE_HFS_SPEC.md for the full wire format,
token ordering rationale, and interoperability status.

NoiseHFS is wired into SwitchBuilder as a second SecureProtocol
variant (withNoiseHFS()), meant to be mounted alongside the existing
Noise so multistream-select falls back to classical /noise
transparently against peers that don't support the hybrid handshake.

Tests cover the MLKEM768 primitive (round-trip, malformed-length
rejection, wrong-key implicit rejection, key uniqueness) and a full
two-node TCP NoiseHFS handshake including peer identity verification
and a peer-id-mismatch rejection case. All 9 pass locally.
paschal533 and others added 3 commits July 11, 2026 08:47
Adds interop/noise-pq/, standalone dial/listen scripts for
Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 independent of the rest
of the test suite, for verifying wire compatibility against other
language implementations of the same profile.

interop_dial.nim was run live against py-libp2p's
scripts/interop_listen_mlkem768.py (feat/pqc-noise-xxhfs branch,
updated to the raw ML-KEM-768 revision, not the earlier X-Wing one).
Both sides completed the full three-message handshake and mutual peer
authentication with no changes needed to either implementation's wire
format - see interop/noise-pq/README.md for the exact run output.

Also switches the interop scripts' identity keys to Ed25519: as of
this writing py-libp2p's protobuf key-type deserializer only covers
Secp256k1, RSA, and Ed25519, so an ECDSA identity key fails at the
peer-authentication step with an unrelated MissingDeserializerError,
after the Noise/KEM handshake itself has already succeeded. Noted in
the README so it isn't mistaken for a wire-compatibility problem.

Updates NOISE_HFS_SPEC.md's interoperability status accordingly. Rust
and JavaScript interop are still open - noted as follow-ups.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: new

Development

Successfully merging this pull request may close these issues.

1 participant