From 1526163eecf19696202fa2aeb8a4487f669bead1 Mon Sep 17 00:00:00 2001 From: Royyan Zahir Date: Mon, 6 Jul 2026 21:29:31 +0400 Subject: [PATCH 1/4] noise: draft hybrid PQ handshake spec (X25519 + ML-KEM-768) --- noise/pq-mlkem768-hfs.md | 89 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 noise/pq-mlkem768-hfs.md diff --git a/noise/pq-mlkem768-hfs.md b/noise/pq-mlkem768-hfs.md new file mode 100644 index 000000000..f66ad4384 --- /dev/null +++ b/noise/pq-mlkem768-hfs.md @@ -0,0 +1,89 @@ +# Hybrid post-quantum Noise (X25519 + ML-KEM-768) + +| Lifecycle Stage | Maturity | Status | Latest Revision | +| --------------- | ------------- | ------ | --------------- | +| 1A | Working Draft | Active | r0, 2026-07-06 | + +Authors: [@royzah]. Interest Group: [@jxs], [@thomaseizinger], [@mxinden], +[@MarcoPolo], [@achingbrain]. + +## Why + +[`/noise`][noise] is X25519 only, so recorded sessions are open to +harvest-now-decrypt-later. This adds a separate handshake that mixes an +ML-KEM-768 KEM into the same `XX` pattern via Noise [HFS][hfs]. It stays secure +if either primitive holds. + +Auth is unchanged and stays classical: in libp2p the identity key signs the +static key, so identity cannot be broken after the fact. Only secrecy is at risk, +so only the ephemeral key exchange is hybridized. + +## Protocol ID + +```text +/noise-mlkem768-hfs/0.1.0 +``` + +Advertised ahead of `/noise`. Two capable peers pick it; otherwise negotiation +falls back to `/noise` with no extra round trip. + +## Suite + +Mixed into the handshake hash, so it must match byte-for-byte: + +```text +Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 +``` + +## Handshake + +`XX` with the `hfs` modifier. Token placement and KDF order are exactly Noise +HFS; this spec adds nothing to them. + +```text +-> e, e1 +<- e, ee, ekem1, s, es +-> s, se +``` + +- `e1`: initiator sends the ML-KEM-768 encap key (1184 B). +- `ekem1`: responder encapsulates, returns the ciphertext (1088 B), mixes the + shared secret. + +The libp2p identity payload (signature over the static key) is carried and +verified as in [`/noise`][noise], unchanged. + +```mermaid +sequenceDiagram + participant I as Initiator + participant R as Responder + I->>R: e, e1 + R->>I: e, ee, ekem1, s, es (+ identity) + I->>R: s, se (+ identity) +``` + +## To pin for interop + +1. The protocol ID string. +2. Test vectors: fixed keys and KEM randomness with per-step handshake hash and + transport keys, so go/js/rust match byte-for-byte. +3. ML-KEM-768 only, or the 512/768/1024 family with 768 as the baseline? + +## Reference + +- rust-libp2p: [#6481][pr], off-by-default `mlkem-hfs` feature; classical path + untouched. Revives [#2168][old]. +- KEM: ML-KEM-768 in `snow` ([mcginty/snow#210][snow]). Until it ships in a + release, the impl pins `snow` via `[patch]`; KATs land with it. + +[noise]: https://github.com/libp2p/specs/blob/master/noise/README.md +[hfs]: https://github.com/noiseprotocol/noise_wiki/wiki/Hybrid-Forward-Secrecy +[pr]: https://github.com/libp2p/rust-libp2p/pull/6481 +[old]: https://github.com/libp2p/rust-libp2p/pull/2168 +[snow]: https://github.com/mcginty/snow/pull/210 +[@royzah]: https://github.com/royzah +[@jxs]: https://github.com/jxs +[@thomaseizinger]: https://github.com/thomaseizinger +[@mxinden]: https://github.com/mxinden +[@MarcoPolo]: https://github.com/MarcoPolo +[@achingbrain]: https://github.com/achingbrain From 9ee89e3033309804ccf0813b462a531641d3c1fc Mon Sep 17 00:00:00 2001 From: Royyan Zahir Date: Mon, 17 Aug 2026 08:39:20 +0400 Subject: [PATCH 2/4] noise: address review on the PQ handshake draft --- noise/pq-mlkem768-hfs.md | 38 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/noise/pq-mlkem768-hfs.md b/noise/pq-mlkem768-hfs.md index f66ad4384..9a86c37b1 100644 --- a/noise/pq-mlkem768-hfs.md +++ b/noise/pq-mlkem768-hfs.md @@ -10,13 +10,13 @@ Authors: [@royzah]. Interest Group: [@jxs], [@thomaseizinger], [@mxinden], ## Why [`/noise`][noise] is X25519 only, so recorded sessions are open to -harvest-now-decrypt-later. This adds a separate handshake that mixes an -ML-KEM-768 KEM into the same `XX` pattern via Noise [HFS][hfs]. It stays secure -if either primitive holds. +harvest-now-decrypt-later. This specification adds a separate handshake that +mixes an ML-KEM-768 KEM into the same `XX` pattern via Noise [HFS][hfs]. It +stays secure if either primitive holds. -Auth is unchanged and stays classical: in libp2p the identity key signs the -static key, so identity cannot be broken after the fact. Only secrecy is at risk, -so only the ephemeral key exchange is hybridized. +Authentication is unchanged and stays classical: in libp2p the identity key +signs the static key, so identity cannot be broken after the fact. Only secrecy +is at risk, so only the ephemeral key exchange is hybridized. ## Protocol ID @@ -24,21 +24,22 @@ so only the ephemeral key exchange is hybridized. /noise-mlkem768-hfs/0.1.0 ``` -Advertised ahead of `/noise`. Two capable peers pick it; otherwise negotiation -falls back to `/noise` with no extra round trip. +An initiator supporting both SHOULD propose `/noise-mlkem768-hfs/0.1.0` before +`/noise`. A responder supporting this protocol MUST accept that protocol ID. If +the responder returns `na`, the initiator MAY subsequently propose `/noise`. ## Suite Mixed into the handshake hash, so it must match byte-for-byte: ```text -Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 +Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256 ``` ## Handshake -`XX` with the `hfs` modifier. Token placement and KDF order are exactly Noise -HFS; this spec adds nothing to them. +`XX` with the `hfs` modifier. Token placement and KDF order are exactly as +specified by Noise HFS; this spec adds nothing to them. ```text -> e, e1 @@ -46,23 +47,14 @@ HFS; this spec adds nothing to them. -> s, se ``` -- `e1`: initiator sends the ML-KEM-768 encap key (1184 B). +- `e1`: initiator sends the ML-KEM-768 encapsulation key (1184 B). - `ekem1`: responder encapsulates, returns the ciphertext (1088 B), mixes the shared secret. The libp2p identity payload (signature over the static key) is carried and verified as in [`/noise`][noise], unchanged. -```mermaid -sequenceDiagram - participant I as Initiator - participant R as Responder - I->>R: e, e1 - R->>I: e, ee, ekem1, s, es (+ identity) - I->>R: s, se (+ identity) -``` - -## To pin for interop +## Open issues 1. The protocol ID string. 2. Test vectors: fixed keys and KEM randomness with per-step handshake hash and @@ -74,7 +66,7 @@ sequenceDiagram - rust-libp2p: [#6481][pr], off-by-default `mlkem-hfs` feature; classical path untouched. Revives [#2168][old]. - KEM: ML-KEM-768 in `snow` ([mcginty/snow#210][snow]). Until it ships in a - release, the impl pins `snow` via `[patch]`; KATs land with it. + release, the implementation pins `snow` via `[patch]`; KATs land with it. [noise]: https://github.com/libp2p/specs/blob/master/noise/README.md [hfs]: https://github.com/noiseprotocol/noise_wiki/wiki/Hybrid-Forward-Secrecy From 9f92de147e97d88a7fc5506ac9fb58f2ed7ea38c Mon Sep 17 00:00:00 2001 From: paschal533 Date: Wed, 23 Sep 2026 23:06:12 +0100 Subject: [PATCH 3/4] noise: security considerations for the negotiation downgrade multistream-select picks the connection encrypter in plaintext and is not authenticated, so an on-path attacker can strip this protocol and leave both peers on /noise. Both then complete a valid, mutually authenticated classical session and neither can tell, which the linked harness shows against two real libp2p nodes rather than arguing. Adds a SHOULD that an implementation provide a mode in which /noise is neither offered nor accepted, so an operator who needs the post-quantum property can choose connection failure over a silent downgrade. That is the only mitigation a deployment has today, and implementations differ on whether it is reachable at all, so it is worth stating normatively. Says plainly that this cannot be fixed inside this handshake: a successful downgrade runs /noise and never reaches the code specified here, so binding the negotiation into this suite's prologue or handshake hash does nothing. An earlier suggestion of mine to do that is withdrawn. Notes that the general fix, an identity signature covering the handshake hash and the offered protocol set, is a change to /noise itself and out of scope for this document. --- noise/pq-mlkem768-hfs.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/noise/pq-mlkem768-hfs.md b/noise/pq-mlkem768-hfs.md index 9a86c37b1..fad1ed670 100644 --- a/noise/pq-mlkem768-hfs.md +++ b/noise/pq-mlkem768-hfs.md @@ -54,6 +54,39 @@ specified by Noise HFS; this spec adds nothing to them. The libp2p identity payload (signature over the static key) is carried and verified as in [`/noise`][noise], unchanged. +## Security considerations + +### Downgrade to classical `/noise` + +The connection encrypter is chosen by multistream-select, which runs in +plaintext and is not authenticated. An on-path attacker can remove this +protocol from the exchange and leave both peers on `/noise`: altering the +proposal makes the responder answer `na`, and the initiator falls back. Both +peers then complete a valid, mutually authenticated classical session. + +Neither peer can detect this afterwards. `connection.encryption` is recorded +locally and never transmitted, identify carries no field for the connection +encrypter, the peer store records neither the encrypter nor the Noise static +key, and the two encrypters use independent static keys that nothing compares. +A runnable demonstration against two libp2p nodes is in [downgrade-demo]. + +It cannot be mitigated inside this handshake. A successful downgrade runs +`/noise` and never reaches the code specified here, so binding the negotiation +into this suite's prologue or handshake hash has no effect on it. + +Implementations SHOULD therefore provide a mode in which `/noise` is neither +offered nor accepted, so that an operator who requires the post-quantum +property can choose connection failure over silent downgrade. This is the only +mitigation available to a deployment today. Implementations differ: the mode is +reachable by configuration in the TypeScript, Python and Nim implementations, +while `protocol_info()` in rust-libp2p returns `/noise` alongside this protocol +unconditionally. + +A general fix belongs to [`/noise`][noise] rather than to this suite. An +identity signature covering the handshake hash and the set of security +protocols a peer offered lets both peers detect a tampered negotiation on the +classical path too, and is out of scope here. + ## Open issues 1. The protocol ID string. @@ -69,6 +102,7 @@ verified as in [`/noise`][noise], unchanged. release, the implementation pins `snow` via `[patch]`; KATs land with it. [noise]: https://github.com/libp2p/specs/blob/master/noise/README.md +[downgrade-demo]: https://github.com/paschal533/pq-noise-artifacts/tree/main/experiments/downgrade-demo [hfs]: https://github.com/noiseprotocol/noise_wiki/wiki/Hybrid-Forward-Secrecy [pr]: https://github.com/libp2p/rust-libp2p/pull/6481 [old]: https://github.com/libp2p/rust-libp2p/pull/2168 From c9f5ae7c8f8de51fed6fa581bc9ecf5e4b52ee54 Mon Sep 17 00:00:00 2001 From: Royyan Zahir Date: Fri, 25 Sep 2026 17:31:21 +0400 Subject: [PATCH 4/4] noise: the Rust side can refuse the fallback now --- noise/pq-mlkem768-hfs.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/noise/pq-mlkem768-hfs.md b/noise/pq-mlkem768-hfs.md index fad1ed670..b5620c67b 100644 --- a/noise/pq-mlkem768-hfs.md +++ b/noise/pq-mlkem768-hfs.md @@ -77,10 +77,9 @@ into this suite's prologue or handshake hash has no effect on it. Implementations SHOULD therefore provide a mode in which `/noise` is neither offered nor accepted, so that an operator who requires the post-quantum property can choose connection failure over silent downgrade. This is the only -mitigation available to a deployment today. Implementations differ: the mode is -reachable by configuration in the TypeScript, Python and Nim implementations, -while `protocol_info()` in rust-libp2p returns `/noise` alongside this protocol -unconditionally. +mitigation available to a deployment today. Every current implementation offers +it: by configuration in the TypeScript, Python and Nim implementations, and as +`Config::with_classical_fallback` in [the Rust implementation][pr]. A general fix belongs to [`/noise`][noise] rather than to this suite. An identity signature covering the handshake hash and the set of security