diff --git a/Cargo.lock b/Cargo.lock index 8a575efb..478789d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,7 +478,7 @@ dependencies = [ [[package]] name = "dimpl" -version = "0.7.1" +version = "0.7.0" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index b79f2a67..98e859b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "dimpl" authors = ["Martin Algesten "] description = "DTLS 1.2/1.3 implementation (Sans‑IO, Sync)" -version = "0.7.1" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/algesten/dimpl" diff --git a/src/crypto/aws_lc_rs/kx_group.rs b/src/crypto/aws_lc_rs/kx_group.rs index 1a79cce9..07746ffc 100644 --- a/src/crypto/aws_lc_rs/kx_group.rs +++ b/src/crypto/aws_lc_rs/kx_group.rs @@ -2,12 +2,20 @@ use aws_lc_rs::agreement::{ECDH_P256, ECDH_P384, UnparsedPublicKey, X25519}; use aws_lc_rs::agreement::{EphemeralPrivateKey, agree_ephemeral}; +use aws_lc_rs::kem::{AlgorithmId, Ciphertext, DecapsulationKey, EncapsulationKey, ML_KEM_768}; use super::super::{ActiveKeyExchange, SupportedKxGroup}; use crate::buffer::Buf; use crate::types::NamedGroup; use crate::{CryptoError, CryptoOperation}; +/// ML-KEM-768 encapsulation key length (client key_share KEM portion), bytes. +const MLKEM768_ENCAP_KEY_LEN: usize = 1184; +/// ML-KEM-768 ciphertext length (server key_share KEM portion), bytes. +const MLKEM768_CIPHERTEXT_LEN: usize = 1088; +/// X25519 public key length, bytes. +const X25519_PUBLIC_LEN: usize = 32; + /// ECDHE key exchange implementation. struct EcdhKeyExchange { group: NamedGroup, @@ -131,14 +139,173 @@ impl SupportedKxGroup for P384 { } } +/// X25519MLKEM768 hybrid key exchange (client / decapsulation role). +/// +/// Wire format (draft-ietf-tls-ecdhe-mlkem): +/// - client key_share body = mlkem768_ek(1184) || x25519_pub(32) +/// - server key_share body = mlkem768_ct(1088) || x25519_pub(32) +/// - shared secret = mlkem768_ss(32) || x25519_ss(32) (ML-KEM first) +struct X25519MlKem768KeyExchange { + decap_key: DecapsulationKey, + x25519_private: EphemeralPrivateKey, + public_key: Buf, +} + +impl std::fmt::Debug for X25519MlKem768KeyExchange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("X25519MlKem768KeyExchange") + .field("public_key_len", &self.public_key.len()) + .finish_non_exhaustive() + } +} + +impl X25519MlKem768KeyExchange { + fn new(mut buf: Buf) -> Result { + // ML-KEM-768 decapsulation key + encapsulation key bytes (1184). + let decap_key = DecapsulationKey::generate(&ML_KEM_768) + .map_err(|_| CryptoError::OperationFailed(CryptoOperation::GenerateEphemeralKey))?; + let encap_key = decap_key + .encapsulation_key() + .map_err(|_| CryptoError::OperationFailed(CryptoOperation::ComputePublicKey))?; + let encap_key_bytes = encap_key + .key_bytes() + .map_err(|_| CryptoError::OperationFailed(CryptoOperation::ComputePublicKey))?; + + // X25519 ephemeral keypair. + let rng = aws_lc_rs::rand::SystemRandom::new(); + let x25519_private = EphemeralPrivateKey::generate(&X25519, &rng) + .map_err(|_| CryptoError::OperationFailed(CryptoOperation::GenerateEphemeralKey))?; + let x25519_public = x25519_private + .compute_public_key() + .map_err(|_| CryptoError::OperationFailed(CryptoOperation::ComputePublicKey))?; + + // Client key_share body = mlkem768_ek(1184) || x25519_pub(32). + buf.clear(); + buf.extend_from_slice(encap_key_bytes.as_ref()); + buf.extend_from_slice(x25519_public.as_ref()); + + Ok(X25519MlKem768KeyExchange { + decap_key, + x25519_private, + public_key: buf, + }) + } +} + +impl ActiveKeyExchange for X25519MlKem768KeyExchange { + fn pub_key(&self) -> &[u8] { + &self.public_key + } + + fn complete(self: Box, peer_pub: &[u8], out: &mut Buf) -> Result<(), CryptoError> { + // Server key_share body = mlkem768_ct(1088) || x25519_pub(32). + if peer_pub.len() != MLKEM768_CIPHERTEXT_LEN + X25519_PUBLIC_LEN { + return Err(CryptoError::InvalidPublicKey(NamedGroup::X25519MLKEM768)); + } + let (ct_bytes, x25519_peer) = peer_pub.split_at(MLKEM768_CIPHERTEXT_LEN); + + // ML-KEM-768 decapsulation -> ss1 (32). + let ml_secret = self + .decap_key + .decapsulate(Ciphertext::from(ct_bytes)) + .map_err(|_| CryptoError::InvalidPublicKey(NamedGroup::X25519MLKEM768))?; + + // Shared secret = mlkem768_ss(32) || x25519_ss(32). ML-KEM first. + out.clear(); + out.extend_from_slice(ml_secret.as_ref()); + + // X25519 agreement -> ss2 (32), appended after the ML-KEM secret. + let peer_key = UnparsedPublicKey::new(&X25519, x25519_peer); + agree_ephemeral( + self.x25519_private, + peer_key, + CryptoError::InvalidPublicKey(NamedGroup::X25519MLKEM768), + |secret| { + out.extend_from_slice(secret); + Ok(()) + }, + ) + } + + fn group(&self) -> NamedGroup { + NamedGroup::X25519MLKEM768 + } +} + +/// X25519MLKEM768 hybrid key exchange group. +#[derive(Debug)] +struct X25519MlKem768Kx; + +impl SupportedKxGroup for X25519MlKem768Kx { + fn name(&self) -> NamedGroup { + NamedGroup::X25519MLKEM768 + } + + fn start_exchange(&self, buf: Buf) -> Result, CryptoError> { + Ok(Box::new(X25519MlKem768KeyExchange::new(buf)?)) + } + + fn start_and_complete( + &self, + client_share: &[u8], + out_share: &mut Buf, + out_secret: &mut Buf, + ) -> Result<(), CryptoError> { + // Server role (encapsulation). client_share = mlkem768_ek(1184) || x25519_pub(32). + if client_share.len() != MLKEM768_ENCAP_KEY_LEN + X25519_PUBLIC_LEN { + return Err(CryptoError::InvalidPublicKey(NamedGroup::X25519MLKEM768)); + } + let (ek_bytes, x25519_client_pub) = client_share.split_at(MLKEM768_ENCAP_KEY_LEN); + + // ML-KEM-768 encapsulate against the client's encapsulation key. + let encap_key = EncapsulationKey::new(&ML_KEM_768, ek_bytes) + .map_err(|_| CryptoError::InvalidPublicKey(NamedGroup::X25519MLKEM768))?; + let (ciphertext, ml_secret) = encap_key + .encapsulate() + .map_err(|_| CryptoError::InvalidPublicKey(NamedGroup::X25519MLKEM768))?; + + // Server X25519 ephemeral keypair. + let rng = aws_lc_rs::rand::SystemRandom::new(); + let x25519_private = EphemeralPrivateKey::generate(&X25519, &rng) + .map_err(|_| CryptoError::OperationFailed(CryptoOperation::GenerateEphemeralKey))?; + let x25519_public = x25519_private + .compute_public_key() + .map_err(|_| CryptoError::OperationFailed(CryptoOperation::ComputePublicKey))?; + + // Server key_share body = mlkem768_ct(1088) || x25519_pub(32). + out_share.clear(); + out_share.extend_from_slice(ciphertext.as_ref()); + out_share.extend_from_slice(x25519_public.as_ref()); + + // Shared secret = mlkem768_ss(32) || x25519_ss(32). ML-KEM first. + out_secret.clear(); + out_secret.extend_from_slice(ml_secret.as_ref()); + let peer_key = UnparsedPublicKey::new(&X25519, x25519_client_pub); + agree_ephemeral( + x25519_private, + peer_key, + CryptoError::InvalidPublicKey(NamedGroup::X25519MLKEM768), + |secret| { + out_secret.extend_from_slice(secret); + Ok(()) + }, + ) + } +} + /// Static instances of supported key exchange groups. static KX_GROUP_X25519: X25519Kx = X25519Kx; static KX_GROUP_P256: P256 = P256; static KX_GROUP_P384: P384 = P384; +static KX_GROUP_X25519_ML_KEM_768: X25519MlKem768Kx = X25519MlKem768Kx; /// All supported key exchange groups. -pub(super) static ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = - &[&KX_GROUP_X25519, &KX_GROUP_P256, &KX_GROUP_P384]; +pub(super) static ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[ + &KX_GROUP_X25519, + &KX_GROUP_P256, + &KX_GROUP_P384, + &KX_GROUP_X25519_ML_KEM_768, +]; #[cfg(test)] mod tests { diff --git a/src/crypto/provider.rs b/src/crypto/provider.rs index a4f0af08..cca71cf4 100644 --- a/src/crypto/provider.rs +++ b/src/crypto/provider.rs @@ -288,6 +288,27 @@ pub trait SupportedKxGroup: CryptoSafe { /// Start a new key exchange, generating ephemeral keypair. /// The provided `buf` will be used to store the public key. fn start_exchange(&self, buf: Buf) -> Result, CryptoError>; + + /// Server-side key exchange: given the client's `key_share`, produce the + /// server's `key_share` (into `out_share`) and the shared secret (into + /// `out_secret`). + /// + /// The default implementation models Diffie-Hellman: it generates an + /// ephemeral keypair (server share = its public key) and agrees against + /// the client's public key. KEM groups (e.g. X25519MLKEM768) override this + /// to encapsulate against the client's encapsulation key, where both the + /// outgoing ciphertext and the secret depend on the client's share. + fn start_and_complete( + &self, + client_share: &[u8], + out_share: &mut Buf, + out_secret: &mut Buf, + ) -> Result<(), CryptoError> { + let kx = self.start_exchange(Buf::new())?; + out_share.clear(); + out_share.extend_from_slice(kx.pub_key()); + kx.complete(client_share, out_secret) + } } /// Signature verification against certificates. diff --git a/src/crypto/validation/mod.rs b/src/crypto/validation/mod.rs index 3b19acce..cb2314fb 100644 --- a/src/crypto/validation/mod.rs +++ b/src/crypto/validation/mod.rs @@ -36,11 +36,15 @@ impl CryptoProvider { /// - X25519 /// - P-256 (secp256r1) /// - P-384 (secp384r1) + /// - X25519MLKEM768 (hybrid post-quantum, DTLS 1.3 only) pub fn supported_kx_groups(&self) -> impl Iterator { self.kx_groups.iter().copied().filter(|kx| { matches!( kx.name(), - NamedGroup::X25519 | NamedGroup::Secp256r1 | NamedGroup::Secp384r1 + NamedGroup::X25519 + | NamedGroup::Secp256r1 + | NamedGroup::Secp384r1 + | NamedGroup::X25519MLKEM768 ) }) } @@ -364,6 +368,23 @@ impl CryptoProvider { for kx in self.supported_kx_groups() { let group = kx.name(); + // The symmetric round-trip self-test below models Diffie-Hellman: + // both parties produce same-format public keys and `complete()` + // consumes the peer's public key. A KEM hybrid like + // X25519MLKEM768 is asymmetric (client encapsulation key vs server + // ciphertext) and only implements the client/decapsulation role, + // so it cannot be self-tested this way. Validate it via + // `start_exchange` only. + if matches!(group, NamedGroup::X25519MLKEM768) { + kx.start_exchange(Buf::new()).map_err(|e| { + provider_error(CryptoProviderValidationError::KeyExchangeStartFailed { + group, + source: e, + }) + })?; + continue; + } + let alice = kx.start_exchange(Buf::new()).map_err(|e| { provider_error(CryptoProviderValidationError::KeyExchangeStartFailed { group, diff --git a/src/dtls13/server.rs b/src/dtls13/server.rs index ebe3ed5b..42d9aaa1 100644 --- a/src/dtls13/server.rs +++ b/src/dtls13/server.rs @@ -703,7 +703,11 @@ impl State { }; }; - // Start ECDHE key exchange + // Server-side key exchange. For ECDHE this generates an ephemeral + // keypair (server share = its public key) and agrees with the client's + // public key; for a KEM (X25519MLKEM768) it encapsulates against the + // client's encapsulation key. Either way it yields the server's + // key_share bytes and the shared secret. let kx_group = server .engine .find_kx_group(selected_group) @@ -711,25 +715,21 @@ impl State { crate::CryptoError::KeyExchangeGroupNotFound(selected_group), ))?; - let kx_buf = server.engine.pop_buffer(); - let key_exchange = kx_group - .start_exchange(kx_buf) - .map_err(Error::CryptoError)?; + let mut server_share = server.engine.pop_buffer(); + let mut shared_secret = server.engine.pop_buffer(); + { + let peer_pub_key = &server.defragment_buffer[peer_key_range]; + kx_group + .start_and_complete(peer_pub_key, &mut server_share, &mut shared_secret) + .map_err(Error::CryptoError)?; + } - // Store server's public key in extension_data + // Store server's key_share in extension_data server.extension_data.clear(); - let pub_key = key_exchange.pub_key(); let pub_key_start = server.extension_data.len(); - server.extension_data.extend_from_slice(pub_key); + server.extension_data.extend_from_slice(&server_share); let pub_key_end = server.extension_data.len(); - // Complete ECDHE with client's public key - let peer_pub_key = &server.defragment_buffer[peer_key_range]; - let mut shared_secret = server.engine.pop_buffer(); - key_exchange - .complete(peer_pub_key, &mut shared_secret) - .map_err(Error::CryptoError)?; - server.shared_secret = Some(shared_secret); // Select SRTP profile: first from client list that the server supports. diff --git a/src/types.rs b/src/types.rs index 08a1a32b..52ee1082 100644 --- a/src/types.rs +++ b/src/types.rs @@ -141,6 +141,9 @@ impl NamedGroup { pub const X25519: Self = Self(29); /// X448 (Curve448 for ECDHE). pub const X448: Self = Self(30); + /// X25519MLKEM768 hybrid post-quantum group (X25519 + ML-KEM-768), + /// draft-ietf-tls-ecdhe-mlkem. DTLS 1.3 only. + pub const X25519MLKEM768: Self = Self(0x11EC); /// Convert a wire format u16 value to a `NamedGroup`. pub const fn from_u16(value: u16) -> Self { @@ -154,7 +157,7 @@ impl NamedGroup { /// Returns true if this is not a known TLS named group wire value. pub const fn is_unknown(&self) -> bool { - !matches!(*self, Self(1..=25 | 29..=30)) + !matches!(*self, Self(1..=25 | 29..=30 | 0x11EC)) } /// Parse a `NamedGroup` from wire format. @@ -169,7 +172,7 @@ impl NamedGroup { } /// All recognized named groups (every non-`Unknown` variant). - pub const fn all() -> &'static [NamedGroup; 27] { + pub const fn all() -> &'static [NamedGroup; 28] { &[ NamedGroup::Sect163k1, NamedGroup::Sect163r1, @@ -198,16 +201,18 @@ impl NamedGroup { NamedGroup::Secp521r1, NamedGroup::X25519, NamedGroup::X448, + NamedGroup::X25519MLKEM768, ] } /// Supported named groups in preference order. - pub const fn supported() -> &'static [NamedGroup; 4] { + pub const fn supported() -> &'static [NamedGroup; 5] { &[ NamedGroup::X25519, NamedGroup::Secp256r1, NamedGroup::Secp384r1, NamedGroup::Secp521r1, + NamedGroup::X25519MLKEM768, ] } } @@ -242,6 +247,7 @@ impl fmt::Debug for NamedGroup { NamedGroup::Secp521r1 => f.write_str("Secp521r1"), NamedGroup::X25519 => f.write_str("X25519"), NamedGroup::X448 => f.write_str("X448"), + NamedGroup::X25519MLKEM768 => f.write_str("X25519MLKEM768"), _ => f.debug_tuple("Unknown").field(&self.0).finish(), } }