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
