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

Reply via email to