From 5af82912dff26fe3fd2c684df012685eacd3708b Mon Sep 17 00:00:00 2001 From: Rushil Mehra Date: Mon, 10 Feb 2025 13:58:42 -0800 Subject: [PATCH] Expose client/server-side ECH Resolves https://github.com/cloudflare/boring/issues/282 --- boring/src/lib.rs | 1 + boring/src/ssl/ech.rs | 43 ++++++++++++++++++ boring/src/ssl/mod.rs | 85 ++++++++++++++++++++++++++++++++++++ boring/src/ssl/test/ech.rs | 67 ++++++++++++++++++++++++++++ boring/src/ssl/test/mod.rs | 2 + boring/test/echconfig | Bin 0 -> 62 bytes boring/test/echconfig-2 | Bin 0 -> 62 bytes boring/test/echconfiglist | Bin 0 -> 64 bytes boring/test/echconfiglist-2 | Bin 0 -> 64 bytes boring/test/echkey | Bin 0 -> 32 bytes boring/test/echkey-2 | 1 + 11 files changed, 199 insertions(+) create mode 100644 boring/src/ssl/ech.rs create mode 100644 boring/src/ssl/test/ech.rs create mode 100644 boring/test/echconfig create mode 100644 boring/test/echconfig-2 create mode 100644 boring/test/echconfiglist create mode 100644 boring/test/echconfiglist-2 create mode 100644 boring/test/echkey create mode 100644 boring/test/echkey-2 diff --git a/boring/src/lib.rs b/boring/src/lib.rs index 93d56943..1b23edac 100644 --- a/boring/src/lib.rs +++ b/boring/src/lib.rs @@ -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; diff --git a/boring/src/ssl/ech.rs b/boring/src/ssl/ech.rs new file mode 100644 index 00000000..d7b49328 --- /dev/null +++ b/boring/src/ssl/ech.rs @@ -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 { + 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(|_| ()) + } + } +} diff --git a/boring/src/ssl/mod.rs b/boring/src/ssl/mod.rs index d4d317a5..01f9c962 100644 --- a/boring/src/ssl/mod.rs +++ b/boring/src/ssl/mod.rs @@ -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. diff --git a/boring/src/ssl/test/ech.rs b/boring/src/ssl/test/ech.rs new file mode 100644 index 00000000..7413240e --- /dev/null +++ b/boring/src/ssl/test/ech.rs @@ -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()) +} diff --git a/boring/src/ssl/test/mod.rs b/boring/src/ssl/test/mod.rs index 6010cf98..ab11780e 100644 --- a/boring/src/ssl/test/mod.rs +++ b/boring/src/ssl/test/mod.rs @@ -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; diff --git a/boring/test/echconfig b/boring/test/echconfig new file mode 100644 index 0000000000000000000000000000000000000000..cd0649cea7d5b603c9d771afcefc68b8faaa31f4 GIT binary patch literal 62 zcmeyz%V5R8punK8TVM0>W7awr$*cNDzc@;i=l5=tdF6k_U<5;E P2KLnC487$1Tm}XJHN+N1 literal 0 HcmV?d00001 diff --git a/boring/test/echconfiglist b/boring/test/echconfiglist new file mode 100644 index 0000000000000000000000000000000000000000..c055475e08ef0f2681f19a9119a6edf4b0549d6b GIT binary patch literal 64 zcmZRu`^U>*#lWDzps-tC^YLTWIu^;R`bWPwN|fjKZj*WCf4_`<_T+?|s3Z>t4hBXr RWM*JbP0r9u&d+6F005gY65Rj* literal 0 HcmV?d00001 diff --git a/boring/test/echconfiglist-2 b/boring/test/echconfiglist-2 new file mode 100644 index 0000000000000000000000000000000000000000..58f0f2241add164da92369ef554629617ace7fb0 GIT binary patch literal 64 zcmZRu`^U>*#mJz*piuWa{@tQQC7b{M%M6_N>)|iYoJ0Qf2nHUIzs literal 0 HcmV?d00001 diff --git a/boring/test/echkey-2 b/boring/test/echkey-2 new file mode 100644 index 00000000..3222ee5e --- /dev/null +++ b/boring/test/echkey-2 @@ -0,0 +1 @@ +§5D$lþ°SLb~Ê.V<À.j¢ç¯:}´rˆ¶… \ No newline at end of file