Change:
(Note: this PR sits on top of , though hopefully the combined diff is also readable enough.) This achieves the same security properties of echhrrkey, but gives us a few improvements: Client-facing servers that offload their HPKE operations to RPCs do not need to worry about race conditions with rotation between the two ClientHellos. (See .)We only perform one addition asymmetric operation with ECH, rather than sometimes two. For servers with offloaded ECH keys, this also avoids an extra RPC call.HPKE implementations for ECH only need SetupBase and not SetupPSK.The description of HRR handling in general can be greatly simplified. Rather than running the server's CH1 algorithm with various modifications, we can just write a straightforward, simpler algorithm. This PR also adds some missing text for GREASE on HRR, which (Although it's worth noting that HRR connections can be easily distinguished by an active attacker anyway. This PR doesn't change this, but makes it much clearer.)
This is much simpler, but, if I understand it correctly, I think it might be a little bit of a regression. The previous export-then-inject design forced the server to process both ECH configs and encapsulated keys in both CH1 and CH2, whereas here the server just ignores those in CH2 and uses the values from CH1. That lets clients change these fields in CH2 without any effect. Now, I don't really know of anything that could go wrong here, but it smells like the sort of "insufficient binding" problem we've continually run into before. (For example, I think an on-path attacker could modify the or fields in CH2, without touching anything else. Decryption would succeed, as the server used CH1's value, and the transcript is unaffected since the server would choose the CH inner value. Is that a way for an on-path attacker to quickly check if the connection is real or fake ECH?) Chris asked if we should require servers to verify these match, and as you pointed out, that's a mess. (We also acknowledged that servers missing these checks is something we should expect to happen in the wild, which is somewhat why we are concerned about this re-binding attack in the first place.) I wonder if we can solve this in a different way: . That would prevent the modification above, I think, and shouldn't be too bad in terms of additional complexity.
I think allowing the actual client to change ignored fields is a general property of HelloRetryRequest. Because we don't expect servers to check consistency between CH1 and CH2, a client that goes out of their way to send something inconsistent may find that their (CH1, CH2) tuple is interpreted ambiguously. But the transcript reflects the whole tuple, so, as far as the handshake itself, the client is just causing its own preferences to be misrepresented here. Ideally ambiguous things would be unrepresentable and consistently treated as errors but HRR is what it is. :-/ I think real vs. fake ECH on HRR connections is entirely hopeless, short of completely redoing HRR. If CH1 uses ECH, we need to require CH2 also use ECH. That means ECH decryption failures in CH2 are fatal, and you can distinguish the two by simply replaying CH1 and perturbing the ciphertext in CH2. (That said, note that CHOuterAAD means whether a ClientHello triggers HRR is not under attacker control. So if a client/server pair doesn't trigger HRR, the attacker can't try to do this.) That said, I think there's a variant of this on CH1 in the optional configid mode. (See below.) Yeah, this is the "I think we got HRR wrong and have to live with it" problem. :-) I don't think I believe this matters for HRR (only attack I can see is real vs fake ECH, which is hopeless with HRR anyway), but now that you mention it, I think there is a variant of this for the optional configid mode in the non-HRR case. In CH1, we don't bind configid, enc, and ciphersuite to the encryption, so a priori an attacker might be able to perturb them without breaking the decryption. Perturbing enc and ciphersuite seems likely to break decryption anyway, but configid is interesting. In the normal mode, it too will break decryption. In the optional configid mode, configid is just a placeholder. That means an attacker can distinguish optional configid ECH from {GREASE or configid ECH} by flipping the config_id, sending it to the server, and seeing if the client can decrypt the result. (Of course, all this is relative to an unclear and undefined "don't stick out" model.) Complexity-wise, I think it's fine, but note it changes the processing order somewhat interestingly, both for the spec and implementations. Right now the spec says: Make your CHInner. This should largely be running through your usual CH assembly logic. Encode it with the extensions you expect to compress. Make your CHOuter, minus the ECH extension. This should largely run through your existing CH assembly logic and computes CHOuterAAD. Take EncodedCHInner and CHOuterAAD and run through the Deserialize, SetupBaseS, context.Seal logic to assemble the final CHOuter. In order to put these fields in AAD, we'd need to say: Make your CHInner. This should largely be running through your usual CH assembly logic. Encode it with the extensions you expect to compress. Make your CHOuter, minus the ECH extension. This should largely run through your existing CH assembly logic. Run Deserialize and SetupBaseS. Compute CHOuterAAD with half an ECH extension. Take EncodedCHInner and CHOuterAAD and run through the context.Seal logic to assemble the final CHOuter.
A similar "don't stick out" distinguisher: Flip a bit of ClientECH.payload in CH2. The client-facing server will abort with "decrypt_error" if real-ECH is in use but not if grease-ECH is in use. This attack is unavoidable if we want to ensure that the server doesn't change from CHInner to CHOuter across the HRR. (Enforcing this invariant seems like a good idea to me.) This seems like a sensible idea, although I'm not super concerned about the "don't stick out" attack it mitigates. I would vote we land this change as-is and follow up with a new PR.
Right, I mentioned that HRR attack above. ( "I think real vs. fake ECH on HRR connections is entirely hopeless [...]".) Since avoiding this is hopeless in HRR, I don't think we should use it as a justification for binding bits of CH2's ClientECH or checking they match (which don't actually do the same thing). Whereas attacks on CH1 are interesting because that situation is less obviously hopeless. So maybe we want to add more things to CHOuterAAD for that, though that is orthogonal to this PR, so I think we should deal with it separately. (CHOuterAAD was originally intended to fix the ClientHello malleability attack, in a way that avoids burning 32 bytes on an inner_digest.)
Agreed. (I was in the middle of editing my comment above to say the same thing when I saw your message!)

This attack is unavoidable if we want to ensure that the server doesn't change from CHInner to CHOuter across the HRR. Yeah, this morning I came to the conclusion that mitigating this sort of distinguisher for HRR was not really possible unless we relax the inner/outer matching invariant. I'm fine pursuing more AAD as a separate change on top of this, since it's strictly an improvement (and HRR is hopeless).
NAME can you please resolve conflicts?
Rebased.
I'll try to put together a PR for this later, but filing this now so I don't forget. (NAME FYI) The draft currently says you randomize this both times, which won't work. Also "MUST check the extension syntactically" appears to have been a little ambiguous, since how deeply you parse ECHConfigs gets a little complicated. I'll adjust that too.
What fields are suggesting to not randomize?
. The draft doesn't quite say to randomize it, but it does say to vary it. (I'm not sure that's correct, actually. It depends on how much variation to expect in server ECHConfigs. If everyone does the AES-GCM / ChaCha20 pair, as they should, then the client will always pick one matching its cipher preference.) Additionally, after , also should not be re-randomized on CH2.
NAME what is the concern here?
Just that GREASE and ECH should be consistent in which fields get copied over and the text doesn't say that right now.
Oh, apparently I already thought of this in and included some text about this: So I'll just mark as closing this too but leave this open just in case we decide not to do .
ECH currently adds an ECDH operation on both the first and second ClientHello. That's extra asymmetric operations, and if folks end up offloading those keys, it's two RPCs. Given that we need to bind the two decryptions together anyway, and HPKE supports multiple encrypted messages (it has an internal sequence number), perhaps it's better to just use that. Here's a possibility: the first ClientHello makes a new HPKE context and calls context.Seal on it. The second ClientHello simply calls context.Seal on the same context, which internally increments the sequence number. Probably we copy over the first ClientHello's ciphersuite, configid, and enc, even though they don't really do anything. The server, if stateful, just calls context.Open to mirror the client behavior. If stateless, probably the simplest thing is to pickle the AEAD key inside the cookie. [Edit: I accidentally wrote "ticket" earlier.] It probably could also recompute the HPKE context and bump the sequence number up by one, maybe checking the selected ECHConfig and enc values didn't change. (The text already requires checking the former.)
EDITED: s/ticket/cookie/ I like this suggestion overall. A few comments: It's not sufficient to store only the key in the cookie: minimally we'll need the key and the initialization vector (called "nonce" in the HPKE spec, unfortunately). We may also end up needing the exporter secret as well, although this is currently only used in ECH to compute the HRR PSK for and this call would go away if we decided to adopt this change. In case of ECH acceptance, the backend server stores the key/IV in the cookie. This is a problem for "Split Mode": as you previously pointed out, there is currently no way for the client-facing server to convey cookie content to the backend server. Perhaps a solution to is a prerequisite to this? HPKE does not specify serialization of the HPKE context. This use case makes me think it should.
(I think you want to s/ticket/cookie/g since ticket is the resumption thing. That was my bad. I wrote ticket instead of cookie too.) Good catch. Yes, we need to store both derived values. Although the difference between storing one short byte string or two short byte strings is largely one of semantics. (You can just as easily concatenate them and store the one resulting short byte string.) You could also store the root secret and rederive it. Whichever. We wouldn't need the exporter secret or SetupPSK in this design. I don't think this introduces a new problem, so there's no need to block on . Though we need to deal with that too. In both this design and the status quo, the client-facing server needs to either be stateful or store something secret in the cookie. Right now it's echhrrkey. In this design it's the key/IV. (Though this design introduces the alternate strategy of storing a less secret thing, where you just remember the client's public bits and recompute the HPKE context.) The serialization doesn't need to be standard or anything. A TLS server might want to keep its cookie small by observing it doesn't need the exporter secret. But some guidance might be useful, sure. (The only thing that needs to consume the cookie is the server, who also produced it. Just as TLS doesn't (and shouldn't) specify session state serialization, yet every TLS implementation has a session state serialization. I could also implement TLS by, rather than keeping connection state in memory, write it to disk and read it back out on every packet. Even an in-memory representation is a serialization.)
This is an interesting consideration. On one hand, I agree that it's simpler to simply reuse the existing context, though on the other hand it seems to run against the general desire to use primarily CH2 for "everything." That said, ECH annoyingly requires binding between the two CHs, which we currently address by tying them together with a PSK, and this proposal is arguably simpler. (Anecdotally, I recall some server stacks verifying PSK binders on CH1 and then ignoring them on CH2, which was a situation that wasn't carefully analyzed in the formal analyses. I think that was fixed, though I don't recall how: are binders verified for both CHs, or only for the latter? And if verifying a binder involves a public key operation, why would ECH be any different?)
The "use CH2 for everything" theory mostly assumes you don't negotiate very many parameters on CH1, whereas you need to resolve ECH early to even get at CH1. And, as you note, we need to bind the two. So I think we're pretty stuck with having ECH resolved at CH1 rather than CH2 anyway. Verifying a binder doesn't require a public key operation. It's mostly an HMAC. If you resolved the PSK at CH1, you've got the PSK available for CH2 and can compute the binder easily enough. If you resolved the PSK at CH2[], then you just do it normally. [] As an aside, you only sort of are able to do this. If your stack implements pskke, which thankfully most folks didn't, you need to resolve PSK at CH1 to know if you need a key share at all. Whereas if you know you only support pskdhe_ke, then you can proceed assuming you need a key share and defer sorting out the PSK to later.
Yeah, I think that's right. What if the key is inside a ticket? Doesn't that require a public key operation? (I should have clarified that I was referring to the whole process, involving getting the key and then using it to verify the binder. Sorry!)
I guess it depends on how you do your tickets. They're typically just encrypted with a symmetric key, but I've also seen folks use asymmetric stuff. But, in any case, if you decrypt the ticket at CH1, by CH2 you've already gotten the decrypted ticket so you just compute the binder and don't redo the ticket business. If you decrypt the ticket at CH2, you only have the one ticket decryption by construction.
PR in .
I was thinking about and the discussion on server HRR checks started by URL, and I'm not sure HRR cookies even work in split mode right now. First off, suppose we don't merge something like . The current text says: This means if you get a cookied HRR only good for ClientHelloInner, there's no requirement to stick the cookie in ClientHelloOuter! But now the client-facing server can't see the cookie, so it can't be stateless. It would seem like fixes this, but now we have a more fundamental problem: whose cookie? Suppose the client-facing server wishes to handle HRR statelessly. It decrypts the first ClientHelloInner and forwards it to the backend server. If the backend server responds with HRR, the client-facing server must forward the HRR as-is, or the transcript breaks. But the client-facing server has no opportunity to bind its state (echhrrkey) into the HRR cookie. (Perhaps there isn't even one. If there is, it's encrypted with the backend server's key anyway. Either way, it's fixed now.) The simplest solution to all this is to only support stateful client-facing servers in split mode, unless the client-facing and backend servers can coordinate enough to share one cookie. I don't know if that would satisfy everyone. I could imagine doing something horrific to URL, akin to what we did to URL, but that sounds really messy.
Let's mark this as an "OPEN ISSUE" in the draft.
Done. URL
NAME mentions possibly binding the outer CH to the ECH encryption in . If we don't do that, we should at least bind the encryption to the non-encrypted parts of the ECH extension (, , and so on). cc NAME
I also like this idea :) AD is cheap.
Closing in favor of . We can revisit the ECH extension AD information later if desired.
Re-opening to consider adding all data to AAD. NAME wrote out the flow if we did this: Make your CHInner. This should largely be running through your usual CH assembly logic. Encode it with the extensions you expect to compress. Make your CHOuter, minus the ECH extension. This should largely run through your existing CH assembly logic. Run Deserialize and SetupBaseS. Compute CHOuterAAD with half an ECH extension. Take EncodedCHInner and CHOuterAAD and run through the context.Seal logic to assemble the final CHOuter. So, not great, but also not too terrible. I'd prefer we avoid falling into the "gee, I sure wish we bound more to the encryption" state, given the history of ECH, though I agree that this isn't cheap. NAME what do you think?
To be clear, the suggestion is to change AEAD encryption so that it authenticates , , and , correct? This change seems sensible to me. One might argue that this is redundant because these parameters are incorporated into the transcript (on ECH rejection). On the other hand, ECH usage is resolved before the transcript is known! It seems like a good idea to authenticate these parameters before consuming them.
They aren't incorporated on ECH accept, which is mildly interesting. ECH accept also doesn't negotiate with them, so it's probably okay, though there is a "don't stick out" failure (depending on threat model) for the optional config_id flow if we don't authenticate them.
This seems generally good. I'm not sure why we didn't do this in the first place. Are reusable contexts new?LGTM.

Discussion:
"pre_shared_key" extension is forbidden in ClientHelloOuter. The client then generates the HPKE encryption context.  Finally, it <del> computes the encapsulated key, context, HRR key (see client-hrr), and payload as: </del> <ins> computes the encapsulated key, context, and payload as: </ins> Note that the HPKE functions Deserialize and SetupBaseS are those which match "ECHConfig.kem_id" and the AEAD/KDF used with "context"

in HelloRetryRequest.  Otherwise, the usual rules for HelloRetryRequest processing apply. <del> Clients bind encryption of the second ClientHelloInner to encryption of the first ClientHelloInner via the derived ech_hrr_key by running a modified encryption process: </del> <ins> The client encodes the second ClientHelloInner as in encoding-inner, using the second ClientHelloOuter for any referenced extensions.  It then encrypts the new EncodedClientHelloInner value as a second message with the previous HPKE context: </ins> <del> The "info" parameter to SetupPSKS is the concatenation of "tls ech", a zero byte, and the serialized ECHConfig.  ClientHelloOuterAAD is computed from the second ClientHelloOuter as described in authenticating-outer.  Note the encrypted value is also authenticated by ech_hrr_key.  The rationale for this is described in flow-hrr- hijack. </del> <ins> ClientHelloOuterAAD is computed as described in authenticating-outer, but again using the second ClientHelloOuter.  Note an HPKE context maintains a sequence number, so this operation internally uses a fresh nonce for the AEAD operation.  Reusing the HPKE context avoids an attack described in flow-hrr-hijack. The client then modifies the "encrypted_client_hello" extension in ClientHelloOuter as follows: "cipher_suite" is unchanged and contains the client's chosen HPKE cipher suite. "config_id" is replaced with the empty string. "enc" is replaced with the empty string. "payload" is replaced with the value computed above. </ins> If the client offered ECH in the first ClientHello, then it MUST offer ECH in the second.  Likewise, if the client did not offer ECH in the first ClientHello, then it MUST NOT not offer ECH in the second. <del> [[OPEN ISSUE: Should we be using the PSK input or the info input?  On the one hand, the requirements on info seem weaker, but maybe actually this needs to be secret?  Analysis needed.]] </del> 6.2. If the client attempts to connect to a server and does not have an ECHConfig structure available for the server, it SHOULD send a GREASE <del> RFC8701 "encrypted_client_hello" extension as follows: </del> <ins> RFC8701 "encrypted_client_hello" extension in the first ClientHello as follows: </ins> Set the "cipher_suite" field to a supported ECHCipherSuite.  The selection SHOULD vary to exercise all supported configurations,

scheme and L is the size of the EncodedClientHelloInner the client would compute when offering ECH, padded according to padding. <ins> When sending a second ClientHello in response to a HelloRetryRequest, the client copies the "cipher_suite" field from the first ClientHello.  It sets "config_id", and "enc" to the empty string. Finally, it generates a new "payload" field, using the length of a padded second EncodedClientHelloInner for L. </ins> If the server sends an "encrypted_client_hello" extension, the client MUST check the extension syntactically and abort the connection with a "decode_error" alert if it is invalid.  It otherwise ignores the

7.1. <del> Upon receiving a non-empty "encrypted_client_hello" extension, the client-facing server determines if it will accept ECH, prior to negotiating any other TLS parameters.  Note that successfully decrypting the extension will result in a new ClientHello to process, so even the client's TLS version preferences may have changed. </del> <ins> Upon receiving a non-empty "encrypted_client_hello" extension in an initial ClientHello, the client-facing server determines if it will accept ECH, prior to negotiating any other TLS parameters.  Note that successfully decrypting the extension will result in a new ClientHello to process, so even the client's TLS version preferences may have changed. </ins> First, the server collects a set of candidate ECHConfigs.  This set is determined by one of the two following methods:

forwards the ClientHelloInner to the appropriate backend server, which proceeds as in backend-server.  If the backend server responds with a HelloRetryRequest, the client-facing server forwards it, <del> decrypts the client's second ClientHelloOuter using the modified procedure in server-hrr, and forwards the resulting second ClientHelloInner.  The client-facing server forwards all other TLS messages between the client and backend server unmodified. </del> <ins> decrypts the client's second ClientHelloOuter using the procedure in server-hrr, and forwards the resulting second ClientHelloInner.  The client-facing server forwards all other TLS messages between the client and backend server unmodified. </ins> Otherwise, if all candidate ECHConfigs fail to decrypt the extension, the client-facing server MUST ignore the extension and proceed with

7.1.1. <del> In case a HelloRetryRequest (HRR) is sent, the client-facing server MUST consistently accept or decline ECH between the two ClientHellos, using the same ECHConfig, and abort the handshake if this is not possible.  This is achieved as follows.  Let CH1 and CH2 denote, respectively, the first and second ClientHello transmitted on the wire by the client: If CH1 contains the "encrypted_client_hello" extension but CH2 does not, or if CH2 contains the "encrypted_client_hello" extension but CH1 does not, then the server MUST abort the handshake with an "illegal_parameter" alert. If the "encrypted_client_hello" extension is sent in CH2, the server follows the procedure in client-facing-server to decrypt the extension, but it uses the previously-selected ECHConfig as the set of candidate ECHConfigs.  If decryption fails, the server aborts the connection with a "decrypt_error" alert rather than continuing the handshake with the second ClientHelloOuter. When decrypting the second ClientECH.payload, the client-facing server performs a corresponding process to client-hrr: ClientHelloOuterAAD is computed from the second ClientHelloOuter as described in authenticating-outer.  The "info" parameter to SetupPSKR is the concatenation of "tls ech", a zero byte, and the serialized ECHConfig. </del> <ins> After sending or forwarding a HelloRetryRequest, the client-facing server does not repeat the steps in client-facing-server with the second ClientHelloOuter.  Instead it continues with the ECHConfig selection from the first ClientHelloOuter as follows: If the client-facing server accepted ECH, it checks the second ClientHelloOuter also contains the "encrypted_client_hello" extension.  If not, it MUST abort the handshake with a "missing_extension" alert.  Otherwise, it checks that ClientECH.cipher_suite is unchanged, and that ClientECH.config_id and ClientECH.enc are empty.  If not, it MUST abort the handshake with an "illegal_parameter" alert. Finally, it decrypts the new ClientECH.payload as a second message with the previous HPKE context: ClientHelloOuterAAD is computed as described in authenticating-outer, but using the second ClientHelloOuter.  If decryption fails, the client-facing server MUST abort the handshake with a "decrypt_error" alert.  Otherwise, it reconstructs the second ClientHelloInner from the new EncodedClientHelloInner as described in encoding-inner, using the second ClientHelloOuter for any referenced extensions. The client-facing server then forwards the resulting ClientHelloInner to the backend server.  It forwards all subsequent TLS messages between the client and backend server unmodified. If the client-facing server rejected ECH, or if the first ClientHello did not include an "encrypted_client_hello" extension, the client- facing server proceeds with the connection as usual.  The server does not decrypt the second ClientHello's ClientECH.payload value, if there is one. </ins> [[OPEN ISSUE: If the client-facing server implements stateless HRR, it has no way to send a cookie, short of as-yet-unspecified

controlled) ClientHello, the Certificate produced would leak the client's chosen SNI to the attacker. <del> This attack is mitigated by binding the first and second ClientHello messages together.  In particular, since the attacker does not possess the ech_hrr_key, it cannot generate a valid encryption of the second inner ClientHello.  The server will attempt decryption using ech_hrr_key, detect failure, and fail the connection. </del> <ins> This attack is mitigated by using the same HPKE context for both ClientHello messages.  The attacker does not possess the context's keys, so it cannot generate a valid encryption of the second inner ClientHello. </ins> <del> If the second ClientHello were not bound to the first, it might be </del> <ins> If the attacker could manipulate the second ClientHello, it might be </ins> possible for the server to act as an oracle if it required parameters from the first ClientHello to match that of the second ClientHello. For example, imagine the client's original SNI value in the inner