Skip to main content

ling_crypto/
hybrid.rs

1//! Hybrid post-quantum key encapsulation: **X25519 + ML-KEM-768**.
2//!
3//! This is the real-world "2030 migration" primitive. During the transition to
4//! post-quantum cryptography nobody fully trusts a brand-new lattice scheme on
5//! its own, and nobody wants to keep relying solely on X25519 once large quantum
6//! computers exist. The answer the whole industry converged on (TLS 1.3's
7//! `X25519MLKEM768`, Signal's PQXDH, the IETF X-Wing draft) is a **hybrid**: run
8//! both KEMs and combine their shared secrets so the result is secure as long as
9//! **at least one** of the two is unbroken.
10//!
11//! - X25519 protects against *classical* attackers today (battle-tested ECDH).
12//! - ML-KEM-768 protects against a future *quantum* attacker who has recorded
13//!   today's traffic ("harvest now, decrypt later").
14//!
15//! ## Construction (X-Wing-inspired)
16//! ```text
17//! public key  = X25519_pk ‖ MLKEM_ek                      (32 + 1184 = 1216 B)
18//! ciphertext  = X25519_eph_pk ‖ MLKEM_ct                  (32 + 1088 = 1120 B)
19//! shared key  = SHA3-256(LABEL ‖ ss_mlkem ‖ ss_x25519 ‖ eph_pk ‖ recipient_pk)
20//! ```
21//! The combiner **hashes both shared secrets together** (not XOR) and binds the
22//! two X25519 public values, which gives IND-CCA2 security under the standard
23//! hybrid KEM combiner assumption.
24
25use crate::pq::{self, MlKem768Keypair};
26use rand::rngs::OsRng;
27use sha3::{Digest, Sha3_256};
28use x25519_dalek::{PublicKey, StaticSecret};
29use zeroize::Zeroize;
30
31const COMBINER_LABEL: &[u8] = b"ling-hybrid-x25519-mlkem768-v1";
32
33const X25519_LEN: usize = 32;
34/// Hybrid public key length: X25519 pk + ML-KEM-768 ek.
35pub const PUBLIC_KEY_LEN: usize = X25519_LEN + pq::ENCAPS_KEY_LEN; // 1216
36/// Hybrid ciphertext length: X25519 ephemeral pk + ML-KEM-768 ct.
37pub const CIPHERTEXT_LEN: usize = X25519_LEN + pq::CIPHERTEXT_LEN; // 1120
38/// Established shared-secret length.
39pub const SHARED_SECRET_LEN: usize = 32;
40
41/// Mix two component shared secrets and the bound transcript into one key.
42fn combine(ss_mlkem: &[u8], ss_x25519: &[u8], eph_pk: &[u8], recipient_pk: &[u8]) -> [u8; 32] {
43    let mut h = Sha3_256::new();
44    h.update(COMBINER_LABEL);
45    h.update(ss_mlkem);
46    h.update(ss_x25519);
47    h.update(eph_pk);
48    h.update(recipient_pk);
49    h.finalize().into()
50}
51
52/// A hybrid keypair holding both an X25519 static secret and an ML-KEM keypair.
53pub struct HybridKeypair {
54    x25519_secret: StaticSecret,
55    x25519_public: [u8; X25519_LEN],
56    mlkem: MlKem768Keypair,
57}
58
59impl HybridKeypair {
60    /// Generate a fresh hybrid keypair from the system CSPRNG.
61    pub fn generate() -> Self {
62        let x25519_secret = StaticSecret::random_from_rng(OsRng);
63        let x25519_public = PublicKey::from(&x25519_secret).to_bytes();
64        Self {
65            x25519_secret,
66            x25519_public,
67            mlkem: MlKem768Keypair::generate(),
68        }
69    }
70
71    /// The hybrid public key to publish: `X25519_pk ‖ MLKEM_ek` (1216 bytes).
72    pub fn public_key(&self) -> Vec<u8> {
73        let mut out = Vec::with_capacity(PUBLIC_KEY_LEN);
74        out.extend_from_slice(&self.x25519_public);
75        out.extend_from_slice(&self.mlkem.encapsulation_key());
76        out
77    }
78
79    /// Decapsulate a hybrid ciphertext to recover the shared secret.
80    pub fn decapsulate(&self, ciphertext: &[u8]) -> Result<[u8; SHARED_SECRET_LEN], &'static str> {
81        if ciphertext.len() != CIPHERTEXT_LEN {
82            return Err("hybrid ciphertext wrong length");
83        }
84        let (eph_pk_bytes, ct_pq) = ciphertext.split_at(X25519_LEN);
85        let mut eph_arr = [0u8; X25519_LEN];
86        eph_arr.copy_from_slice(eph_pk_bytes);
87
88        // X25519 leg: DH between our static secret and the sender's ephemeral pk.
89        let eph_pk = PublicKey::from(eph_arr);
90        let mut ss_x = self.x25519_secret.diffie_hellman(&eph_pk).to_bytes();
91
92        // ML-KEM leg.
93        let ss_pq = self.mlkem.decapsulate(ct_pq)?;
94
95        let out = combine(&ss_pq, &ss_x, eph_pk_bytes, &self.x25519_public);
96        ss_x.zeroize();
97        Ok(out)
98    }
99}
100
101/// Encapsulate to a peer's hybrid public key.
102///
103/// Returns `(ciphertext, shared_secret)`. Send the ciphertext to the peer; both
104/// sides then share `shared_secret`.
105pub fn encapsulate(
106    hybrid_public_key: &[u8],
107) -> Result<(Vec<u8>, [u8; SHARED_SECRET_LEN]), &'static str> {
108    if hybrid_public_key.len() != PUBLIC_KEY_LEN {
109        return Err("hybrid public key wrong length");
110    }
111    let (x_pk_bytes, mlkem_ek) = hybrid_public_key.split_at(X25519_LEN);
112    let mut x_pk_arr = [0u8; X25519_LEN];
113    x_pk_arr.copy_from_slice(x_pk_bytes);
114
115    // X25519 leg: ephemeral DH against the recipient's static X25519 key.
116    let eph_secret = StaticSecret::random_from_rng(OsRng);
117    let eph_public = PublicKey::from(&eph_secret).to_bytes();
118    let mut ss_x = eph_secret
119        .diffie_hellman(&PublicKey::from(x_pk_arr))
120        .to_bytes();
121
122    // ML-KEM leg.
123    let (ct_pq, ss_pq) = pq::encapsulate(mlkem_ek)?;
124
125    let shared = combine(&ss_pq, &ss_x, &eph_public, x_pk_bytes);
126    ss_x.zeroize();
127
128    let mut ciphertext = Vec::with_capacity(CIPHERTEXT_LEN);
129    ciphertext.extend_from_slice(&eph_public);
130    ciphertext.extend_from_slice(&ct_pq);
131    Ok((ciphertext, shared))
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn round_trip() {
140        let kp = HybridKeypair::generate();
141        let pk = kp.public_key();
142        assert_eq!(pk.len(), PUBLIC_KEY_LEN);
143
144        let (ct, ss_send) = encapsulate(&pk).expect("encapsulate");
145        assert_eq!(ct.len(), CIPHERTEXT_LEN);
146
147        let ss_recv = kp.decapsulate(&ct).expect("decapsulate");
148        assert_eq!(ss_send, ss_recv, "hybrid shared secrets agree");
149    }
150
151    #[test]
152    fn distinct_encapsulations_differ() {
153        let kp = HybridKeypair::generate();
154        let pk = kp.public_key();
155        let (_, a) = encapsulate(&pk).unwrap();
156        let (_, b) = encapsulate(&pk).unwrap();
157        assert_ne!(a, b, "fresh randomness yields distinct shared secrets");
158    }
159
160    #[test]
161    fn tampered_ciphertext_changes_secret() {
162        // Hybrid binds the transcript: flipping the ML-KEM ciphertext (implicit
163        // rejection) yields a different secret on the recipient side, so the two
164        // sides no longer agree.
165        let kp = HybridKeypair::generate();
166        let pk = kp.public_key();
167        let (mut ct, ss_send) = encapsulate(&pk).unwrap();
168        let last = ct.len() - 1;
169        ct[last] ^= 0xFF;
170        let ss_recv = kp.decapsulate(&ct).unwrap();
171        assert_ne!(ss_send, ss_recv);
172    }
173
174    #[test]
175    fn wrong_lengths_rejected() {
176        let kp = HybridKeypair::generate();
177        assert!(encapsulate(&[0u8; 10]).is_err());
178        assert!(kp.decapsulate(&[0u8; 10]).is_err());
179    }
180}