Skip to content

noise-pq: add Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 spec (Stage 1 Working Draft) - #716

Open
paschal533 wants to merge 2 commits into
libp2p:masterfrom
paschal533:master
Open

noise-pq: add Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 spec (Stage 1 Working Draft)#716
paschal533 wants to merge 2 commits into
libp2p:masterfrom
paschal533:master

Conversation

@paschal533

@paschal533 paschal533 commented Apr 28, 2026

Copy link
Copy Markdown

Summary

This PR adds a new protocol spec for Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256, a post-quantum hybrid extension of the classical Noise XX handshake used in libp2p.

Update (2026-06-24): The spec has been revised from an earlier X-Wing draft to use raw ML-KEM-768 (FIPS 203). The Noise XXhfs pattern already provides classical security through three independent DH operations (ee, es, se), so using X-Wing (which bundles an extra X25519 inside the KEM) would be redundant — adding 64 bytes of wire overhead with no security benefit. Raw ML-KEM-768 gives the same hybrid security guarantee with a smaller footprint, and aligns with the Rust reference implementation royzah/rust-libp2p PR #1 (Rust).

The spec is filed at Stage 1 (Working Draft) per the libp2p spec lifecycle.

  • Specifies the e1 and ekem1 HFS token extensions from the Noise HFS draft, applied to the existing Noise XX pattern
  • Uses raw ML-KEM-768 (NIST FIPS 203) as the KEM primitive in the ekem1 slot
  • Covers wire format (Msg A 1,216 B, Msg B 1,200 B, Msg C 64 B), token ordering requirements, cipher state split, ML-KEM implicit rejection semantics, and interoperability requirements
  • Includes a test vector schema matching the reference implementations

Motivation

NIST finalized ML-KEM (FIPS 203) in August 2024. Harvest-now-decrypt-later attacks make forward secrecy the most urgent deployment target. The XXhfs pattern adds quantum-safe forward secrecy with no changes to the classical authentication layer, giving operators a safe incremental migration path.

Reference Implementations

Three independent implementations exist and have been validated against each other:

Language Link Tests
TypeScript js-libp2p-noise PR #665 (JS) 99 tests, 5 deterministic test vectors
Python libp2p/py-libp2p libp2p/py-libp2p#1310 68 tests
Rust royzah/rust-libp2p PR #1 (Rust) ml-kem crate (RustCrypto)

Triangle interoperability (2026-06-24)

All three implementations were tested pairwise over real TCP connections:

Pair Result
TypeScript ↔ Python ✅ PASS
Rust ↔ TypeScript ✅ PASS
Rust ↔ Python ✅ PASS

Test plan

  • Review spec against Noise HFS extension spec for correctness
  • Review token ordering (encryptAndHash before mixKey in ekem1)
  • Verify wire format sizes match the reference implementations (Msg A: 1,216 B, Msg B: 1,200 B, Msg C: 64 B)
  • Check lifecycle header format matches other specs in this repo
  • Identify interest group members (need 3+ for Stage 2)

@paschal533 paschal533 changed the title noise-pq: add Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256 spec (Stage 1 Working Draft) noise-pq: add Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 spec (Stage 1 Working Draft) Jun 25, 2026
- Protocol name: Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256
- Protocol ID: /noise-mlkem768-hfs/0.1.0
- KEM: raw ML-KEM-768 (FIPS 203), removing X-Wing composite wrapper
- Wire sizes: Msg A 1216 B, Msg B 1200 B (was 1248/1232 with X-Wing)
- Add Rust reference impl (royzah/rust-libp2p PR libp2p#1)
- Add triangle interop results (JS/Python/Rust -- 3/3 PASS, 2026-06-24)
@paschal533

Copy link
Copy Markdown
Author

Status update on this draft. Since I opened it in June, the evidence base has changed enough that I think it is worth summarising in one place, because the two questions a spec like this normally has to answer — is it implementable from the text alone? and is it affordable? — now have concrete answers rather than estimates.

Four independent implementations

Language Where KEM library Tests
TypeScript ChainSafe/js-libp2p-noise#665 @noble/post-quantum 99 + 5 vectors
Python libp2p/py-libp2p#1310 kyber-py 68
Rust royzah/rust-libp2p#1 RustCrypto ml-kem reference impl
Nim vacp2p/nim-libp2p#2811 BoringSSL 8

Four different ML-KEM-768 libraries, four codebases written separately against the prose in this document.

All six pairwise combinations interoperate

TypeScript Python Rust Nim
TypeScript 2026-06-24 2026-06-24 2026-09-05
Python 2026-06-24 2026-06-24 2026-07-11
Rust 2026-06-24 2026-06-24 2026-09-05
Nim 2026-09-05 2026-07-11 2026-09-05

Every pair completed a live handshake over TCP. No implementation required a protocol change to interoperate with any other. That is the main thing I wanted to establish about this text: four people reading it separately produced the same bytes on the wire.

Where a pair also exchanged application data after the handshake, that covers both transport keys, since split() gives initiator and responder opposite cipher states and each direction therefore exercises a different one. The one gap I would flag honestly: the Nim↔Rust pair is one-directional and handshake-only, because rust-libp2p has a listener example but no dialer.

The affordability question

This is the part that has genuinely changed. The early numbers made hybrid key exchange look expensive — +5.0x over classical in JavaScript, +12.9x in Python. Benchmarking the Nim implementation, which is the only one whose KEM is compiled assembly-optimised C rather than interpreted or WASM, gives a different picture:

Metric JavaScript Python Nim
Classical XX 8.75 ms 3.32 ms 2.838 ms
XXhfs 44.18 ms 42.96 ms 3.245 ms
Overhead +5.0x +12.9x +1.14x
KEM share of handshake ~48% ~63% ~9.9%

Same wire protocol, same 2,288 bytes of overhead, same lattice operations. The cost of quantum resistance in this design is not a property of the protocol — it is a property of whether the runtime ships an optimised ML-KEM-768. With a good one the KEM is under a tenth of handshake time and the hybrid handshake costs 14% more than the classical one. The rest is classical crypto that a deployment was already paying for.

Two caveats I would rather state than have someone find. nim-libp2p optimises only the KEM — its X25519 and ChaCha20-Poly1305 come from BearSSL, SHA-256 from a pure-Nim library, Ed25519 from a pure-Nim reference port — so its 2.8 ms classical baseline is not a floor, and 1.14x is the ratio that particular composition pays rather than a general figure. And all interop runs so far were on one machine over loopback, so they establish wire-format agreement, not behaviour under loss or reordering on a real path.

What I am asking for

I am not asking for a merge. Per the spec lifecycle this needs interest group formation and broader review to move past Stage 1, and I do not think that is something I can do from the PR alone. What would help most:

  1. Whether the document is in the right shape for a Stage 1 Working Draft, or whether it is missing sections the process expects.
  2. Whether the four implementations and the interop matrix are the right kind of evidence, or whether something else is wanted before this can advance.
  3. Who to approach about interest group formation, or whether raising it on the forum or a community call is the better route.

Happy to split, restructure, or rewrite whatever is useful. All four implementation PRs are gated on this document, so any direction at all is more useful to me than the PR continuing to sit.

@paschal533

Copy link
Copy Markdown
Author

Correcting the performance table in my previous comment. The direction of the correction matters for this draft, because the earlier figures overstated the cost of the protocol this document specifies.

Corrected measurements

All four implementations, measured the same week, each holding its own cryptographic backend constant:

Python JavaScript Rust Nim
KEM library kyber-py @noble/post-quantum RustCrypto ml-kem BoringSSL
Overhead over classical Noise_XX 10.5x 1.47x 1.30x 1.13x

Previously I reported 12.9x, 5.0x and 1.14x, with Rust unmeasured. Three faults produced the difference, and every one of them inflated the apparent cost:

  1. Two harnesses were still measuring X-Wing. Both the JavaScript and Python benchmarks continued calling the composite KEM for three months after the protocol migrated to raw ML-KEM-768. X-Wing performs an extra X25519 exchange and a SHA3-256 combiner per operation, so both sets of figures included work this specification does not require. Both now independently derive the 2,288-byte overhead this draft states, which is a useful third check on that number.

  2. The JavaScript comparison changed the crypto backend as well as the KEM. noise() defaults to native bindings; noiseHFS() defaults to pure JavaScript. Comparing them attributed a wholesale switch from native to interpreted ChaCha20-Poly1305, SHA-256, X25519 and HKDF to post-quantum cryptography. Isolating it: the backend change is ~41 ms, the KEM is ~12 ms, and the like-for-like overhead is 1.47x rather than 4-5x.

  3. Three of four harnesses do not pair their samples. They measure all classical handshakes then all hybrid ones, which admits machine drift into the ratio. The one paired harness holds to 2% across five passes; the others span 39%, 49% and 78%.

Why this bears on the draft

The practical objection to a hybrid handshake is usually cost, and the honest answer is now considerably better than the one I gave in June. Three of four independent implementations put the overhead between 1.1x and 1.5x. The outlier is Python at 10.5x, and that is entirely attributable to kyber-py performing lattice arithmetic in pure Python — the arithmetic closes cleanly, with the KEM accounting for roughly 87% of the handshake, so a liboqs backend is predicted to bring it to about 1.2x as well.

Rust is a new data point since my last comment. Its benchmark existed in the repository and had simply never been run. It matters because Rust and Nim use entirely different KEM libraries and land at 1.30x and 1.13x, which is what allows the result to be attributed to the protocol design rather than to any single vendor's optimisation work.

The wire overhead of 2,288 bytes, which is the figure this draft specifies, is unchanged and is now derived independently by three implementations.

Where the draft stands

Still open since 25 June with no review. Four implementations are complete, tested, and interoperable in all six pairwise combinations, and none of them can merge until the protocol exists as a specification.

The three questions from my earlier comment stand, and I would still value an answer to any of them:

  1. Is the document the right shape for a Stage 1 Working Draft, or is it missing sections the process expects?
  2. Are four implementations and a complete interoperability matrix the right kind of evidence, or is something else wanted before this can advance?
  3. Who should I approach about interest group formation — or is the forum or a community call the better route than this PR?

I am happy to restructure, split, or rewrite whatever would help. Silence is the only outcome I cannot work with.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

1 participant