Skip to main content

mlkem_tls/
lib.rs

1//! # mlkem-tls
2//!
3//! `X25519MLKEM768` and `X25519MLKEM1024` hybrid post-quantum kems, per
4//! [draft-ietf-tls-ecdhe-mlkem][1]. wire-format compatible with the
5//! TLS 1.3 codepoint `0x11EC`, which Cloudflare, Chrome, Firefox and
6//! `rustls >= 0.23.27` ship today.
7//!
8//! ## hybrid construction
9//!
10//! - **classical half:** [x25519-dalek][2] (audited, constant-time).
11//! - **post-quantum half:** [mlkem-rs][3] (FIPS 203 ML-KEM in pure rust).
12//! - **combiner:** concatenation of the two shared secrets, ML-KEM first.
13//!   no kdf wrapper. matches ยง1.5 of draft-ietf-tls-ecdhe-mlkem-04.
14//!
15//! the wire byte order also matches the draft: ML-KEM bytes come first,
16//! X25519 bytes come second, both for public keys (sent client to server)
17//! and ciphertext (sent server to client).
18//!
19//! security falls back to the *stronger* of the two halves: a quantum
20//! adversary that breaks X25519 still cannot read traffic protected by
21//! the resulting key, and a classical adversary that breaks ML-KEM still
22//! cannot read traffic protected by it.
23//!
24//! ## quick start
25//!
26//! ```
27//! use mlkem_tls::X25519MlKem768;
28//! use rand::thread_rng;
29//!
30//! let mut rng = thread_rng();
31//!
32//! // bob: generate the long-term hybrid keypair, send the encaps key over the wire.
33//! let (bob_ek, bob_dk) = X25519MlKem768::keygen(&mut rng);
34//!
35//! // alice: encapsulate against bob's encaps key.
36//! let (ct, alice_ss) = X25519MlKem768::encapsulate(&bob_ek, &mut rng);
37//!
38//! // bob: decapsulate to recover the same 64-byte shared secret.
39//! let bob_ss = X25519MlKem768::decapsulate(&bob_dk, &ct);
40//! assert_eq!(alice_ss.as_bytes(), bob_ss.as_bytes());
41//! ```
42//!
43//! ## variants
44//!
45//! - [`X25519MlKem768`]: TLS codepoint `0x11EC`. encaps key 1216 B, ciphertext 1120 B,
46//!   shared secret 64 B. this is the one browsers ship.
47//! - [`X25519MlKem1024`]: non-standard symmetric variant for those who want the
48//!   higher security category. encaps key 1600 B, ciphertext 1600 B,
49//!   shared secret 64 B.
50//!
51//! ## features
52//!
53//! - `std` (default): standard-library hooks on the dependencies. disable for
54//!   `no_std` + `alloc` builds (cortex-m, wasm32).
55//!
56//! ## not audited
57//!
58//! the post-quantum half delegates to `mlkem-rs`, which is unaudited. for
59//! production cryptography, please use rustls's built-in PQ provider, which
60//! ships rustcrypto's audited `ml-kem` plus the same X25519 hybrid combiner.
61//! this crate exists for stacks that don't use rustls (custom QUIC, MLS PQ
62//! ciphersuites, HPKE PQ extensions, embedded TLS) and need the hybrid
63//! combiner as a stand-alone reusable kem.
64//!
65//! [1]: https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/
66//! [2]: https://crates.io/crates/x25519-dalek
67//! [3]: https://crates.io/crates/mlkem-rs
68
69#![cfg_attr(not(feature = "std"), no_std)]
70#![warn(clippy::all, clippy::pedantic)]
71#![warn(missing_debug_implementations)]
72#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
73
74use rand_core::{CryptoRng, RngCore};
75use subtle::ConstantTimeEq;
76use x25519_dalek::{PublicKey as XPub, StaticSecret};
77use zeroize::{Zeroize, ZeroizeOnDrop};
78
79/// length of the X25519 public key in bytes.
80pub const X25519_BYTES: usize = 32;
81
82/// length of the X25519 shared secret in bytes.
83pub const X25519_SS_BYTES: usize = 32;
84
85/// length of the ML-KEM portion of the shared secret in bytes.
86pub const MLKEM_SS_BYTES: usize = 32;
87
88/// total hybrid shared-secret length: ML-KEM ss (32) || X25519 ss (32).
89pub const SHARED_SECRET_BYTES: usize = MLKEM_SS_BYTES + X25519_SS_BYTES;
90
91/// returned when bytes handed to `try_from` have the wrong length.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub struct LengthError {
94    pub expected: usize,
95    pub got: usize,
96}
97
98impl core::fmt::Display for LengthError {
99    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
100        write!(
101            f,
102            "wrong byte length: expected {}, got {}",
103            self.expected, self.got
104        )
105    }
106}
107
108#[cfg(feature = "std")]
109impl std::error::Error for LengthError {}
110
111// internal helper: build an x25519 secret from 32 random bytes.
112fn x25519_keypair_from_seed(seed: [u8; 32]) -> (StaticSecret, XPub) {
113    let sk = StaticSecret::from(seed);
114    let pk = XPub::from(&sk);
115    (sk, pk)
116}
117
118// internal helper: hand a CryptoRng's bytes to x25519 and the rest to ml-kem.
119fn fill_seed_pair<R: RngCore + CryptoRng>(rng: &mut R) -> ([u8; 32], [u8; 64]) {
120    let mut x = [0u8; 32];
121    let mut m = [0u8; 64];
122    rng.fill_bytes(&mut x);
123    rng.fill_bytes(&mut m);
124    (x, m)
125}
126
127// per the draft, ML-KEM-768 secret-key serialized form already includes ek + h(ek) + z;
128// for the hybrid we additionally store the X25519 secret alongside.
129
130/// macro instantiating one hybrid level. `$pq` is the pure-rust ml-kem entry
131/// point, `$pq_pk`/`$pq_sk`/`$pq_ct` are its byte sizes.
132macro_rules! hybrid_kem {
133    ($name:ident, $pq:ident, $pq_pk_ty:ident, $pq_sk_ty:ident, $pq_ct_ty:ident,
134     $ek_ty:ident, $dk_ty:ident, $ct_ty:ident, $ss_ty:ident,
135     $pq_pk:expr, $pq_sk:expr, $pq_ct:expr,
136     $ek_size:expr, $dk_size:expr, $ct_size:expr) => {
137        #[derive(Debug)]
138        pub struct $name;
139
140        impl $name {
141            /// encaps-key size on the wire (ML-KEM ek then X25519 pub).
142            pub const ENCAPSULATION_KEY_SIZE: usize = $ek_size;
143            /// decaps-key opaque size (ML-KEM dk then X25519 secret).
144            pub const DECAPSULATION_KEY_SIZE: usize = $dk_size;
145            /// hybrid ciphertext size on the wire (ML-KEM ct then X25519 pub).
146            pub const CIPHERTEXT_SIZE: usize = $ct_size;
147            /// 64-byte hybrid shared secret (ML-KEM ss || X25519 ss).
148            pub const SHARED_SECRET_SIZE: usize = SHARED_SECRET_BYTES;
149
150            pub fn keygen<R: RngCore + CryptoRng>(rng: &mut R) -> ($ek_ty, $dk_ty) {
151                let (x_seed, m_seed) = fill_seed_pair(rng);
152                let (xsk, xpk) = x25519_keypair_from_seed(x_seed);
153                let (mpk, msk) = mlkem::$pq::keygen_deterministic(&m_seed);
154
155                let mut ek = [0u8; $ek_size];
156                ek[..$pq_pk].copy_from_slice(mpk.as_bytes());
157                ek[$pq_pk..].copy_from_slice(xpk.as_bytes());
158
159                let mut dk = [0u8; $dk_size];
160                dk[..$pq_sk].copy_from_slice(msk.as_bytes());
161                dk[$pq_sk..].copy_from_slice(&xsk.to_bytes());
162
163                ($ek_ty(ek), $dk_ty(dk))
164            }
165
166            pub fn encapsulate<R: RngCore + CryptoRng>(
167                ek: &$ek_ty,
168                rng: &mut R,
169            ) -> ($ct_ty, $ss_ty) {
170                let mpk_bytes: &[u8; $pq_pk] =
171                    (&ek.0[..$pq_pk]).try_into().expect("ek length checked");
172                let xpk_bytes: &[u8; X25519_BYTES] =
173                    (&ek.0[$pq_pk..]).try_into().expect("ek length checked");
174                let mpk = mlkem::$pq_pk_ty::from_bytes(mpk_bytes);
175                let xpk = XPub::from(*xpk_bytes);
176
177                // ml-kem encapsulate
178                let (mct, mss) = mlkem::$pq::encapsulate(&mpk, rng);
179
180                // ephemeral x25519 keypair, agree with the responder's public key.
181                let mut x_seed = [0u8; 32];
182                rng.fill_bytes(&mut x_seed);
183                let xsk = ReusableSecretWrapper::from(x_seed);
184                let xpk_eph = XPub::from(&xsk.0);
185                let xss = xsk.0.diffie_hellman(&xpk);
186
187                let mut ct = [0u8; $ct_size];
188                ct[..$pq_ct].copy_from_slice(mct.as_bytes());
189                ct[$pq_ct..].copy_from_slice(xpk_eph.as_bytes());
190
191                let mut ss = [0u8; SHARED_SECRET_BYTES];
192                ss[..MLKEM_SS_BYTES].copy_from_slice(mss.as_bytes());
193                ss[MLKEM_SS_BYTES..].copy_from_slice(xss.as_bytes());
194
195                ($ct_ty(ct), $ss_ty(ss))
196            }
197
198            pub fn decapsulate(dk: &$dk_ty, ct: &$ct_ty) -> $ss_ty {
199                let msk_bytes: &[u8; $pq_sk] =
200                    (&dk.0[..$pq_sk]).try_into().expect("dk length checked");
201                let xsk_bytes: &[u8; X25519_BYTES] =
202                    (&dk.0[$pq_sk..]).try_into().expect("dk length checked");
203                let msk = mlkem::$pq_sk_ty::from_bytes(msk_bytes);
204                let xsk = StaticSecret::from(*xsk_bytes);
205
206                let mct_bytes: &[u8; $pq_ct] =
207                    (&ct.0[..$pq_ct]).try_into().expect("ct length checked");
208                let xpk_bytes: &[u8; X25519_BYTES] =
209                    (&ct.0[$pq_ct..]).try_into().expect("ct length checked");
210                let mct = mlkem::$pq_ct_ty::from_bytes(mct_bytes);
211                let xpk = XPub::from(*xpk_bytes);
212
213                let mss = mlkem::$pq::decapsulate(&msk, &mct);
214                let xss = xsk.diffie_hellman(&xpk);
215
216                let mut ss = [0u8; SHARED_SECRET_BYTES];
217                ss[..MLKEM_SS_BYTES].copy_from_slice(mss.as_bytes());
218                ss[MLKEM_SS_BYTES..].copy_from_slice(xss.as_bytes());
219                $ss_ty(ss)
220            }
221        }
222
223        #[derive(Clone)]
224        pub struct $ek_ty(pub(crate) [u8; $ek_size]);
225        #[derive(Clone, ZeroizeOnDrop)]
226        pub struct $dk_ty(pub(crate) [u8; $dk_size]);
227        #[derive(Clone)]
228        pub struct $ct_ty(pub(crate) [u8; $ct_size]);
229        #[derive(Clone, ZeroizeOnDrop)]
230        pub struct $ss_ty(pub(crate) [u8; SHARED_SECRET_BYTES]);
231
232        impl $ek_ty {
233            pub fn as_bytes(&self) -> &[u8; $ek_size] {
234                &self.0
235            }
236            pub fn from_bytes(b: &[u8; $ek_size]) -> Self {
237                Self(*b)
238            }
239        }
240        impl $dk_ty {
241            pub fn as_bytes(&self) -> &[u8; $dk_size] {
242                &self.0
243            }
244            pub fn from_bytes(b: &[u8; $dk_size]) -> Self {
245                Self(*b)
246            }
247        }
248        impl $ct_ty {
249            pub fn as_bytes(&self) -> &[u8; $ct_size] {
250                &self.0
251            }
252            pub fn from_bytes(b: &[u8; $ct_size]) -> Self {
253                Self(*b)
254            }
255        }
256        impl $ss_ty {
257            pub fn as_bytes(&self) -> &[u8; SHARED_SECRET_BYTES] {
258                &self.0
259            }
260        }
261
262        impl AsRef<[u8]> for $ek_ty {
263            fn as_ref(&self) -> &[u8] {
264                &self.0
265            }
266        }
267        impl AsRef<[u8]> for $ct_ty {
268            fn as_ref(&self) -> &[u8] {
269                &self.0
270            }
271        }
272        impl AsRef<[u8]> for $ss_ty {
273            fn as_ref(&self) -> &[u8] {
274                &self.0
275            }
276        }
277        impl AsRef<[u8]> for $dk_ty {
278            fn as_ref(&self) -> &[u8] {
279                &self.0
280            }
281        }
282
283        impl TryFrom<&[u8]> for $ek_ty {
284            type Error = LengthError;
285            fn try_from(b: &[u8]) -> Result<Self, LengthError> {
286                if b.len() != $ek_size {
287                    return Err(LengthError {
288                        expected: $ek_size,
289                        got: b.len(),
290                    });
291                }
292                let mut a = [0u8; $ek_size];
293                a.copy_from_slice(b);
294                Ok(Self(a))
295            }
296        }
297        impl TryFrom<&[u8]> for $ct_ty {
298            type Error = LengthError;
299            fn try_from(b: &[u8]) -> Result<Self, LengthError> {
300                if b.len() != $ct_size {
301                    return Err(LengthError {
302                        expected: $ct_size,
303                        got: b.len(),
304                    });
305                }
306                let mut a = [0u8; $ct_size];
307                a.copy_from_slice(b);
308                Ok(Self(a))
309            }
310        }
311        impl TryFrom<&[u8]> for $dk_ty {
312            type Error = LengthError;
313            fn try_from(b: &[u8]) -> Result<Self, LengthError> {
314                if b.len() != $dk_size {
315                    return Err(LengthError {
316                        expected: $dk_size,
317                        got: b.len(),
318                    });
319                }
320                let mut a = [0u8; $dk_size];
321                a.copy_from_slice(b);
322                Ok(Self(a))
323            }
324        }
325
326        impl PartialEq for $ek_ty {
327            fn eq(&self, other: &Self) -> bool {
328                self.0.ct_eq(&other.0).into()
329            }
330        }
331        impl Eq for $ek_ty {}
332        impl PartialEq for $ct_ty {
333            fn eq(&self, other: &Self) -> bool {
334                self.0.ct_eq(&other.0).into()
335            }
336        }
337        impl Eq for $ct_ty {}
338        impl PartialEq for $ss_ty {
339            fn eq(&self, other: &Self) -> bool {
340                self.0.ct_eq(&other.0).into()
341            }
342        }
343        impl Eq for $ss_ty {}
344        impl PartialEq for $dk_ty {
345            fn eq(&self, other: &Self) -> bool {
346                self.0.as_slice().ct_eq(other.0.as_slice()).into()
347            }
348        }
349        impl Eq for $dk_ty {}
350
351        impl core::fmt::Debug for $ek_ty {
352            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
353                write!(
354                    f,
355                    concat!(stringify!($ek_ty), "(..{} bytes..)"),
356                    self.0.len()
357                )
358            }
359        }
360        impl core::fmt::Debug for $dk_ty {
361            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
362                write!(f, concat!(stringify!($dk_ty), "(..REDACTED..)"))
363            }
364        }
365        impl core::fmt::Debug for $ct_ty {
366            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
367                write!(
368                    f,
369                    concat!(stringify!($ct_ty), "(..{} bytes..)"),
370                    self.0.len()
371                )
372            }
373        }
374        impl core::fmt::Debug for $ss_ty {
375            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
376                write!(f, concat!(stringify!($ss_ty), "(..REDACTED..)"))
377            }
378        }
379
380        impl Zeroize for $dk_ty {
381            fn zeroize(&mut self) {
382                self.0.zeroize();
383            }
384        }
385        impl Zeroize for $ss_ty {
386            fn zeroize(&mut self) {
387                self.0.zeroize();
388            }
389        }
390    };
391}
392
393// thin newtype around x25519-dalek's StaticSecret so we can construct it from
394// a fixed seed inside the macro context. (the upstream constructor takes the
395// raw 32-byte seed via `From<[u8; 32]>`.)
396struct ReusableSecretWrapper(StaticSecret);
397impl From<[u8; 32]> for ReusableSecretWrapper {
398    fn from(b: [u8; 32]) -> Self {
399        Self(StaticSecret::from(b))
400    }
401}
402
403// ml-kem-768: pq pk 1184, pq sk 2400, pq ct 1088
404// hybrid ek = 1184 + 32 = 1216
405// hybrid dk = 2400 + 32 = 2432
406// hybrid ct = 1088 + 32 = 1120
407hybrid_kem!(
408    X25519MlKem768,
409    MlKem768,
410    PublicKey768,
411    SecretKey768,
412    Ciphertext768,
413    EncapsKey768,
414    DecapsKey768,
415    Ciphertext768Hybrid,
416    SharedSecret768Hybrid,
417    1184,
418    2400,
419    1088,
420    1216,
421    2432,
422    1120
423);
424
425// ml-kem-1024: pq pk 1568, pq sk 3168, pq ct 1568
426// hybrid ek = 1568 + 32 = 1600
427// hybrid dk = 3168 + 32 = 3200
428// hybrid ct = 1568 + 32 = 1600
429hybrid_kem!(
430    X25519MlKem1024,
431    MlKem1024,
432    PublicKey1024,
433    SecretKey1024,
434    Ciphertext1024,
435    EncapsKey1024,
436    DecapsKey1024,
437    Ciphertext1024Hybrid,
438    SharedSecret1024Hybrid,
439    1568,
440    3168,
441    1568,
442    1600,
443    3200,
444    1600
445);