feat(secure): add NoiseHFS - post-quantum hybrid Noise (Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256) - #2811
paschal533 wants to merge 31 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! |
…libp2p Completes the cross-implementation coverage for Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256. The README previously listed the JavaScript and Rust pairings as not yet run; both now pass. - nim listener <- js dialer, and js listener <- nim dialer, against ChainSafe/js-libp2p-noise PR iftech#665 on Node.js v22.17.1 - rust listener <- nim dialer, against royzah/rust-libp2p PR iftech#1. Only that direction: rust-libp2p has a listener example but no dialer. interop_dial gains an opt-in --chat flag that reads one post-handshake message and replies. Completing the handshake proves both sides agreed on the handshake hash and the KEM shared secret, but not that the transport cipher states came out of split() with the same orientation - a swapped cs1/cs2 still prints HANDSHAKE_OK and only fails on the first data frame. The exchange covers both transport keys, one per direction. Default behaviour is unchanged, so the existing Python and Rust pairings still work as before. The dialer also waits for the peer to close before tearing down in chat mode; closing immediately after the write was resetting the connection and discarding the frame before the peer could read it. interop_all.sh runs every pairing against local checkouts, skipping any whose directory variable is unset.
Measures the hybrid XXhfs handshake against classical Noise XX, plus ML-KEM-768 keygen/encap/decap, so the Nim implementation is comparable with the published JavaScript and Python figures for the same protocol. Results on Windows 11 Pro x64, Nim 2.2.10, -d:release, medians over five runs: KEM round-trip 0.321 ms; classical XX 2.838 ms; XXhfs 3.245 ms, an overhead of 1.14x with the KEM at ~9.9% of handshake time. The two protocols are measured interleaved rather than in consecutive phases. Measuring 500 classical handshakes and then 500 hybrid ones puts the two populations in different time windows, so drift on the machine lands in the ratio between them. That is not hypothetical: with phase-separated sampling, nine sequential runs gave ratios from 0.99x to 1.27x, and 0.99x would mean the hybrid handshake is cheaper than the classical one, which cannot be true. Absolute latencies drift ~0.6 ms between runs while the effect is ~0.35 ms. Alternating within each iteration makes the drift common-mode; the paired ratio then held at 1.134-1.140 across five runs. Other methodology notes: - Ed25519 identity keys, not the ECDSA default used elsewhere in the tests, since the figures being compared against assume a handshake that verifies Ed25519 signatures. - In-memory bridgedConnections() rather than loopback TCP, so no syscall cost is attributed to the cryptography. - 1000 KEM and 500 handshake iterations rather than the 100/30 used for the other implementations, which were picked when a handshake cost ~44 ms rather than ~3 ms. Worth noting for anyone reading the numbers: only ML-KEM-768 comes from BoringSSL. X25519 and ChaCha20-Poly1305 come from BearSSL, SHA-256 from nimcrypto, and Ed25519 from our own pure-Nim ref10 port, which is why the classical baseline is 2.8 ms and why the KEM is only a tenth of the hybrid handshake. The 1.14x ratio is what this composition pays, not a general figure for a native hybrid handshake.
|
Two updates: the interop coverage this PR was missing is now done, and I have benchmark numbers. InteropThe README previously listed the JavaScript and Rust pairings as not yet run. Both pass now, on 2026-09-05:
With the earlier py-libp2p test that puts all six pairwise combinations across the four implementations of this profile at a verified live handshake, with no protocol changes needed anywhere. Two things worth calling out. In the direction where the JS side listens, the peer id we report as The Rust pair is one-directional because rust-libp2p has a listener example but no dialer. BenchmarksNew
The hybrid handshake costs about 14% more than the classical one, and the KEM is only ~9.9% of it. For comparison the same protocol costs +5.0x in the JavaScript implementation and +12.9x in the Python one, where the KEM is 48% and 63% of handshake time respectively. The part that might interest you more than the PQC workThe reason the KEM is such a small slice here is not only that BoringSSL's ML-KEM is fast. It is that everything around it is comparatively slow. A 2.8 ms classical Noise XX handshake is a lot for compiled code, and it breaks down like this: X25519 and ChaCha20-Poly1305 come from BearSSL, which is deliberately compact rather than optimised; SHA-256 and HMAC come from So nim-libp2p currently pays more for the classical half of a post-quantum handshake than for the post-quantum half. That is a pre-existing cost, unrelated to this PR, but the benchmark makes it visible and it seems worth flagging. If the classical primitives were routed through BoringSSL too, both numbers would drop noticeably. One methodology note in case anyone reruns this. I originally measured 500 classical handshakes and then 500 hybrid ones, and got overhead ratios ranging from 0.99x to 1.27x across nine runs — 0.99x meaning the hybrid handshake came out faster, which is impossible. Absolute latencies drift by ~0.6 ms between runs while the effect is ~0.35 ms, so the phase separation was putting the drift straight into the ratio. Interleaving the two protocols within each iteration makes the drift common-mode; the paired ratio then held at 1.134–1.140 across five runs. The harness does it the paired way. |
|
A short follow-up to the benchmark numbers I posted earlier. The Nim figures held up; the ones I posted for the other implementations did not, and the corrected comparison is more flattering to this PR than the original. Nim, re-measuredFive serial passes on a quiet machine, medians:
Essentially unchanged from what I posted before (1.14x), which is what I would hope for, and the paired ratio held to 2% across passes even though absolute latencies on this machine varied by more than a factor of two between sessions. The pairing in Rust, for comparisonI finally ran
Two things worth drawing out of that. Rust's classical handshake is 1.531 ms against our 2.943 ms — for the same protocol, same parameters. That is the BearSSL and pure-Nim classical stack showing up in the measurement, exactly as the earlier discussion of And note the direction of the ratios. Rust has the faster classical stack and the higher overhead ratio — 1.30x against our 1.13x — despite an equally quick KEM. That is the arithmetic working as expected: a smaller denominator makes the same numerator look larger. I mention it because 1.13x is a flattering number and I would rather not have it quoted without that context. It is partly a consequence of our classical baseline being slower than it needs to be. What changed elsewhereThe JavaScript and Python figures I quoted in my earlier comment were both wrong. Both benchmark harnesses were still measuring X-Wing months after that migration, and the JavaScript one was additionally comparing a native-backend classical handshake against a pure-JavaScript hybrid one, so it was attributing a whole change of crypto backend to the KEM. Corrected, the four implementations look like this:
Three of four sit between 1.1x and 1.5x. Only Python is an order of magnitude out, and only because Nothing here needs action on this PR — the implementation and the interop results are unchanged. I am posting it because the numbers in my previous comment are now superseded and I would rather not leave incorrect figures standing in the thread. |
Recorded results: Rust-Python, Rust-JS and Python-JS pass via scripts/interop_all.sh in libp2p/py-libp2p#1310; Nim-Python passes per iftech/nim-libp2p#2811. Nim-Rust and Nim-JS have not been run, which the nim PR says explicitly. The README previously showed only the Python-against-Rust transcript, which understated what was tested while leaving "all six pairwise" available as a claim nobody had checked.
The protocol name is hashed into h, so this is wire-incompatible with earlier builds; NoiseHFSCodec moves to /noise-mlkem768-hfs/0.2.0. Adds a test pinning the protocol name, which had none.
…n Nim harnesses The listener was handshake-only. Both harnesses now exchange one encrypted frame each way and print LOCAL/PEER; orchestration moves to the neutral matrix runner in pq-noise-artifacts.
firstLine accepted a whole message as the greeting when it had no '\n',
so a truncated greeting passed where the JS, Python and Rust harnesses fail.
Raise ValueError("truncated greeting"); the top-level handler prints it as
ERROR and exits 1.
…handshake hash The Nim interop README said the Ed25519 identity signature is over the handshake hash. The libp2p signature covers the Noise static key; agreement on h follows from decrypting that key and payload with h as AEAD associated data.
Corrects the timeline stated in be162b4. The rename did not reach libp2p/rust-libp2p#6481 only with the 16 September 2026 force-push: royzah/rust-libp2p 1ae21ce (authored 2026-08-17) was on the #6481 branch by 22 August 2026 at the latest (PushEvent 1ae21ce -> 3ad742e; 3ad742e already spells MLKEM768 and is the force-push's beforeCommit). e7a1286 is the rebased copy. The 5 September nim<->rust run was therefore already behind the pull request.
- Coverage: mark the June to September 2026 pairwise table as a superseded, handshake-only record (the js-libp2p-noise listener run is the one exception that exchanged messages) and point to the 17 September 2026 matrix run 20260917T134954Z in pq-noise-artifacts. - Rust note: the listener and dialer harnesses are in royzah/rust-libp2p#1, not in rust-libp2p itself. - Note that interop_dial's --chat flag has been removed; the greeting is now always exchanged in both directions.
|
Pushed the protocol rename, the id bump and always-on bidirectional interop harnesses. This is a breaking change on the wire. Rename. Id. Interop, 48/48. Negative controls. A TypeScript build with only the old name fails against Nim in both roles, with the dialer failing tag authentication on message B (control A, re-run with the matrix). A fake peer id is caught by the identity cross-check (control B, from the earlier run Corrections to my 5 September comment.
Benchmarks. 2026-09-17 re-run on the renamed suite: SUMMARY.md. For Nim: 1.19x. That's the ratio of each pass's hybrid median to its classical median (5 passes × 500 interleaved iterations), medianed over the passes, with a range of 1.16–1.21x. Absolute latencies from that session aren't comparable with earlier sessions (see the file). |
Upstream iftech#3105 (dd86972) renamed all three Opt.withValue overloads in libp2p/utils/opt.nim to ifValue with no alias, and updated the one call site on master, libp2p/protocols/secure/noise.nim. noisehfs.nim is not on master, so its single call was left behind and the branch stopped compiling after master was merged in. This is the same one-word change the upstream PR made to noise.nim.
libp2p/specs#727 (Stage 1A Working Draft, by royzah) is the single spec for Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256. The separate draft libp2p/specs#716 by this author was closed on 2026-09-18 in its favour; NOISE_HFS_SPEC.md records that and links the closed text. Both docs now state /noise-mlkem768-hfs/0.2.0 as what this implementation ships rather than as a spec-endorsed identifier: iftech#727 writes 0.1.0 and lists the identifier string as its first open issue.
The 2026-09-19 matrix ran with Nim at 824cce0, after the security-fix round in the other three implementations. This commit is documentation only and sits on top of the commit the matrix tested.
The cited run was two behind: 053615Z predates both the security-fix round and the versions.txt provenance fix, so its provenance rows name upstream repositories paired with local branch names and are not fetchable. 223056Z is the first run where every row resolves. Nim was tested at 824cce0 in that run, which the line above already states correctly.
|
Two new matrix runs that bear on this branch, both at the current head Regression, mechanism off: 48 of 48. Same 4x4x3 shape as the run in the description, re-run on 2026-09-25 against the current heads of all four implementations. Nim is unchanged since Extension variant, 48 of 48, with Nim deliberately unbound. The TypeScript and Python branches now implement transcript-bound security protocol negotiation, a defence against the plaintext multistream-select downgrade. In that run only those two were bound; the Nim and Rust harnesses have no flag for it and ran unbound: 20260925T124411Z. That is the useful part for this branch: it is direct evidence that Nim needs no changes to interoperate with peers that use the extension. The mechanism carries its protocol list in If you do want it, the shape is small: one extension field pair, a canonical length-prefixed encoding of the offered protocol list, and a comparison of the negotiated protocol against what the two signed lists imply. Wire format and both variants are in On the stale citation I mentioned earlier: |
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+MLKEM768_ChaChaPoly_SHA256under protocol id/noise-mlkem768-hfs/0.2.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. The profile itself is specified in libp2p/specs#727, a Stage 1A Working Draft by @royzah. My own draft, libp2p/specs#716, was closed on 2026-09-18 in favour of #727 so that one document rather than two carries the proposal; its text stays readable at https://github.com/paschal533/specs/tree/master/noise-pq, and the material it covered (test vectors, the interoperability matrix, wire sizes and the downgrade analysis) was offered to #727.Breaking change (2026-09-17): the suite was renamed from
Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256, because Noise (revision 34, §8.2) allows only alphanumerics and/in algorithm names. The name is hashed into the handshake hash, so peers on the old and new names cannot complete a handshake, andNoiseHFSCodecmoved from/noise-mlkem768-hfs/0.1.0to/noise-mlkem768-hfs/0.2.0. Message sizes are unchanged./noise-mlkem768-hfs/0.2.0is the id this branch ships, not a spec-endorsed one: #727 writes/noise-mlkem768-hfs/0.1.0and lists the identifier string as the first of its open issues, so this will follow whatever #727 settles on.Why raw ML-KEM-768 specifically
This wire format is meant to match
Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256as described in a research paper I've been working on ("Post-Quantum Cryptography Integration into the Noise Protocol"), which covers the TypeScript, Python, Rust and Nim 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 sameMLKEM768_*API that BoringSSL's own TLS stack calls for its X25519MLKEM768 key share. 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, 8 tests, all passing at2de86ac): the protocol id and name, 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.Cross-language interop, all four implementations, both directions.
interop/noise-pq/interop_listen.nimandinterop_dial.nimalways exchange one encrypted greeting each way after the handshake and follow a stdout contract shared with the TypeScript, Python and Rust harnesses. On 2026-09-19 a neutral runner ran every ordered listener/dialer pairing of this branch, js-libp2p-noise PR #665 (TypeScript), py-libp2p PR #1310 (Python) and rust-libp2p PR #6481 by @royzah (Rust, with the harness from royzah/rust-libp2p PR #1), each implementation against itself included, three times each, with Nim atb3f203d: 48 runs, 48 passed.A run passes only if both sides exit cleanly, each side reports the other's actual peer id, and each side decrypts the other's greeting, which exercises both
split()cipher states. All runs were over loopback TCP on one Windows 11 machine. The harnesses start the handshake directly on TCP, so multistream-select negotiation is not covered. Results,versions.txtand all 96 logs: pq-noise-artifacts, run 20260919T223056Z, at Nimb3f203d, TypeScript0c55599, Pythonc8d16e63and Rustbdd417e. Negative controls (an old-name build that fails against every other implementation, and a fabricated identity that the cross-check catches): interop/negative-controls.The unit tests ran at
2de86acand the matrix at the current headb3f203d.2de86acis2b8efb5plus two documentation commits (bb75dcb,e3d2d14), a merge ofmaster(48fcf892), and a one-line fix (2de86ac) that follows #3105's rename ofOpt.withValuetoifValue, without which the branch does not compile after the merge. The commits since are2c275cd,824cce0andb3f203d, which only update documentation, and5a756f5, a second merge ofmaster. Every NoiseHFS source, test, harness and benchmark file is byte-identical between2de86acandb3f203d: the only differences in the NoiseHFS area areNOISE_HFS_SPEC.mdandinterop/noise-pq/README.md.A stale citation in the branch.
NOISE_HFS_SPEC.mdatb3f203dstill cites the previous matrix run,20260919T053615Zat Nim824cce0. The run above supersedes it. That is a one-line documentation fix and I have left it rather than push another commit to a branch that is otherwise settled; say the word and I will fold it into the next push.What changed in the newest run, and why nothing here changed for it. The Python implementation switched its ML-KEM-768 backend from the pure-Python
kyber-pyto a C-backed one through thecryptographypackage, so this is the first matrix run with that backend on the other side of the wire. Nothing in this branch changed, and 48 of 48 still pass, which is direct evidence that a peer's choice of KEM implementation is not observable in the wire format. That change is Python's alone; none of the Nim figures in this description are affected by it.Earlier results, corrected. An earlier version of this description reported a live run against py-libp2p's
scripts/interop_listen_mlkem768.py(2026-07-11,HANDSHAKE_OK), on the hyphenated name and/noise-mlkem768-hfs/0.1.0, and said Rust and JavaScript interop hadn't been run. That run covered the handshake only, with no transport messages exchanged, and predates the rename; the matrix above replaces it. The note on why the harnesses use Ed25519 identity keys rather than the crypto module's default ECDSA (at the time, py-libp2p's key-type deserializer didn't cover ECDSA) is still ininterop/noise-pq/README.md, alongside the older runs, which are labelled there as a superseded historical record.What's left
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/crypto/mlkem768layout.nim- BoringSSLMLKEM768_*key struct sizes, with a compile-time layout guardlibp2p/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 testsbenchmarks/bench_noisehfs.nim- classical XX vs NoiseHFS handshake and ML-KEM-768 benchmarksinterop/noise-pq/- dial/listen interop harnesses and their README