feat(secure): add NoiseHFS - post-quantum hybrid Noise (Noise_XXhfs_25519+ML-KEM-768) - #2811
feat(secure): add NoiseHFS - post-quantum hybrid Noise (Noise_XXhfs_25519+ML-KEM-768)#2811paschal533 wants to merge 10 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.
AkshayaMani
left a comment
There was a problem hiding this comment.
Cross-checked against the spec. Looks good. Left a couple of (non-blocking) suggestions inline.
| ## ML-KEM-768 shared secret size, in bytes. | ||
|
|
||
| # Opaque struct sizes copied from BoringSSL's `include/openssl/mlkem.h`. | ||
| # These layouts are BoringSSL-internal and unstable across versions; they |
There was a problem hiding this comment.
Might be worth extending with a compile-time assert to guard against a future BoringSSL bump silently writing past the fixed-size buffers.
| let sharedSecret = mlkem768.decapsulate(ciphertext, hs.e1).valueOr: | ||
| raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS ekem1, invalid ciphertext") | ||
| hs.ss.mixKey(sharedSecret) # after decrypt, mirroring the sender's order | ||
|
|
There was a problem hiding this comment.
Suggestion:
sharedSecret is no longer needed. Binding as var and adding burnMem(sharedSecret) keeps defensive zeroization consistent (same pattern as burnMem(hs) below).
| raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS ekem1, invalid remote e1") | ||
| let ekem1bytes = hs.ss.encryptAndHash(encapRes.ciphertext) | ||
| hs.ss.mixKey(encapRes.sharedSecret) # after encrypt, per the wire spec | ||
|
|
There was a problem hiding this comment.
Same pattern as comment at line 112: encapRes.sharedSecret here would benefit from the same burnMem.
Addresses review feedback on the NoiseHFS PR from AkshayaMani. mlkem768.nim hardcodes the byte sizes of BoringSSL's opaque MLKEM768_public_key/private_key structs, copied from include/openssl/mlkem.h, since those layouts are never meant to be inspected, only passed back into the MLKEM768_* C API by pointer. If a future BoringSSL bump changed those layouts, our fixed-size Nim arrays would silently no longer match, and MLKEM768_generate_key/_encap/_decap would write past them. Added a _Static_assert against the real sizeof(struct MLKEM768_public_key)/private_key from the vendored header to catch that at C build time instead. That assert couldn't live in mlkem768.nim itself: pulling in <openssl/mlkem.h> there puts BoringSSL's real, strongly-typed MLKEM768_* prototypes in the same generated C file as mlkem768.nim's own loosely-typed (byte*) importc declarations of those same functions, which gcc rejects as conflicting declarations. Moved the size constants and the assert into a new mlkem768layout.nim, which never calls into boringssl and so never emits those prototypes. Also burn the decapsulated/encapsulated ML-KEM shared secret in both handshake directions right after mixKey consumes it, rather than waiting for the whole handshake state to be zeroized in the top-level finally block - same defensive-zeroization pattern already used for hs and handshakeRes elsewhere in this file. Verified by installing Nim 2.2.10 + MinGW and compiling mlkem768.nim, mlkem768layout.nim and noisehfs.nim standalone, then running tests/libp2p/protocols/test_noisehfs.nim: 8/8 passing.
|
Thanks for the review @AkshayaMani ! I addressed all three... struct size guard: added a |
|
LGTM on the fixes, thanks! |
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