Skip to content

feat(noise): hybrid post-quantum handshake (X25519 + ML-KEM-768) - #6481

Draft
royzah wants to merge 4 commits into
libp2p:masterfrom
royzah:feat/noise-mlkem-hfs
Draft

royzah wants to merge 4 commits into
libp2p:masterfrom
royzah:feat/noise-mlkem-hfs

Conversation

@royzah

@royzah royzah commented Jun 14, 2026

Copy link
Copy Markdown

Revives #2168.

Summary

Additive, off-by-default mlkem-hfs feature: a hybrid post-quantum Noise handshake (Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256) negotiated alongside /noise, falling back to classical X25519 for peers without it.

Why

/noise is classical X25519, so recorded sessions are exposed to harvest-now-decrypt-later. This mixes an ML-KEM-768 ephemeral KEM into the handshake (secure if either primitive holds). Static-key auth stays classical, which cannot be broken retroactively.

Changes

  • mlkem-hfs feature (off by default) -> snow/use-ml-kem
  • new protocol id /noise-mlkem768-hfs/0.1.0, advertised ahead of /noise; pattern selected from the negotiated id
  • KEM sourced from snow's pure-Rust DefaultResolver (ring has no KEM)
  • widened handshake scratch buffer for KEM material (feature-gated)
  • ports the crate to snow 0.10
  • tests: two-peer hybrid handshake + classical fallback

Open questions

  • Protocol id /noise-mlkem768-hfs/0.1.0 is provisional; it needs a cross-implementation spec (go/js-libp2p) before stabilizing. Happy to take it to libp2p/specs.
  • Depends on ML-KEM-768 landing in snow; until released, [patch] points snow at a fork branch.
  • ml-kem is unaudited (passes NIST vectors).

@royzah

royzah commented Jun 14, 2026

Copy link
Copy Markdown
Author

Note on the snow [patch] (intentional, temporary):

  • snow 0.10.0 is published, so the default/classical path builds against released snow with no fork. The [patch] only matters when building the off-by-default mlkem-hfs feature, which needs snow's use-ml-kem (Add ML-KEM-768 (FIPS 203) to the HFS extension mcginty/snow#210).
  • Once that lands in a snow release, the [patch] is removed entirely and the feature resolves from crates.io. Keeping this a draft until then.

@getong

getong commented Jun 21, 2026

Copy link
Copy Markdown

how about changing to use clatter, no_std compatible implementation of Noise protocol framework with Post-Quantum extensions.

@royzah

royzah commented Jun 21, 2026

Copy link
Copy Markdown
Author

Thanks @getong for clatter shout-outs! It is great crate though.

However, in libp2p the Noise static key doesn't prove who you are, the identity key signs it for that. So the handshake only needs PQ for secrecy (harvest-now-decrypt-later), which is what hfs already does. clatter's extra is PQ auth on the static key, which seems redundant here (that's an identity-key job, Ed25519 -> ML-DSA).

Do you see where the static key itself needs to be PQ?

Also interop: the protocol name feeds the handshake hash, so clatter's Noise_hybridXX... and the hfs Noise_XXhfs_25519+ML-KEM-768 wouldn't talk. Feels more like a specs#723 call than a crate one.

WDYT ?

@getong

getong commented Jun 21, 2026

Copy link
Copy Markdown

You are right , go ahead.

@royzah

royzah commented Jun 21, 2026

Copy link
Copy Markdown
Author

Cheers @getong 🙏 Keeping clatter in mind for a future no_std path.

@jxs

jxs commented Jun 22, 2026

Copy link
Copy Markdown
Member

H @royzah, thanks for this! Happy to have this in rust-libp2p but I think we should start by raising the issue in the specs repo, thanks for opening libp2p/specs#723, we should wait there for development before acting on this one.

@royzah

royzah commented Jul 6, 2026

Copy link
Copy Markdown
Author

Hey @jxs. Only thing left blocking this is snow shipping ML-KEM so we can drop the [patch] (mcginty/snow#210, clean and feature-gated).

@royzah

royzah commented Aug 22, 2026

Copy link
Copy Markdown
Author

Rebased on master and conflicts resolved. Both sides had independently migrated to snow 0.10, so the upstream shape won in protocol.rs (the split builder and Random::try_fill_bytes mapping the never-type) and the wasm dependency line keeps master's default-resolver-crypto plus the getrandom pin.

Still a draft, still blocked on the same two things, neither of which has moved:

  • libp2p/specs#723 is open, no development yet. Per your earlier note, that is what this should follow.
  • mcginty/snow#210 is open, so snow's latest release (0.10.0) still exposes only hfs and pqclean_kyber1024, no use-ml-kem. The [patch] therefore stays; it only matters when building the off-by-default mlkem-hfs feature, and the classical path resolves from crates.io untouched.

Keeping it current so it does not rot while those resolve.

paschal533 added a commit to paschal533/pq-noise-artifacts that referenced this pull request Sep 16, 2026
libp2p/rust-libp2p#6481 was written independently by @royzah. We did
not contribute to it; we ran live interop against it from Python and
the full three-message handshake completed with mutual authentication.

Two implementations written separately from the same specification and
interoperating on the wire is stronger evidence that the specification
is unambiguous than more implementations by one author, so the README
now says so rather than counting it as a fourth of ours.
@paschal533

Copy link
Copy Markdown

Hi @royzah, I've been working on the same handshake from the other side of the ecosystem and wanted to share something useful

All six pairwise combinations across the four implementations now complete a live TCP handshake, and no implementation needed a protocol change to interoperate with any other. The pairs involving yours:

listener dialer post-handshake transport
Rust (this PR) TypeScript encrypted frame exchanged
Rust (this PR) Python encrypted frame exchanged
Rust (this PR) Nim handshake only

and the rest of the matrix, for completeness: TypeScript with Python, TypeScript with Nim in both roles, and Python with Nim.

The four were written independently against the prose spec, and against four different ML-KEM-768 libraries, yours is RustCrypto's ml-kem, the others are @noble/post-quantum, kyber-py and BoringSSL. I think that's the strongest evidence available that the pattern is unambiguous at the wire level, and it's mostly evidence about the spec rather than about any one implementation.

One thing you might want to add, and it's the reason your pairs are listed as one-directional. The Rust tree provides noise_hfs_listener but no dialer example, so Nim and Python are always the initiator against you. That matters more than it sounds, completing a handshake proves both sides agreed on the handshake hash and the ML-KEM-768 shared secret, but not that the two cipher states came out of split() assigned to the same directions. A swapped cs1/cs2 still completes and reports success, failing only on the first application data frame. Since split() gives initiator and responder opposite states, testing one direction leaves one transport key unexercised. A small dialer example would close that.

I've published the interop vectors at https://github.com/paschal533/pq-noise-artifacts. Five vectors, fully seeded and deterministic, covering all three messages, so the Rust side can be checked against them without running a peer. There's a second independently generated suite in py-libp2p too, so a new implementation has two conformance baselines rather than one.

On the spec question @jxs raised. I opened libp2p/specs#716 in April proposing this pattern and it's been sitting without review since. I don't think it and your #723 should compete, and if #723 is the better framing I'm happy for it to carry this. What seems to be missing either way is someone who owns the decision on whether libp2p wants a post-quantum Noise pattern at all. Right now there are two raised threads and no shepherd for either.

One number in case it helps the case here. Holding the cryptographic backend constant within each implementation, the same protocol costs 10.7x classical in Python, 1.51x in JavaScript, 1.24x in Rust and 1.13x in Nim. Three of four sit between 1.1x and 1.5x. Worth saying that a lower ratio isn't automatically better, Nim's 1.13x is partly a larger denominator, since only its KEM reaches BoringSSL while its classical primitives come from BearSSL and pure Nim. Yours is the faster stack carrying the higher ratio, which is the arithmetic working as expected rather than anything wrong with it.

Happy to run more interop against this as it changes, and to test anything you want checked from the Python, TypeScript or Nim side.

royzah and others added 3 commits September 16, 2026 20:46
Adds an additive, off-by-default 'mlkem-hfs' feature advertising
Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 under a new protocol id,
falling back to classical /noise for older peers. Ports the crate to
snow 0.10 (patched to our fork carrying the ML-KEM KEM) and widens the
handshake scratch buffer for the KEM material.
@royzah
royzah force-pushed the feat/noise-mlkem-hfs branch from 3ad742e to 7f4ab76 Compare September 16, 2026 16:48
@royzah

royzah commented Sep 16, 2026

Copy link
Copy Markdown
Author

Thanks, this is useful. Four implementations, four different ML-KEM-768 libraries, no protocol change needed, argues for the pattern better than any one of them can.

On split(): half right, and that half is fixed. The test already sent a real application frame initiator to responder, so a swapped cs1/cs2 would have failed there. The reverse leg was missing, which left one transport key unexercised. Both directions covered now.

On the dialer: the tree ships no examples at all, only integration tests. Happy to add a pair if it helps your harness.

Will check against your vectors. Two conformance baselines beats one.

On the spec, no point in libp2p/specs#716 and libp2p/specs#723 competing. Neither has a shepherd, which is the real problem. Better raised once, together.

One thing gates this PR regardless: it carries a [patch.crates-io] onto a snow fork, since snow 0.10 ships hfs with pqclean_kyber1024 and no ML-KEM. That cannot land upstream, so mcginty/snow#210 is the hard dependency. The spec unblocks the design, snow unblocks the merge.

@jxs this is the spec development you asked to wait for in June: four independent implementations interoperating with no protocol change on any side. Happy for either specs issue to carry it.

@royzah

royzah commented Sep 17, 2026

Copy link
Copy Markdown
Author

Checked the vectors. They cannot validate this branch as it stands.

The protocol names differ. Yours is Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256, mine is Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256. That string seeds h, so handshake_hash, cs1_k and cs2_k all diverge and neither side can finish a handshake with the other.

I renamed on 2026-08-17 in both mcginty/snow#210 and here, so I think your Rust runs were a pre-rename checkout.

I lean to the unhyphenated form, since HFS already spells its KEM Kyber1024 not Kyber-1024. But the spec should decide

@paschal533

Copy link
Copy Markdown

Hey @royzah, thanks for the update. I'm working on it right now. I will give my feedback as soon as possible.

paschal533 added a commit to paschal533/pq-noise-artifacts that referenced this pull request Sep 17, 2026
Corrects the timeline stated in 122756a. 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
feat/noise-mlkem-hfs 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 run was therefore already behind the pull request.

Also note that negative control B comes from the earlier run
20260917T015709Z.
paschal533 added a commit to paschal533/specs that referenced this pull request Sep 17, 2026
ChainSafe/js-libp2p-noise#665, libp2p/py-libp2p#1310 and
libp2p/rust-libp2p#6481 are open draft pull requests (gh pr view
--json isDraft,state), matching the existing Nim row.
paschal533 added a commit to paschal533/nim-libp2p that referenced this pull request Sep 17, 2026
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.
@paschal533

paschal533 commented Sep 17, 2026

Copy link
Copy Markdown

You're right, and thanks for checking.

Rename. Agreed on MLKEM768: §8.2 allows only alphanumerics and /, and as you say HFS already writes Kyber1024. TypeScript, Python and Nim now use Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256, and so does libp2p/specs#716. The vectors are regenerated under the new name, and both the TypeScript and Python fixtures carry handshake_hash, cs1_k and cs2_k. Message A is unchanged; everything from the encrypted part of B onwards changes, as you'd expect. I haven't replayed them against this branch myself. royzah#1 test notes that snow's ML-KEM key generation doesn't take the injected RNG, so how did you check them? If there's a seam I've missed, I'd like to use it.

Re-run against your head. I rebuilt royzah#1 on a648280 and re-ran all four implementations, every ordered pair including each against itself, three times: 48/48. Each run sends one encrypted message each way, so both split() keys are exercised, and the runner checks that each side authenticated the other's actual peer id. Rebuilding TypeScript with the old hyphenated name makes every pair with Python, Nim or Rust fail on message B, so the check does catch the mismatch. The harnesses start the handshake directly on TCP, so this doesn't cover multistream negotiation. Results and logs: https://github.com/paschal533/pq-noise-artifacts/tree/main/interop/results/20260917T134954Z

Protocol id. I'd suggest moving to /noise-mlkem768-hfs/0.2.0 alongside the rename, so old-name and new-name peers fail at negotiation instead of with a decryption error on B. None of this is merged anywhere, so it's cheap now. It's a separate commit in the first test(c2e4e30), one line in src/lib.rs plus the tests, harness and bench, so take it or drop it. While writing a test for it I noticed params_for quietly falls back to classical XX for any id it doesn't recognise. As far as I can see that's only reachable through a direct upgrade_inbound/upgrade_outbound call, since protocol_info() only offers the hybrid id and /noise, but a hard error there would make a typo loud. tests/hfs_protocol_id.rs in royzah#1 pins the current behaviour by message A's size (1,216 bytes for hybrid, 32 for classical).

Two small things, only if they're useful. The snow patch in Cargo.toml tracks the branch rather than a rev, and the accepted suite name has already changed once on that branch, so pinning 858dc27 might save someone a confusing build. And nothing in the crate's tests pins the name string in PARAMS_XX_HFS itself. The cross-implementation runs catch a drift, but a one-line assertion in protocol.rs would catch it locally.

Dialer. No need for examples on your side: royzah#1 now has both a listener and a dialer.

snow#210. Understood that it's the actual merge gate. If it helps, I can post the regenerated vectors and the interop result there as well.

Spec. Agreed, once and together. libp2p/specs#716 and your libp2p/specs#727 now use the same suite name, MLKEM768; the id still differs (0.2.0 vs 0.1.0).

@paschal533

Copy link
Copy Markdown

Hey @royzah. I'm closing libp2p/specs#716, let's continue with your spec libp2p/specs#727

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.

4 participants