Skip to content

Commit d1f73a9

Browse files
0x676e67bwesterbghedoevanrittenhousekornelski
authored
Expose client/server-side ECH (#48)
* RTG-3333 Support X25519MLKEM768 by default, but don't sent it as client X25519MLKEM768 is the standardised successor of the preliminary X25519Kyber768Draft00. Latest browsers have switched to X25519MLKEM768. Cloudflare supports both on the edge. We've had support for X25519MLKEM768 in this crate for a while, but didn't enable by default. We're now enabling serverside support by default. We also let clients advertise support when set to kx-client-pq-supported. We don't enable support by default yet for clients set to kx-client-pq-preferred, as that would cause an extra round-trip due to HelloRetryRequest if the server doesn't support X25519MLKEM768 yet. BoringSSL against which we build must support X25519MLKEM768, otherwise this will fail. * replace once_cell with LazyLock We can drop the once_cell dependency since the same functionality is implemented in std now. Requires bumping MSRV to 1.80. * fix manual_c_str_literals clippy warning * chore: Fix docs on SslRef::replace_ex_data * Detailed error codes * Clean up boring_sys::init() We don't need the workaround that was initially introduced for a bug in openssl, and OPENSSL_init_ssl always calls into CRYPTO_library_init on boringssl, so just call it explicitly. * Expose EVP_HPKE_KEY * Expose client/server-side ECH Resolves cloudflare/boring#282 --------- Co-authored-by: Bas Westerbaan <[email protected]> Co-authored-by: Alessandro Ghedini <[email protected]> Co-authored-by: Evan Rittenhouse <[email protected]> Co-authored-by: Kornel <[email protected]> Co-authored-by: Rushil Mehra <[email protected]>
1 parent dded5d4 commit d1f73a9

File tree

12 files changed

+231
-0
lines changed

12 files changed

+231
-0
lines changed

boring/src/hpke.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
use crate::error::ErrorStack;
2+
use crate::{cvt_0i, cvt_p, ffi};
3+
4+
use foreign_types::ForeignType;
5+
6+
foreign_type_and_impl_send_sync! {
7+
type CType = ffi::EVP_HPKE_KEY;
8+
fn drop = ffi::EVP_HPKE_KEY_free;
9+
10+
pub struct HpkeKey;
11+
}
12+
13+
impl HpkeKey {
14+
/// Allocates and initializes a key with the `EVP_HPKE_KEY` type using the
15+
/// `EVP_hpke_x25519_hkdf_sha256` KEM algorithm.
16+
pub fn dhkem_p256_sha256(pkey: &[u8]) -> Result<HpkeKey, ErrorStack> {
17+
unsafe {
18+
ffi::init();
19+
let hpke = cvt_p(ffi::EVP_HPKE_KEY_new()).map(|p| HpkeKey::from_ptr(p))?;
20+
21+
cvt_0i(ffi::EVP_HPKE_KEY_init(
22+
hpke.as_ptr(),
23+
ffi::EVP_hpke_x25519_hkdf_sha256(),
24+
pkey.as_ptr(),
25+
pkey.len(),
26+
))?;
27+
28+
Ok(hpke)
29+
}
30+
}
31+
}

boring/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ pub mod error;
123123
pub mod ex_data;
124124
pub mod fips;
125125
pub mod hash;
126+
#[cfg(not(feature = "fips"))]
127+
pub mod hpke;
126128
pub mod memcmp;
127129
pub mod nid;
128130
pub mod pkcs12;

boring/src/ssl/ech.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use crate::ffi;
2+
use foreign_types::{ForeignType, ForeignTypeRef};
3+
use libc::c_int;
4+
5+
use crate::error::ErrorStack;
6+
use crate::hpke::HpkeKey;
7+
use crate::{cvt_0i, cvt_p};
8+
9+
foreign_type_and_impl_send_sync! {
10+
type CType = ffi::SSL_ECH_KEYS;
11+
fn drop = ffi::SSL_ECH_KEYS_free;
12+
13+
pub struct SslEchKeys;
14+
}
15+
16+
impl SslEchKeys {
17+
pub fn new() -> Result<SslEchKeys, ErrorStack> {
18+
unsafe {
19+
ffi::init();
20+
cvt_p(ffi::SSL_ECH_KEYS_new()).map(|p| SslEchKeys::from_ptr(p))
21+
}
22+
}
23+
}
24+
25+
impl SslEchKeysRef {
26+
pub fn add_key(
27+
&mut self,
28+
is_retry_config: bool,
29+
ech_config: &[u8],
30+
key: HpkeKey,
31+
) -> Result<(), ErrorStack> {
32+
unsafe {
33+
cvt_0i(ffi::SSL_ECH_KEYS_add(
34+
self.as_ptr(),
35+
is_retry_config as c_int,
36+
ech_config.as_ptr(),
37+
ech_config.len(),
38+
key.as_ptr(),
39+
))
40+
.map(|_| ())
41+
}
42+
}
43+
}

boring/src/ssl/mod.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ use crate::pkey::{HasPrivate, PKeyRef, Params, Private};
8787
use crate::srtp::{SrtpProtectionProfile, SrtpProtectionProfileRef};
8888
use crate::ssl::bio::BioMethod;
8989
use crate::ssl::callbacks::*;
90+
#[cfg(not(feature = "fips"))]
91+
use crate::ssl::ech::SslEchKeys;
9092
use crate::ssl::error::InnerError;
9193
use crate::stack::{Stack, StackRef, Stackable};
9294
use crate::x509::store::{X509Store, X509StoreBuilderRef, X509StoreRef};
@@ -114,6 +116,8 @@ mod callbacks;
114116
#[cfg(feature = "cert-compression")]
115117
mod cert_compression;
116118
mod connector;
119+
#[cfg(not(feature = "fips"))]
120+
mod ech;
117121
mod error;
118122
mod mut_only;
119123
#[cfg(test)]
@@ -2014,6 +2018,16 @@ impl SslContextBuilder {
20142018
}
20152019
}
20162020

2021+
/// Registers a list of ECH keys on the context. This list should contain new and old
2022+
/// ECHConfigs to allow stale DNS caches to update. Unlike most `SSL_CTX` APIs, this function
2023+
/// is safe to call even after the `SSL_CTX` has been associated with connections on various
2024+
/// threads.
2025+
#[cfg(not(feature = "fips"))]
2026+
#[corresponds(SSL_CTX_set1_ech_keys)]
2027+
pub fn set_ech_keys(&mut self, keys: SslEchKeys) -> Result<(), ErrorStack> {
2028+
unsafe { cvt(ffi::SSL_CTX_set1_ech_keys(self.as_ptr(), keys.as_ptr())).map(|_| ()) }
2029+
}
2030+
20172031
/// Consumes the builder, returning a new `SslContext`.
20182032
pub fn build(self) -> SslContext {
20192033
self.ctx
@@ -3573,6 +3587,77 @@ impl SslRef {
35733587
pub fn add_chain_cert(&mut self, cert: &X509Ref) -> Result<(), ErrorStack> {
35743588
unsafe { cvt(ffi::SSL_add1_chain_cert(self.as_ptr(), cert.as_ptr())).map(|_| ()) }
35753589
}
3590+
3591+
/// Configures `ech_config_list` on `SSL` for offering ECH during handshakes. If the server
3592+
/// cannot decrypt the encrypted ClientHello, `SSL` will instead handshake using
3593+
/// the cleartext parameters of the ClientHelloOuter.
3594+
///
3595+
/// Clients should use `get_ech_name_override` to verify the server certificate in case of ECH
3596+
/// rejection, and follow up with `get_ech_retry_configs` to retry the connection with a fresh
3597+
/// set of ECHConfigs. If the retry also fails, clients should report a connection failure.
3598+
#[cfg(not(feature = "fips"))]
3599+
#[corresponds(SSL_set1_ech_config_list)]
3600+
pub fn set_ech_config_list(&mut self, ech_config_list: &[u8]) -> Result<(), ErrorStack> {
3601+
unsafe {
3602+
cvt_0i(ffi::SSL_set1_ech_config_list(
3603+
self.as_ptr(),
3604+
ech_config_list.as_ptr(),
3605+
ech_config_list.len(),
3606+
))
3607+
.map(|_| ())
3608+
}
3609+
}
3610+
3611+
/// This function returns a serialized `ECHConfigList` as provided by the
3612+
/// server, if one exists.
3613+
///
3614+
/// Clients should call this function when handling an `SSL_R_ECH_REJECTED` error code to
3615+
/// recover from potential key mismatches. If the result is `Some`, the client should retry the
3616+
/// connection using the returned `ECHConfigList`.
3617+
#[cfg(not(feature = "fips"))]
3618+
#[corresponds(SSL_get0_ech_retry_configs)]
3619+
pub fn get_ech_retry_configs(&self) -> Option<&[u8]> {
3620+
unsafe {
3621+
let mut data = ptr::null();
3622+
let mut len: usize = 0;
3623+
ffi::SSL_get0_ech_retry_configs(self.as_ptr(), &mut data, &mut len);
3624+
3625+
if data.is_null() {
3626+
None
3627+
} else {
3628+
Some(slice::from_raw_parts(data, len))
3629+
}
3630+
}
3631+
}
3632+
3633+
/// If `SSL` is a client and the server rejects ECH, this function returns the public name
3634+
/// associated with the ECHConfig that was used to attempt ECH.
3635+
///
3636+
/// Clients should call this function during the certificate verification callback to
3637+
/// ensure the server's certificate is valid for the public name, which is required to
3638+
/// authenticate retry configs.
3639+
#[cfg(not(feature = "fips"))]
3640+
#[corresponds(SSL_get0_ech_name_override)]
3641+
pub fn get_ech_name_override(&self) -> Option<&[u8]> {
3642+
unsafe {
3643+
let mut data: *const c_char = ptr::null();
3644+
let mut len: usize = 0;
3645+
ffi::SSL_get0_ech_name_override(self.as_ptr(), &mut data, &mut len);
3646+
3647+
if data.is_null() {
3648+
None
3649+
} else {
3650+
Some(slice::from_raw_parts(data as *const u8, len))
3651+
}
3652+
}
3653+
}
3654+
3655+
// Whether or not `SSL` negotiated ECH.
3656+
#[cfg(not(feature = "fips"))]
3657+
#[corresponds(SSL_ech_accepted)]
3658+
pub fn ech_accepted(&self) -> bool {
3659+
unsafe { ffi::SSL_ech_accepted(self.as_ptr()) != 0 }
3660+
}
35763661
}
35773662

35783663
/// An SSL stream midway through the handshake process.

boring/src/ssl/test/ech.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use crate::hpke::HpkeKey;
2+
use crate::ssl::ech::SslEchKeys;
3+
use crate::ssl::test::Server;
4+
use crate::ssl::HandshakeError;
5+
6+
// For future reference, these configs are generated by building the bssl tool (the binary is built
7+
// alongside boringssl) and running the following command:
8+
//
9+
// ./bssl generate-ech -out-ech-config-list ./list -out-ech-config ./config -out-private-key ./key
10+
// -public-name ech.com -config-id 1
11+
static ECH_CONFIG_LIST: &[u8] = include_bytes!("../../../test/echconfiglist");
12+
static ECH_CONFIG: &[u8] = include_bytes!("../../../test/echconfig");
13+
static ECH_KEY: &[u8] = include_bytes!("../../../test/echkey");
14+
15+
static ECH_CONFIG_2: &[u8] = include_bytes!("../../../test/echconfig-2");
16+
static ECH_KEY_2: &[u8] = include_bytes!("../../../test/echkey-2");
17+
18+
#[test]
19+
fn ech() {
20+
let server = {
21+
let key = HpkeKey::dhkem_p256_sha256(ECH_KEY).unwrap();
22+
let mut ech_keys = SslEchKeys::new().unwrap();
23+
ech_keys.add_key(true, ECH_CONFIG, key).unwrap();
24+
25+
let mut builder = Server::builder();
26+
builder.ctx().set_ech_keys(ech_keys).unwrap();
27+
28+
builder.build()
29+
};
30+
31+
let mut client = server.client_with_root_ca().build().builder();
32+
client.ssl().set_ech_config_list(ECH_CONFIG_LIST).unwrap();
33+
client.ssl().set_hostname("foobar.com").unwrap();
34+
35+
let ssl_stream = client.connect();
36+
assert!(ssl_stream.ssl().ech_accepted())
37+
}
38+
39+
#[test]
40+
fn ech_rejection() {
41+
let server = {
42+
let key = HpkeKey::dhkem_p256_sha256(ECH_KEY_2).unwrap();
43+
let mut ech_keys = SslEchKeys::new().unwrap();
44+
ech_keys.add_key(true, ECH_CONFIG_2, key).unwrap();
45+
46+
let mut builder = Server::builder();
47+
builder.ctx().set_ech_keys(ech_keys).unwrap();
48+
49+
builder.build()
50+
};
51+
52+
let mut client = server.client_with_root_ca().build().builder();
53+
// Server is initialized using `ECH_CONFIG_2`, so using `ECH_CONFIG_LIST` instead of
54+
// `ECH_CONFIG_LIST_2` should trigger rejection.
55+
client.ssl().set_ech_config_list(ECH_CONFIG_LIST).unwrap();
56+
client.ssl().set_hostname("foobar.com").unwrap();
57+
let HandshakeError::Failure(failed_ssl_stream) = client.connect_err() else {
58+
panic!("wrong HandshakeError failure variant!");
59+
};
60+
61+
assert_eq!(
62+
failed_ssl_stream.ssl().get_ech_name_override(),
63+
Some(b"ech.com".to_vec().as_ref())
64+
);
65+
assert!(failed_ssl_stream.ssl().get_ech_retry_configs().is_some());
66+
assert!(!failed_ssl_stream.ssl().ech_accepted())
67+
}

boring/src/ssl/test/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ use super::CompliancePolicy;
2626

2727
mod cert_verify;
2828
mod custom_verify;
29+
#[cfg(not(feature = "fips"))]
30+
mod ech;
2931
mod private_key_method;
3032
mod server;
3133
mod session;

boring/test/echconfig

62 Bytes
Binary file not shown.

boring/test/echconfig-2

62 Bytes
Binary file not shown.

boring/test/echconfiglist

64 Bytes
Binary file not shown.

boring/test/echconfiglist-2

64 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)