use ml_kem::kem::{Decapsulate, Encapsulate, Kem};
use ml_kem::{DecapsulationKey, EncapsulationKey, KeyExport, MlKem768, TryKeyInit};
use rustls::crypto::{ActiveKeyExchange, CompletedKeyExchange, CryptoProvider, SharedSecret, SupportedKxGroup};
use rustls::ffdhe_groups::FfdheGroup;
use rustls::{Error, NamedGroup, PeerMisbehaved, ProtocolVersion};
use zeroize::Zeroize;
pub const MLKEM768_ENCAP_LEN: usize = 1184;
pub const MLKEM768_CIPHERTEXT_LEN: usize = 1088;
pub const X25519_LEN: usize = 32;
const SECRET_LEN: usize = 32;
pub static X25519MLKEM768: &dyn SupportedKxGroup = &X25519MlKem768;
pub fn provider() -> CryptoProvider {
let mut p = rustls::crypto::ring::default_provider();
p.kx_groups.insert(0, X25519MLKEM768);
p
}
fn classical() -> &'static dyn SupportedKxGroup {
rustls::crypto::ring::kx_group::X25519
}
fn invalid() -> Error {
Error::PeerMisbehaved(PeerMisbehaved::InvalidKeyShare)
}
fn joined(mut pq: [u8; SECRET_LEN], classical: &[u8]) -> SharedSecret {
let mut secret = Vec::with_capacity(SECRET_LEN + classical.len());
secret.extend_from_slice(&pq);
secret.extend_from_slice(classical);
pq.zeroize();
SharedSecret::from(secret)
}
#[derive(Debug)]
struct X25519MlKem768;
impl SupportedKxGroup for X25519MlKem768 {
fn start(&self) -> Result<Box<dyn ActiveKeyExchange>, Error> {
let classical = classical().start()?;
let (dk, ek) = MlKem768::generate_keypair();
let mut pub_key = Vec::with_capacity(MLKEM768_ENCAP_LEN + X25519_LEN);
pub_key.extend_from_slice(&ek.to_bytes());
pub_key.extend_from_slice(classical.pub_key());
Ok(Box::new(Active { dk, classical, pub_key }))
}
fn start_and_complete(&self, client_share: &[u8]) -> Result<CompletedKeyExchange, Error> {
if client_share.len() != MLKEM768_ENCAP_LEN + X25519_LEN {
return Err(invalid());
}
let (pq_share, classical_share) = client_share.split_at(MLKEM768_ENCAP_LEN);
let ek = EncapsulationKey::<MlKem768>::new_from_slice(pq_share).map_err(|_| invalid())?;
let cl = classical().start_and_complete(classical_share)?;
let (ct, ss) = ek.encapsulate();
let mut pub_key = Vec::with_capacity(MLKEM768_CIPHERTEXT_LEN + X25519_LEN);
pub_key.extend_from_slice(&ct);
pub_key.extend_from_slice(&cl.pub_key);
Ok(CompletedKeyExchange { group: NamedGroup::X25519MLKEM768, pub_key, secret: joined(ss.into(), cl.secret.secret_bytes()) })
}
fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
None
}
fn name(&self) -> NamedGroup {
NamedGroup::X25519MLKEM768
}
fn fips(&self) -> bool {
false
}
fn usable_for_version(&self, version: ProtocolVersion) -> bool {
version == ProtocolVersion::TLSv1_3
}
}
struct Active {
dk: DecapsulationKey<MlKem768>,
classical: Box<dyn ActiveKeyExchange>,
pub_key: Vec<u8>,
}
impl ActiveKeyExchange for Active {
fn complete(self: Box<Self>, server_share: &[u8]) -> Result<SharedSecret, Error> {
if server_share.len() != MLKEM768_CIPHERTEXT_LEN + X25519_LEN {
return Err(invalid());
}
let (ct, classical_share) = server_share.split_at(MLKEM768_CIPHERTEXT_LEN);
let ss = self.dk.decapsulate_slice(ct).map_err(|_| invalid())?;
let cl = self.classical.complete(classical_share)?;
Ok(joined(ss.into(), cl.secret_bytes()))
}
fn hybrid_component(&self) -> Option<(NamedGroup, &[u8])> {
Some((self.classical.group(), self.classical.pub_key()))
}
fn complete_hybrid_component(self: Box<Self>, peer_pub_key: &[u8]) -> Result<SharedSecret, Error> {
self.classical.complete(peer_pub_key)
}
fn pub_key(&self) -> &[u8] {
&self.pub_key
}
fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
None
}
fn group(&self) -> NamedGroup {
NamedGroup::X25519MLKEM768
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mtls::InstallerCa;
use rustls::crypto::SupportedKxGroup;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use rustls::{NamedGroup, ProtocolVersion};
use std::io::{Read, Write};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::sync::Arc;
use std::time::Duration;
const H: Duration = Duration::from_secs(3600);
fn the_group() -> &'static dyn SupportedKxGroup {
*provider().kx_groups.iter().find(|g| g.name() == NamedGroup::X25519MLKEM768).expect("the provider offers X25519MLKEM768")
}
fn server_cert() -> (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>, String) {
let ca = InstallerCa::mint("pq.test", H).unwrap();
let door = ca.issue_door(Ipv4Addr::LOCALHOST, H).unwrap();
let certs = CertificateDer::pem_slice_iter(door.cert_pem.as_bytes()).collect::<Result<Vec<_>, _>>().unwrap();
(certs, PrivateKeyDer::from_pem_slice(door.key_pem.as_bytes()).unwrap(), ca.cert_pem)
}
fn server_config(p: CryptoProvider) -> (Arc<rustls::ServerConfig>, String) {
let (certs, key, ca) = server_cert();
let cfg = rustls::ServerConfig::builder_with_provider(Arc::new(p)).with_safe_default_protocol_versions().unwrap().with_no_client_auth().with_single_cert(certs, key).unwrap();
(Arc::new(cfg), ca)
}
fn client_config(p: CryptoProvider, ca_pem: &str) -> Arc<rustls::ClientConfig> {
let mut roots = rustls::RootCertStore::empty();
for c in CertificateDer::pem_slice_iter(ca_pem.as_bytes()) {
roots.add(c.unwrap()).unwrap();
}
Arc::new(rustls::ClientConfig::builder_with_provider(Arc::new(p)).with_safe_default_protocol_versions().unwrap().with_root_certificates(roots).with_no_client_auth())
}
fn handshake(server: CryptoProvider, client: CryptoProvider) -> Result<(NamedGroup, NamedGroup), String> {
let (scfg, ca) = server_config(server);
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap();
let t = std::thread::spawn(move || -> Result<NamedGroup, String> {
let (s, _) = l.accept().map_err(|e| e.to_string())?;
let mut tls = rustls::StreamOwned::new(rustls::ServerConnection::new(scfg).unwrap(), s);
let mut b = [0u8; 4];
tls.read_exact(&mut b).map_err(|e| format!("server read: {e}"))?;
tls.write_all(b"pong").and_then(|_| tls.flush()).map_err(|e| format!("server write: {e}"))?;
tls.conn.negotiated_key_exchange_group().map(|g| g.name()).ok_or_else(|| "no group".to_string())
});
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let conn = rustls::ClientConnection::new(client_config(client, &ca), ServerName::IpAddress(Ipv4Addr::LOCALHOST.into())).unwrap();
let mut tls = rustls::StreamOwned::new(conn, s);
let sent = tls.write_all(b"ping").and_then(|_| tls.flush());
let mut b = [0u8; 4];
let read = tls.read_exact(&mut b);
let client_group = tls.conn.negotiated_key_exchange_group().map(|g| g.name());
drop(tls);
let server_group = t.join().unwrap();
match (sent, read, server_group, client_group) {
(Ok(()), Ok(()), Ok(s), Some(c)) if &b == b"pong" => Ok((s, c)),
(s, r, sg, cg) => Err(format!("sent {s:?} read {r:?} server {sg:?} client {cg:?}")),
}
}
fn only(groups: &[&'static dyn SupportedKxGroup]) -> CryptoProvider {
CryptoProvider { kx_groups: groups.to_vec(), ..rustls::crypto::ring::default_provider() }
}
#[test]
fn the_provider_offers_x25519mlkem768_first_and_keeps_every_classical_group_after_it() {
let names: Vec<NamedGroup> = provider().kx_groups.iter().map(|g| g.name()).collect();
assert_eq!(names.first(), Some(&NamedGroup::X25519MLKEM768), "{names:?}");
assert_eq!(&names[1..], &[NamedGroup::X25519, NamedGroup::secp256r1, NamedGroup::secp384r1], "the classical groups stay, in ring's order");
let g = the_group();
assert!(g.usable_for_version(ProtocolVersion::TLSv1_3) && !g.usable_for_version(ProtocolVersion::TLSv1_2), "TLS 1.3 only");
assert_eq!(u16::from(g.name()), 0x11EC, "the IANA codepoint");
}
#[test]
fn two_ends_on_the_provider_negotiate_x25519mlkem768() {
assert_eq!(handshake(provider(), provider()), Ok((NamedGroup::X25519MLKEM768, NamedGroup::X25519MLKEM768)));
}
#[test]
fn a_client_offering_only_x25519mlkem768_is_answered() {
assert_eq!(handshake(provider(), only(&[the_group()])), Ok((NamedGroup::X25519MLKEM768, NamedGroup::X25519MLKEM768)));
}
#[test]
fn an_older_client_offering_only_classical_groups_still_connects_on_x25519() {
assert_eq!(handshake(provider(), rustls::crypto::ring::default_provider()), Ok((NamedGroup::X25519, NamedGroup::X25519)));
}
#[test]
fn a_post_quantum_client_still_reaches_an_older_server_on_x25519() {
assert_eq!(handshake(rustls::crypto::ring::default_provider(), provider()), Ok((NamedGroup::X25519, NamedGroup::X25519)));
}
#[test]
fn malformed_shares_are_refused_as_invalid_key_shares() {
let g = the_group();
let invalid = rustls::Error::PeerMisbehaved(rustls::PeerMisbehaved::InvalidKeyShare);
assert_eq!(g.start_and_complete(&[0u8; 32]).err(), Some(invalid.clone()), "an X25519 share alone");
assert_eq!(g.start_and_complete(&[0u8; MLKEM768_ENCAP_LEN + X25519_LEN + 1]).err(), Some(invalid.clone()), "one byte too many");
let mut bad = vec![0xFFu8; MLKEM768_ENCAP_LEN + X25519_LEN];
bad[MLKEM768_ENCAP_LEN..].copy_from_slice(&[9u8; X25519_LEN]);
assert_eq!(g.start_and_complete(&bad).err(), Some(invalid.clone()), "fails the modulus check");
let client = g.start().unwrap();
assert_eq!(client.pub_key().len(), MLKEM768_ENCAP_LEN + X25519_LEN);
assert_eq!(client.complete(&[0u8; MLKEM768_CIPHERTEXT_LEN]).err(), Some(invalid), "a ciphertext with no X25519 half");
}
#[test]
fn the_secret_is_the_ml_kem_secret_then_the_x25519_secret() {
let g = the_group();
let client = g.start().unwrap();
assert_eq!(client.hybrid_component().map(|(n, k)| (n, k.len())), Some((NamedGroup::X25519, X25519_LEN)));
assert_eq!(&client.pub_key()[MLKEM768_ENCAP_LEN..], client.hybrid_component().unwrap().1, "the X25519 half is LAST in the share");
let server = g.start_and_complete(client.pub_key()).unwrap();
assert_eq!(server.group, NamedGroup::X25519MLKEM768);
assert_eq!(server.pub_key.len(), MLKEM768_CIPHERTEXT_LEN + X25519_LEN);
let theirs = server.secret.secret_bytes().to_vec();
let ours = client.complete(&server.pub_key).unwrap();
assert_eq!(ours.secret_bytes().len(), 64);
assert_eq!(ours.secret_bytes(), &theirs[..]);
}
#[test]
fn openssl_negotiates_x25519mlkem768_with_us_both_ways() {
if !openssl_has_the_group() {
assert!(std::env::var("PQ_INTEROP_REQUIRED").as_deref() != Ok("1"), "PQ_INTEROP_REQUIRED=1 and no openssl ≥ 3.5 with X25519MLKEM768 on PATH");
eprintln!("SKIP: no openssl with X25519MLKEM768 on PATH");
return;
}
let (scfg, _) = server_config(provider());
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let t = std::thread::spawn(move || {
let (s, _) = l.accept().unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut tls = rustls::StreamOwned::new(rustls::ServerConnection::new(scfg).unwrap(), s);
let mut b = [0u8; 1];
let _ = tls.read(&mut b);
tls.conn.negotiated_key_exchange_group().map(|g| g.name())
});
let out = std::process::Command::new("openssl")
.args(["s_client", "-connect", &format!("127.0.0.1:{port}"), "-groups", "X25519MLKEM768", "-brief"])
.stdin(std::process::Stdio::piped())
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string() + &String::from_utf8_lossy(&out.stderr);
assert!(text.contains("Negotiated TLS1.3 group: X25519MLKEM768"), "openssl s_client said:\n{text}");
assert_eq!(t.join().unwrap(), Some(NamedGroup::X25519MLKEM768), "our server's own view");
let dir = std::env::temp_dir().join(format!("pqtls-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let ca = InstallerCa::mint("pq.test", H).unwrap();
let door = ca.issue_door(Ipv4Addr::LOCALHOST, H).unwrap();
std::fs::write(dir.join("c.pem"), &door.cert_pem).unwrap();
std::fs::write(dir.join("k.pem"), &door.key_pem).unwrap();
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let mut server = std::process::Command::new("openssl")
.args(["s_server", "-accept", &format!("127.0.0.1:{port}"), "-groups", "X25519MLKEM768", "-naccept", "1", "-www", "-cert"])
.arg(dir.join("c.pem"))
.arg("-key")
.arg(dir.join("k.pem"))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let mut s = None;
for _ in 0..100 {
if let Ok(c) = TcpStream::connect(("127.0.0.1", port)) {
s = Some(c);
break;
}
std::thread::sleep(Duration::from_millis(50));
}
let s = s.expect("openssl s_server listens");
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let conn = rustls::ClientConnection::new(client_config(provider(), &ca.cert_pem), ServerName::IpAddress(Ipv4Addr::LOCALHOST.into())).unwrap();
let mut tls = rustls::StreamOwned::new(conn, s);
let sent = tls.write_all(b"GET / HTTP/1.0\r\n\r\n").and_then(|_| tls.flush());
let mut page = Vec::new();
let _ = tls.read_to_end(&mut page);
let group = tls.conn.negotiated_key_exchange_group().map(|g| g.name());
let _ = server.kill();
let _ = server.wait();
let _ = std::fs::remove_dir_all(&dir);
assert!(sent.is_ok(), "{sent:?}");
assert_eq!(group, Some(NamedGroup::X25519MLKEM768));
assert!(String::from_utf8_lossy(&page).contains("X25519MLKEM768"), "s_server's -www page names the group it shared");
}
fn openssl_has_the_group() -> bool {
std::process::Command::new("openssl")
.args(["list", "-tls-groups"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).split(':').any(|g| g.trim() == "X25519MLKEM768"))
.unwrap_or(false)
}
}