Thanks for writing your experience, Martin.
Would it be useful to have some kind of "recommended outer TP" value?
Having multiple implementations use the same value would reduce
fingerprinting even more.
-- Christian Huitema
On 5/26/2025 11:11 PM, Martin Thomson wrote:
Inspired by some offline discussions, I thought I'd share the outcome of my
recent attempt to split what our implementation includes in the inner and outer
ClientHello for ECH.
Until recently, we put all the transport parameters in both inner and outer
ClientHello. That's the most efficient, as TLS can compress that down to
basically nothing. However, there are a few things that don't really need to
be on the outer envelope and now that we have plenty of space to burn thanks to
blowing the one-packet limit on PQ key exchange, it seemed like a good time to
look into what it would take to do better.
The core realization is that the outer ClientHello is only good for getting the
handshake done and a fresh ECH configuration from the server. That takes very,
very little of QUIC. Most of the stuff in transport parameters is unused if
you have to use the outer ClientHello. No streams or datagrams. Most
extensions are inoperative, as they only operate post-handshake generally.
The way I implemented this was as a simple filter: an inner ClientHello has no
filter on which transport parameters are included, but the outer ClientHello
has a filter that discards any unnecessary transport parameters as it writes
the extension.
The filter needs to retain transport parameters that are critical for the
handshake. This is a short list: just the connection ID parameters that are
used to validate connection ID changes due to Retry; plus the version
negotiation transport parameter that validates any version negotiation. If
either of these are absent, connections will be rejected by servers (though not
always for version negotiation, read on).
Trick for our implementation was to avoid having to switch to a different
connection configuration depending on the choice of ClientHello. For instance,
we could drop the version negotiation parameter if we completely disabled QUIC
version 2 if the outer ClientHello is used, but that's pretty annoying to get
right. Better to stick with a single configuration that works either way.
There are three transport parameters that are not clearly one way or the other.
You could get away with bad values of these in the case of an ECH fallback,
but you might not want to:
* We set a maximum ACK delay that is different from the default, so if we
omitted that, we'd be creating a mismatch between what we advertise and our
actual configuration. You see, it is probably the case that you could rely on
defaults for maximum ACK delay here, with the only effect being that the RTT
estimate for the peer is off by any difference. That might not have serious
consequences for a connection that is so short-lived.
* The ACK delay exponent is not something we allow to change on our side, but
it's in the filter because setting it seems safest, even if - by the same logic
as applies to maximum ACK delay - the consequence isn't serious.
* The same is true for the UDP payload size, which we don't have specific
configuration for that would have it vary from the default. Even if we had a
cap and were enforcing it, it would look like a constrained MTU on the path,
which is probably harmless. The cost being a few lost packets, if we ever
added a way to reduce this from the default.
I ended up playing it safe by including these three transport parameters in the
filter. With just one of them (max ACK delay) being written out in our current
code. I can see why others might choose to drop them entirely if space were an
issue.
Then, there are all the things that are definitely not needed: anything related
to streams, flow control, connection IDs (a default of 2 is more than enough),
preferred address, migrations, idle timeouts, and extensions. The connection
isn't around long enough for these to matter.
For extensions, we really only have delayed ACK and datagram implemented, but
others would include multipath and almost every extension I can conceive of.
Almost. There aren't many extensions where the transport parameters you send
bind you to operate differently. For instance, even with multipath QUIC, you
can still send ordinary ACK frames, even though the extension strongly
encourages the use of PATH_ACK instead (that's a good choice, by the way). We
do have the greased QUIC bit implemented, which we don't signal support for in
the outer ClientHello without also turning off the code for decoding packets
without the bit set. That is safe to do: no peer is going to abort if you
accept a packet with the bit cleared...geez, I sure hope they don't...
The result is that the handshake gets a little larger (duplicating the
connection IDs being the main culprit), but it is a fair bit smaller. I hope
that this inspires others to do the same work. It really wasn't that hard.
For the most part, the effect on privacy is actually zero. If we concede that
our handshake is already identifiable as being from this particular
implementation, the transport parameters we advertise are generally the same
for any connection we establish. Still, it makes sense to minimize the outward
profile, so as not to undermine other efforts (like efforts to eliminate TLS
extension differences).
Most importantly, it clears a path to using the inner ClientHello for stuff
where privacy really matters.
Cheers,
Martin