ech: handle ECH processing for hello retry requests

This commit continues the implementation of client-side ECH by
supporting the case where we offer ECH and the server replies with
a hello retry request (HRR).

In this case we detect ECH acceptance in a slightly different manner. If
ECH was accepted we update the separate ECH transcript using the
received HRR and proceed to offer ECH again in our retried hello. After
this point ECH acceptance is handled as normal.

This completes the primary functionality of client-side ECH. The
remaining work involves GREASE ECH and extension compression.
This commit is contained in:
Daniel McCarney 2024-04-12 11:05:22 -04:00
parent 6d3a98e4f7
commit 5e4eed89dd
5 changed files with 196 additions and 20 deletions

View File

@ -24,12 +24,13 @@ use crate::msgs::handshake::{
use crate::msgs::message::{Message, MessagePayload};
use crate::msgs::persist;
use crate::msgs::persist::Retrieved;
use crate::tls13::key_schedule::KeyScheduleEarly;
use crate::tls13::key_schedule::KeyScheduleHandshakeStart;
use crate::tls13::key_schedule::{
server_ech_hrr_confirmation_secret, KeyScheduleEarly, KeyScheduleHandshakeStart,
};
use crate::CipherSuite::TLS_EMPTY_RENEGOTIATION_INFO_SCSV;
use crate::{
AlertDescription, CommonState, EncryptedClientHelloError, Error, HandshakeType,
PeerIncompatible, ProtocolVersion,
PeerIncompatible, PeerMisbehaved, ProtocolVersion, Tls13CipherSuite,
};
/// Configuration for performing encrypted client hello.
@ -367,6 +368,74 @@ impl EchState {
)
}
pub(crate) fn confirm_hrr_acceptance(
&self,
hrr: &HelloRetryRequest,
cs: &Tls13CipherSuite,
common: &mut CommonState,
) -> Result<bool, Error> {
// The client checks for the "encrypted_client_hello" extension.
let ech_conf = match hrr.ech() {
// If none is found, the server has implicitly rejected ECH.
None => return Ok(false),
// Otherwise, if it has a length other than 8, the client aborts the
// handshake with a "decode_error" alert.
Some(ech_conf) if ech_conf.len() != 8 => {
return Err({
common.send_fatal_alert(
AlertDescription::DecodeError,
PeerMisbehaved::IllegalHelloRetryRequestWithInvalidEch,
)
})
}
Some(ech_conf) => ech_conf,
};
// Otherwise the client computes hrr_accept_confirmation as described in Section
// 7.2.1
let confirmation_transcript = self.inner_hello_transcript.clone();
let mut confirmation_transcript =
confirmation_transcript.start_hash(cs.common.hash_provider);
confirmation_transcript.rollup_for_hrr();
confirmation_transcript.add_message(&Self::hello_retry_request_conf(hrr));
let derived = server_ech_hrr_confirmation_secret(
cs.hkdf_provider,
&self.inner_hello_random.0,
confirmation_transcript.current_hash(),
);
Ok(
match ConstantTimeEq::ct_eq(derived.as_ref(), ech_conf).into() {
true => {
trace!("ECH accepted by server in hello retry request");
true
}
false => {
trace!("ECH rejected by server in hello retry request");
false
}
},
)
}
/// Update the ECH context inner hello transcript based on a received hello retry request message.
///
/// This will start the in-progress transcript using the given `hash`, convert it into an HRR
/// buffer, and then add the hello retry message `m`.
pub(crate) fn transcript_hrr_update(&mut self, hash: &'static dyn Hash, m: &Message) {
trace!("Updating ECH inner transcript for HRR");
let inner_transcript = self
.inner_hello_transcript
.clone()
.start_hash(hash);
let mut inner_transcript_buffer = inner_transcript.into_hrr_buffer();
inner_transcript_buffer.add_message(m);
self.inner_hello_transcript = inner_transcript_buffer;
}
fn encode_inner_hello(
&mut self,
outer_hello: &ClientHelloPayload,
@ -576,6 +645,13 @@ impl EchState {
})
}
fn hello_retry_request_conf(retry_req: &HelloRetryRequest) -> Message {
Self::ech_conf_message(HandshakeMessagePayload {
typ: HandshakeType::HelloRetryRequest,
payload: HandshakePayload::HelloRetryRequest(retry_req.clone()),
})
}
fn ech_conf_message(hmp: HandshakeMessagePayload) -> Message {
let mut hmp_encoded = Vec::new();
hmp.payload_encode(&mut hmp_encoded, Encoding::EchConfirmation);

View File

@ -803,15 +803,10 @@ impl ExpectServerHelloOrHelloRetryRequest {
}
fn handle_hello_retry_request(
self,
mut self,
cx: &mut ClientContext<'_>,
m: Message,
) -> NextStateOrError<'static> {
// TODO(@cpu): Handle confirming ECH for HRR.
if self.next.ech_state.is_some() {
todo!("ECH confirmation handling for HRR");
}
let hrr = require_handshake_msg!(
m,
HandshakeType::HelloRetryRequest,
@ -929,10 +924,36 @@ impl ExpectServerHelloOrHelloRetryRequest {
}
};
// Or offers ECH related extensions when we didn't offer ECH.
if cx.data.ech_status == EchStatus::NotOffered && hrr.ech().is_some() {
return Err({
cx.common.send_fatal_alert(
AlertDescription::UnsupportedExtension,
PeerMisbehaved::IllegalHelloRetryRequestWithInvalidEch,
)
});
}
// HRR selects the ciphersuite.
cx.common.suite = Some(cs);
cx.common.handshake_kind = Some(HandshakeKind::FullWithHelloRetryRequest);
// If we offered ECH, we need to confirm that the server accepted it.
match (self.next.ech_state.as_ref(), cs.tls13()) {
(Some(ech_state), Some(tls13_cs)) => {
if !ech_state.confirm_hrr_acceptance(hrr, tls13_cs, cx.common)? {
// If the server did not confirm, then note the new ECH status but
// continue the handshake. We will abort with an ECH required error
// at the end.
cx.data.ech_status = EchStatus::Rejected;
}
}
(Some(_), None) => {
unreachable!("ECH state should only be set when TLS 1.3 was negotiated")
}
_ => {}
};
// This is the draft19 change where the transcript became a tree
let transcript = self
.next
@ -941,6 +962,12 @@ impl ExpectServerHelloOrHelloRetryRequest {
let mut transcript_buffer = transcript.into_hrr_buffer();
transcript_buffer.add_message(&m);
// If we offered ECH and the server accepted, we also need to update the separate
// ECH transcript with the hello retry request message.
if let Some(ech_state) = self.next.ech_state.as_mut() {
ech_state.transcript_hrr_update(cs.hash_provider(), &m);
}
// Early data is not allowed after HelloRetryrequest
if cx.data.early_data.is_enabled() {
cx.data.early_data.rejected();
@ -971,7 +998,7 @@ impl ExpectServerHelloOrHelloRetryRequest {
Some(cs),
self.next.input,
cx,
None, // TODO(@cpu): handle ECH HRR
self.next.ech_state,
)
}
}

View File

@ -195,6 +195,7 @@ pub enum PeerMisbehaved {
IllegalHelloRetryRequestWithUnofferedNamedGroup,
IllegalHelloRetryRequestWithUnsupportedVersion,
IllegalHelloRetryRequestWithWrongSessionId,
IllegalHelloRetryRequestWithInvalidEch,
IllegalMiddleboxChangeCipherSpec,
IllegalTlsInnerPlaintext,
IncorrectBinder,

View File

@ -1033,6 +1033,7 @@ pub(crate) enum HelloRetryExtension {
KeyShare(NamedGroup),
Cookie(PayloadU16),
SupportedVersions(ProtocolVersion),
EchHelloRetryRequest(Vec<u8>),
Unknown(UnknownExtension),
}
@ -1042,6 +1043,7 @@ impl HelloRetryExtension {
Self::KeyShare(_) => ExtensionType::KeyShare,
Self::Cookie(_) => ExtensionType::Cookie,
Self::SupportedVersions(_) => ExtensionType::SupportedVersions,
Self::EchHelloRetryRequest(_) => ExtensionType::EncryptedClientHello,
Self::Unknown(ref r) => r.typ,
}
}
@ -1056,6 +1058,9 @@ impl Codec<'_> for HelloRetryExtension {
Self::KeyShare(ref r) => r.encode(nested.buf),
Self::Cookie(ref r) => r.encode(nested.buf),
Self::SupportedVersions(ref r) => r.encode(nested.buf),
Self::EchHelloRetryRequest(ref r) => {
nested.buf.extend_from_slice(r);
}
Self::Unknown(ref r) => r.encode(nested.buf),
}
}
@ -1071,6 +1076,7 @@ impl Codec<'_> for HelloRetryExtension {
ExtensionType::SupportedVersions => {
Self::SupportedVersions(ProtocolVersion::read(&mut sub)?)
}
ExtensionType::EncryptedClientHello => Self::EchHelloRetryRequest(sub.rest().to_vec()),
_ => Self::Unknown(UnknownExtension::read(typ, &mut sub)),
};
@ -1093,12 +1099,7 @@ pub struct HelloRetryRequest {
impl Codec<'_> for HelloRetryRequest {
fn encode(&self, bytes: &mut Vec<u8>) {
self.legacy_version.encode(bytes);
HELLO_RETRY_REQUEST_RANDOM.encode(bytes);
self.session_id.encode(bytes);
self.cipher_suite.encode(bytes);
Compression::Null.encode(bytes);
self.extensions.encode(bytes);
self.payload_encode(bytes, Encoding::Standard)
}
fn read(r: &mut Reader) -> Result<Self, InvalidMessage> {
@ -1135,6 +1136,7 @@ impl HelloRetryRequest {
ext.ext_type() != ExtensionType::KeyShare
&& ext.ext_type() != ExtensionType::SupportedVersions
&& ext.ext_type() != ExtensionType::Cookie
&& ext.ext_type() != ExtensionType::EncryptedClientHello
})
}
@ -1167,6 +1169,48 @@ impl HelloRetryRequest {
_ => None,
}
}
pub(crate) fn ech(&self) -> Option<&Vec<u8>> {
let ext = self.find_extension(ExtensionType::EncryptedClientHello)?;
match *ext {
HelloRetryExtension::EchHelloRetryRequest(ref ech) => Some(ech),
_ => None,
}
}
fn payload_encode(&self, bytes: &mut Vec<u8>, purpose: Encoding) {
self.legacy_version.encode(bytes);
HELLO_RETRY_REQUEST_RANDOM.encode(bytes);
self.session_id.encode(bytes);
self.cipher_suite.encode(bytes);
Compression::Null.encode(bytes);
match purpose {
// Standard encoding encodes extensions as they appear.
Encoding::Standard => {
self.extensions.encode(bytes);
}
// For the purpose of ECH confirmation, the Encrypted Client Hello extension
// must have its payload replaced by 8 zero bytes.
//
// See draft-ietf-tls-esni-18 7.2.1:
// <https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-18#name-sending-helloretryrequest-2>
Encoding::EchConfirmation => {
let extensions = LengthPrefixedBuffer::new(ListLength::U16, bytes);
for ext in &self.extensions {
match ext.ext_type() {
ExtensionType::EncryptedClientHello => {
HelloRetryExtension::EchHelloRetryRequest(vec![0u8; 8])
.encode(extensions.buf);
}
_ => {
ext.encode(extensions.buf);
}
}
}
}
}
}
}
#[derive(Clone, Debug)]
@ -2477,11 +2521,13 @@ impl<'a> HandshakeMessagePayload<'a> {
let nested = LengthPrefixedBuffer::new(ListLength::U24 { max: usize::MAX }, bytes);
match &self.payload {
// for Server Hello payloads we need to encode the payload differently
// based on the purpose of the encoding.
// for Server Hello and HelloRetryRequest payloads we need to encode the payload
// differently based on the purpose of the encoding.
HandshakePayload::ServerHello(payload) => payload.payload_encode(nested.buf, encoding),
// TODO(@cpu): We will also need to handle HelloRetryRequests differently for HRR
// ECH confirmation.
HandshakePayload::HelloRetryRequest(payload) => {
payload.payload_encode(nested.buf, encoding)
}
// All other payload types are encoded the same regardless of purpose.
_ => self.payload.encode(nested.buf),
}

View File

@ -24,6 +24,7 @@ enum SecretKind {
ResumptionMasterSecret,
DerivedSecret,
ServerEchConfirmationSecret,
ServerEchHrrConfirmationSecret,
}
impl SecretKind {
@ -41,6 +42,8 @@ impl SecretKind {
DerivedSecret => b"derived",
// https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-18#section-7.2
ServerEchConfirmationSecret => b"ech accept confirmation",
// https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-18#section-7.2.1
ServerEchHrrConfirmationSecret => b"hrr ech accept confirmation",
}
}
@ -802,6 +805,29 @@ fn hkdf_expand_label_slice(
})
}
pub(crate) fn server_ech_hrr_confirmation_secret(
hkdf_provider: &'static dyn Hkdf,
client_hello_inner_random: &[u8],
hs_hash: hash::Output,
) -> [u8; 8] {
/*
Per ietf-tls-esni-17 section 7.2.1:
<https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-17#section-7.2.1>
hrr_accept_confirmation = HKDF-Expand-Label(
HKDF-Extract(0, ClientHelloInner1.random),
"hrr ech accept confirmation",
transcript_hrr_ech_conf,
8)
*/
hkdf_expand_label(
hkdf_provider
.extract_from_secret(None, client_hello_inner_random)
.as_ref(),
SecretKind::ServerEchHrrConfirmationSecret.to_bytes(),
hs_hash.as_ref(),
)
}
pub(crate) fn derive_traffic_key(expander: &dyn HkdfExpander, aead_key_len: usize) -> AeadKey {
hkdf_expand_label_aead_key(expander, aead_key_len, b"key", &[])
}