Expose client/server-side ECH

Resolves https://github.com/cloudflare/boring/issues/282
This commit is contained in:
Rushil Mehra 2025-02-10 13:58:42 -08:00 committed by Kornel
parent 2561bdf64d
commit 5af82912df
11 changed files with 199 additions and 0 deletions

View File

@ -128,6 +128,7 @@ pub mod error;
pub mod ex_data;
pub mod fips;
pub mod hash;
#[cfg(not(feature = "fips"))]
pub mod hpke;
pub mod memcmp;
pub mod nid;

43
boring/src/ssl/ech.rs Normal file
View File

@ -0,0 +1,43 @@
use crate::ffi;
use foreign_types::{ForeignType, ForeignTypeRef};
use libc::c_int;
use crate::error::ErrorStack;
use crate::hpke::HpkeKey;
use crate::{cvt_0i, cvt_p};
foreign_type_and_impl_send_sync! {
type CType = ffi::SSL_ECH_KEYS;
fn drop = ffi::SSL_ECH_KEYS_free;
pub struct SslEchKeys;
}
impl SslEchKeys {
pub fn new() -> Result<SslEchKeys, ErrorStack> {
unsafe {
ffi::init();
cvt_p(ffi::SSL_ECH_KEYS_new()).map(|p| SslEchKeys::from_ptr(p))
}
}
}
impl SslEchKeysRef {
pub fn add_key(
&mut self,
is_retry_config: bool,
ech_config: &[u8],
key: HpkeKey,
) -> Result<(), ErrorStack> {
unsafe {
cvt_0i(ffi::SSL_ECH_KEYS_add(
self.as_ptr(),
is_retry_config as c_int,
ech_config.as_ptr(),
ech_config.len(),
key.as_ptr(),
))
.map(|_| ())
}
}
}

View File

@ -87,6 +87,8 @@ use crate::pkey::{HasPrivate, PKeyRef, Params, Private};
use crate::srtp::{SrtpProtectionProfile, SrtpProtectionProfileRef};
use crate::ssl::bio::BioMethod;
use crate::ssl::callbacks::*;
#[cfg(not(feature = "fips"))]
use crate::ssl::ech::SslEchKeys;
use crate::ssl::error::InnerError;
use crate::stack::{Stack, StackRef, Stackable};
use crate::x509::store::{X509Store, X509StoreBuilderRef, X509StoreRef};
@ -110,6 +112,8 @@ mod async_callbacks;
mod bio;
mod callbacks;
mod connector;
#[cfg(not(feature = "fips"))]
mod ech;
mod error;
mod mut_only;
#[cfg(test)]
@ -1956,6 +1960,16 @@ impl SslContextBuilder {
}
}
/// Registers a list of ECH keys on the context. This list should contain new and old
/// ECHConfigs to allow stale DNS caches to update. Unlike most `SSL_CTX` APIs, this function
/// is safe to call even after the `SSL_CTX` has been associated with connections on various
/// threads.
#[cfg(not(feature = "fips"))]
#[corresponds(SSL_CTX_set1_ech_keys)]
pub fn set_ech_keys(&mut self, keys: SslEchKeys) -> Result<(), ErrorStack> {
unsafe { cvt(ffi::SSL_CTX_set1_ech_keys(self.as_ptr(), keys.as_ptr())).map(|_| ()) }
}
/// Consumes the builder, returning a new `SslContext`.
pub fn build(self) -> SslContext {
self.ctx
@ -3623,6 +3637,77 @@ impl SslRef {
pub fn add_chain_cert(&mut self, cert: &X509Ref) -> Result<(), ErrorStack> {
unsafe { cvt(ffi::SSL_add1_chain_cert(self.as_ptr(), cert.as_ptr())).map(|_| ()) }
}
/// Configures `ech_config_list` on `SSL` for offering ECH during handshakes. If the server
/// cannot decrypt the encrypted ClientHello, `SSL` will instead handshake using
/// the cleartext parameters of the ClientHelloOuter.
///
/// Clients should use `get_ech_name_override` to verify the server certificate in case of ECH
/// rejection, and follow up with `get_ech_retry_configs` to retry the connection with a fresh
/// set of ECHConfigs. If the retry also fails, clients should report a connection failure.
#[cfg(not(feature = "fips"))]
#[corresponds(SSL_set1_ech_config_list)]
pub fn set_ech_config_list(&mut self, ech_config_list: &[u8]) -> Result<(), ErrorStack> {
unsafe {
cvt_0i(ffi::SSL_set1_ech_config_list(
self.as_ptr(),
ech_config_list.as_ptr(),
ech_config_list.len(),
))
.map(|_| ())
}
}
/// This function returns a serialized `ECHConfigList` as provided by the
/// server, if one exists.
///
/// Clients should call this function when handling an `SSL_R_ECH_REJECTED` error code to
/// recover from potential key mismatches. If the result is `Some`, the client should retry the
/// connection using the returned `ECHConfigList`.
#[cfg(not(feature = "fips"))]
#[corresponds(SSL_get0_ech_retry_configs)]
pub fn get_ech_retry_configs(&self) -> Option<&[u8]> {
unsafe {
let mut data = ptr::null();
let mut len: usize = 0;
ffi::SSL_get0_ech_retry_configs(self.as_ptr(), &mut data, &mut len);
if data.is_null() {
None
} else {
Some(slice::from_raw_parts(data, len))
}
}
}
/// If `SSL` is a client and the server rejects ECH, this function returns the public name
/// associated with the ECHConfig that was used to attempt ECH.
///
/// Clients should call this function during the certificate verification callback to
/// ensure the server's certificate is valid for the public name, which is required to
/// authenticate retry configs.
#[cfg(not(feature = "fips"))]
#[corresponds(SSL_get0_ech_name_override)]
pub fn get_ech_name_override(&self) -> Option<&[u8]> {
unsafe {
let mut data: *const c_char = ptr::null();
let mut len: usize = 0;
ffi::SSL_get0_ech_name_override(self.as_ptr(), &mut data, &mut len);
if data.is_null() {
None
} else {
Some(slice::from_raw_parts(data as *const u8, len))
}
}
}
// Whether or not `SSL` negotiated ECH.
#[cfg(not(feature = "fips"))]
#[corresponds(SSL_ech_accepted)]
pub fn ech_accepted(&self) -> bool {
unsafe { ffi::SSL_ech_accepted(self.as_ptr()) != 0 }
}
}
/// An SSL stream midway through the handshake process.

View File

@ -0,0 +1,67 @@
use crate::hpke::HpkeKey;
use crate::ssl::ech::SslEchKeys;
use crate::ssl::test::Server;
use crate::ssl::HandshakeError;
// For future reference, these configs are generated by building the bssl tool (the binary is built
// alongside boringssl) and running the following command:
//
// ./bssl generate-ech -out-ech-config-list ./list -out-ech-config ./config -out-private-key ./key
// -public-name ech.com -config-id 1
static ECH_CONFIG_LIST: &[u8] = include_bytes!("../../../test/echconfiglist");
static ECH_CONFIG: &[u8] = include_bytes!("../../../test/echconfig");
static ECH_KEY: &[u8] = include_bytes!("../../../test/echkey");
static ECH_CONFIG_2: &[u8] = include_bytes!("../../../test/echconfig-2");
static ECH_KEY_2: &[u8] = include_bytes!("../../../test/echkey-2");
#[test]
fn ech() {
let server = {
let key = HpkeKey::dhkem_p256_sha256(ECH_KEY).unwrap();
let mut ech_keys = SslEchKeys::new().unwrap();
ech_keys.add_key(true, ECH_CONFIG, key).unwrap();
let mut builder = Server::builder();
builder.ctx().set_ech_keys(ech_keys).unwrap();
builder.build()
};
let mut client = server.client_with_root_ca().build().builder();
client.ssl().set_ech_config_list(ECH_CONFIG_LIST).unwrap();
client.ssl().set_hostname("foobar.com").unwrap();
let ssl_stream = client.connect();
assert!(ssl_stream.ssl().ech_accepted())
}
#[test]
fn ech_rejection() {
let server = {
let key = HpkeKey::dhkem_p256_sha256(ECH_KEY_2).unwrap();
let mut ech_keys = SslEchKeys::new().unwrap();
ech_keys.add_key(true, ECH_CONFIG_2, key).unwrap();
let mut builder = Server::builder();
builder.ctx().set_ech_keys(ech_keys).unwrap();
builder.build()
};
let mut client = server.client_with_root_ca().build().builder();
// Server is initialized using `ECH_CONFIG_2`, so using `ECH_CONFIG_LIST` instead of
// `ECH_CONFIG_LIST_2` should trigger rejection.
client.ssl().set_ech_config_list(ECH_CONFIG_LIST).unwrap();
client.ssl().set_hostname("foobar.com").unwrap();
let HandshakeError::Failure(failed_ssl_stream) = client.connect_err() else {
panic!("wrong HandshakeError failure variant!");
};
assert_eq!(
failed_ssl_stream.ssl().get_ech_name_override(),
Some(b"ech.com".to_vec().as_ref())
);
assert!(failed_ssl_stream.ssl().get_ech_retry_configs().is_some());
assert!(!failed_ssl_stream.ssl().ech_accepted())
}

View File

@ -26,6 +26,8 @@ use super::CompliancePolicy;
mod cert_verify;
mod custom_verify;
#[cfg(not(feature = "fips"))]
mod ech;
mod private_key_method;
mod server;
mod session;

BIN
boring/test/echconfig Normal file

Binary file not shown.

BIN
boring/test/echconfig-2 Normal file

Binary file not shown.

BIN
boring/test/echconfiglist Normal file

Binary file not shown.

BIN
boring/test/echconfiglist-2 Normal file

Binary file not shown.

BIN
boring/test/echkey Normal file

Binary file not shown.

1
boring/test/echkey-2 Normal file
View File

@ -0,0 +1 @@
§5D$lþ°SLb~Ê.<11>V<À.j¢ç¯:}´rˆ¶…