diff --git a/Cargo.lock b/Cargo.lock index 3a2933a..555447e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "211f05e03c7d03754740fd9e585de910a095d6b99f8bcfffdef8319fa02a8331" dependencies = [ + "getrandom 0.4.1", "hybrid-array", "rand_core", ] @@ -471,6 +472,25 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "keccak" +version = "0.2.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a412fe37705d515cba9dbf1448291a717e187e2351df908cfc0137cbec3d480" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "kem" +version = "0.3.0-rc.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ae2c3347ff4a7af4f679a9e397c2c7e6034a00b773dd2dd3c001d7f40897c9" +dependencies = [ + "crypto-common", + "rand_core", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -501,6 +521,33 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "ml-kem" +version = "0.3.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc807923f3029ad8676c21a667e1dc941e323538190a6d46cde130e7d55beef" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "rand_core", + "sha3", + "subtle", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfecc750073acc09af2f8899b2342d520d570392ba1c3aed53eeb0d84ca4103" +dependencies = [ + "hybrid-array", + "num-traits", + "subtle", + "zeroize", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -772,6 +819,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.4.1", "hmac", + "ml-kem", "p256", "p384", "paste", @@ -783,6 +831,7 @@ dependencies = [ "sha2", "signature", "x25519-dalek", + "zeroize", ] [[package]] @@ -879,6 +928,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.11.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5bfe7820113e633d8886e839aae78c1184b8d7011000db6bc7eb61e34f28350" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 754c0f1..c074083 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ ecdsa = { version = "0.17.0-rc.16", default-features = false, features = ["alloc ed25519-dalek = { version = "3.0.0-pre.6", default-features = false, features = ["pkcs8"] } getrandom = { version = "0.4", default-features = false, features = ["sys_rng"] } hmac = { version = "0.13.0-rc.5", default-features = false } +ml-kem = { version = "0.3.0-rc.0", default-features = false, features = ["getrandom"] } p256 = { version = "0.14.0-rc.7", default-features = false, features = ["pem", "ecdsa", "ecdh"] } p384 = { version = "0.14.0-rc.7", default-features = false, features = ["pem", "ecdsa", "ecdh"] } paste = { version = "1", default-features = false } @@ -39,6 +40,7 @@ sec1 = { version = "0.8.0-rc.13", default-features = false } sha2 = { version = "0.11.0-rc.5", default-features = false } signature = { version = "3.0.0-rc.10", default-features = false } x25519-dalek = { version = "3.0.0-pre.6", default-features = false } +zeroize = { version = "1.8", default-features = false, optional = true } [features] default = ["std", "tls12", "zeroize"] @@ -52,4 +54,4 @@ tls12 = ["rustls/tls12"] std = ["alloc", "pki-types/std", "rustls/std"] # TODO: go through all of these to ensure to_vec etc. impls are exposed alloc = ["pki-types/alloc", "aead/alloc", "ed25519-dalek/alloc"] -zeroize = ["ed25519-dalek/zeroize", "x25519-dalek/zeroize"] +zeroize = ["ed25519-dalek/zeroize", "x25519-dalek/zeroize", "ml-kem/zeroize", "dep:zeroize"] diff --git a/src/kx.rs b/src/kx.rs index 3f8a4dd..4251d81 100644 --- a/src/kx.rs +++ b/src/kx.rs @@ -7,6 +7,11 @@ use getrandom::rand_core::UnwrapErr; use paste::paste; use rustls::crypto; +mod hybrid; +mod mlkem; + +pub use hybrid::{SecP256r1MLKEM768, SecP384r1MLKEM1024, X25519MLKEM768}; + #[derive(Debug)] pub struct X25519; @@ -109,4 +114,56 @@ macro_rules! impl_kx { impl_kx! {SecP256R1, rustls::NamedGroup::secp256r1, p256::ecdh::EphemeralSecret, p256::PublicKey} impl_kx! {SecP384R1, rustls::NamedGroup::secp384r1, p384::ecdh::EphemeralSecret, p384::PublicKey} -pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[&X25519, &SecP256R1, &SecP384R1]; +pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[ + &X25519, + &SecP256R1, + &SecP384R1, + &X25519MLKEM768, + &SecP256r1MLKEM768, + &SecP384r1MLKEM1024, +]; + +#[cfg(test)] +mod test { + + // Make sure every key exchange algorithm can round-trip with itself. + #[test] + fn kx_roundtrip() -> Result<(), rustls::Error> { + for kx in super::ALL_KX_GROUPS { + let client_state = kx.start()?; + let server_output = kx.start_and_complete(client_state.pub_key())?; + let client_output = client_state.complete(&server_output.pub_key)?; + + assert_eq!(server_output.group, kx.name()); + assert_eq!( + server_output.secret.secret_bytes(), + client_output.secret_bytes() + ); + } + Ok(()) + } + + // Make sure that the hybrid optimization works for each key + // exchange that provides it. + #[test] + fn kx_hybrid_optimization() -> Result<(), rustls::Error> { + for kx in super::ALL_KX_GROUPS { + let client_state = kx.start()?; + if let Some((grp, client_pubkey)) = client_state.hybrid_component() { + let server_kx = super::ALL_KX_GROUPS + .iter() + .find(|g| g.name() == grp) + .unwrap(); + let server_output = server_kx.start_and_complete(client_pubkey)?; + let client_output = + client_state.complete_hybrid_component(&server_output.pub_key)?; + assert_eq!(server_output.group, grp); + assert_eq!( + server_output.secret.secret_bytes(), + client_output.secret_bytes() + ); + } + } + Ok(()) + } +} diff --git a/src/kx/hybrid.rs b/src/kx/hybrid.rs new file mode 100644 index 0000000..b4a1972 --- /dev/null +++ b/src/kx/hybrid.rs @@ -0,0 +1,165 @@ +//! Implement the hybrid postquantum key exchanges from +//! https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/ . +//! +//! These key exchanges work by combining the key_exchange shares from +//! an elliptic curve key exchange and an MLKEM key exchange, and +//! simply concatenating them. +//! +//! Since all of the encodings are constant-length, concatenation and +//! splitting is trivial. + +use alloc::{boxed::Box, vec::Vec}; +use crypto::SupportedKxGroup as _; +use paste::paste; +use rustls::{crypto, NamedGroup}; + +use super::mlkem::{MLKEM1024, MLKEM768}; +use super::{SecP256R1, SecP384R1, X25519}; + +const SECP256R1MLKEM768_ID: u16 = 4587; +const X25519MLKEM768_ID: u16 = 4588; +const SECP384R1MLKEM1024_ID: u16 = 4589; + +/// Make a new vector by concatenating two slices. +/// +/// Only allocates once. (This is important, since reallocating would +/// imply that secret data could be left on the heap by the realloc +/// call.) +fn concat(b1: &[u8], b2: &[u8]) -> Vec { + let mut v = Vec::with_capacity(b1.len() + b2.len()); + v.extend_from_slice(b1); + v.extend_from_slice(b2); + v +} + +/// Replacement for slice::split_at_checked, which is not available +/// at the current MSRV. +fn split_at_checked(slice: &[u8], mid: usize) -> Option<(&[u8], &[u8])> { + if mid <= slice.len() { + Some(slice.split_at(mid)) + } else { + None + } +} + +fn first(tup: (A, A)) -> A { + tup.0 +} +fn second(tup: (A, A)) -> A { + tup.1 +} + +// Positions to split the client and server keyshare components respectively +// in the X25519MLKEM768 handshake. +const X25519MLKEM768_CKE_SPLIT: usize = 1184; +const X25519MLKEM768_SKE_SPLIT: usize = 1088; + +// Positions to split the client and server keyshare components respectively +// in the SecP256r1MLKEM768 handshake. +const SECP256R1MLKEM768_CKE_SPLIT: usize = 65; +const SECP256R1MLKEM768_SKE_SPLIT: usize = 65; + +// Positions to split the client and server keyshare components respectively +// in the SecP384r1MLKEM1024 handshake. +const SECP384R1MLKEM1024_CKE_SPLIT: usize = 97; +const SECP384R1MLKEM1024_SKE_SPLIT: usize = 97; + +macro_rules! hybrid_kex { + ($name:ident, $kex1:ty, $kex2:ty, $kex_ec:ty, $ec_member:expr) => { + paste! { + #[derive(Debug)] + pub struct $name; + + struct [< $name KeyExchange >] { + // Note: This is redundant with pub_key in kx1 and kx2. + pub_key: Box<[u8]>, + kx1: Box, + kx2: Box, + } + + impl crypto::SupportedKxGroup for $name { + fn name(&self) -> NamedGroup { + NamedGroup::from([< $name:upper _ID >]) + } + + fn usable_for_version(&self, version: rustls::ProtocolVersion) -> bool { + version == rustls::ProtocolVersion::TLSv1_3 + } + + fn start(&self) -> Result, rustls::Error> { + let kx1 = $kex1.start()?; + let kx2 = $kex2.start()?; + Ok(Box::new([< $name KeyExchange >] { + pub_key: concat(kx1.pub_key(), kx2.pub_key()).into(), + kx1, + kx2, + })) + } + + fn start_and_complete( + &self, + peer: &[u8], + ) -> Result { + let (kx1_pubkey, kx2_pubkey) = + split_at_checked(peer, [< $name:upper _CKE_SPLIT >]) + .ok_or_else(|| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?; + let kx1_completed = $kex1.start_and_complete(kx1_pubkey)?; + let kx2_completed = $kex2.start_and_complete(kx2_pubkey)?; + + Ok(crypto::CompletedKeyExchange { + group: self.name(), + pub_key: concat(&kx1_completed.pub_key, &kx2_completed.pub_key).into(), + secret: concat( + kx1_completed.secret.secret_bytes(), + kx2_completed.secret.secret_bytes(), + ) + .into(), + }) + } + } + + impl crypto::ActiveKeyExchange for [< $name KeyExchange >] { + fn group(&self) -> NamedGroup { + NamedGroup::from([< $name:upper _ID >]) + } + + fn pub_key(&self) -> &[u8] { + &self.pub_key + } + + fn complete(self: Box, peer: &[u8]) -> Result { + let (kx1_pubkey, kx2_pubkey) = + split_at_checked(peer, [< $name:upper _SKE_SPLIT >]) + .ok_or_else(|| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?; + let secret1 = self.kx1.complete(kx1_pubkey)?; + let secret2 = self.kx2.complete(kx2_pubkey)?; + Ok(concat(secret1.secret_bytes(), secret2.secret_bytes()).into()) + } + + fn hybrid_component(&self) -> Option<(NamedGroup, &[u8])> { + let pk = self.pub_key.split_at([< $name:upper _CKE_SPLIT >]); + let ec_pk = ($ec_member)(pk); + Some(( + $kex_ec.name(), + ec_pk, + )) + } + + fn complete_hybrid_component( + self: Box, + peer: &[u8], + ) -> Result { + let ec_kx = ($ec_member)((self.kx1, self.kx2)); + ec_kx.complete(peer) + } + } + } + } +} + +// Note: The EC key appears first in the SecP* groups, +// but (for historical reasons) appears second in X25519MLKEM768. + +hybrid_kex! { X25519MLKEM768, MLKEM768, X25519, X25519, second } +hybrid_kex! { SecP256r1MLKEM768, SecP256R1, MLKEM768, SecP256R1, first } +hybrid_kex! { SecP384r1MLKEM1024, SecP384R1, MLKEM1024, SecP384R1, first } diff --git a/src/kx/mlkem.rs b/src/kx/mlkem.rs new file mode 100644 index 0000000..1f34567 --- /dev/null +++ b/src/kx/mlkem.rs @@ -0,0 +1,97 @@ +//! Wrap the FIPS203 ML-KEM algorithms as key exchange groups. +//! +//! The existence of this module _does not_ imply that it is a good +//! idea to use these as raw SupportedKxGroups. +//! Instead, using hybrid PQ handshakes is a more conservative choice. + +use alloc::boxed::Box; +use crypto::SupportedKxGroup; +use ml_kem::{ + Decapsulate as _, Encapsulate as _, Kem, KeyExport as _, MlKem1024, MlKem768, TryKeyInit as _, +}; +use paste::paste; +use rustls::{crypto, NamedGroup}; + +// From https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml +const MLKEM768_ID: u16 = 513; +const MLKEM1024_ID: u16 = 514; + +macro_rules! mlkem_exchange { + { $mlkem:ty } => { + paste! { + + #[derive(Debug)] + pub(super) struct [< $mlkem:upper >]; + + struct [< $mlkem KeyExchange >] { + priv_key: <$mlkem as Kem>::DecapsulationKey, + pub_key: Box<[u8]>, + } + + impl SupportedKxGroup for [< $mlkem:upper >] { + fn name(&self) -> rustls::NamedGroup { + NamedGroup::from([< $mlkem:upper _ID >]) + } + + fn usable_for_version(&self, _version: rustls::ProtocolVersion) -> bool { + // These groups are left disabled and unexposed for now: + // Even if they are someday standardized they should probably + // not be enabled by default for some while. + // + // version == rustls::ProtocolVersion::TLSv1_3 + false + } + + fn start(&self) -> Result, rustls::Error> { + let (priv_key, pub_key) = $mlkem::generate_keypair(); + let pub_key = (pub_key).to_bytes().into(); + Ok(Box::new([< $mlkem KeyExchange >] { priv_key, pub_key })) + } + + fn start_and_complete( + &self, + peer: &[u8], + ) -> Result { + let encapsulation_key = <$mlkem as Kem>::EncapsulationKey::new_from_slice(peer) + .map_err(|_| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?; + + let (ciphertext, shared_secret) = encapsulation_key.encapsulate(); + + #[cfg(feature = "zeroize")] + let shared_secret = zeroize::Zeroizing::new(shared_secret); + + Ok(crypto::CompletedKeyExchange { + group: self.name(), + pub_key: ciphertext.to_vec(), + secret: shared_secret.as_slice().into(), + }) + } + } + + impl crypto::ActiveKeyExchange for [< $mlkem KeyExchange >] { + fn group(&self) -> NamedGroup { + NamedGroup::from([< $mlkem:upper _ID>]) + } + + fn pub_key(&self) -> &[u8] { + &self.pub_key + } + + fn complete(self: Box, peer: &[u8]) -> Result { + let shared_secret = self + .priv_key + .decapsulate_slice(peer) + .map_err(|_| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?; + + #[cfg(feature = "zeroize")] + let shared_secret = zeroize::Zeroizing::new(shared_secret); + + Ok(shared_secret.as_slice().into()) + } + } + } + } +} + +mlkem_exchange! { MlKem768 } +mlkem_exchange! { MlKem1024 }