Skip to content

fix(ech): build the inner ClientHello of a custom spec from the spec - #432

Open
fabifont wants to merge 1 commit into
refraction-networking:masterfrom
fabifont:fix/ech-inner-hello-from-spec
Open

fabifont wants to merge 1 commit into
refraction-networking:masterfrom
fabifont:fix/ech-inner-hello-from-spec

Conversation

@fabifont

Copy link
Copy Markdown

With a custom ClientHelloSpec and Config.EncryptedClientHelloConfigList set, UConn.MarshalClientHello built the ClientHelloInner with makeClientHello, that is from the Config's defaults, and copied the outer's key shares, signature algorithms, session ID and groups into it. A server that accepts ECH negotiates with the inner hello, so it saw three cipher suites and Go's extension set instead of the browser's: no GREASE, no ALPS, no compress_certificate, and, since #383 routed the inner's ALPN through Config.NextProtos, no ALPN at all unless the application set one. Parroting a browser with real ECH therefore produced a Go fingerprint at the origin.

This change builds the inner hello from the spec's extensions the way BoringSSL derives Chrome's from one configuration (ssl_add_clienthello_tlsext_inner and ssl_write_client_cipher_list in ssl/):

  • cipher suites: the outer's GREASE and TLS 1.3 suites only, in order;
  • server_name with the configured name, encrypted_client_hello with the inner marker, and supported_versions with the outer's GREASE values and TLS 1.3 only: these differ from the outer's and come first, uncompressed, in outer order;
  • renegotiation_info, extended_master_secret, session_ticket, ec_point_formats and next_protocol_negotiation are TLS 1.2-only and left out, as are padding and pre-shared keys;
  • every other extension is the outer's, so it is compressed; ECH can only compress one contiguous run, so they follow the uncompressed ones, in outer order. The encoded inner replaces that run with ech_outer_extensions.

Config.NextProtos, when set, still replaces the spec's ALPN in the inner hello only, uncompressed, so #383's use case keeps working; otherwise the inner carries the spec's ALPN, compressed, as Chrome's does.

The inner hello is marshaled from the extension objects and parsed back into a clientHelloMsg, so the handshake's checks on the accepted hello see the same bytes the server does, and the encoded form is derived from those bytes rather than from Go's field marshaller, which cannot express the spec's extensions. A HelloRetryRequest rebuilds the inner from the outer's updated extensions, keeping the inner random. That path also had a bug: the outer's key share was refreshed from hs.hello, the outer, instead of from the hello the retry regenerated, so ECH with a HelloRetryRequest failed on the custom path with "illegal parameter" or "invalid server key share". It now completes, with ECH accepted and the retried group negotiated.

Tests run the Chrome and Firefox specs with real ECH against a Go server holding the ECH key, record the inner hello it negotiates with, and compare it to the rules applied to the outer hello actually built, with and without a HelloRetryRequest, plus the NextProtos override. The inner hello now offers psk_key_exchange_modes as the browser's does, so servers issue tickets to these connections; testECHSpec opts out of loading them, since resumption with a custom ECH ClientHello is still unsupported.

Verified with Go 1.27.3: go test ./..., go test -race on the ECH tests, go vet, gofmt -s, go fix -diff, staticcheck and golangci-lint report nothing.

With a custom ClientHelloSpec and an ECH config list, MarshalClientHello
built the ClientHelloInner with makeClientHello, from the Config's
defaults, and copied the outer's key shares, signature algorithms,
session ID and groups into it. A server that accepts ECH negotiates
with the inner hello, so it saw three cipher suites and Go's extension
set instead of the browser's: no GREASE, no ALPS, no
compress_certificate and, since the inner's ALPN came from
Config.NextProtos, no ALPN unless the application set one. Parroting a
browser with real ECH gave the origin a Go fingerprint.

Build the inner hello from the spec's extensions as BoringSSL derives
Chrome's from one configuration: the outer's GREASE and TLS 1.3 cipher
suites; server_name with the configured name, the inner ECH marker and
supported_versions with TLS 1.3 only, uncompressed and first, in outer
order; the TLS 1.2-only extensions, padding and pre-shared keys left
out; everything else the outer's, compressed, in one run after them.
Config.NextProtos still replaces the spec's ALPN in the inner only.

The inner is marshaled from the extension objects and parsed back, so
the handshake's checks see the bytes the server does, and its encoded
form is derived from those bytes. A HelloRetryRequest rebuilds it from
the outer's updated extensions, keeping the inner random. That path
also refreshed the outer's key share from the outer hello instead of
the retried one, so ECH with a HelloRetryRequest failed on the custom
path; it completes now with ECH accepted and the retried group.

The inner hello offers psk_key_exchange_modes as the browser's does, so
servers issue tickets to these connections; testECHSpec opts out of
loading them, since resumption with a custom ECH ClientHello is still
unsupported.
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.

1 participant