diff --git a/boring/src/hmac.rs b/boring/src/hmac.rs new file mode 100644 index 000000000..7e50e6137 --- /dev/null +++ b/boring/src/hmac.rs @@ -0,0 +1,32 @@ +use crate::cvt; +use crate::error::ErrorStack; +use crate::foreign_types::ForeignTypeRef; +use crate::hash::MessageDigest; + +foreign_type_and_impl_send_sync! { + type CType = ffi::HMAC_CTX; + fn drop = ffi::HMAC_CTX_free; + + pub struct HmacCtx; +} + +impl HmacCtxRef { + /// Configures HmacCtx to use `md` as the hash function and `key` as the key. + /// + /// https://commondatastorage.googleapis.com/chromium-boringssl-docs/hmac.h.html#HMAC_Init_ex + pub fn init(&mut self, key: &[u8], md: &MessageDigest) -> Result<(), ErrorStack> { + ffi::init(); + + unsafe { + cvt(ffi::HMAC_Init_ex( + self.as_ptr(), + key.as_ptr().cast(), + key.len(), + md.as_ptr(), + // ENGINE api is deprecated + core::ptr::null_mut(), + )) + .map(|_| ()) + } + } +} diff --git a/boring/src/lib.rs b/boring/src/lib.rs index 77f3e726f..932bdd354 100644 --- a/boring/src/lib.rs +++ b/boring/src/lib.rs @@ -137,6 +137,7 @@ pub mod error; pub mod ex_data; pub mod fips; pub mod hash; +pub mod hmac; pub mod hpke; pub mod memcmp; pub mod nid; diff --git a/boring/src/ssl/callbacks.rs b/boring/src/ssl/callbacks.rs index f618e591d..ed724f791 100644 --- a/boring/src/ssl/callbacks.rs +++ b/boring/src/ssl/callbacks.rs @@ -8,12 +8,15 @@ use super::{ }; use crate::error::ErrorStack; use crate::ffi; +use crate::hmac::HmacCtxRef; +use crate::ssl::TicketKeyCallbackResult; +use crate::symm::CipherCtxRef; use crate::x509::{X509StoreContext, X509StoreContextRef}; use foreign_types::ForeignType; use foreign_types::ForeignTypeRef; -use libc::c_char; -use libc::{c_int, c_uchar, c_uint, c_void}; +use libc::{c_char, c_int, c_uchar, c_uint, c_void}; use std::ffi::CStr; +use std::mem::MaybeUninit; use std::ptr; use std::slice; use std::str; @@ -269,6 +272,68 @@ where } } +unsafe fn to_uninit<'a, T: 'a>(ptr: *mut T) -> &'a mut MaybeUninit { + assert!(!ptr.is_null()); + unsafe { &mut *ptr.cast::>() } +} + +pub(super) unsafe extern "C" fn raw_ticket_key( + ssl: *mut ffi::SSL, + key_name: *mut u8, + iv: *mut u8, + evp_ctx: *mut ffi::EVP_CIPHER_CTX, + hmac_ctx: *mut ffi::HMAC_CTX, + encrypt: c_int, +) -> c_int +where + F: Fn( + &SslRef, + &mut [u8; 16], + &mut [u8; ffi::EVP_MAX_IV_LENGTH as usize], + &mut CipherCtxRef, + &mut HmacCtxRef, + bool, + ) -> TicketKeyCallbackResult + + 'static + + Sync + + Send, +{ + // SAFETY: boring provides valid inputs. + let ssl = unsafe { SslRef::from_ptr_mut(ssl) }; + + let ssl_context = ssl.ssl_context().to_owned(); + let callback = ssl_context + .ex_data::(SslContext::cached_ex_index::()) + .expect("expected session resumption callback"); + + // SAFETY: the callback guarantees that key_name is 16 bytes + let key_name = + unsafe { to_uninit(key_name.cast::<[u8; ffi::SSL_TICKET_KEY_NAME_LEN as usize]>()) }; + + // SAFETY: the callback provides 16 bytes iv + // + // https://github.com/google/boringssl/blob/main/ssl/ssl_session.cc#L331 + let iv = unsafe { to_uninit(iv.cast::<[u8; ffi::EVP_MAX_IV_LENGTH as usize]>()) }; + + // When encrypting a new ticket, encrypt will be one. + let encrypt = encrypt == 1; + + // Zero-initialize the key_name and iv, since the application is expected to populate these + // fields in the encrypt mode. + if encrypt { + *key_name = MaybeUninit::zeroed(); + *iv = MaybeUninit::zeroed(); + } + let key_name = unsafe { key_name.assume_init_mut() }; + let iv = unsafe { iv.assume_init_mut() }; + + // The EVP_CIPHER_CTX and HMAC_CTX are owned by boringSSL. + let evp_ctx = unsafe { CipherCtxRef::from_ptr_mut(evp_ctx) }; + let hmac_ctx = unsafe { HmacCtxRef::from_ptr_mut(hmac_ctx) }; + + callback(ssl, key_name, iv, evp_ctx, hmac_ctx, encrypt).into() +} + pub(super) unsafe extern "C" fn raw_alpn_select( ssl: *mut ffi::SSL, out: *mut *const c_uchar, diff --git a/boring/src/ssl/mod.rs b/boring/src/ssl/mod.rs index 49788c3e6..c5f5f39b5 100644 --- a/boring/src/ssl/mod.rs +++ b/boring/src/ssl/mod.rs @@ -81,6 +81,7 @@ use crate::dh::DhRef; use crate::ec::EcKeyRef; use crate::error::ErrorStack; use crate::ex_data::Index; +use crate::hmac::HmacCtxRef; use crate::nid::Nid; use crate::pkey::{HasPrivate, PKeyRef, Params, Private}; use crate::srtp::{SrtpProtectionProfile, SrtpProtectionProfileRef}; @@ -88,6 +89,7 @@ use crate::ssl::bio::BioMethod; use crate::ssl::callbacks::*; use crate::ssl::error::InnerError; use crate::stack::{Stack, StackRef, Stackable}; +use crate::symm::CipherCtxRef; use crate::x509::store::{X509Store, X509StoreBuilder, X509StoreBuilderRef, X509StoreRef}; use crate::x509::verify::X509VerifyParamRef; use crate::x509::{ @@ -888,6 +890,53 @@ pub enum SslInfoCallbackValue { Alert(SslInfoCallbackAlert), } +/// Ticket key callback status. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum TicketKeyCallbackResult { + /// Abort the handshake. + Error, + + /// Continue with a full handshake. + /// + /// When in decryption mode, this indicates that the peer supplied session ticket was not + /// recognized. When in encryption mode, this instructs boring to not send a session ticket. + /// + /// # Note + /// + /// This is a decryption specific status code when using the submoduled BoringSSL. + Noop, + + /// Resumption callback was successful. + /// + /// When in decryption mode, attempt an abbreviated handshake via session resumption. When in + /// encryption mode, provide a new ticket to the client. + Success, + + /// Resumption callback was successful. Attempt an abbreviated handshake, and additionally + /// provide new session tickets to the peer. + /// + /// Session resumption short-circuits some security checks of a full-handshake, in exchange for + /// potential performance gains. For this reason, a session ticket should only be valid for a + /// limited time. Providing the peer with renewed session tickets allows them to continue + /// session resumption with the new tickets. + /// + /// # Note + /// + /// This is a decryption specific status code. + DecryptSuccessRenew, +} + +impl From for c_int { + fn from(value: TicketKeyCallbackResult) -> Self { + match value { + TicketKeyCallbackResult::Error => -1, + TicketKeyCallbackResult::Noop => 0, + TicketKeyCallbackResult::Success => 1, + TicketKeyCallbackResult::DecryptSuccessRenew => 2, + } + } +} + #[derive(Hash, Copy, Clone, PartialOrd, Ord, Eq, PartialEq, Debug)] pub struct SslInfoCallbackAlert(c_int); @@ -1180,6 +1229,52 @@ impl SslContextBuilder { } } + /// Configures a custom session ticket key callback for session resumption. + /// + /// Session Resumption uses the security context (aka. session tickets) of a previous + /// connection to establish a new connection via an abbreviated handshake. Skipping portions of + /// a handshake can potentially yield performance gains. + /// + /// An attacker that compromises a server's session ticket key can impersonate the server and, + /// prior to TLS 1.3, retroactively decrypt all application traffic from sessions using that + /// ticket key. Thus ticket keys must be regularly rotated for forward secrecy. + /// + /// CipherCtx and HmacCtx are guaranteed to be initialized. + /// + /// # Panics + /// + /// This method panics if this `Ssl` is associated with a RPK context. + /// + /// # Safety + /// + /// The application is responsible for correctly setting the key_name, iv, encryption context + /// and hmac context. See the [`SSL_CTX_set_tlsext_ticket_key_cb`] docs for additional info. + /// + /// [`SSL_CTX_set_tlsext_ticket_key_cb`]: https://commondatastorage.googleapis.com/chromium-boringssl-docs/ssl.h.html#SSL_CTX_set_tlsext_ticket_key_cb + #[corresponds(SSL_CTX_set_tlsext_ticket_key_cb)] + pub unsafe fn set_ticket_key_callback(&mut self, callback: F) + where + F: Fn( + &SslRef, + &mut [u8; 16], + &mut [u8; ffi::EVP_MAX_IV_LENGTH as usize], + &mut CipherCtxRef, + &mut HmacCtxRef, + bool, + ) -> TicketKeyCallbackResult + + 'static + + Sync + + Send, + { + #[cfg(feature = "rpk")] + assert!(!self.is_rpk, "This API is not supported for RPK"); + + unsafe { + self.replace_ex_data(SslContext::cached_ex_index::(), callback); + ffi::SSL_CTX_set_tlsext_ticket_key_cb(self.as_ptr(), Some(raw_ticket_key::)) + }; + } + /// Sets the certificate verification depth. /// /// If the peer's certificate chain is longer than this value, verification will fail. diff --git a/boring/src/ssl/test/mod.rs b/boring/src/ssl/test/mod.rs index 6ac6ca751..8007c3c9b 100644 --- a/boring/src/ssl/test/mod.rs +++ b/boring/src/ssl/test/mod.rs @@ -31,6 +31,7 @@ mod ech; mod private_key_method; mod server; mod session; +mod session_resumption; mod verify; static ROOT_CERT: &[u8] = include_bytes!("../../../test/root-ca.pem"); diff --git a/boring/src/ssl/test/session_resumption.rs b/boring/src/ssl/test/session_resumption.rs new file mode 100644 index 000000000..808abe304 --- /dev/null +++ b/boring/src/ssl/test/session_resumption.rs @@ -0,0 +1,242 @@ +use super::server::Server; +use crate::ssl::test::MessageDigest; +use crate::ssl::HmacCtxRef; +use crate::ssl::SslRef; +use crate::ssl::SslSession; +use crate::ssl::SslSessionCacheMode; +use crate::ssl::TicketKeyCallbackResult; +use crate::symm::Cipher; +use crate::symm::CipherCtxRef; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::OnceLock; + +static SUCCESS_ENCRYPTION_CALLED_BACK: AtomicU8 = AtomicU8::new(0); +static SUCCESS_DECRYPTION_CALLED_BACK: AtomicU8 = AtomicU8::new(0); +static NOOP_ENCRYPTION_CALLED_BACK: AtomicU8 = AtomicU8::new(0); +static NOOP_DECRYPTION_CALLED_BACK: AtomicU8 = AtomicU8::new(0); + +#[test] +fn resume_session() { + static SESSION_TICKET: OnceLock = OnceLock::new(); + static NST_RECIEVED_COUNT: AtomicU8 = AtomicU8::new(0); + + let mut server = Server::builder(); + server.expected_connections_count(2); + let server = server.build(); + + let mut client = server.client(); + client + .ctx() + .set_session_cache_mode(SslSessionCacheMode::CLIENT); + client.ctx().set_new_session_callback(|_, session| { + NST_RECIEVED_COUNT.fetch_add(1, Ordering::SeqCst); + // The server sends multiple session tickets but we only care to retrieve one. + let _ = SESSION_TICKET.set(session); + }); + let ssl_stream = client.connect(); + + assert!(!ssl_stream.ssl().session_reused()); + assert!(SESSION_TICKET.get().is_some()); + assert_eq!(NST_RECIEVED_COUNT.load(Ordering::SeqCst), 2); + + // Retrieve the session ticket + let session_ticket = SESSION_TICKET.get().unwrap(); + + // Attempt to resume the connection using the session ticket + let client_2 = server.client(); + let mut ssl_builder = client_2.build().builder(); + unsafe { ssl_builder.ssl().set_session(session_ticket).unwrap() }; + let ssl_stream_2 = ssl_builder.connect(); + + assert!(ssl_stream_2.ssl().session_reused()); +} + +#[test] +fn custom_callback_success() { + static SESSION_TICKET: OnceLock = OnceLock::new(); + static NST_RECIEVED_COUNT: AtomicU8 = AtomicU8::new(0); + + let mut server = Server::builder(); + server.expected_connections_count(2); + unsafe { + server + .ctx() + .set_ticket_key_callback(test_success_tickey_key_callback) + }; + let server = server.build(); + + let mut client = server.client(); + client + .ctx() + .set_session_cache_mode(SslSessionCacheMode::CLIENT); + client.ctx().set_new_session_callback(|_, session| { + NST_RECIEVED_COUNT.fetch_add(1, Ordering::SeqCst); + // The server sends multiple session tickets but we only care to retrieve one. + let _ = SESSION_TICKET.set(session); + }); + let ssl_stream = client.connect(); + + assert!(!ssl_stream.ssl().session_reused()); + assert!(SESSION_TICKET.get().is_some()); + assert_eq!(SUCCESS_ENCRYPTION_CALLED_BACK.load(Ordering::SeqCst), 2); + assert_eq!(SUCCESS_DECRYPTION_CALLED_BACK.load(Ordering::SeqCst), 0); + assert_eq!(NST_RECIEVED_COUNT.load(Ordering::SeqCst), 2); + + // Retrieve the session ticket + let session_ticket = SESSION_TICKET.get().unwrap(); + + // Attempt to resume the connection using the session ticket + let client_2 = server.client(); + let mut ssl_builder = client_2.build().builder(); + unsafe { ssl_builder.ssl().set_session(session_ticket).unwrap() }; + let ssl_stream_2 = ssl_builder.connect(); + + assert!(ssl_stream_2.ssl().session_reused()); + assert_eq!(SUCCESS_ENCRYPTION_CALLED_BACK.load(Ordering::SeqCst), 4); + assert_eq!(SUCCESS_DECRYPTION_CALLED_BACK.load(Ordering::SeqCst), 1); +} + +#[test] +fn custom_callback_unrecognized_decryption_ticket() { + static SESSION_TICKET: OnceLock = OnceLock::new(); + static NST_RECIEVED_COUNT: AtomicU8 = AtomicU8::new(0); + + let mut server = Server::builder(); + server.expected_connections_count(2); + unsafe { + server + .ctx() + .set_ticket_key_callback(test_noop_tickey_key_callback) + }; + let server = server.build(); + + let mut client = server.client(); + client + .ctx() + .set_session_cache_mode(SslSessionCacheMode::CLIENT); + client.ctx().set_new_session_callback(|_, session| { + NST_RECIEVED_COUNT.fetch_add(1, Ordering::SeqCst); + // The server sends multiple session tickets but we only care to retrieve one. + let _ = SESSION_TICKET.set(session); + }); + let ssl_stream = client.connect(); + + assert!(!ssl_stream.ssl().session_reused()); + assert!(SESSION_TICKET.get().is_some()); + assert_eq!(NOOP_ENCRYPTION_CALLED_BACK.load(Ordering::SeqCst), 2); + assert_eq!(NOOP_DECRYPTION_CALLED_BACK.load(Ordering::SeqCst), 0); + assert_eq!(NST_RECIEVED_COUNT.load(Ordering::SeqCst), 2); + + // Retrieve the session ticket + let session_ticket = SESSION_TICKET.get().unwrap(); + + // Attempt to resume the connection using the session ticket + let client_2 = server.client(); + let mut ssl_builder = client_2.build().builder(); + unsafe { ssl_builder.ssl().set_session(session_ticket).unwrap() }; + let ssl_stream_2 = ssl_builder.connect(); + + // Second connection was NOT resumed due to TicketKeyCallbackResult::Noop on decryption + assert!(!ssl_stream_2.ssl().session_reused()); + assert_eq!(NOOP_ENCRYPTION_CALLED_BACK.load(Ordering::SeqCst), 4); + assert_eq!(NOOP_DECRYPTION_CALLED_BACK.load(Ordering::SeqCst), 1); +} + +// Successfully return a session ticket in encryption mode but return a +// TicketKeyCallbackResult::Noop in decryption mode. +fn test_noop_tickey_key_callback( + _ssl: &SslRef, + key_name: &mut [u8; 16], + iv: &mut [u8; ffi::EVP_MAX_IV_LENGTH as usize], + evp_ctx: &mut CipherCtxRef, + hmac_ctx: &mut HmacCtxRef, + encrypt: bool, +) -> TicketKeyCallbackResult { + // These should only be used for testing purposes. + const TEST_KEY_NAME: [u8; 16] = [5; 16]; + const TEST_CBC_IV: [u8; ffi::EVP_MAX_IV_LENGTH as usize] = [1; ffi::EVP_MAX_IV_LENGTH as usize]; + const TEST_AES_128_CBC_KEY: [u8; 16] = [2; 16]; + const TEST_HMAC_KEY: [u8; 32] = [3; 32]; + + let digest = MessageDigest::sha256(); + let cipher = Cipher::aes_128_cbc(); + + if encrypt { + NOOP_ENCRYPTION_CALLED_BACK.fetch_add(1, Ordering::SeqCst); + + // Ensure key_name and iv are initialized and set test values. + assert_eq!(key_name, &[0; 16]); + assert_eq!(iv, &[0; 16]); + key_name.copy_from_slice(&TEST_KEY_NAME); + iv.copy_from_slice(&TEST_CBC_IV); + + // Set the encryption context. + evp_ctx + .init_encrypt(&cipher, &TEST_AES_128_CBC_KEY, &TEST_CBC_IV) + .unwrap(); + + // Set the hmac context. + hmac_ctx.init(&TEST_HMAC_KEY, &digest).unwrap(); + + TicketKeyCallbackResult::Success + } else { + NOOP_DECRYPTION_CALLED_BACK.fetch_add(1, Ordering::SeqCst); + + // Check key_name matches. + assert_eq!(key_name, &TEST_KEY_NAME); + + TicketKeyCallbackResult::Noop + } +} + +// Custom callback to encrypt and decrypt session tickets +fn test_success_tickey_key_callback( + _ssl: &SslRef, + key_name: &mut [u8; 16], + iv: &mut [u8; ffi::EVP_MAX_IV_LENGTH as usize], + evp_ctx: &mut CipherCtxRef, + hmac_ctx: &mut HmacCtxRef, + encrypt: bool, +) -> TicketKeyCallbackResult { + // These should only be used for testing purposes. + const TEST_KEY_NAME: [u8; 16] = [5; 16]; + const TEST_CBC_IV: [u8; ffi::EVP_MAX_IV_LENGTH as usize] = [1; ffi::EVP_MAX_IV_LENGTH as usize]; + const TEST_AES_128_CBC_KEY: [u8; 16] = [2; 16]; + const TEST_HMAC_KEY: [u8; 32] = [3; 32]; + + let digest = MessageDigest::sha256(); + let cipher = Cipher::aes_128_cbc(); + + if encrypt { + SUCCESS_ENCRYPTION_CALLED_BACK.fetch_add(1, Ordering::SeqCst); + + // Ensure key_name and iv are initialized and set test values. + assert_eq!(key_name, &[0; 16]); + assert_eq!(iv, &[0; 16]); + key_name.copy_from_slice(&TEST_KEY_NAME); + iv.copy_from_slice(&TEST_CBC_IV); + + // Set the encryption context. + evp_ctx + .init_encrypt(&cipher, &TEST_AES_128_CBC_KEY, &TEST_CBC_IV) + .unwrap(); + + // Set the hmac context. + hmac_ctx.init(&TEST_HMAC_KEY, &digest).unwrap(); + } else { + SUCCESS_DECRYPTION_CALLED_BACK.fetch_add(1, Ordering::SeqCst); + + // Check key_name matches. + assert_eq!(key_name, &TEST_KEY_NAME); + + // Set the decryption context. + evp_ctx + .init_decrypt(&cipher, &TEST_AES_128_CBC_KEY, iv) + .unwrap(); + + // Set the hmac context. + hmac_ctx.init(&TEST_HMAC_KEY, &digest).unwrap(); + } + + TicketKeyCallbackResult::Success +} diff --git a/boring/src/symm.rs b/boring/src/symm.rs index fff8a4a10..38fab76dc 100644 --- a/boring/src/symm.rs +++ b/boring/src/symm.rs @@ -53,6 +53,7 @@ //! ``` use crate::ffi; +use foreign_types::ForeignTypeRef; use libc::{c_int, c_uint}; use openssl_macros::corresponds; use std::cmp; @@ -68,6 +69,71 @@ pub enum Mode { Decrypt, } +foreign_type_and_impl_send_sync! { + type CType = ffi::EVP_CIPHER_CTX; + fn drop = ffi::EVP_CIPHER_CTX_free; + + pub struct CipherCtx; +} + +impl CipherCtxRef { + /// Configures CipherCtx for a fresh encryption operation using `cipher`. + /// + /// https://commondatastorage.googleapis.com/chromium-boringssl-docs/cipher.h.html#EVP_EncryptInit_ex + pub fn init_encrypt( + &mut self, + cipher: &Cipher, + key: &[u8], + iv: &[u8; ffi::EVP_MAX_IV_LENGTH as usize], + ) -> Result<(), ErrorStack> { + ffi::init(); + + if key.len() != cipher.key_len() { + return Err(ErrorStack::get()); + } + + unsafe { + cvt(ffi::EVP_EncryptInit_ex( + self.as_ptr(), + cipher.as_ptr(), + // ENGINE api is deprecated + ptr::null_mut(), + key.as_ptr(), + iv.as_ptr(), + )) + .map(|_| ()) + } + } + + /// Configures CipherCtx for a fresh decryption operation using `cipher`. + /// + /// https://commondatastorage.googleapis.com/chromium-boringssl-docs/cipher.h.html#EVP_DecryptInit_ex + pub fn init_decrypt( + &mut self, + cipher: &Cipher, + key: &[u8], + iv: &[u8; ffi::EVP_MAX_IV_LENGTH as usize], + ) -> Result<(), ErrorStack> { + ffi::init(); + + if key.len() != cipher.key_len() { + return Err(ErrorStack::get()); + } + + unsafe { + cvt(ffi::EVP_DecryptInit_ex( + self.as_ptr(), + cipher.as_ptr(), + // ENGINE api is deprecated + ptr::null_mut(), + key.as_ptr(), + iv.as_ptr(), + )) + .map(|_| ()) + } + } +} + /// Represents a particular cipher algorithm. /// /// See OpenSSL doc at [`EVP_EncryptInit`] for more information on each algorithms.