Skip to content

Commit 52d923a

Browse files
committed
Implement hybrid EC+MLKEM groups from draft-ietf-tls-ecdhe-mlkem
This draft is already implemented in OpenSSL, NSS, and AWS_LC, making it reasonable to support here. I've gone with the simplest reasonable implementation I could here, using the RustCrypto `ml-kem` crate, and the existing EC key exchange groups. See comments for implementation details.
1 parent 8bb8f30 commit 52d923a

File tree

5 files changed

+382
-2
lines changed

5 files changed

+382
-2
lines changed

Cargo.lock

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ ecdsa = { version = "0.17.0-rc.16", default-features = false, features = ["alloc
2828
ed25519-dalek = { version = "3.0.0-pre.6", default-features = false, features = ["pkcs8"] }
2929
getrandom = { version = "0.4", default-features = false, features = ["sys_rng"] }
3030
hmac = { version = "0.13.0-rc.5", default-features = false }
31+
ml-kem = { version = "0.3.0-rc.0", default-features = false, features = ["getrandom"] }
3132
p256 = { version = "0.14.0-rc.7", default-features = false, features = ["pem", "ecdsa", "ecdh"] }
3233
p384 = { version = "0.14.0-rc.7", default-features = false, features = ["pem", "ecdsa", "ecdh"] }
3334
paste = { version = "1", default-features = false }
@@ -39,6 +40,7 @@ sec1 = { version = "0.8.0-rc.13", default-features = false }
3940
sha2 = { version = "0.11.0-rc.5", default-features = false }
4041
signature = { version = "3.0.0-rc.10", default-features = false }
4142
x25519-dalek = { version = "3.0.0-pre.6", default-features = false }
43+
zeroize = { version = "1.8", default-features = false, optional = true }
4244

4345
[features]
4446
default = ["std", "tls12", "zeroize"]
@@ -52,4 +54,4 @@ tls12 = ["rustls/tls12"]
5254
std = ["alloc", "pki-types/std", "rustls/std"]
5355
# TODO: go through all of these to ensure to_vec etc. impls are exposed
5456
alloc = ["pki-types/alloc", "aead/alloc", "ed25519-dalek/alloc"]
55-
zeroize = ["ed25519-dalek/zeroize", "x25519-dalek/zeroize"]
57+
zeroize = ["ed25519-dalek/zeroize", "x25519-dalek/zeroize", "ml-kem/zeroize", "dep:zeroize"]

src/kx.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ use getrandom::rand_core::UnwrapErr;
77
use paste::paste;
88
use rustls::crypto;
99

10+
mod hybrid;
11+
mod mlkem;
12+
13+
pub use hybrid::{SecP256r1MLKEM768, SecP384r1MLKEM1024, X25519MLKEM768};
14+
1015
#[derive(Debug)]
1116
pub struct X25519;
1217

@@ -109,4 +114,56 @@ macro_rules! impl_kx {
109114
impl_kx! {SecP256R1, rustls::NamedGroup::secp256r1, p256::ecdh::EphemeralSecret, p256::PublicKey}
110115
impl_kx! {SecP384R1, rustls::NamedGroup::secp384r1, p384::ecdh::EphemeralSecret, p384::PublicKey}
111116

112-
pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[&X25519, &SecP256R1, &SecP384R1];
117+
pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[
118+
&X25519,
119+
&SecP256R1,
120+
&SecP384R1,
121+
&X25519MLKEM768,
122+
&SecP256r1MLKEM768,
123+
&SecP384r1MLKEM1024,
124+
];
125+
126+
#[cfg(test)]
127+
mod test {
128+
129+
// Make sure every key exchange algorithm can round-trip with itself.
130+
#[test]
131+
fn kx_roundtrip() -> Result<(), rustls::Error> {
132+
for kx in super::ALL_KX_GROUPS {
133+
let client_state = kx.start()?;
134+
let server_output = kx.start_and_complete(client_state.pub_key())?;
135+
let client_output = client_state.complete(&server_output.pub_key)?;
136+
137+
assert_eq!(server_output.group, kx.name());
138+
assert_eq!(
139+
server_output.secret.secret_bytes(),
140+
client_output.secret_bytes()
141+
);
142+
}
143+
Ok(())
144+
}
145+
146+
// Make sure that the hybrid optimization works for each key
147+
// exchange that provides it.
148+
#[test]
149+
fn kx_hybrid_optimization() -> Result<(), rustls::Error> {
150+
for kx in super::ALL_KX_GROUPS {
151+
let client_state = kx.start()?;
152+
if let Some((grp, client_pubkey)) = client_state.hybrid_component() {
153+
let server_kx = super::ALL_KX_GROUPS
154+
.iter()
155+
.find(|g| g.name() == grp)
156+
.unwrap();
157+
let server_output = server_kx.start_and_complete(client_pubkey)?;
158+
let client_output =
159+
client_state.complete_hybrid_component(&server_output.pub_key)?;
160+
assert_eq!(server_output.group, grp);
161+
assert_eq!(
162+
server_output.secret.secret_bytes(),
163+
client_output.secret_bytes()
164+
);
165+
}
166+
}
167+
Ok(())
168+
}
169+
}

src/kx/hybrid.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
//! Implement the hybrid postquantum key exchanges from
2+
//! https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/ .
3+
//!
4+
//! These key exchanges work by combining the key_exchange shares from
5+
//! an elliptic curve key exchange and an MLKEM key exchange, and
6+
//! simply concatenating them.
7+
//!
8+
//! Since all of the encodings are constant-length, concatenation and
9+
//! splitting is trivial.
10+
11+
use alloc::{boxed::Box, vec::Vec};
12+
use crypto::SupportedKxGroup as _;
13+
use paste::paste;
14+
use rustls::{crypto, NamedGroup};
15+
16+
use super::mlkem::{MLKEM1024, MLKEM768};
17+
use super::{SecP256R1, SecP384R1, X25519};
18+
19+
const SECP256R1MLKEM768_ID: u16 = 4587;
20+
const X25519MLKEM768_ID: u16 = 4588;
21+
const SECP384R1MLKEM1024_ID: u16 = 4589;
22+
23+
/// Make a new vector by concatenating two slices.
24+
///
25+
/// Only allocates once. (This is important, since reallocating would
26+
/// imply that secret data could be left on the heap by the realloc
27+
/// call.)
28+
fn concat(b1: &[u8], b2: &[u8]) -> Vec<u8> {
29+
let mut v = Vec::with_capacity(b1.len() + b2.len());
30+
v.extend_from_slice(b1);
31+
v.extend_from_slice(b2);
32+
v
33+
}
34+
35+
/// Replacement for slice::split_at_checked, which is not available
36+
/// at the current MSRV.
37+
fn split_at_checked(slice: &[u8], mid: usize) -> Option<(&[u8], &[u8])> {
38+
if mid <= slice.len() {
39+
Some(slice.split_at(mid))
40+
} else {
41+
None
42+
}
43+
}
44+
45+
fn first<A>(tup: (A, A)) -> A {
46+
tup.0
47+
}
48+
fn second<A>(tup: (A, A)) -> A {
49+
tup.1
50+
}
51+
52+
// Positions to split the client and server keyshare components respectively
53+
// in the X25519MLKEM768 handshake.
54+
const X25519MLKEM768_CKE_SPLIT: usize = 1184;
55+
const X25519MLKEM768_SKE_SPLIT: usize = 1088;
56+
57+
// Positions to split the client and server keyshare components respectively
58+
// in the SecP256r1MLKEM768 handshake.
59+
const SECP256R1MLKEM768_CKE_SPLIT: usize = 65;
60+
const SECP256R1MLKEM768_SKE_SPLIT: usize = 65;
61+
62+
// Positions to split the client and server keyshare components respectively
63+
// in the SecP384r1MLKEM1024 handshake.
64+
const SECP384R1MLKEM1024_CKE_SPLIT: usize = 97;
65+
const SECP384R1MLKEM1024_SKE_SPLIT: usize = 97;
66+
67+
macro_rules! hybrid_kex {
68+
($name:ident, $kex1:ty, $kex2:ty, $kex_ec:ty, $ec_member:expr) => {
69+
paste! {
70+
#[derive(Debug)]
71+
pub struct $name;
72+
73+
struct [< $name KeyExchange >] {
74+
// Note: This is redundant with pub_key in kx1 and kx2.
75+
pub_key: Box<[u8]>,
76+
kx1: Box<dyn crypto::ActiveKeyExchange>,
77+
kx2: Box<dyn crypto::ActiveKeyExchange>,
78+
}
79+
80+
impl crypto::SupportedKxGroup for $name {
81+
fn name(&self) -> NamedGroup {
82+
NamedGroup::from([< $name:upper _ID >])
83+
}
84+
85+
fn usable_for_version(&self, version: rustls::ProtocolVersion) -> bool {
86+
version == rustls::ProtocolVersion::TLSv1_3
87+
}
88+
89+
fn start(&self) -> Result<Box<dyn crypto::ActiveKeyExchange>, rustls::Error> {
90+
let kx1 = $kex1.start()?;
91+
let kx2 = $kex2.start()?;
92+
Ok(Box::new([< $name KeyExchange >] {
93+
pub_key: concat(kx1.pub_key(), kx2.pub_key()).into(),
94+
kx1,
95+
kx2,
96+
}))
97+
}
98+
99+
fn start_and_complete(
100+
&self,
101+
peer: &[u8],
102+
) -> Result<crypto::CompletedKeyExchange, rustls::Error> {
103+
let (kx1_pubkey, kx2_pubkey) =
104+
split_at_checked(peer, [< $name:upper _CKE_SPLIT >])
105+
.ok_or_else(|| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?;
106+
let kx1_completed = $kex1.start_and_complete(kx1_pubkey)?;
107+
let kx2_completed = $kex2.start_and_complete(kx2_pubkey)?;
108+
109+
Ok(crypto::CompletedKeyExchange {
110+
group: self.name(),
111+
pub_key: concat(&kx1_completed.pub_key, &kx2_completed.pub_key).into(),
112+
secret: concat(
113+
kx1_completed.secret.secret_bytes(),
114+
kx2_completed.secret.secret_bytes(),
115+
)
116+
.into(),
117+
})
118+
}
119+
}
120+
121+
impl crypto::ActiveKeyExchange for [< $name KeyExchange >] {
122+
fn group(&self) -> NamedGroup {
123+
NamedGroup::from([< $name:upper _ID >])
124+
}
125+
126+
fn pub_key(&self) -> &[u8] {
127+
&self.pub_key
128+
}
129+
130+
fn complete(self: Box<Self>, peer: &[u8]) -> Result<crypto::SharedSecret, rustls::Error> {
131+
let (kx1_pubkey, kx2_pubkey) =
132+
split_at_checked(peer, [< $name:upper _SKE_SPLIT >])
133+
.ok_or_else(|| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?;
134+
let secret1 = self.kx1.complete(kx1_pubkey)?;
135+
let secret2 = self.kx2.complete(kx2_pubkey)?;
136+
Ok(concat(secret1.secret_bytes(), secret2.secret_bytes()).into())
137+
}
138+
139+
fn hybrid_component(&self) -> Option<(NamedGroup, &[u8])> {
140+
let pk = self.pub_key.split_at([< $name:upper _CKE_SPLIT >]);
141+
let ec_pk = ($ec_member)(pk);
142+
Some((
143+
$kex_ec.name(),
144+
ec_pk,
145+
))
146+
}
147+
148+
fn complete_hybrid_component(
149+
self: Box<Self>,
150+
peer: &[u8],
151+
) -> Result<crypto::SharedSecret, rustls::Error> {
152+
let ec_kx = ($ec_member)((self.kx1, self.kx2));
153+
ec_kx.complete(peer)
154+
}
155+
}
156+
}
157+
}
158+
}
159+
160+
// Note: The EC key appears first in the SecP* groups,
161+
// but (for historical reasons) appears second in X25519MLKEM768.
162+
163+
hybrid_kex! { X25519MLKEM768, MLKEM768, X25519, X25519, second }
164+
hybrid_kex! { SecP256r1MLKEM768, SecP256R1, MLKEM768, SecP256R1, first }
165+
hybrid_kex! { SecP384r1MLKEM1024, SecP384R1, MLKEM1024, SecP384R1, first }

0 commit comments

Comments
 (0)