Change:
This PR cleans up the client text, in hopes of making it more precise and organized somewhat more chronologically. It then uses this cleanup to clarify bits we forgot to write down in the accept/reject/HRR machinery: Handling the Server Response is now Determining ECH Acceptance and only discusses the initial accept/reject decision. The portion of Handling HelloRetryRequest discussing the accept/reject decision is moved to Determining ECH Acceptence. Determining ECH Acceptance is tidied up to discuss to what happens if you see an older TLS version (it's a reject). In particular, in DTLS 1.2, the first server message might be HelloVerifyRequest and not ServerHello or HelloRetryRequest. Accepted/Rejected ECH are now Handshaking with ClientHelloInner/ClientHelloOuter to fit with the other section names (lots of VERBing). They are unindented from (1) because that section only talks about the accept/reject decision. This gives us a place to discuss all changes to the handshake. We describe what it actually means to handshake with ClientHelloInner, including implications to the transcript. And... URL portion of Handling HelloRetryRequest that discussed constructing the second ClientHello never applied to the ECH Reject case anyway, only ECH Accept. Move this to (4). Also expand on it so it's a bit more clearly defined. I've also moved HPKE setup out of Encrypting the ClientHello, as that's only relevant to one of the two "callers" anyway. I'm not thrilled with this notion of "partial ClientHelloOuterAAD", but the text used in Offering ECH and Encrypting the ClientHello sections otherwise doesn't actually match. Before this PR, both think they are responsible for constructing ClientHelloOuter. With this and (1), Handling HelloRetryRequest is now empty and removed. We forgot to write down the client half of URL There is now a place for it in Handshaking with ClientHelloInner. We say that the ECH reject case ignores the URL extension, so ECH GREASE says the same thing, to keep them aligned. The Handshaking with ClientHelloOuter section could also do with an editorial pass, but I've omitted it from this PR because it was getting a bit large as it is.
I suspect this is editorial, but it would be helpful to clarify exactly how the client computes its transcript when it generates its keys, based on the permutations of who gets what. IIUC: If the server authenticates the inner SNI, the client must construct the client hello as seen by the back-end server, including any OuterExtensions compression, and uses it in the transcript. If the server authenticates the public SNI, the client uses the entire client hello in the hash as-is, including the (unreadable) encryptedclienthello extension. A server that combines the client-facing and back-end functions also has to duplicate this logic, since it can see everything but maybe shouldn't use it all, and has to go through the motions of formally constructing a proper client hello to a back-end server. I found this all quite subtle to work out and asked some questions of one of the authors to fully understand. I'll leave it to the editors as to whether the draft needs some text to make this clearer. [Aside: is it computationally feasible to add some sort of "checksum" that would neutralize the difference in the hashes? That would make this a little less tricky, IMO]
+1 and test vectors would be good too
Yes, agree. This is really tricky: "The client presumes acceptance if the last 8 bytes of URL are equal to the first 8 bytes of accept_confirmation as defined in Section 7.2. Otherwise, it presumes rejection."
Here's what I ended up writing after I solved this: "This issue is that the ECH transcript needs to be calculated from an unsent inner ClientHello message, which differs in a few ways from the EncodedClientHelloInner used in HPKE. Upon ECH acceptance, the transcript from the ClientHelloOuter is discarded, then substituted with one calculated from the unsent inner ClientHello message, and the client random is changed to the inner random value. After that, the ServerHello message is added to the transcript as normal. The transcript used for ECH acceptance is separate from the normal transcript."
I think the way to think about this is that there are two possible handshakes here, joined at the first message. Either we handshake with ClientHelloOuter, or we handshake with ClientHelloInner. Whichever, we handshake with, we follow the usual RFC8446 rules, including incorporating the chosen ClientHello into the transcript. Indeed on the server, the protocol even supports a client-facing vs. backend server split where the connection is actually rewritten to use ClientHelloInner. EncodedClientHelloInner, per the name, is just an encoding. We don't want to send two copies of things like key shares. The actual ClientHello being conveyed is still ClientHelloInner. (This is important since the transcript needs to reflect all inputs to TLS parameter negotiation.) But, yes, on the client this gets a little fun because the client needs to offer these two somewhat related ClientHellos, learn which was to used a message later, and then complete the handshake accordingly.
Perhaps there's missing text in "6.1.4.1. Accepted ECH", where client transcript handling could be addressed. My feedback is that it was possible to construct a conformant EncodedClientHelloInner and enclosing ClientHelloOuter from reading the spec as a non-author, but I had to test with other implementations to discover what I was supposed to do with the ClientHelloInner. If you get this part wrong, then you just hit a decryption error after ServerHello. I did understand that I had to update the transcript, but I initially thought I should do it with an EncodedClientHelloInner added to the ClientHelloOuter transcript. Once I discovered that was not the case, I had to construct a ClientHelloInner that still had the fields that are compressed in EncodedClientHelloInner. That doesn't cross anything in the text, but it wasn't obvious to me.
I think we're in agreement here, but is the encoding of ClientHelloInner deterministic enough that the client-facing server can't possibly express the expanded ClientHelloInner in a way that breaks the transcript?
It's decode, not encode, that needs to be deterministic. And yes, it is deterministic.
Here's what I ended up writing to get this working. I don't have HRR support done yet, so I expect these comments will change a little bit:
Which transcript is used should be better clarified now that landed. Closing as such. (If we want to track test vectors for ECH, let's open a new issue.)
Thanks for capping the amount of changes in this PR :)

Discussion:
handshake.  This is referred to as accepting ECH. Upon receiving the server's response, the client determines whether <del> or not ECH was accepted (handle-server-response) and proceeds with the handshake accordingly.  When ECH is rejected, the resulting </del> <ins> or not ECH was accepted (determining-ech-acceptance) and proceeds with the handshake accordingly.  When ECH is rejected, the resulting </ins> connection is not usable by the client for application data. Instead, ECH rejection allows the client to retry with up-to-date configuration (rejected-ech).

generated as described in grease-ech.) The client then constructs EncodedClientHelloInner as described in <del> encoding-inner.  Finally, it constructs the ClientHelloOuter message just as it does a standard ClientHello, with the exception of the following rules: </del> <ins> encoding-inner.  It also computes an HPKE encryption context and "enc" value as: Next, it constructs a partial ClientHelloOuterAAD as it does a standard ClientHello, with the exception of the following rules: </ins> It MUST offer to negotiate TLS 1.3 or above.

ClientHelloOuter to safely ignore any early data sent by the client per RFC8446, Section 4.2.10. <del> It MUST include an "encrypted_client_hello" extension with a payload constructed as described in encrypting-clienthello. </del> Note that these rules may change in the presence of an application profile specifying otherwise.

extensions are not offered in the ClientHelloOuter.  See outer- clienthello for additional guidance. <del> 6.1.1. To construct the "encrypted_client_hello", the client first determines the encapsulated key and HPKE encryption context.  If constructing the first ClientHelloOuter, it computes them as: </del> <ins> Finally, the client encrypts the EncodedClientHelloInner with the above values, as described in encrypting-clienthello, to construct a ClientHelloOuter.  It sends this to the server, and processes the response as described in determining-ech-acceptance. </ins> <del> If constructing the second ClientHelloOuter (client-hrr), it reuses the encryption context computed for the first ClientHelloOuter, and sets "enc" to the empty string.  Note that the HPKE context maintains a sequence number, so this operation internally uses a fresh nonce for each AEAD operation.  Reusing the HPKE context avoids an attack described in flow-hrr-hijack. </del> <ins> 6.1.1. </ins> <del> The client then computes ClientHelloOuterAAD (authenticating-outer) by constructing a ClientHello with all other extensions determined as in real-ech. </del> <ins> Given an EncodedClientHelloInner, an HPKE encryption context and "enc" value, and a partial ClientHelloOuterAAD, the client constructs a ClientHelloOuter as follows. </ins> <del> Next, the client determines the length L of encrypting </del> <ins> First, the client determines the length L of encrypting </ins> EncodedClientHelloInner with the selected HPKE AEAD.  This is typically the sum of the plaintext length and the AEAD tag length. <del> The client fills in an "encrypted_client_hello" extension with the outer variant of ECHClientHello with the following values: </del> <ins> The client then completes the ClientHelloOuterAAD with an "encrypted_client_hello" extension.  This extension value contains the outer variant of ECHClientHello with the following fields: </ins> "config_id", the identifier corresponding to the chosen ECHConfig structure; "cipher_suite", the client's chosen cipher suite; <del> "enc", as computed above; and </del> <ins> "enc", as given above; and </ins> "payload", a placeholder byte string containing L zeros.

ClientHelloOuter. The client serializes this structure to construct the <del> ClientHelloOuterAAD.  It then computes the payload as: </del> <ins> ClientHelloOuterAAD.  It then computes the final payload as: </ins> Finally, the client replaces "payload" with "final_payload" to obtain ClientHelloOuter.  The two values have the same length, so it is not

6.1.4. <del> As described in server-behavior, the server MAY either accept ECH and use ClientHelloInner or reject it and use ClientHelloOuter.  In handling the server's response, the client's first step is to determine which value was used. </del> <ins> As described in server-behavior, the server may either accept ECH and use ClientHelloInner or reject it and use ClientHelloOuter.  This is determined by the server's initial message. If the message does not negotiate TLS 1.3 or higher, the server has rejected ECH.  Otherwise, it is either a ServerHello or HelloRetryRequest. </ins> <del> If the server replied with a HelloRetryRequest, then the client proceeds as described in client-hrr.  Otherwise, if the server replied with a ServerHello, then the client checks if the last 8 bytes of "ServerHello.random" are equal to "accept_confirmation" as defined in backend-server.  If so, then it presumes acceptance. Otherwise, the client presumes rejection. </del> <ins> If the message is a ServerHello, the client computes "accept_confirmation" as described in backend-server.  If this value matches the last 8 bytes of "ServerHello.random", the server has accepted ECH.  Otherwise, it has rejected ECH. </ins> <del> 6.1.4.1. </del> <ins> If the message is a HelloRetryRequest, the client checks for the "encrypted_client_hello" extension.  If none is found, the server has rejected ECH.  Otherwise, if it has a length other than 8, the client aborts the handshake with a "decode_error" alert.  Otherwise, the client computes "hrr_accept_confirmation" as described in backend- server-hrr.  If this value matches the extension payload, the server has accept ECH.  Otherwise, it has rejected ECH. [[OPEN ISSUE: Depending on what we do for issue#450, it may be appropriate to change the client behavior if the HRR extension is present but with the wrong value.]] </ins> <del> If the server used ClientHelloInner, the client proceeds with the connection as usual, authenticating the connection for the true server name. </del> <ins> If the server accepts ECH, the client handshakes with ClientHelloInner as described in accepted-ech.  Otherwise, the client handshakes with ClientHelloOuter as described in rejected-ech. </ins> <del> 6.1.4.2. </del> <ins> 6.1.5. </ins> <del> If the server used ClientHelloOuter, the client proceeds with the handshake, authenticating for ECHConfig.contents.public_name as described in auth-public-name.  If authentication or the handshake fails, the client MUST return a failure to the calling application. It MUST NOT use the retry configurations. </del> <ins> If the server accepts ECH, the client proceeds with the connection as in RFC8446, with the following modifications: The client behaves as if it had sent ClientHelloInner as the ClientHello.  That is, it evaluates the handshake using the ClientHelloInner's preferences, and, when computing the transcript hash (Section 4.4.1 of RFC8446), it uses ClientHelloInner as the first ClientHello. If the server responds with a HelloRetryRequest, the client computes the updated ClientHello message as follows: It computes a second ClientHelloInner based on the first ClientHelloInner, as in Section 4.1.4 of RFC8446.  The ClientHelloInner's "encrypted_client_hello" extension is left unmodified. It constructs EncodedClientHelloInner as described in encoding- inner. It constructs a second partial ClientHelloOuterAAD message.  This message MUST be syntactically valid.  The extensions MAY be copied from the original ClientHelloOuter unmodified, or omitted.  If not sensitive, the client MAY copy updated extensions from the second ClientHelloInner for compression. It encrypts EncodedClientHelloInner as described in encrypting- clienthello, using the second partial ClientHelloOuterAAD, to obtain a second ClientHelloOuter.  It reuses the original HPKE encryption context computed in real-ech and uses the empty string for "enc". The HPKE context maintains a sequence number, so this operation internally uses a fresh nonce for each AEAD operation.  Reusing the HPKE context avoids an attack described in flow-hrr-hijack. The client then sends the second ClientHelloOuter to the server. However, as above, it uses the second ClientHelloInner for preferences, and both the ClientHelloInner messages for the transcript hash.  Additionally, it checks the resulting ServerHello for ECH acceptance as in determining-ech-acceptance.  If the ServerHello does not also indicate ECH acceptance, the client MUST terminate the connection with an "illegal_parameter" alert. 6.1.6. If the server rejects ECH, the client proceeds with the handshake, authenticating for ECHConfig.contents.public_name as described in auth-public-name.  If authentication or the handshake fails, the client MUST return a failure to the calling application.  It MUST NOT use the retry configurations. </ins> Otherwise, if both authentication and the handshake complete successfully, the client MUST abort the connection with an

"encrypted_client_hello" extension.  If the client does not retry in either scenario, it MUST report an error to the calling application. <del> 6.1.4.3. </del> <ins> 6.1.7. </ins> <del> When the server rejects ECH or otherwise ignores "encrypted_client_hello" extension, it continues with the handshake using the plaintext "server_name" extension instead (see server- behavior).  Clients that offer ECH then authenticate the connection with the public name, as follows: </del> <ins> When the server rejects ECH, it continues with the handshake using the plaintext "server_name" extension instead (see server-behavior). Clients that offer ECH then authenticate the connection with the public name, as follows: </ins> The client MUST verify that the certificate is valid for ECHConfig.contents.public_name.  If invalid, it MUST abort the

instance, by reporting a failed connection with a dedicated error code. <del> 6.1.5. When the server sends a HelloRetryRequest, the client checks for the presence of an "encrypted_client_hello" extension.  If none is found, then the client presumes rejection and handles the HelloRetryRequest using ClientHelloOuter.  (Note that the client-facing server does not send this extension in its HelloRetryRequest.  [[NOTE: This may change, depending on the outcome of issue#450.]]) Otherwise it proceeds as follows. If the extension's payload has a length other than 8, then the client aborts the handshake with an "decode_error" alert.  If the payload length is equal to 8, then the client checks if the payload is equal to "hrr_accept_confirmation" as defined in backend-server.  If so, then it presumes acceptance and handles handles the HelloRetryRequest using ClientHelloInner.  Otherwise, it presumes rejection. [[OPEN ISSUE: Depending on what we do for issue#450, it may be appropriate to change the client behavior if the HRR payload is missing or malformed.]] 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 as described in encrypting- clienthello. </del> 6.2. If the client attempts to connect to a server and does not have an

scheme and L is the size of the EncodedClientHelloInner the client would compute when offering ECH, padded according to padding. <del> When sending a second ClientHello in response to a HelloRetryRequest, </del> <ins> If sending a second ClientHello in response to a HelloRetryRequest, </ins> the client copies the entire "encrypted_client_hello" extension from the first ClientHello. <del> [[OPEN ISSUE: The above doesn't match HRR handling for either ECH acceptance or rejection.  See issue https://github.com/tlswg/draft- ietf-tls-esni/issues/358.]] </del> <ins> If the server sends an "encrypted_client_hello" extension in either HelloRetryRequest or EncryptedExtensions, the client MUST check the extension syntactically and abort the connection with a "decode_error" alert if it is invalid.  It otherwise ignores the extension.  It MUST NOT save the "retry_config" value in EncryptedExtensions. </ins> <del> 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 extension and MUST NOT use the retry keys. </del> <ins> [[OPEN ISSUE: Depending on what we do for issue#450, it may be appropriate to change the client behavior if the HRR extension is present.]] </ins> Offering a GREASE extension is not considered offering an encrypted ClientHello for purposes of requirements in real-ech.  In particular,