Skip to content

tls: Add ECH - #730

Open
MarcoPolo wants to merge 1 commit into
masterfrom
marco/ech
Open

MarcoPolo wants to merge 1 commit into
masterfrom
marco/ech

Conversation

@MarcoPolo

@MarcoPolo MarcoPolo commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

This document specifies minor changes to the libp2p TLS Handshake to enable support for RFC 9849: TLS Encrypted Client Hello. The primary benefit is to make identifying libp2p connections harder to passive network observers by hiding the “libp2p” ALPN in the encrypted ClientHelloInner.

Related and prereq PRs:

Comment thread tls/ECH.md
Comment on lines +36 to +41
In RFC 9849, clients can only use the server provided `retry_configs` if the
outer handshake authenticates successfully with the given
ECHConfig.contents.public_name. This means that if servers wish to support the
`retry_configs` fallback they MUST use a valid domain name and hold the
corresponding Server Certificate. This is the retry mechanism in RFC 9849;
there is nothing libp2p specific about this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
In RFC 9849, clients can only use the server provided `retry_configs` if the
outer handshake authenticates successfully with the given
ECHConfig.contents.public_name. This means that if servers wish to support the
`retry_configs` fallback they MUST use a valid domain name and hold the
corresponding Server Certificate. This is the retry mechanism in RFC 9849;
there is nothing libp2p specific about this.
Servers MUST use a valid domain name and hold the corresponding Server Certificate to support the `retry_configs` fallback. This is the retry mechanism in RFC 9849; there is nothing libp2p specific about this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1, and it needs a rule on public_name: it goes out as cleartext SNI on every ECH connection (RFC 9849, section 6.1, step 5). Reusing a /tls/ws cert whose name embeds the peer ID or a libp2p-specific domain would leak what ECH is meant to hide.

List caveats explicitly:

Suggested change
In RFC 9849, clients can only use the server provided `retry_configs` if the
outer handshake authenticates successfully with the given
ECHConfig.contents.public_name. This means that if servers wish to support the
`retry_configs` fallback they MUST use a valid domain name and hold the
corresponding Server Certificate. This is the retry mechanism in RFC 9849;
there is nothing libp2p specific about this.
Servers MUST use a valid domain name and hold the corresponding Server Certificate
to support the `retry_configs` fallback. This is the retry mechanism in RFC 9849;
there is nothing libp2p specific about this.
`public_name` is sent in cleartext on every ECH connection. It MUST NOT contain
the peer ID or a libp2p-specific domain. It MUST be a host name with at least
two labels, even without a certificate, as some TLS stacks reject single-label
names.

Comment thread tls/ECH.md
Comment on lines +43 to +44
If the server does not have a valid public_name and certificate, the client can
only fail the connection.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
If the server does not have a valid public_name and certificate, the client can
only fail the connection.
The client MUST fail the connection If the server does not have a valid public_name and certificate pair.

@lidel lidel Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Strong +1 to the MUST here.

Failing this connection does not end the dial, though: go-libp2p moves on to the peer's other addresses and starts TCP dials some ms after QUIC unless QUIC already connected.

👉 RFC 9848, section 5.1 forbids falling back to a non-ECH connection when all DNS-advertised endpoints have ECH, because it "would negate the privacy benefits of ECH". Section 8 calls a mix of ECH and non-ECH endpoints a downgrade risk.

Building on the suggestion above:

Suggested change
If the server does not have a valid public_name and certificate, the client can
only fail the connection.
If the server does not have a valid public_name and certificate, the client MUST
fail the connection.
Failing the connection does not end the dial: libp2p dialers try a peer's other
addresses, often in parallel. A non-ECH libp2p TLS handshake sends the `libp2p` ALPN in
cleartext, so an on-path observer only has to drop ECH handshakes to see it. A
client that holds an `/ech` address for a peer MUST NOT dial that peer's non-ECH
addresses, in parallel or after an ECH failure.

Comment thread tls/ECH.md
Comment on lines +53 to +54
Servers SHOULD rotate their keys once a month, and keep the prior ECH Config
keys around for 1 week to assist stale clients.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe add the reasoning behind the time windows here? (i.e. once a month, 1 week)

@lidel lidel Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1

Suggested change
Servers SHOULD rotate their keys once a month, and keep the prior ECH Config
keys around for 1 week to assist stale clients.
Servers SHOULD rotate their ECH key pairs once a month, and keep the prior ECH Config
keys around for 1 week to assist stale clients.
Servers MUST persist ECH private keys across restarts until the rotation
window above retires them: an `/ech` value has no TTL and ends up where multiaddrs live for months:
bootstrap lists, static peering configs, and
caller-provided addresses. Without a certificate for `public_name` clients cannot use
`retry_configs`, so dials with a config for a dropped key fail until the client
learns a fresh `/ech` address.

Comment thread tls/ECH.md

## Client ECH Config Caching

Clients SHOULD cache the ECHConfigList for no more than 48 hours. Note that a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reasoning on the 48h?

Comment thread tls/ECH.md

## Server Key Rotation

Servers SHOULD rotate their keys once a month, and keep the prior ECH Config

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
Servers SHOULD rotate their keys once a month, and keep the prior ECH Config
Servers SHOULD rotate their ECH key pairs once a month, and keep the prior ECH Config

Comment thread tls/ECH.md
## Overview

This document specifies minor changes to the [libp2p TLS Handshake](./tls.md) to
enable support for [RFC 9849]: TLS Encrypted Client Hello. The primary benefit

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
enable support for [RFC 9849]: TLS Encrypted Client Hello. The primary benefit
enable support for [RFC 9849]: TLS Encrypted ClientHello. The primary benefit

Comment thread tls/ECH.md

#### Authenticated Rejection

In RFC 9849, clients can only use the server provided `retry_configs` if 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
In RFC 9849, clients can only use the server provided `retry_configs` if the
In RFC 9849, clients can only use the server-provided `retry_configs` if the

Comment thread tls/ECH.md
The `/ech` component SHOULD appear after the `/tls` or `/quic-v1` component.
Servers use this multiaddr to advertise ECH support and their ECHConfigList.

## Caller Provided ECHConfigList

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
## Caller Provided ECHConfigList
## Caller-Provided ECHConfigList

Comment thread tls/ECH.md

## Overview

This document specifies minor changes to the [libp2p TLS Handshake](./tls.md) to

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For TCP, how would this work? because with TCP the flow is:

1 - TCP connection
2 - Multistream negotiation in plaintext sending /multistream/1.0.0 and /tls/1.0.0
3 - Then the TLS handshake happen

meaning that an observer should be able to recognize libp2p being used. Would using /tls/ech mean that we gotta start TLS handshake directly, and skip the multistream select negotiation? if so, probably worth mentioning in the spec

@lidel lidel Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1. The multiaddr has the same gap: in today's specs /tls under /tcp is only defined for /tls/ws or /tls/http, which run WebPKI TLS rather than the libp2p handshake, so /tcp/<port>/tls/ech has no defined meaning. Scoping this spec to QUIC until a TLS-first TCP mode exists may be simplest, but also defer solving problem. Better to, at very least, clearly state it in the spec.

@lidel lidel left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for kicking this off, it is worth doing. Honestly though, given where ipfs/libp2p maintenance is heading, and the TLS stacks are today, getting it right will take a while, so worth setting expectations low and focus on specs being realistic.

FWIW:

  • (Bad) Sadly, in its current form this is a no-op in Go and Rust 🙈 IIUC stock Go crypto/tls (also used by quic-go) and rustls send the same ALPN list in the inner and outer ClientHello, so the libp2p ALPN still goes out in cleartext while ECH reports success (details). Users would think libp2p is hidden when it is not ( ❗ )
  • (Good) Once TLS stacks allow a separate outer ALPN, ECH does beat deep packet inspection matching on the libp2p string.

See the inline comments, they mostly narrow what the spec promises and add rules where a quick LLM-assisted implementation would leak or break.

I'd suggest parking this spec until Go and Rust implementations exist and the upstream gaps above are closed, so it describes something real rather than security theater :)

ps. two caveats worth writing down for drive-by reader in case someone thinks ECH is a silver bullet for "privacy in libp2p":

  • a censor that drops ECH handshakes gets a plain libp2p handshake via the peer's other addresses, unless the spec forbids that fallback (suggestion)
  • peers announced via the DHT can be labeled by destination alone by anyone with a DHT crawl (prior art: https://github.com/dennis-tra/nebula); ECH cannot fix that, but a short section noting it would set honest expectations, e.g. that ECH inspection-resistance truly works only when peer addresses are not publicly discoverable

Comment thread tls/ECH.md
Comment on lines +31 to +32
for QUIC connections. The ALPN MUST NOT include `libp2p` or the muxer protocol
IDs used by [inlined muxer negotiation].

@lidel lidel Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@MarcoPolo FYSA stock Go crypto/tls (and quic-go on top of it) builds ClientHelloOuter by cloning the inner hello and keeps its ALPN list, so libp2p and, on TCP, the muxer IDs still go out in cleartext while ECH reports success (golang/go#71220).

This is not "golang bad" type of thing, rustls has the same single ALPN list (code, cc @jxs for rust-libp2p: afaik no rustls issue tracks this yet) 🙃. So.. without a rule, the straightforward^W LLM implementation ships a silent no-op 🙈

Spec should clearly state this known and common problem:

Suggested change
for QUIC connections. The ALPN MUST NOT include `libp2p` or the muxer protocol
IDs used by [inlined muxer negotiation].
for QUIC connections. The ALPN MUST NOT include `libp2p` or the muxer protocol
IDs used by [inlined muxer negotiation].
Some TLS stacks send the same ALPN list in ClientHelloOuter and ClientHelloInner.
On such a stack the `libp2p` ALPN stays in cleartext even when ECH is
accepted. Implementations MUST NOT offer ECH unless their TLS stack can set the
ClientHelloOuter ALPN independently of the ClientHelloInner ALPN.

Comment thread tls/ECH.md
Comment on lines +43 to +44
If the server does not have a valid public_name and certificate, the client can
only fail the connection.

@lidel lidel Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Strong +1 to the MUST here.

Failing this connection does not end the dial, though: go-libp2p moves on to the peer's other addresses and starts TCP dials some ms after QUIC unless QUIC already connected.

👉 RFC 9848, section 5.1 forbids falling back to a non-ECH connection when all DNS-advertised endpoints have ECH, because it "would negate the privacy benefits of ECH". Section 8 calls a mix of ECH and non-ECH endpoints a downgrade risk.

Building on the suggestion above:

Suggested change
If the server does not have a valid public_name and certificate, the client can
only fail the connection.
If the server does not have a valid public_name and certificate, the client MUST
fail the connection.
Failing the connection does not end the dial: libp2p dialers try a peer's other
addresses, often in parallel. A non-ECH libp2p TLS handshake sends the `libp2p` ALPN in
cleartext, so an on-path observer only has to drop ECH handshakes to see it. A
client that holds an `/ech` address for a peer MUST NOT dial that peer's non-ECH
addresses, in parallel or after an ECH failure.

Comment thread tls/ECH.md
Comment on lines +36 to +41
In RFC 9849, clients can only use the server provided `retry_configs` if the
outer handshake authenticates successfully with the given
ECHConfig.contents.public_name. This means that if servers wish to support the
`retry_configs` fallback they MUST use a valid domain name and hold the
corresponding Server Certificate. This is the retry mechanism in RFC 9849;
there is nothing libp2p specific about this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1, and it needs a rule on public_name: it goes out as cleartext SNI on every ECH connection (RFC 9849, section 6.1, step 5). Reusing a /tls/ws cert whose name embeds the peer ID or a libp2p-specific domain would leak what ECH is meant to hide.

List caveats explicitly:

Suggested change
In RFC 9849, clients can only use the server provided `retry_configs` if the
outer handshake authenticates successfully with the given
ECHConfig.contents.public_name. This means that if servers wish to support the
`retry_configs` fallback they MUST use a valid domain name and hold the
corresponding Server Certificate. This is the retry mechanism in RFC 9849;
there is nothing libp2p specific about this.
Servers MUST use a valid domain name and hold the corresponding Server Certificate
to support the `retry_configs` fallback. This is the retry mechanism in RFC 9849;
there is nothing libp2p specific about this.
`public_name` is sent in cleartext on every ECH connection. It MUST NOT contain
the peer ID or a libp2p-specific domain. It MUST be a host name with at least
two labels, even without a certificate, as some TLS stacks reject single-label
names.

Comment thread tls/ECH.md
`retry_configs` fallback they MUST use a valid domain name and hold the
corresponding Server Certificate. This is the retry mechanism in RFC 9849;
there is nothing libp2p specific about this.

@lidel lidel Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hm.. not sure if i got this right, but holding the cert here feels to be not enough: after rejecting ECH the server continues with ClientHelloOuter, and a server that keeps the libp2p TLS ALPN rule aborts with no_application_protocol before retry_configs are sent.

Should below missing steps be added here?

Suggested change
On ECH rejection, if the ClientHelloOuter ALPN does not offer `libp2p`, the server MUST complete the outer handshake without the libp2p
TLS ALPN rules: accept the ClientHelloOuter ALPN, present the `public_name`
certificate, send `retry_configs`, and let the client abort with `ech_required`.

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.

4 participants