feat(secure): add NoiseHFS - post-quantum hybrid Noise (Noise_XXhfs_25519+ML-KEM-768)#2811
Draft
paschal533 wants to merge 7 commits into
Draft
feat(secure): add NoiseHFS - post-quantum hybrid Noise (Noise_XXhfs_25519+ML-KEM-768)#2811paschal533 wants to merge 7 commits into
paschal533 wants to merge 7 commits into
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, implementingNoise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256under protocol id/noise-mlkem768-hfs/0.1.0. It applies the Noise Hybrid Forward Secrecy extension (thee1/ekem1tokens) 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: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
ekem1token ordering (which matters - swapping two steps there silently produces divergent chaining keys) are written up inlibp2p/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_SHA256as 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.nimbinds directly to theMLKEM768_*C API that BoringSSL already ships (crypto/mlkem/mlkem.cc), which nim-libp2p already compiles in through itsboringssldependency 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
NoiseHFSreusesnoise.nim'sSymmetricState/CipherState/KeyPairand 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 classicalNoiseclass's behaviour unchanged). Only thee1/ekem1token handling and the top-level connection encrypter are new.It's registered as a second
SecureProtocolvariant alongside the existing one, with awithNoiseHFS()builder method, meant to be mounted next to plainNoiseso a hybrid-capable node still falls back to classical/noisetransparently 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, branchfeat/pqc-noise-xxhfs, migrated to raw ML-KEM-768). I addedinterop/noise-pq/interop_dial.nimand ran it against py-libp2p'sscripts/interop_listen_mlkem768.py: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
interop/noise-pq/dial/listen scripts should work against both once those toolchains are set up - that's the natural next step.libp2p.nim's public re-exports - kept the surface area small for this first pass; happy to add that plus any other builder ergonomics reviewers want if the general approach looks right.Files
libp2p/crypto/mlkem768.nim- raw ML-KEM-768 via BoringSSLlibp2p/protocols/secure/noise.nim- exports the primitives NoiseHFS reuses (no behaviour change)libp2p/protocols/secure/noisehfs.nim- the NoiseHFS connection encrypterlibp2p/protocols/secure/NOISE_HFS_SPEC.md- wire format, design rationale, interop statuslibp2p/builders.nim-SecureProtocol.NoiseHFS/withNoiseHFS()tests/libp2p/protocols/test_noisehfs.nim- unit + integration testsinterop/noise-pq/- standalone dial/listen scripts and the confirmed py-libp2p interop run