Skip to content

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

Open
royzah wants to merge 5 commits into
libp2p:masterfrom
royzah:noise-pq-mlkem768-hfs
Open

royzah wants to merge 5 commits into
libp2p:masterfrom
royzah:noise-pq-mlkem768-hfs

Conversation

@royzah

@royzah royzah commented Jul 6, 2026

Copy link
Copy Markdown

Concrete draft for #723, so we have something byte-level to react to instead of an open question.

Additive, separately negotiated handshake that mixes ML-KEM-768 into the XX pattern via Noise HFS. Advertised ahead of /noise, falls back with no flag day. Auth stays classical (identity key signs the static key), only secrecy is hybridized.

Pins the protocol id and suite, defers token/KDF order to Noise HFS. Still open (called out in the doc):

  1. the protocol id string
  2. test vectors so go/js/rust match byte-for-byte
  3. ML-KEM-768 only vs the 512/768/1024 family

Reference impl: libp2p/rust-libp2p#6481 (off-by-default mlkem-hfs). Would love go/js eyes early. cc @jxs @thomaseizinger @mxinden @MarcoPolo @achingbrain

Comment thread noise/pq-mlkem768-hfs.md Outdated
Mixed into the handshake hash, so it must match byte-for-byte:

```text
Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, 8.2 allows alphanumerics and / only. Suite is now Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256, and both implementations follow:

Handshake tests pass on both. Thanks for catching it.

Comment thread noise/pq-mlkem768-hfs.md Outdated
```

Advertised ahead of `/noise`. Two capable peers pick it; otherwise negotiation
falls back to `/noise` with no extra round trip.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that there's still a roundtrip if the receiver does not support mlkem768:

  1. Emitter sends /noise-mlkem768-hfs/0.1.0
  2. Receiver replies na
  3. Emitter sends /noise
  4. Receiver replies back with /noise
  5. Handshake starts

@richard-ramos richard-ramos Aug 13, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea for lines 27-28:

An initiator supporting both protocols 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, the na costs a round trip. Took your wording verbatim, so the claim is gone.

Comment thread noise/pq-mlkem768-hfs.md Outdated
Comment on lines +65 to +66
## To pin for interop

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rename this to Open issues or similar.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to "Open issues".

Comment thread noise/pq-mlkem768-hfs.md Outdated
-> s, se
```

- `e1`: initiator sends the ML-KEM-768 encap key (1184 B).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- `e1`: initiator sends the ML-KEM-768 encap key (1184 B).
- `e1`: initiator sends the ML-KEM-768 encapsulation key (1184 B).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied.

Comment thread noise/pq-mlkem768-hfs.md Outdated
## Why

[`/noise`][noise] is X25519 only, so recorded sessions are open to
harvest-now-decrypt-later. This adds a separate handshake that mixes an

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
harvest-now-decrypt-later. This adds a separate handshake that mixes an
harvest-now-decrypt-later. This specification adds a separate handshake that mixes an

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied.

Comment thread noise/pq-mlkem768-hfs.md Outdated
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Auth is unchanged and stays classical: in libp2p the identity key signs the
Authentication is unchanged and stays classical: in libp2p the identity key signs the

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied.

Comment thread noise/pq-mlkem768-hfs.md Outdated

## Handshake

`XX` with the `hfs` modifier. Token placement and KDF order are exactly Noise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`XX` with the `hfs` modifier. Token placement and KDF order are exactly Noise
`XX` with the `hfs` modifier. Token placement and KDF order are exactly as specified by Noise

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied.

Comment thread noise/pq-mlkem768-hfs.md Outdated
- 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
release, the impl pins `snow` via `[patch]`; KATs land with it.
release, the implementation pins `snow` via `[patch]`; KATs land with it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied.

Comment thread noise/pq-mlkem768-hfs.md Outdated
Comment on lines +56 to +63
```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)
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this diagram needed or would the pattern described in line 43 be enough?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the pattern block says it. Diagram removed.

@paschal533

Copy link
Copy Markdown

I'm closing #716 in favour of this one, so there's a single spec for the suite.

Some of what #716 carried maps onto your open issues, and it's yours to take or leave:

  • Test vectors (your open issue 2). Five deterministic vectors with fixed keys and seeded KEM randomness, carrying all three handshake messages, the final handshake hash and both split() cipher keys. They're reproduced byte for byte by two independent implementations, TypeScript and Python, from separate generators: fixture. I haven't replayed them against snow, because its ML-KEM key generation doesn't take an injected RNG.
  • Interop evidence. Every ordered listener/dialer pairing of TypeScript, Python, Nim and rust-libp2p#6481, each against itself included, three runs each: 48 of 48 passed, one encrypted message each way so both cipher states are exercised, with a negative control that fails the same matrix when one side uses the old suite name. Results and logs.
  • Wire sizes. Messages are 1,216, 1,200 and 64 bytes with an empty payload, and message B measured 1,306 bytes with an Ed25519 identity payload, so nothing crosses a 1,500 byte MTU.
  • Security considerations. An on-path attacker can suppress the hybrid protocol id during multistream negotiation and leave both peers on /noise, since negotiation is unauthenticated. noise-pq: add Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256 spec (Stage 1 Working Draft) #716 described that and a strict mode that refuses the classical fallback.

Say which of these you want and I'll open a PR against your branch, or just lift the text.

One factual note, not an argument: the four implementations above currently ship /noise-mlkem768-hfs/0.2.0, because renaming the suite changed the handshake hash. I'll follow whatever this spec settles on for your open issue 1.

paschal533 added a commit to paschal533/pq-noise-artifacts that referenced this pull request Sep 18, 2026
Our spec PR libp2p/specs#716 was closed on 2026-09-18 in favour of
royzah's libp2p/specs#727, which is now the single Working Draft for
Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256. The README's work table
cites #727 and records #716 as closed, with the closed text still
readable on the fork branch it was opened from.

Also states /noise-mlkem768-hfs/0.2.0 as what the implementations ship
rather than as a spec-endorsed identifier: #727 writes 0.1.0 and lists
the identifier string as its first open issue.
paschal533 added a commit to paschal533/js-libp2p-noise that referenced this pull request Sep 18, 2026
libp2p/specs#716, this author's own draft, was closed on 2026-09-18 in
favour of royzah's libp2p/specs#727, now the single Working Draft for
Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256. NOISE_HFS_SPEC.md cites
#727 for the raw ML-KEM-768 pattern and records #716 as closed.

Both docs now state /noise-mlkem768-hfs/0.2.0 as what this
implementation ships rather than as a spec-endorsed identifier: #727
writes 0.1.0 and lists the identifier string as its first open issue.
paschal533 added a commit to paschal533/nim-libp2p that referenced this pull request Sep 18, 2026
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.
paschal533 added a commit to paschal533/py-libp2p that referenced this pull request Sep 18, 2026
libp2p/specs#727 (Stage 1A Working Draft, by royzah) is the spec for
Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256. The kem.py docstring cited
the tracking issue libp2p/specs#723 in a way that read as if the spec
endorsed /noise-mlkem768-hfs/0.2.0; it does not. libp2p#727 writes 0.1.0 and
lists the identifier string as its first open issue, so both docstrings
now state 0.2.0 as what this implementation ships. No behaviour change.
paschal533 added a commit to paschal533/rust-libp2p that referenced this pull request Sep 18, 2026
Comments only, no code change. libp2p/specs#727 (Stage 1A Working Draft,
by royzah) is the spec for Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256.

The HFS/STALE_HFS constants now carry a note that
/noise-mlkem768-hfs/0.2.0 is what this branch ships rather than a
spec-endorsed id: libp2p#727 writes 0.1.0 and lists the identifier string as
its first open issue.
paschal533 added a commit to paschal533/specs that referenced this pull request Sep 18, 2026
…p#727

libp2p#716 was closed on 2026-09-18 in favour of libp2p#727
by royzah, so one document rather than two carries the proposal for
Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256. This text is kept for
reference; the material it carries was offered to libp2p#727.
@paschal533

Copy link
Copy Markdown

One thing I would like to propose for the draft, with a demonstration behind it rather than an argument.

The downgrade is real and silent, and I can now show it rather than assert it. Two js-libp2p nodes, both configured with the hybrid encrypter first and /noise second, negotiate over plaintext multistream-select. An on-path proxy that corrupts the hybrid protocol id makes the genuine listener answer na; both peers then complete a mutually authenticated classical /noise session and neither signals anything. Runnable harness and transcripts: experiments/downgrade-demo.

Nothing detects it afterwards, which is the part worth knowing. I checked four channels: connection.encryption is recorded locally on each side and never transmitted, so there is nothing to compare; the identify protobuf has no field for the connection encrypter, only the application-stream protocol set; the peer store records neither the encrypter nor the Noise static key; and the two encrypters generate separate static keys, but nothing ever compares them across connections.

Suggested addition to the draft, in security considerations: an implementation SHOULD provide a mode in which /noise is neither offered nor accepted, so that an operator who needs the post-quantum property can choose connection failure over silent downgrade. That mode is the only mitigation available today. It is worth stating normatively because it is a deployment choice the spec can make visible, and because implementations differ right now: it is reachable by configuration in the TypeScript, Python and Nim implementations, while protocol_info() in the Rust implementation returns /noise alongside the hybrid identifier unconditionally, so a Rust operator cannot opt out at all. That looks like a small addition if you think it is worth making.

Two things I am deliberately not proposing here. Binding the negotiation transcript into the Noise prologue does not work, because a successful downgrade runs classical /noise and never reaches the hybrid handshake code; I proposed that earlier and have withdrawn it. And the actual fix, having the identity signature cover the handshake hash plus the offered protocol set on the classical path too, is a change to /noise rather than to this suite, so it belongs in its own proposal rather than holding this one up.

Happy to write the security-considerations text if you want it.

@royzah

royzah commented Sep 23, 2026

Copy link
Copy Markdown
Author

You're right, I checked. protocol_info() hands back [hybrid, /noise] and there's no way to drop the second one. Off gives classical only, on gives both, hybrid-only doesn't exist, and it's a build flag rather than something you pick at deploy. So a Rust operator genuinely can't opt out.

I'll put a runtime knob on Config in libp2p/rust-libp2p#6481

Security considerations text: yes please, write it. The SHOULD reads right, and https://github.com/paschal533/pq-noise-artifacts/tree/main/experiments/downgrade-demo is why. Agree on dropping the prologue binding too, a downgrade never reaches that code.

On the id, I'm on 0.1.0 and you're on 0.2.0. I'll follow whatever the spec lands on.

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.
paschal533 added a commit to paschal533/js-libp2p-noise that referenced this pull request Sep 25, 2026
The two variants were selectable on both encrypters, which made one of the
four combinations unusable: the identity variant on /noise. It widens what
identity_sig covers, and /noise is an identifier every libp2p
implementation already answers to, so such a peer negotiates /noise
successfully and then fails signature verification against all of them, in
any mode. Offering it there is offering a network partition behind a flag.

The extension variant stays on /noise, since it is the only one that can
ship incrementally: an older peer ignores the unknown fields. The identity
variant is refused at construction there, which turns that partition into
a configuration error the operator sees at once rather than a fleet of
handshake failures.

On the hybrid suite the identity variant is available and moves the
protocol identifier, /noise-mlkem768-hfs/0.3.0 rather than 0.2.0. The
identifier is the mechanism, not bookkeeping: a peer verifying a different
message must not answer to the identifier used by peers that do not, or
multistream-select pairs them and the failure surfaces as a signature
error instead of as no common protocol. 0.3.0 is derived from the 0.2.0
this implementation ships and follows whatever libp2p/specs#727 settles on
for the base.

Tests follow the split rather than looping both variants over one
encrypter: extension handshakes on /noise, construction refusal for
identity there, and the identity variant's honest handshake, downgrade
refusal and false-positive control on the hybrid encrypter, including that
it advertises the separate identifier only when the binding is on.
paschal533 added a commit to paschal533/py-libp2p that referenced this pull request Sep 25, 2026
Mirrors the split made on the TypeScript side. The two variants were
selectable on both transports, which made one of the four combinations
unusable: the identity variant on /noise. It widens what identity_sig
covers, and /noise is an identifier every libp2p implementation already
answers to, so such a peer negotiates /noise successfully and then fails
signature verification against all of them, in any mode. Offering it
there is offering a network partition behind a flag, so Transport now
refuses it at construction and says where it is available instead.

TransportPQ gains IDENTITY_BOUND_PROTOCOL_ID, /noise-mlkem768-hfs/0.3.0,
and a protocol_id attribute derived from the configuration by the new
protocol_id_for(). The identifier is the mechanism rather than
bookkeeping: a peer verifying a different message must not answer to the
identifier used by peers that do not, or multistream-select pairs them and
the difference surfaces as a signature failure instead of as no protocol
in common.

Because a caller builds TranscriptBindingConfig here rather than the
transport deriving it, the two can disagree. The downgrade check compares
the negotiated protocol against actual_protocol, so a config naming an
identifier this transport does not advertise would compare against the
wrong thing and either miss a downgrade or invent one. TransportPQ now
rejects that at construction.

0.3.0 derives from the 0.2.0 this implementation ships and follows
whatever libp2p/specs#727 settles on for the base identifier.
@paschal533

Copy link
Copy Markdown

@royzah the security considerations text is up as a PR against your branch rather than pasted here, so it is a diff you can take or rewrite: royzah#1. One section before Open issues plus one link definition.

It carries the SHOULD, says plainly that this cannot be fixed inside the handshake, and notes the general fix belongs to /noise. I left the rust-libp2p sentence describing only current protocol_info() behaviour rather than the runtime knob, since a spec that tracks an open PR goes stale when it merges; adjust that freely once it lands.

@royzah

royzah commented Sep 25, 2026

Copy link
Copy Markdown
Author

Merged, thanks. Took it whole:
royzah#1

I only changed the rust-libp2p sentence, since the knob landed in between. It now says every implementation has the mode, with Config::with_classical_fallback pointing at the PR and not a release, so it does not claim something is shipped:
libp2p/rust-libp2p#6481

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.

3 participants