Skip to main content

huddle_protocol/crypto/
pqc.rs

1//! huddle 1.3: post-quantum hybrid key-agreement primitives (ML-KEM-768).
2//!
3//! This module is the quantum-resistant half of huddle's **hybrid** DM key
4//! agreement. It wraps the FIPS 203 ML-KEM-768 KEM (RustCrypto `ml-kem`,
5//! pure Rust) and a transcript-binding HKDF **combiner** that mixes a
6//! classical X25519 shared secret with the ML-KEM shared secret. The result
7//! is at least as strong as the *stronger* of the two:
8//!
9//!   * a future quantum computer that breaks X25519 (via Shor) still cannot
10//!     recover the key without also breaking ML-KEM, and
11//!   * a (hypothetical) cryptanalytic break of ML-KEM still leaves the
12//!     classical X25519 secret protecting the key.
13//!
14//! That is exactly the "harvest-now, decrypt-later" defence: an adversary who
15//! records DM ciphertext today and builds a quantum computer tomorrow gains
16//! nothing, because the X25519 secret they can eventually recover is only one
17//! of the two HKDF inputs.
18//!
19//! ## Design choices specific to huddle's *non-interactive, static* DM model
20//!
21//! huddle's classical DM key (`crypto::dm::derive_dm_key`) is non-interactive
22//! and reproducible: both peers derive the same `[u8; 32]` from long-term keys
23//! with no stored per-DM state. ML-KEM is a KEM (directional: one side
24//! encapsulates, the other decapsulates), so a hybrid path cannot be perfectly
25//! symmetric — a ciphertext must travel from the initiator to the responder.
26//! We keep everything else stateless by being *deterministic*:
27//!
28//!   * The ML-KEM keypair is **derived from the peer's long-term Ed25519
29//!     identity seed** (`PqKeypair::from_identity_seed`, HKDF domain-
30//!     separated), so it needs no extra storage and is stable across restarts.
31//!     The public encapsulation key is still *published* to peers — they
32//!     cannot compute it from the Ed25519 *public* key alone.
33//!   * Encapsulation is **deterministic** (`encapsulate_deterministic`),
34//!     seeded by a message `m` that the caller derives from the *initiator's*
35//!     Ed25519 secret (see `crypto::dm`). This lets the initiator reproduce
36//!     the exact ciphertext + shared secret with no per-DM secret state, while
37//!     staying PQ-secure: `m` is unknown to anyone without the initiator's
38//!     seed, so a Shor attacker who recovers the X25519 secret *still* cannot
39//!     reconstruct the ML-KEM shared secret (that needs either `m`, which is
40//!     seed-derived, or the responder's decapsulation key).
41//!
42//! See `crypto::dm::derive_dm_key_hybrid_initiator` / `_responder` for the
43//! protocol wiring.
44
45use hkdf::Hkdf;
46use ml_kem::array::Array;
47use ml_kem::{Decapsulate, DecapsulationKey, EncapsulationKey, KeyExport, MlKem768};
48use sha2::Sha256;
49use zeroize::Zeroizing;
50
51use crate::error::{ProtocolError, Result};
52
53/// Serialized length of an ML-KEM-768 encapsulation (public) key.
54pub const MLKEM_EK_LEN: usize = 1184;
55/// Serialized length of an ML-KEM-768 ciphertext.
56pub const MLKEM_CT_LEN: usize = 1088;
57/// Shared-secret length (ML-KEM, X25519, and our combined output all 32B).
58pub const SS_LEN: usize = 32;
59
60/// HKDF label for expanding an identity's Ed25519 seed into ML-KEM seed bytes.
61const MLKEM_SEED_LABEL: &[u8] = b"huddle-mlkem-768-seed-v1";
62/// HKDF salt for the hybrid combiner.
63const HYBRID_COMBINE_SALT: &[u8] = b"huddle-hybrid-kem-v1";
64
65/// A deterministically-derived ML-KEM-768 keypair bound to a huddle identity.
66///
67/// The decapsulation (secret) key never leaves the owner; the encapsulation
68/// (public) key is published so peers can encapsulate a DM key to it. The
69/// inner `DecapsulationKey` zeroizes its secret material on drop (ml-kem's
70/// `zeroize` feature).
71pub struct PqKeypair {
72    dk: DecapsulationKey<MlKem768>,
73}
74
75impl PqKeypair {
76    /// Derive the identity's ML-KEM-768 keypair from its 32-byte Ed25519
77    /// secret seed. Deterministic and domain-separated, so the same identity
78    /// always yields the same keypair with **zero** extra storage.
79    ///
80    /// The ML-KEM seed is `HKDF-SHA256(seed; salt = MLKEM_SEED_LABEL)` expanded
81    /// to 64 bytes (ML-KEM's `d || z`), making the post-quantum key material
82    /// cryptographically independent of both the Ed25519 signing key and the
83    /// X25519 DM scalar (which use different derivations / labels).
84    pub fn from_identity_seed(ed25519_seed: &[u8; 32]) -> Self {
85        let mut seed64 = Zeroizing::new([0u8; 64]);
86        let hk = Hkdf::<Sha256>::new(Some(MLKEM_SEED_LABEL), ed25519_seed);
87        hk.expand(b"", seed64.as_mut_slice())
88            .expect("HKDF expand to 64 bytes is within SHA-256's output limit");
89        // Build ML-KEM's 64-byte Seed array, then derive the keypair. The
90        // transient `seed` array is a short-lived stack copy; the long-lived
91        // secret lives in `dk`, which zeroizes on drop.
92        let seed: ml_kem::Seed =
93            Array::try_from(seed64.as_slice()).expect("ML-KEM seed is exactly 64 bytes");
94        let dk = DecapsulationKey::<MlKem768>::from_seed(seed);
95        Self { dk }
96    }
97
98    /// The serialized encapsulation (public) key, to publish to peers.
99    pub fn encapsulation_key_bytes(&self) -> [u8; MLKEM_EK_LEN] {
100        let encoded = self.dk.encapsulation_key().to_bytes();
101        let mut out = [0u8; MLKEM_EK_LEN];
102        out.copy_from_slice(&encoded);
103        out
104    }
105
106    /// Decapsulate a ciphertext a peer produced by encapsulating to our
107    /// encapsulation key, recovering the shared ML-KEM secret.
108    ///
109    /// ML-KEM decapsulation is infallible by construction (FIPS 203 implicit
110    /// rejection: a malformed/forged ciphertext yields a pseudo-random secret
111    /// rather than an error), so the only error path here is a wrong-length
112    /// ciphertext.
113    pub fn decapsulate(&self, ciphertext: &[u8]) -> Result<Zeroizing<[u8; SS_LEN]>> {
114        if ciphertext.len() != MLKEM_CT_LEN {
115            return Err(ProtocolError::Session(format!(
116                "ML-KEM ciphertext is {} bytes, expected {MLKEM_CT_LEN}",
117                ciphertext.len()
118            )));
119        }
120        let ct = Array::try_from(ciphertext)
121            .map_err(|_| ProtocolError::Session("ML-KEM ciphertext decode failed".into()))?;
122        let ss = self.dk.decapsulate(&ct);
123        let mut out = Zeroizing::new([0u8; SS_LEN]);
124        out.copy_from_slice(&ss);
125        Ok(out)
126    }
127}
128
129/// Encapsulate to a peer's ML-KEM-768 encapsulation key using a caller-supplied
130/// **deterministic** 32-byte message `m`. Returns `(ciphertext, shared_secret)`.
131///
132/// `m` MUST be uniformly-distributed and secret. In huddle it is an HKDF output
133/// keyed by the initiator's long-term Ed25519 seed (`crypto::dm`). The
134/// determinism is intentional — it lets the initiator reproduce the exact DM
135/// key with no per-DM secret state — and safe, because `m`'s secrecy rests on
136/// the initiator's stored seed, not on the (quantum-breakable) X25519 secret.
137pub fn encapsulate_deterministic(
138    partner_ek_bytes: &[u8],
139    m: &[u8; SS_LEN],
140) -> Result<(Vec<u8>, Zeroizing<[u8; SS_LEN]>)> {
141    if partner_ek_bytes.len() != MLKEM_EK_LEN {
142        return Err(ProtocolError::Session(format!(
143            "ML-KEM encapsulation key is {} bytes, expected {MLKEM_EK_LEN}",
144            partner_ek_bytes.len()
145        )));
146    }
147    let ek_arr = Array::try_from(partner_ek_bytes)
148        .map_err(|_| ProtocolError::Session("ML-KEM encapsulation key decode failed".into()))?;
149    let ek = EncapsulationKey::<MlKem768>::new(&ek_arr)
150        .map_err(|_| ProtocolError::Session("invalid ML-KEM encapsulation key".into()))?;
151    let m_arr: ml_kem::B32 =
152        Array::try_from(&m[..]).expect("encapsulation message is exactly 32 bytes");
153    let (ct, ss) = ek.encapsulate_deterministic(&m_arr);
154    let mut ss_out = Zeroizing::new([0u8; SS_LEN]);
155    ss_out.copy_from_slice(&ss);
156    Ok((ct.to_vec(), ss_out))
157}
158
159/// Combine a classical X25519 shared secret and an ML-KEM shared secret into a
160/// single 32-byte hybrid key, binding the KEM ciphertext and a context label
161/// into the transcript.
162///
163/// Construction:
164/// ```text
165/// HKDF-SHA256(
166///     salt = "huddle-hybrid-kem-v1",
167///     IKM  = ss_x25519 || ss_mlkem,
168///     info = ct || context )
169/// ```
170///
171/// This is a standard concatenation KEM-combiner: feeding both secrets as IKM
172/// means the output is a secure key as long as *either* secret is secure (HKDF
173/// behaves as a PRF keyed by the full IKM). Binding the ciphertext `ct` into
174/// `info` ties the key to this exact exchange, so an attacker cannot pair our
175/// shared secret with a different ciphertext. The result is never weaker than
176/// the classical-only key: even if ML-KEM contributed nothing, `ss_x25519` is
177/// still mixed in under the same HKDF.
178pub fn combine_hybrid(
179    ss_x25519: &[u8; SS_LEN],
180    ss_mlkem: &[u8; SS_LEN],
181    kem_ciphertext: &[u8],
182    context: &[u8],
183) -> Zeroizing<[u8; SS_LEN]> {
184    let mut ikm = Zeroizing::new([0u8; 2 * SS_LEN]);
185    ikm[..SS_LEN].copy_from_slice(ss_x25519);
186    ikm[SS_LEN..].copy_from_slice(ss_mlkem);
187
188    let mut info = Vec::with_capacity(kem_ciphertext.len() + context.len());
189    info.extend_from_slice(kem_ciphertext);
190    info.extend_from_slice(context);
191
192    let hk = Hkdf::<Sha256>::new(Some(HYBRID_COMBINE_SALT), ikm.as_slice());
193    let mut out = Zeroizing::new([0u8; SS_LEN]);
194    hk.expand(&info, out.as_mut_slice())
195        .expect("HKDF expand to 32 bytes is within SHA-256's output limit");
196    out
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn seed(n: u8) -> [u8; 32] {
204        [n; 32]
205    }
206
207    #[test]
208    fn keypair_is_deterministic_from_seed() {
209        let a = PqKeypair::from_identity_seed(&seed(7));
210        let b = PqKeypair::from_identity_seed(&seed(7));
211        assert_eq!(
212            a.encapsulation_key_bytes(),
213            b.encapsulation_key_bytes(),
214            "same identity seed must yield the same ML-KEM public key"
215        );
216    }
217
218    #[test]
219    fn different_seeds_yield_different_keys() {
220        let a = PqKeypair::from_identity_seed(&seed(1));
221        let b = PqKeypair::from_identity_seed(&seed(2));
222        assert_ne!(a.encapsulation_key_bytes(), b.encapsulation_key_bytes());
223    }
224
225    #[test]
226    fn ek_has_expected_size() {
227        let kp = PqKeypair::from_identity_seed(&seed(9));
228        assert_eq!(kp.encapsulation_key_bytes().len(), MLKEM_EK_LEN);
229    }
230
231    #[test]
232    fn encapsulate_decapsulate_round_trip() {
233        let responder = PqKeypair::from_identity_seed(&seed(42));
234        let ek = responder.encapsulation_key_bytes();
235        let m = [3u8; SS_LEN];
236
237        let (ct, ss_send) = encapsulate_deterministic(&ek, &m).unwrap();
238        assert_eq!(ct.len(), MLKEM_CT_LEN);
239
240        let ss_recv = responder.decapsulate(&ct).unwrap();
241        assert_eq!(
242            *ss_send, *ss_recv,
243            "encapsulator and decapsulator must agree"
244        );
245    }
246
247    #[test]
248    fn deterministic_encapsulation_reproduces() {
249        let responder = PqKeypair::from_identity_seed(&seed(11));
250        let ek = responder.encapsulation_key_bytes();
251        let m = [5u8; SS_LEN];
252        let (ct1, ss1) = encapsulate_deterministic(&ek, &m).unwrap();
253        let (ct2, ss2) = encapsulate_deterministic(&ek, &m).unwrap();
254        assert_eq!(ct1, ct2, "same m + ek must reproduce the same ciphertext");
255        assert_eq!(*ss1, *ss2, "same m + ek must reproduce the same secret");
256    }
257
258    #[test]
259    fn different_m_yields_different_ciphertext_and_secret() {
260        let responder = PqKeypair::from_identity_seed(&seed(11));
261        let ek = responder.encapsulation_key_bytes();
262        let (ct_a, ss_a) = encapsulate_deterministic(&ek, &[1u8; SS_LEN]).unwrap();
263        let (ct_b, ss_b) = encapsulate_deterministic(&ek, &[2u8; SS_LEN]).unwrap();
264        assert_ne!(ct_a, ct_b);
265        assert_ne!(*ss_a, *ss_b);
266    }
267
268    #[test]
269    fn tampered_ciphertext_does_not_recover_secret() {
270        // ML-KEM implicit rejection: a flipped ciphertext bit decapsulates to
271        // a *different* (pseudo-random) secret rather than erroring. The point
272        // is that the attacker cannot force agreement on the real secret.
273        let responder = PqKeypair::from_identity_seed(&seed(99));
274        let ek = responder.encapsulation_key_bytes();
275        let (mut ct, ss_send) = encapsulate_deterministic(&ek, &[8u8; SS_LEN]).unwrap();
276        ct[0] ^= 0x01;
277        let ss_recv = responder.decapsulate(&ct).unwrap();
278        assert_ne!(
279            *ss_send, *ss_recv,
280            "a tampered ciphertext must not recover the encapsulated secret"
281        );
282    }
283
284    #[test]
285    fn wrong_ek_length_is_rejected() {
286        let err = encapsulate_deterministic(&[0u8; 10], &[0u8; SS_LEN]);
287        assert!(err.is_err());
288    }
289
290    #[test]
291    fn wrong_ct_length_is_rejected() {
292        let kp = PqKeypair::from_identity_seed(&seed(1));
293        assert!(kp.decapsulate(&[0u8; 10]).is_err());
294    }
295
296    #[test]
297    fn combiner_is_deterministic_and_input_sensitive() {
298        let ss_x = [1u8; SS_LEN];
299        let ss_pq = [2u8; SS_LEN];
300        let ct = vec![3u8; MLKEM_CT_LEN];
301        let ctx = b"room-1";
302
303        let k = *combine_hybrid(&ss_x, &ss_pq, &ct, ctx);
304        let k_again = *combine_hybrid(&ss_x, &ss_pq, &ct, ctx);
305        assert_eq!(k, k_again, "combiner must be deterministic");
306
307        // Sensitive to each input.
308        assert_ne!(k, *combine_hybrid(&[9u8; SS_LEN], &ss_pq, &ct, ctx));
309        assert_ne!(k, *combine_hybrid(&ss_x, &[9u8; SS_LEN], &ct, ctx));
310        let mut ct2 = ct.clone();
311        ct2[0] ^= 0xFF;
312        assert_ne!(k, *combine_hybrid(&ss_x, &ss_pq, &ct2, ctx));
313        assert_ne!(k, *combine_hybrid(&ss_x, &ss_pq, &ct, b"room-2"));
314    }
315
316    #[test]
317    fn combiner_differs_from_either_raw_secret() {
318        let ss_x = [4u8; SS_LEN];
319        let ss_pq = [5u8; SS_LEN];
320        let ct = vec![6u8; MLKEM_CT_LEN];
321        let k = *combine_hybrid(&ss_x, &ss_pq, &ct, b"ctx");
322        assert_ne!(k, ss_x, "hybrid key must not equal the raw X25519 secret");
323        assert_ne!(k, ss_pq, "hybrid key must not equal the raw ML-KEM secret");
324    }
325
326    #[test]
327    fn full_two_party_hybrid_agreement() {
328        // End-to-end: initiator encapsulates to responder's identity-derived
329        // ek; both run the combiner over the SAME (ss_x, ss_pq, ct) and agree.
330        let responder = PqKeypair::from_identity_seed(&seed(21));
331        let ek = responder.encapsulation_key_bytes();
332        let ss_x = [7u8; SS_LEN]; // stand-in for the X25519 half (tested in dm.rs)
333        let m = [13u8; SS_LEN];
334
335        let (ct, ss_pq_send) = encapsulate_deterministic(&ek, &m).unwrap();
336        let key_initiator = *combine_hybrid(&ss_x, &ss_pq_send, &ct, b"dm-room");
337
338        let ss_pq_recv = responder.decapsulate(&ct).unwrap();
339        let key_responder = *combine_hybrid(&ss_x, &ss_pq_recv, &ct, b"dm-room");
340
341        assert_eq!(key_initiator, key_responder);
342    }
343}