mirror of https://github.com/ctz/rustls
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:
parent
e79aa27433
commit
1f35ba07a2
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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`].
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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=";
|
||||
|
|
Loading…
Reference in New Issue