ech: client configuration setup

This commit introduces the first piece of ECH support to the public API:
configuring a client connection with an optional ECH config.

Since ECH requires TLS 1.3 we offer a configuration entry-point
`with_ech(...)` on the `ConfigBuilder<ClientConfig,
WantsVersions>` state, and fix the supported protocol versions to only
TLS 1.3.

Handling configurations in a way that will be forward compatible and
adhering to the draft spec requires some extra care. Notably we need to
be able to treat ECH configs in an ECH config list with an unsupported
version as opaque blobs. This requires splitting our config
representation into an enum with two variants: one for a recognized ECH
config and one for an unsupported one.

Similarly we need to process optional extensions in ECH config contents
to ensure that:

1. There MUST NOT be duplicate extensions.
2. We MUST parse and check for unsupported mandatory extensions.

An example TLS client that fetches ECH configurations using
DNS-over-HTTPS and configures Rustls to use ECH is added to the
`provider-example` crate. When we've implemented an ECH provider using
the `aws-lc-rs` or `*ring*` we can move the example - presently we're
relying on the RustCrypto backend of `hpke-rs` and so it's a better fit
for the provider example crate.

An updated pki-types dependency is required for the new shared
`EchConfigListBytes` type, representing raw TLS-encoded ECH
configuration data in list form.

As of this commit the ECH configuration is not used (except as a TODO to
avoid a `dead_code` warning). Subsequent commits will introduce actual
ECH offering and protocol support.
This commit is contained in:
Daniel McCarney 2024-04-12 11:05:03 -04:00
parent e79aa27433
commit 1f35ba07a2
14 changed files with 496 additions and 35 deletions

2
Cargo.lock generated
View File

@ -2300,6 +2300,7 @@ dependencies = [
"ecdsa",
"env_logger",
"hex",
"hickory-resolver",
"hmac",
"hpke-rs",
"hpke-rs-crypto",
@ -2310,6 +2311,7 @@ dependencies = [
"rcgen",
"rsa",
"rustls 0.23.5",
"rustls-pemfile 2.1.2",
"rustls-pki-types",
"rustls-webpki 0.102.3",
"serde",

View File

@ -4,7 +4,6 @@ mod ech_config {
use hickory_resolver::proto::rr::{RData, RecordType};
use hickory_resolver::Resolver;
use rustls::internal::msgs::codec::{Codec, Reader};
use rustls::internal::msgs::enums::EchVersion;
use rustls::internal::msgs::handshake::EchConfig;
use rustls::pki_types::EchConfigListBytes;
@ -33,7 +32,7 @@ mod ech_config {
assert!(!parsed_configs.is_empty());
assert!(parsed_configs
.iter()
.all(|config| config.version == EchVersion::V14));
.all(|config| matches!(config, EchConfig::V18(_))));
}
/// Use `resolver` to make an HTTPS record type query for `domain`, returning the

View File

@ -10,6 +10,7 @@ publish = false
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
der = "0.7"
ecdsa = "0.16.8"
hickory-resolver = { version = "0.24", features = ["dns-over-https-rustls", "webpki-roots"] }
hmac = "0.12"
hpke-rs = "0.2"
hpke-rs-crypto = "0.2"
@ -19,6 +20,7 @@ pkcs8 = "0.10.2"
pki-types = { package = "rustls-pki-types", version = "1" }
rand_core = { version = "0.6", features = ["getrandom"] }
rustls = { path = "../rustls", default-features = false, features = ["logging", "tls12"] }
rustls-pemfile = "2"
rsa = { version = "0.9", features = ["sha2"], default-features = false }
sha2 = { version = "0.10", default-features = false }
signature = "2"

View File

@ -0,0 +1,101 @@
//! This is a simple example demonstrating how to use Encrypted Client Hello (ECH) with
//! rustls and hickory-dns.
//!
//! Note that `unwrap()` is used to deal with networking errors; this is not something
//! that is sensible outside of example code.
use std::io::{stdout, Read, Write};
use std::net::TcpStream;
use std::sync::Arc;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use hickory_resolver::proto::rr::rdata::svcb::{SvcParamKey, SvcParamValue};
use hickory_resolver::proto::rr::{RData, RecordType};
use hickory_resolver::Resolver;
use rustls::client::EchConfig;
use rustls::RootCertStore;
fn main() {
// Find raw ECH configs using DNS-over-HTTPS with Hickory DNS:
let resolver = Resolver::new(ResolverConfig::google_https(), ResolverOpts::default()).unwrap();
let ech_config_list = lookup_ech_configs(&resolver, "defo.ie");
// NOTE: we defer setting up env_logger and setting the trace default filter level until
// after doing the DNS-over-HTTPS lookup above - we don't want to muddy the output
// with the rustls debug logs from the lookup.
env_logger::Builder::new()
.parse_filters("trace")
.init();
// Select a compatible ECH config.
let ech_config =
EchConfig::new(ech_config_list, rustls_provider_example::HPKE_PROVIDER).unwrap();
let root_store = RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
};
// Construct a rustls client config with a custom provider, and ECH enabled.
let mut config =
rustls::ClientConfig::builder_with_provider(rustls_provider_example::provider().into())
.with_ech(ech_config)
.unwrap()
.with_root_certificates(root_store)
.with_no_client_auth();
// Allow using SSLKEYLOGFILE.
config.key_log = Arc::new(rustls::KeyLogFile::new());
// The "inner" SNI that we're really trying to reach.
let server_name = "www.defo.ie".try_into().unwrap();
let mut conn = rustls::ClientConnection::new(Arc::new(config), server_name).unwrap();
// The "outer" server that we're connecting to.
let mut sock = TcpStream::connect("defo.ie:443").unwrap();
let mut tls = rustls::Stream::new(&mut conn, &mut sock);
tls.write_all(
concat!(
"GET /ech-check.php HTTP/1.1\r\n",
"Host: defo.ie\r\n",
"Connection: close\r\n",
"Accept-Encoding: identity\r\n",
"\r\n"
)
.as_bytes(),
)
.unwrap();
let ciphersuite = tls
.conn
.negotiated_cipher_suite()
.unwrap();
writeln!(
&mut std::io::stderr(),
"Current ciphersuite: {:?}",
ciphersuite.suite()
)
.unwrap();
let mut plaintext = Vec::new();
tls.read_to_end(&mut plaintext).unwrap();
stdout().write_all(&plaintext).unwrap();
}
// TODO(@cpu): consider upstreaming to hickory-dns
fn lookup_ech_configs(resolver: &Resolver, domain: &str) -> pki_types::EchConfigListBytes<'static> {
resolver
.lookup(domain, RecordType::HTTPS)
.expect("failed to lookup HTTPS record type")
.record_iter()
.find_map(|r| match r.data() {
Some(RData::HTTPS(svcb)) => svcb
.svc_params()
.iter()
.find_map(|sp| match sp {
(SvcParamKey::EchConfigList, SvcParamValue::EchConfigList(e)) => {
Some(e.clone().0)
}
_ => None,
}),
_ => None,
})
.expect("missing expected HTTPS SvcParam EchConfig record")
.into()
}

View File

@ -4,6 +4,7 @@ use alloc::vec::Vec;
use core::fmt;
use core::marker::PhantomData;
use crate::client::EchConfig;
use crate::crypto::CryptoProvider;
use crate::error::Error;
use crate::msgs::handshake::ALL_KEY_EXCHANGE_ALGORITHMS;
@ -251,6 +252,7 @@ impl<S: ConfigSide> ConfigBuilder<S, WantsVersions> {
provider: self.state.provider,
versions: versions::EnabledVersions::new(versions),
time_provider: self.state.time_provider,
client_ech_config: None,
},
side: self.side,
})
@ -265,6 +267,7 @@ pub struct WantsVerifier {
pub(crate) provider: Arc<CryptoProvider>,
pub(crate) versions: versions::EnabledVersions,
pub(crate) time_provider: Arc<dyn TimeProvider>,
pub(crate) client_ech_config: Option<EchConfig>,
}
/// Helper trait to abstract [`ConfigBuilder`] over building a [`ClientConfig`] or [`ServerConfig`].

View File

@ -6,14 +6,30 @@ use pki_types::{CertificateDer, PrivateKeyDer};
use super::client_conn::Resumption;
use crate::builder::{ConfigBuilder, WantsVerifier};
use crate::client::{handy, ClientConfig, ResolvesClientCert};
use crate::client::{handy, ClientConfig, EchConfig, ResolvesClientCert};
use crate::crypto::CryptoProvider;
use crate::error::Error;
use crate::key_log::NoKeyLog;
use crate::msgs::handshake::CertificateChain;
use crate::time_provider::TimeProvider;
use crate::versions::TLS13;
use crate::webpki::{self, WebPkiServerVerifier};
use crate::{verify, versions};
use crate::{verify, versions, WantsVersions};
impl ConfigBuilder<ClientConfig, WantsVersions> {
/// Enable Encrypted Client Hello (ECH) with the given configuration.
///
/// This implicitly selects TLS 1.3 as the only supported protocol version to meet the
/// requirement to support ECH.
pub fn with_ech(
self,
config: EchConfig,
) -> Result<ConfigBuilder<ClientConfig, WantsVerifier>, Error> {
let mut res = self.with_protocol_versions(&[&TLS13][..])?;
res.state.client_ech_config = Some(config);
Ok(res)
}
}
impl ConfigBuilder<ClientConfig, WantsVerifier> {
/// Choose how to verify server certificates.
@ -56,6 +72,7 @@ impl ConfigBuilder<ClientConfig, WantsVerifier> {
versions: self.state.versions,
verifier,
time_provider: self.state.time_provider,
client_ech_config: self.state.client_ech_config,
},
side: PhantomData,
}
@ -78,6 +95,7 @@ pub struct WantsClientCert {
versions: versions::EnabledVersions,
verifier: Arc<dyn verify::ServerCertVerifier>,
time_provider: Arc<dyn TimeProvider>,
client_ech_config: Option<EchConfig>,
}
impl ConfigBuilder<ClientConfig, WantsClientCert> {
@ -130,6 +148,7 @@ impl ConfigBuilder<ClientConfig, WantsClientCert> {
#[cfg(feature = "tls12")]
require_ems: cfg!(feature = "fips"),
time_provider: self.state.time_provider,
ech_config: self.state.client_ech_config,
}
}
}
@ -161,6 +180,7 @@ pub(super) mod danger {
versions: self.cfg.state.versions,
verifier,
time_provider: self.cfg.state.time_provider,
client_ech_config: self.cfg.state.client_ech_config,
},
side: PhantomData,
}

View File

@ -9,6 +9,7 @@ use pki_types::{ServerName, UnixTime};
use super::handy::NoClientSessionStorage;
use super::hs;
use crate::builder::ConfigBuilder;
use crate::client::EchConfig;
use crate::common_state::{CommonState, Protocol, Side};
use crate::conn::{ConnectionCore, UnbufferedConnectionCommon};
use crate::crypto::{CryptoProvider, SupportedKxGroup};
@ -215,6 +216,9 @@ pub struct ClientConfig {
/// How to verify the server certificate chain.
pub(super) verifier: Arc<dyn verify::ServerCertVerifier>,
/// How to offer Encrypted Client Hello (ECH). The default is to not offer ECH.
pub(super) ech_config: Option<EchConfig>,
}
impl ClientConfig {
@ -390,6 +394,7 @@ impl Clone for ClientConfig {
#[cfg(feature = "tls12")]
require_ems: self.require_ems,
time_provider: Arc::clone(&self.time_provider),
ech_config: self.ech_config.clone(),
}
}
}

111
rustls/src/client/ech.rs Normal file
View File

@ -0,0 +1,111 @@
use alloc::vec::Vec;
use pki_types::EchConfigListBytes;
use crate::crypto::hpke::{HpkeProvider, HpkeSuite};
#[cfg(feature = "logging")]
use crate::log::{debug, warn};
use crate::msgs::codec::{Codec, Reader};
use crate::msgs::handshake::EchConfig as EchConfigMsg;
use crate::{EncryptedClientHelloError, Error};
/// Configuration for performing encrypted client hello.
///
/// Note: differs from the protocol-encoded EchConfig (`EchConfigMsg`).
#[derive(Clone, Debug)]
pub struct EchConfig {
/// The provider to use for HPKE operations.
pub(crate) hpke_provider: &'static dyn HpkeProvider,
/// The selected EchConfig.
pub(crate) config: EchConfigMsg,
/// An HPKE suite from the `config` we have selected as compatible with the `hpke_provider`.
pub(crate) suite: HpkeSuite,
}
impl EchConfig {
/// Construct an EchConfig by selecting a ECH config from the provided bytes that is compatible
/// with the given HPKE provider.
///
/// The config list bytes should be sourced from a DNS-over-HTTPS lookup resolving the `HTTPS`
/// resource record for the host name of the server you wish to connect to using ECH,
/// and extracting the ECH configuration from the `ech` parameter. The extracted bytes should
/// be base64 decoded to yield the `EchConfigListBytes` you provide to rustls.
///
/// One of the provided ECH configurations must be compatible with the HPKE provider's supported
/// suites or an error will be returned.
///
/// See the [ech-client.rs] example for a complete example of fetching ECH configs from DNS.
///
/// [ech-client.rs]: https://github.com/rustls/rustls/blob/main/provider-example/examples/ech-client.rs
pub fn new(
ech_config_list: EchConfigListBytes<'_>,
hpke_provider: &'static dyn HpkeProvider,
) -> Result<Self, Error> {
let ech_configs =
Vec::<EchConfigMsg>::read(&mut Reader::init(&ech_config_list)).map_err(|_| {
Error::InvalidEncryptedClientHello(EncryptedClientHelloError::InvalidConfigList)
})?;
let (config, suite) = Self::select_config_and_suite(ech_configs, hpke_provider)?;
Ok(Self {
hpke_provider,
config,
suite,
})
}
fn select_config_and_suite(
configs: Vec<EchConfigMsg>,
hpke_provider: &'static dyn HpkeProvider,
) -> Result<(EchConfigMsg, HpkeSuite), Error> {
// Note: we name the index var _i because if the log feature is disabled
// it is unused.
#[cfg_attr(not(feature = "std"), allow(clippy::unused_enumerate_index))]
for (_i, config) in configs.iter().enumerate() {
let contents = match config {
EchConfigMsg::V18(contents) => contents,
EchConfigMsg::Unknown {
version: _version, ..
} => {
warn!(
"ECH config {} has unsupported version {:?}",
_i + 1,
_version
);
continue; // Unsupported version.
}
};
if contents.has_unknown_mandatory_extension() || contents.has_duplicate_extension() {
warn!(
"ECH config {} has duplicate, or unknown mandatory extensions",
_i + 1,
);
continue; // Unsupported, or malformed extensions.
}
let key_config = &contents.key_config;
for cipher_suite in &key_config.symmetric_cipher_suites {
if cipher_suite.aead_id.tag_len().is_none() {
continue; // Unsupported EXPORT_ONLY AEAD cipher suite.
}
let suite = HpkeSuite {
kem: key_config.kem_id,
sym: *cipher_suite,
};
if hpke_provider.supports_suite(&suite) {
debug!(
"selected ECH config ID {:?} suite {:?}",
key_config.config_id, suite
);
return Ok((config.clone(), suite));
}
}
}
Err(EncryptedClientHelloError::NoCompatibleConfig.into())
}
}

View File

@ -145,6 +145,18 @@ pub(super) fn start_handshake(
let random = Random::new(config.provider.secure_random)?;
let extension_order_seed = crate::rand::random_u16(config.provider.secure_random)?;
// TODO(XXX): Construct ECH context state, use to perform ECH flow when
// emitting client hello.
let _ech_context = config
.ech_config
.as_ref()
.map(|ech_config| {
let _provider = ech_config.hpke_provider;
let _suite = &ech_config.suite;
let _config = &ech_config.config;
Some(())
});
Ok(emit_client_hello_for_retry(
transcript_buffer,
None,

View File

@ -35,6 +35,9 @@ pub enum Error {
got_type: HandshakeType,
},
/// An error occurred while handling Encrypted Client Hello (ECH).
InvalidEncryptedClientHello(EncryptedClientHelloError),
/// The peer sent us a TLS message with invalid contents.
InvalidMessage(InvalidMessage),
@ -469,6 +472,23 @@ impl From<CertRevocationListError> for Error {
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq)]
/// An error that occurred while handling Encrypted Client Hello (ECH).
pub enum EncryptedClientHelloError {
/// The provided ECH configuration list was invalid.
InvalidConfigList,
/// No compatible ECH configuration.
NoCompatibleConfig,
}
impl From<EncryptedClientHelloError> for Error {
#[inline]
fn from(e: EncryptedClientHelloError) -> Self {
Self::InvalidEncryptedClientHello(e)
}
}
fn join<T: fmt::Debug>(items: &[T]) -> String {
items
.iter()
@ -513,6 +533,9 @@ impl fmt::Display for Error {
Self::NoCertificatesPresented => write!(f, "peer sent no certificates"),
Self::UnsupportedNameType => write!(f, "presented server name type wasn't supported"),
Self::DecryptError => write!(f, "cannot decrypt peer's message"),
Self::InvalidEncryptedClientHello(ref err) => {
write!(f, "encrypted client hello failure: {:?}", err)
}
Self::EncryptError => write!(f, "cannot encrypt message"),
Self::PeerSentOversizedRecord => write!(f, "peer sent excess record size"),
Self::HandshakeNotComplete => write!(f, "handshake not complete"),

View File

@ -508,8 +508,8 @@ pub use crate::enums::{
SignatureScheme,
};
pub use crate::error::{
CertRevocationListError, CertificateError, Error, InvalidMessage, OtherError, PeerIncompatible,
PeerMisbehaved,
CertRevocationListError, CertificateError, EncryptedClientHelloError, Error, InvalidMessage,
OtherError, PeerIncompatible, PeerMisbehaved,
};
pub use crate::key_log::{KeyLog, NoKeyLog};
#[cfg(feature = "std")]
@ -536,6 +536,7 @@ pub mod client {
pub(super) mod builder;
mod client_conn;
mod common;
mod ech;
pub(super) mod handy;
mod hs;
#[cfg(feature = "tls12")]
@ -549,6 +550,7 @@ pub mod client {
};
#[cfg(feature = "std")]
pub use client_conn::{ClientConnection, WriteEarlyData};
pub use ech::EchConfig;
#[cfg(any(feature = "std", feature = "hashbrown"))]
pub use handy::ClientSessionMemoryCache;

View File

@ -324,6 +324,19 @@ enum_builder! {
}
}
impl HpkeAead {
/// Returns the length of the tag for the AEAD algorithm, or none if the AEAD is EXPORT_ONLY.
pub(crate) fn tag_len(&self) -> Option<usize> {
match self {
// See RFC 9180 Section 7.3, column `Nt`, the length in bytes of the authentication tag
// for the algorithm.
// https://www.rfc-editor.org/rfc/rfc9180.html#section-7.3
Self::AES_128_GCM | Self::AES_256_GCM | Self::CHACHA20_POLY_1305 => Some(16),
_ => None,
}
}
}
impl Default for HpkeAead {
// TODO(XXX): revisit the default configuration. This is just what Cloudflare ships right now.
fn default() -> Self {
@ -340,7 +353,7 @@ enum_builder! {
/// [draft-ietf-tls-esni Section 4]: <https://www.ietf.org/archive/id/draft-ietf-tls-esni-17.html#section-4>
@U16
pub enum EchVersion {
V14 => 0xfe0d,
V18 => 0xfe0d,
}
}

View File

@ -2511,7 +2511,30 @@ pub struct EchConfigContents {
pub key_config: HpkeKeyConfig,
pub maximum_name_length: u8,
pub public_name: DnsName<'static>,
pub extensions: PayloadU16,
pub extensions: Vec<EchConfigExtension>,
}
impl EchConfigContents {
/// Returns true if there is more than one extension of a given
/// type.
pub(crate) fn has_duplicate_extension(&self) -> bool {
has_duplicates::<_, _, u16>(
self.extensions
.iter()
.map(|ext| ext.ext_type()),
)
}
/// Returns true if there is at least one mandatory unsupported extension.
pub(crate) fn has_unknown_mandatory_extension(&self) -> bool {
self.extensions
.iter()
// An extension is considered mandatory if the high bit of its type is set.
.any(|ext| {
matches!(ext.ext_type(), ExtensionType::Unknown(_))
&& u16::from(ext.ext_type()) & 0x8000 != 0
})
}
}
impl Codec<'_> for EchConfigContents {
@ -2532,39 +2555,102 @@ impl Codec<'_> for EchConfigContents {
.map_err(|_| InvalidMessage::InvalidServerName)?
.to_owned()
},
extensions: PayloadU16::read(r)?,
extensions: Vec::read(r)?,
})
}
}
/// An encrypted client hello (ECH) config.
#[derive(Clone, Debug, PartialEq)]
pub struct EchConfig {
pub version: EchVersion,
pub contents: EchConfigContents,
pub enum EchConfig {
/// A recognized V18 ECH configuration.
V18(EchConfigContents),
/// An unknown version ECH configuration.
Unknown {
version: EchVersion,
contents: PayloadU16,
},
}
impl TlsListElement for EchConfig {
const SIZE_LEN: ListLength = ListLength::U16;
}
impl Codec<'_> for EchConfig {
fn encode(&self, bytes: &mut Vec<u8>) {
self.version.encode(bytes);
let mut contents = Vec::with_capacity(128);
self.contents.encode(&mut contents);
let length: &mut [u8; 2] = &mut [0, 0];
codec::put_u16(contents.len() as u16, length);
bytes.extend_from_slice(length);
bytes.extend(contents);
match self {
Self::V18(c) => {
// Write the version, the length, and the contents.
EchVersion::V18.encode(bytes);
let inner = LengthPrefixedBuffer::new(ListLength::U16, bytes);
c.encode(inner.buf);
}
Self::Unknown { version, contents } => {
// Unknown configuration versions are opaque.
version.encode(bytes);
contents.encode(bytes);
}
}
}
fn read(r: &mut Reader) -> Result<Self, InvalidMessage> {
let version = EchVersion::read(r)?;
let length = u16::read(r)?;
Ok(Self {
version,
contents: EchConfigContents::read(&mut r.sub(length as usize)?)?,
let mut contents = r.sub(length as usize)?;
Ok(match version {
EchVersion::V18 => Self::V18(EchConfigContents::read(&mut contents)?),
_ => {
// Note: we don't PayloadU16::read() here because we've already read the length prefix.
let data = PayloadU16::new(contents.rest().into());
Self::Unknown {
version,
contents: data,
}
}
})
}
}
impl TlsListElement for EchConfig {
#[derive(Clone, Debug, PartialEq)]
pub enum EchConfigExtension {
Unknown(UnknownExtension),
}
impl EchConfigExtension {
pub(crate) fn ext_type(&self) -> ExtensionType {
match *self {
Self::Unknown(ref r) => r.typ,
}
}
}
impl Codec<'_> for EchConfigExtension {
fn encode(&self, bytes: &mut Vec<u8>) {
self.ext_type().encode(bytes);
let nested = LengthPrefixedBuffer::new(ListLength::U16, bytes);
match *self {
Self::Unknown(ref r) => r.encode(nested.buf),
}
}
fn read(r: &mut Reader) -> Result<Self, InvalidMessage> {
let typ = ExtensionType::read(r)?;
let len = u16::read(r)? as usize;
let mut sub = r.sub(len)?;
#[allow(clippy::match_single_binding)] // Future-proofing.
let ext = match typ {
_ => Self::Unknown(UnknownExtension::read(typ, &mut sub)),
};
sub.expect_empty("EchConfigExtension")
.map(|_| ext)
}
}
impl TlsListElement for EchConfigExtension {
const SIZE_LEN: ListLength = ListLength::U16;
}
@ -2579,3 +2665,56 @@ fn has_duplicates<I: IntoIterator<Item = E>, E: Into<T>, T: Eq + Ord>(iter: I) -
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ech_config_dupe_exts() {
let unknown_ext = EchConfigExtension::Unknown(UnknownExtension {
typ: ExtensionType::Unknown(0x42),
payload: Payload::new(vec![0x42]),
});
let mut config = config_template();
config
.extensions
.push(unknown_ext.clone());
config.extensions.push(unknown_ext);
assert!(config.has_duplicate_extension());
assert!(!config.has_unknown_mandatory_extension());
}
#[test]
fn test_ech_config_mandatory_exts() {
let mandatory_unknown_ext = EchConfigExtension::Unknown(UnknownExtension {
typ: ExtensionType::Unknown(0x42 | 0x8000), // Note: high bit set.
payload: Payload::new(vec![0x42]),
});
let mut config = config_template();
config
.extensions
.push(mandatory_unknown_ext);
assert!(!config.has_duplicate_extension());
assert!(config.has_unknown_mandatory_extension());
}
fn config_template() -> EchConfigContents {
EchConfigContents {
key_config: HpkeKeyConfig {
config_id: 0,
kem_id: HpkeKem::DHKEM_P256_HKDF_SHA256,
public_key: PayloadU16(b"xxx".into()),
symmetric_cipher_suites: vec![HpkeSymmetricCipherSuite {
kdf_id: HpkeKdf::HKDF_SHA256,
aead_id: HpkeAead::AES_128_GCM,
}],
},
maximum_name_length: 0,
public_name: DnsName::try_from("example.com").unwrap(),
extensions: vec![],
}
}
}

View File

@ -2,18 +2,19 @@ use base64::prelude::{Engine, BASE64_STANDARD};
use pki_types::DnsName;
use rustls::internal::msgs::codec::{Codec, Reader};
use rustls::internal::msgs::enums::{EchVersion, HpkeAead, HpkeKdf, HpkeKem};
use rustls::internal::msgs::handshake::{EchConfig, HpkeKeyConfig, HpkeSymmetricCipherSuite};
use rustls::internal::msgs::handshake::{
EchConfig, EchConfigContents, HpkeKeyConfig, HpkeSymmetricCipherSuite,
};
#[test]
fn test_decode_config_list() {
fn assert_config(config: &EchConfig, public_name: impl AsRef<[u8]>, max_len: u8) {
assert_eq!(config.version, EchVersion::V14);
assert_eq!(config.contents.maximum_name_length, max_len);
fn assert_config(contents: &EchConfigContents, public_name: impl AsRef<[u8]>, max_len: u8) {
assert_eq!(contents.maximum_name_length, max_len);
assert_eq!(
config.contents.public_name,
contents.public_name,
DnsName::try_from(public_name.as_ref()).unwrap()
);
assert!(config.contents.extensions.0.is_empty());
assert!(contents.extensions.is_empty());
}
fn assert_key_config(
@ -29,9 +30,12 @@ fn test_decode_config_list() {
let config_list = get_ech_config(BASE64_ECHCONFIG_LIST_LOCALHOST);
assert_eq!(config_list.len(), 1);
assert_config(&config_list[0], "localhost", 128);
let EchConfig::V18(contents) = &config_list[0] else {
panic!("unexpected ECH config version: {:?}", config_list[0]);
};
assert_config(contents, "localhost", 128);
assert_key_config(
&config_list[0].contents.key_config,
&contents.key_config,
0,
HpkeKem::DHKEM_X25519_HKDF_SHA256,
vec![
@ -48,9 +52,12 @@ fn test_decode_config_list() {
let config_list = get_ech_config(BASE64_ECHCONFIG_LIST_CF);
assert_eq!(config_list.len(), 2);
assert_config(&config_list[0], "cloudflare-esni.com", 37);
let EchConfig::V18(contents_a) = &config_list[0] else {
panic!("unexpected ECH config version: {:?}", config_list[0]);
};
assert_config(contents_a, "cloudflare-esni.com", 37);
assert_key_config(
&config_list[0].contents.key_config,
&contents_a.key_config,
195,
HpkeKem::DHKEM_X25519_HKDF_SHA256,
vec![HpkeSymmetricCipherSuite {
@ -58,9 +65,12 @@ fn test_decode_config_list() {
aead_id: HpkeAead::AES_128_GCM,
}],
);
assert_config(&config_list[1], "cloudflare-esni.com", 42);
let EchConfig::V18(contents_b) = &config_list[1] else {
panic!("unexpected ECH config version: {:?}", config_list[1]);
};
assert_config(contents_b, "cloudflare-esni.com", 42);
assert_key_config(
&config_list[1].contents.key_config,
&contents_b.key_config,
3,
HpkeKem::DHKEM_P256_HKDF_SHA256,
vec![HpkeSymmetricCipherSuite {
@ -68,6 +78,21 @@ fn test_decode_config_list() {
aead_id: HpkeAead::AES_128_GCM,
}],
);
let config_list = get_ech_config(BASE64_ECHCONFIG_LIST_WITH_UNSUPPORTED);
assert_eq!(config_list.len(), 4);
// The first config should be unsupported.
assert!(matches!(
config_list[0],
EchConfig::Unknown {
version: EchVersion::Unknown(0xBADD),
..
}
));
// The other configs should be recognized.
for config in config_list.iter().skip(1) {
assert!(matches!(config, EchConfig::V18(_)));
}
}
#[test]
@ -81,6 +106,7 @@ fn test_echconfig_serialization() {
assert_round_trip_eq(BASE64_ECHCONFIG_LIST_LOCALHOST);
assert_round_trip_eq(BASE64_ECHCONFIG_LIST_CF);
assert_round_trip_eq(BASE64_ECHCONFIG_LIST_WITH_UNSUPPORTED);
}
fn get_ech_config(s: &str) -> Vec<EchConfig> {
@ -95,3 +121,6 @@ const BASE64_ECHCONFIG_LIST_LOCALHOST: &str =
// Two EchConfigs, both with server-name "cloudflare-esni.com".
const BASE64_ECHCONFIG_LIST_CF: &str =
"AK3+DQBCwwAgACAJ9T5U4FeM6631r2bvAuGtmEd8zQaoTkFAtArTcMl/XQAEAAEAASUTY2xvdWRmbGFyZS1lc25pLmNvbQAA/g0AYwMAEABBBGGbUlGLuGRorUeFwmrgHImkrh9uxoPrnFKpS5bQvnc5grfMS3PvymQ2FYL02WQi1ZzZJg5OsYYdzlaGYnEoJNsABAABAAEqE2Nsb3VkZmxhcmUtZXNuaS5jb20AAA==";
// Three EchConfigs, the first one with an unsupported version.
const BASE64_ECHCONFIG_LIST_WITH_UNSUPPORTED: &str = "AQW63QAFBQQDAgH+DQBmAAAQAEEE5itp4r9ln5e+Lx4NlIpM1Zdrt6keDUb73ampHp3culoB59aXqAoY+cPEox5W4nyDSNsWGhz1HX7xlC1Lz3IiwQAMAAEAAQABAAIAAQADQA5wdWJsaWMuZXhhbXBsZQAA/g0APQAAIAAgfWYWFXMCFK7ucFMzZvNqYJ6tZcDCCOYjIjRqtbzY3hwABBERIiJADnB1YmxpYy5leGFtcGxlAAD+DQBNAAAgACCFvWoDJ3wlQntS4mngx3qOtSS6HrPS8TJmLUsKxstzVwAMAAEAAQABAAIAAQADQA5wdWJsaWMuZXhhbXBsZQAIqqoABHRlc3Q=";