Skip to main content

metamorphic_crypto/
hybrid.rs

1//! Hybrid post-quantum KEM: ML-KEM-768 + X25519 (Cat-3) and ML-KEM-1024 + X25519 (Cat-5).
2//!
3//! This module implements hybrid KEMs combining ML-KEM with X25519, ensuring
4//! byte-level compatibility with existing production ciphertext.
5//!
6//! ## Security Levels
7//!
8//! | Level | ML-KEM | NIST Category | Equivalent | Version Tag |
9//! |-------|--------|---------------|------------|-------------|
10//! | Cat-3 | 768    | 3             | ~AES-192   | `0x02`      |
11//! | Cat-5 | 1024   | 5             | ~AES-256   | `0x03`      |
12//!
13//! ## Construction (from noble source)
14//!
15//! ```text
16//! combineKEMS(
17//!   seedLen = 32,
18//!   outputLen = 32,
19//!   expandSeed = SHAKE256(seed, dkLen=96 | 128),
20//!   combiner = SHA3-256(ss_mlkem || ss_x25519 || ct_x25519 || pk_x25519 || b"\\.//^\\"),
21//!   ml_kem{768|1024},
22//!   ecdhKem(x25519)
23//! )
24//! ```
25//!
26//! ## Key layout (Cat-3 / Cat-5)
27//!
28//! | Component | Cat-3 (768) | Cat-5 (1024) | Description |
29//! |-----------|-------------|--------------|-------------|
30//! | Secret key (seed) | 32 bytes | 32 bytes | Root seed expanded via SHAKE256 |
31//! | Public key | 1216 bytes | 1600 bytes | ML-KEM ek ‖ X25519 pk (32) |
32//! | Ciphertext | 1120 bytes | 1600 bytes | ML-KEM ct ‖ X25519 ephemeral pk (32) |
33//! | Shared secret | 32 bytes | 32 bytes | SHA3-256 combiner output |
34//!
35//! ## Sealed-box ciphertext format
36//!
37//! ```text
38//! v2: 0x02 || hybrid_ciphertext_768 (1120 B) || nonce (24 B) || secretbox_ct
39//! v3: 0x03 || hybrid_ciphertext_1024 (1600 B) || nonce (24 B) || secretbox_ct
40//! ```
41
42use ml_kem::{Decapsulate, MlKem768, MlKem1024};
43use ml_kem::{DecapsulationKey, EncapsulationKey, KeyExport};
44use sha3::Shake256;
45use sha3::digest::{ExtendableOutput, Update, XofReader};
46use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret};
47use zeroize::Zeroize;
48
49use crypto_secretbox::aead::Aead;
50use crypto_secretbox::aead::generic_array::GenericArray;
51use crypto_secretbox::{KeyInit, XSalsa20Poly1305};
52
53use crate::CryptoError;
54use crate::b64;
55
56// === Constants ===
57
58/// Version tag for Cat-3 hybrid ciphertext (ML-KEM-768).
59const VERSION_HYBRID_768: u8 = 0x02;
60/// Version tag for Cat-5 hybrid ciphertext (ML-KEM-1024).
61const VERSION_HYBRID_1024: u8 = 0x03;
62/// XSalsa20 nonce length.
63const NONCE_LEN: usize = 24;
64/// X25519 key size.
65const X25519_LEN: usize = 32;
66/// Root seed length.
67const SEED_LEN: usize = 32;
68/// Poly1305 MAC.
69const MAC_LEN: usize = 16;
70/// Noble's domain-separation label.
71const LABEL: &[u8] = b"\\.//^\\";
72
73// ML-KEM-768 (Cat-3)
74/// ML-KEM-768 encapsulation key size.
75const MLKEM768_EK_LEN: usize = 1184;
76/// ML-KEM-768 ciphertext size.
77const MLKEM768_CT_LEN: usize = 1088;
78/// ML-KEM-768 seed portion (64 bytes).
79const MLKEM768_SEED_LEN: usize = 64;
80/// Expanded seed for Cat-3: ML-KEM seed (64) + X25519 secret (32).
81const EXPANDED_SEED_768_LEN: usize = 96;
82/// Combined public key for Cat-3: ML-KEM ek (1184) + X25519 pk (32).
83const COMBINED_PK_768_LEN: usize = MLKEM768_EK_LEN + X25519_LEN;
84/// Combined ciphertext for Cat-3: ML-KEM ct (1088) + X25519 ephemeral pk (32).
85const COMBINED_CT_768_LEN: usize = MLKEM768_CT_LEN + X25519_LEN;
86
87// ML-KEM-1024 (Cat-5)
88/// ML-KEM-1024 encapsulation key size.
89const MLKEM1024_EK_LEN: usize = 1568;
90/// ML-KEM-1024 ciphertext size.
91const MLKEM1024_CT_LEN: usize = 1568;
92/// ML-KEM-1024 seed portion (64 bytes).
93const MLKEM1024_SEED_LEN: usize = 64;
94/// Expanded seed for Cat-5: ML-KEM seed (64) + X25519 secret (32).
95const EXPANDED_SEED_1024_LEN: usize = 96;
96/// Combined public key for Cat-5: ML-KEM ek (1568) + X25519 pk (32).
97const COMBINED_PK_1024_LEN: usize = MLKEM1024_EK_LEN + X25519_LEN;
98/// Combined ciphertext for Cat-5: ML-KEM ct (1568) + X25519 ephemeral pk (32).
99const COMBINED_CT_1024_LEN: usize = MLKEM1024_CT_LEN + X25519_LEN;
100
101// === Types ===
102
103/// A hybrid ML-KEM + X25519 keypair (base64-encoded).
104#[derive(Debug, Clone)]
105pub struct HybridKeyPair {
106    /// Combined public key: ML-KEM ek ‖ X25519 pk. Base64.
107    pub public_key: String,
108    /// Root seed (32 bytes). Base64.
109    pub secret_key: String,
110}
111
112/// Security level for hybrid PQ operations.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum SecurityLevel {
115    /// NIST Category 3: ML-KEM-768 + X25519 (~AES-192). Default.
116    #[default]
117    Cat3,
118    /// NIST Category 5: ML-KEM-1024 + X25519 (~AES-256).
119    Cat5,
120}
121
122// === Helpers ===
123
124/// Fill buffer with OS random bytes.
125#[inline]
126fn random_bytes(buf: &mut [u8]) {
127    getrandom::getrandom(buf).expect("OS CSPRNG unavailable");
128}
129
130/// Expand a 32-byte seed using SHAKE256.
131fn expand_seed(seed: &[u8; SEED_LEN], output_len: usize) -> Vec<u8> {
132    let mut hasher = Shake256::default();
133    hasher.update(seed);
134    let mut reader = hasher.finalize_xof();
135    let mut out = vec![0u8; output_len];
136    reader.read(&mut out);
137    out
138}
139
140/// SHA3-256 combiner: `SHA3-256(ss_mlkem || ss_x25519 || ct_x25519 || pk_x25519 || label)`
141fn combine(
142    ss_mlkem: &[u8],
143    ss_x25519: &[u8],
144    ct_x25519: &[u8; X25519_LEN],
145    pk_x25519: &[u8; X25519_LEN],
146) -> [u8; 32] {
147    use sha3::Digest;
148    let mut hasher = sha3::Sha3_256::new();
149    Digest::update(&mut hasher, ss_mlkem);
150    Digest::update(&mut hasher, ss_x25519);
151    Digest::update(&mut hasher, ct_x25519);
152    Digest::update(&mut hasher, pk_x25519);
153    Digest::update(&mut hasher, LABEL);
154    hasher.finalize().into()
155}
156
157/// Encrypt plaintext with a 32-byte shared secret using XSalsa20-Poly1305.
158fn secretbox_encrypt(
159    shared_secret: &[u8; 32],
160    plaintext: &[u8],
161) -> Result<(Vec<u8>, [u8; NONCE_LEN]), CryptoError> {
162    let cipher = XSalsa20Poly1305::new(GenericArray::from_slice(shared_secret));
163    let mut nonce_buf = [0u8; NONCE_LEN];
164    random_bytes(&mut nonce_buf);
165    let nonce = GenericArray::from_slice(&nonce_buf);
166    let ct = cipher
167        .encrypt(nonce, plaintext)
168        .map_err(|_| CryptoError::Hybrid("secretbox encrypt failed".into()))?;
169    Ok((ct, nonce_buf))
170}
171
172/// Decrypt ciphertext with a 32-byte shared secret using XSalsa20-Poly1305.
173fn secretbox_decrypt(
174    shared_secret: &[u8; 32],
175    nonce: &[u8],
176    ciphertext: &[u8],
177) -> Result<Vec<u8>, CryptoError> {
178    let cipher = XSalsa20Poly1305::new(GenericArray::from_slice(shared_secret));
179    let nonce = GenericArray::from_slice(nonce);
180    cipher
181        .decrypt(nonce, ciphertext)
182        .map_err(|_| CryptoError::Decryption)
183}
184
185// === Public API: Cat-3 (ML-KEM-768, default) ===
186
187/// Generate a hybrid ML-KEM-768 + X25519 keypair (Cat-3, default).
188pub fn generate_hybrid_keypair() -> HybridKeyPair {
189    generate_hybrid_keypair_with_level(SecurityLevel::Cat3)
190}
191
192/// Seal `plaintext` to a Cat-3 hybrid public key (ML-KEM-768).
193///
194/// Returns base64: `0x02 || hybrid_ct (1120 B) || nonce (24 B) || secretbox_ct`.
195pub fn hybrid_seal(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
196    hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat3)
197}
198
199/// Open a Cat-3 or Cat-5 hybrid-sealed ciphertext. Auto-detects from version tag.
200pub fn hybrid_open(ct_b64: &str, seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
201    let combined = b64::decode(ct_b64)?;
202    match combined.first() {
203        Some(&VERSION_HYBRID_768) => hybrid_open_768(&combined, seed_b64),
204        Some(&VERSION_HYBRID_1024) => hybrid_open_1024(&combined, seed_b64),
205        _ => Err(CryptoError::Hybrid(
206            "not a hybrid ciphertext (bad version tag)".into(),
207        )),
208    }
209}
210
211/// Returns `true` if the base64 blob starts with a hybrid version tag (0x02 or 0x03).
212pub fn is_hybrid_ciphertext(ct_b64: &str) -> bool {
213    b64::decode(ct_b64)
214        .map(|bytes| {
215            matches!(
216                bytes.first(),
217                Some(&VERSION_HYBRID_768) | Some(&VERSION_HYBRID_1024)
218            )
219        })
220        .unwrap_or(false)
221}
222
223// === Public API: Cat-5 (ML-KEM-1024) ===
224
225/// Generate a hybrid ML-KEM-1024 + X25519 keypair (Cat-5).
226pub fn generate_hybrid_keypair_1024() -> HybridKeyPair {
227    generate_hybrid_keypair_with_level(SecurityLevel::Cat5)
228}
229
230/// Seal `plaintext` to a Cat-5 hybrid public key (ML-KEM-1024).
231///
232/// Returns base64: `0x03 || hybrid_ct (1600 B) || nonce (24 B) || secretbox_ct`.
233pub fn hybrid_seal_1024(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
234    hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat5)
235}
236
237// === Public API: Level-parametric ===
238
239/// Generate a hybrid keypair at the specified security level.
240pub fn generate_hybrid_keypair_with_level(level: SecurityLevel) -> HybridKeyPair {
241    let mut seed = [0u8; SEED_LEN];
242    random_bytes(&mut seed);
243
244    let expanded_len = match level {
245        SecurityLevel::Cat3 => EXPANDED_SEED_768_LEN,
246        SecurityLevel::Cat5 => EXPANDED_SEED_1024_LEN,
247    };
248    let mlkem_seed_len = match level {
249        SecurityLevel::Cat3 => MLKEM768_SEED_LEN,
250        SecurityLevel::Cat5 => MLKEM1024_SEED_LEN,
251    };
252
253    let mut expanded = expand_seed(&seed, expanded_len);
254    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[mlkem_seed_len..].try_into().unwrap();
255
256    // X25519 keypair
257    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
258    let x25519_pk = X25519PublicKey::from(&x25519_sk);
259
260    let combined_pk = match level {
261        SecurityLevel::Cat3 => {
262            let mlkem_seed: [u8; MLKEM768_SEED_LEN] =
263                expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
264            let dk = DecapsulationKey::<MlKem768>::from_seed(mlkem_seed.into());
265            let ek = dk.encapsulation_key();
266            let ek_bytes = ek.to_bytes();
267            let mut pk = Vec::with_capacity(COMBINED_PK_768_LEN);
268            pk.extend_from_slice(&ek_bytes);
269            pk.extend_from_slice(x25519_pk.as_bytes());
270            pk
271        }
272        SecurityLevel::Cat5 => {
273            let mlkem_seed: [u8; MLKEM1024_SEED_LEN] =
274                expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
275            let dk = DecapsulationKey::<MlKem1024>::from_seed(mlkem_seed.into());
276            let ek = dk.encapsulation_key();
277            let ek_bytes = ek.to_bytes();
278            let mut pk = Vec::with_capacity(COMBINED_PK_1024_LEN);
279            pk.extend_from_slice(&ek_bytes);
280            pk.extend_from_slice(x25519_pk.as_bytes());
281            pk
282        }
283    };
284
285    let pair = HybridKeyPair {
286        public_key: b64::encode(&combined_pk),
287        secret_key: b64::encode(&seed),
288    };
289
290    seed.zeroize();
291    expanded.zeroize();
292    pair
293}
294
295/// Seal plaintext at the specified security level.
296pub fn hybrid_seal_with_level(
297    plaintext: &[u8],
298    combined_pk_b64: &str,
299    level: SecurityLevel,
300) -> Result<String, CryptoError> {
301    let pk_bytes = b64::decode(combined_pk_b64)?;
302
303    let (expected_pk_len, mlkem_ek_len, version_tag) = match level {
304        SecurityLevel::Cat3 => (COMBINED_PK_768_LEN, MLKEM768_EK_LEN, VERSION_HYBRID_768),
305        SecurityLevel::Cat5 => (COMBINED_PK_1024_LEN, MLKEM1024_EK_LEN, VERSION_HYBRID_1024),
306    };
307
308    if pk_bytes.len() != expected_pk_len {
309        return Err(CryptoError::InvalidLength {
310            expected: expected_pk_len,
311            got: pk_bytes.len(),
312        });
313    }
314
315    // Split combined public key
316    let mlkem_ek_bytes = &pk_bytes[..mlkem_ek_len];
317    let x25519_pk_bytes: [u8; X25519_LEN] = pk_bytes[mlkem_ek_len..].try_into().unwrap();
318
319    // ML-KEM encapsulate
320    let mut mlkem_coins = [0u8; 32];
321    random_bytes(&mut mlkem_coins);
322
323    let (mlkem_ct_bytes, ss_mlkem_bytes) = match level {
324        SecurityLevel::Cat3 => {
325            let ek = EncapsulationKey::<MlKem768>::new(
326                mlkem_ek_bytes
327                    .try_into()
328                    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ek".into()))?,
329            )
330            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 encapsulation key".into()))?;
331            let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
332            (ct.as_slice().to_vec(), ss.as_slice().to_vec())
333        }
334        SecurityLevel::Cat5 => {
335            let ek = EncapsulationKey::<MlKem1024>::new(
336                mlkem_ek_bytes
337                    .try_into()
338                    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ek".into()))?,
339            )
340            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 encapsulation key".into()))?;
341            let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
342            (ct.as_slice().to_vec(), ss.as_slice().to_vec())
343        }
344    };
345    mlkem_coins.zeroize();
346
347    // X25519 encapsulate (ephemeral DH)
348    let mut x25519_eph_bytes = [0u8; X25519_LEN];
349    random_bytes(&mut x25519_eph_bytes);
350    let x25519_eph_sk = X25519StaticSecret::from(x25519_eph_bytes);
351    let x25519_eph_pk = X25519PublicKey::from(&x25519_eph_sk);
352    let x25519_recipient_pk = X25519PublicKey::from(x25519_pk_bytes);
353    let ss_x25519 = x25519_eph_sk.diffie_hellman(&x25519_recipient_pk);
354    x25519_eph_bytes.zeroize();
355
356    // Combine shared secrets
357    let ct_x25519: [u8; X25519_LEN] = *x25519_eph_pk.as_bytes();
358    let mut shared_secret = combine(
359        &ss_mlkem_bytes,
360        ss_x25519.as_bytes(),
361        &ct_x25519,
362        &x25519_pk_bytes,
363    );
364
365    // Encrypt plaintext
366    let (secretbox_ct, nonce_buf) = secretbox_encrypt(&shared_secret, plaintext)?;
367    shared_secret.zeroize();
368
369    // Assemble: version || mlkem_ct || x25519_eph_pk || nonce || secretbox_ct
370    let combined_ct_len = mlkem_ct_bytes.len() + X25519_LEN;
371    let mut out = Vec::with_capacity(1 + combined_ct_len + NONCE_LEN + secretbox_ct.len());
372    out.push(version_tag);
373    out.extend_from_slice(&mlkem_ct_bytes);
374    out.extend_from_slice(&ct_x25519);
375    out.extend_from_slice(&nonce_buf);
376    out.extend_from_slice(&secretbox_ct);
377
378    Ok(b64::encode(&out))
379}
380
381// === Internal: Cat-3 open ===
382
383fn hybrid_open_768(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
384    let seed_bytes = b64::decode(seed_b64)?;
385    if seed_bytes.len() != SEED_LEN {
386        return Err(CryptoError::InvalidLength {
387            expected: SEED_LEN,
388            got: seed_bytes.len(),
389        });
390    }
391    if combined.len() < 1 + COMBINED_CT_768_LEN + NONCE_LEN + MAC_LEN {
392        return Err(CryptoError::TooShort);
393    }
394
395    let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
396    let mut expanded = expand_seed(&seed, EXPANDED_SEED_768_LEN);
397    let mlkem_seed: [u8; MLKEM768_SEED_LEN] = expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
398    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM768_SEED_LEN..].try_into().unwrap();
399    expanded.zeroize();
400
401    // Parse ciphertext
402    let mlkem_ct = &combined[1..1 + MLKEM768_CT_LEN];
403    let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
404        [1 + MLKEM768_CT_LEN..1 + COMBINED_CT_768_LEN]
405        .try_into()
406        .unwrap();
407    let nonce_slice = &combined[1 + COMBINED_CT_768_LEN..1 + COMBINED_CT_768_LEN + NONCE_LEN];
408    let encrypted = &combined[1 + COMBINED_CT_768_LEN + NONCE_LEN..];
409
410    // ML-KEM-768 decapsulate
411    let dk = DecapsulationKey::<MlKem768>::from_seed(mlkem_seed.into());
412    let kem_ct = mlkem_ct
413        .try_into()
414        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ciphertext".into()))?;
415    let ss_mlkem = dk.decapsulate(kem_ct);
416
417    // X25519 decapsulate
418    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
419    let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
420    let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
421
422    let x25519_pk = X25519PublicKey::from(&x25519_sk);
423    let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
424
425    let mut shared_secret = combine(
426        ss_mlkem.as_slice(),
427        ss_x25519.as_bytes(),
428        &x25519_eph_pk_bytes,
429        &pk_x25519,
430    );
431
432    let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
433    shared_secret.zeroize();
434    result
435}
436
437// === Internal: Cat-5 open ===
438
439fn hybrid_open_1024(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
440    let seed_bytes = b64::decode(seed_b64)?;
441    if seed_bytes.len() != SEED_LEN {
442        return Err(CryptoError::InvalidLength {
443            expected: SEED_LEN,
444            got: seed_bytes.len(),
445        });
446    }
447    if combined.len() < 1 + COMBINED_CT_1024_LEN + NONCE_LEN + MAC_LEN {
448        return Err(CryptoError::TooShort);
449    }
450
451    let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
452    let mut expanded = expand_seed(&seed, EXPANDED_SEED_1024_LEN);
453    let mlkem_seed: [u8; MLKEM1024_SEED_LEN] = expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
454    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM1024_SEED_LEN..].try_into().unwrap();
455    expanded.zeroize();
456
457    // Parse ciphertext
458    let mlkem_ct = &combined[1..1 + MLKEM1024_CT_LEN];
459    let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
460        [1 + MLKEM1024_CT_LEN..1 + COMBINED_CT_1024_LEN]
461        .try_into()
462        .unwrap();
463    let nonce_slice = &combined[1 + COMBINED_CT_1024_LEN..1 + COMBINED_CT_1024_LEN + NONCE_LEN];
464    let encrypted = &combined[1 + COMBINED_CT_1024_LEN + NONCE_LEN..];
465
466    // ML-KEM-1024 decapsulate
467    let dk = DecapsulationKey::<MlKem1024>::from_seed(mlkem_seed.into());
468    let kem_ct = mlkem_ct
469        .try_into()
470        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ciphertext".into()))?;
471    let ss_mlkem = dk.decapsulate(kem_ct);
472
473    // X25519 decapsulate
474    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
475    let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
476    let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
477
478    let x25519_pk = X25519PublicKey::from(&x25519_sk);
479    let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
480
481    let mut shared_secret = combine(
482        ss_mlkem.as_slice(),
483        ss_x25519.as_bytes(),
484        &x25519_eph_pk_bytes,
485        &pk_x25519,
486    );
487
488    let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
489    shared_secret.zeroize();
490    result
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    // === Cat-3 (existing behavior) ===
498
499    #[test]
500    fn cat3_roundtrip() {
501        let kp = generate_hybrid_keypair();
502        let pt = b"32-byte symmetric context key!!!";
503        let ct = hybrid_seal(pt, &kp.public_key).unwrap();
504        assert!(is_hybrid_ciphertext(&ct));
505        let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
506        assert_eq!(opened, pt);
507    }
508
509    #[test]
510    fn cat3_wrong_key_fails() {
511        let kp1 = generate_hybrid_keypair();
512        let kp2 = generate_hybrid_keypair();
513        let ct = hybrid_seal(b"x", &kp1.public_key).unwrap();
514        assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
515    }
516
517    #[test]
518    fn cat3_version_tag() {
519        let kp = generate_hybrid_keypair();
520        let raw = b64::decode(&hybrid_seal(b"x", &kp.public_key).unwrap()).unwrap();
521        assert_eq!(raw[0], VERSION_HYBRID_768);
522    }
523
524    #[test]
525    fn cat3_nondeterministic() {
526        let kp = generate_hybrid_keypair();
527        let c1 = hybrid_seal(b"x", &kp.public_key).unwrap();
528        let c2 = hybrid_seal(b"x", &kp.public_key).unwrap();
529        assert_ne!(c1, c2);
530    }
531
532    #[test]
533    fn cat3_empty_plaintext() {
534        let kp = generate_hybrid_keypair();
535        let ct = hybrid_seal(b"", &kp.public_key).unwrap();
536        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
537    }
538
539    #[test]
540    fn cat3_key_sizes() {
541        let kp = generate_hybrid_keypair();
542        let pk = b64::decode(&kp.public_key).unwrap();
543        let sk = b64::decode(&kp.secret_key).unwrap();
544        assert_eq!(pk.len(), COMBINED_PK_768_LEN); // 1216
545        assert_eq!(sk.len(), SEED_LEN); // 32
546    }
547
548    #[test]
549    fn cat3_ciphertext_size() {
550        let kp = generate_hybrid_keypair();
551        let pt = b"exactly 32 bytes of key material";
552        let raw = b64::decode(&hybrid_seal(pt, &kp.public_key).unwrap()).unwrap();
553        // 1 + 1120 + 24 + 32 + 16 = 1193
554        assert_eq!(
555            raw.len(),
556            1 + COMBINED_CT_768_LEN + NONCE_LEN + 32 + MAC_LEN
557        );
558    }
559
560    // === Cat-5 (new) ===
561
562    #[test]
563    fn cat5_roundtrip() {
564        let kp = generate_hybrid_keypair_1024();
565        let pt = b"32-byte symmetric context key!!!";
566        let ct = hybrid_seal_1024(pt, &kp.public_key).unwrap();
567        assert!(is_hybrid_ciphertext(&ct));
568        let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
569        assert_eq!(opened, pt);
570    }
571
572    #[test]
573    fn cat5_version_tag() {
574        let kp = generate_hybrid_keypair_1024();
575        let raw = b64::decode(&hybrid_seal_1024(b"x", &kp.public_key).unwrap()).unwrap();
576        assert_eq!(raw[0], VERSION_HYBRID_1024);
577    }
578
579    #[test]
580    fn cat5_wrong_key_fails() {
581        let kp1 = generate_hybrid_keypair_1024();
582        let kp2 = generate_hybrid_keypair_1024();
583        let ct = hybrid_seal_1024(b"x", &kp1.public_key).unwrap();
584        assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
585    }
586
587    #[test]
588    fn cat5_key_sizes() {
589        let kp = generate_hybrid_keypair_1024();
590        let pk = b64::decode(&kp.public_key).unwrap();
591        let sk = b64::decode(&kp.secret_key).unwrap();
592        assert_eq!(pk.len(), COMBINED_PK_1024_LEN); // 1600
593        assert_eq!(sk.len(), SEED_LEN); // 32
594    }
595
596    #[test]
597    fn cat5_ciphertext_size() {
598        let kp = generate_hybrid_keypair_1024();
599        let pt = b"exactly 32 bytes of key material";
600        let raw = b64::decode(&hybrid_seal_1024(pt, &kp.public_key).unwrap()).unwrap();
601        // 1 + 1600 + 24 + 32 + 16 = 1673
602        assert_eq!(
603            raw.len(),
604            1 + COMBINED_CT_1024_LEN + NONCE_LEN + 32 + MAC_LEN
605        );
606    }
607
608    #[test]
609    fn cat5_nondeterministic() {
610        let kp = generate_hybrid_keypair_1024();
611        let c1 = hybrid_seal_1024(b"x", &kp.public_key).unwrap();
612        let c2 = hybrid_seal_1024(b"x", &kp.public_key).unwrap();
613        assert_ne!(c1, c2);
614    }
615
616    #[test]
617    fn cat5_empty_plaintext() {
618        let kp = generate_hybrid_keypair_1024();
619        let ct = hybrid_seal_1024(b"", &kp.public_key).unwrap();
620        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
621    }
622
623    // === Cross-level ===
624
625    #[test]
626    fn cat3_ct_cannot_open_with_cat5_key() {
627        let kp3 = generate_hybrid_keypair();
628        let kp5 = generate_hybrid_keypair_1024();
629        let ct = hybrid_seal(b"test", &kp3.public_key).unwrap();
630        assert!(hybrid_open(&ct, &kp5.secret_key).is_err());
631    }
632
633    #[test]
634    fn cat5_ct_cannot_open_with_cat3_key() {
635        let kp3 = generate_hybrid_keypair();
636        let kp5 = generate_hybrid_keypair_1024();
637        let ct = hybrid_seal_1024(b"test", &kp5.public_key).unwrap();
638        assert!(hybrid_open(&ct, &kp3.secret_key).is_err());
639    }
640
641    #[test]
642    fn legacy_not_hybrid() {
643        let legacy = b64::encode(&[0x01, 0x02, 0x03]);
644        assert!(!is_hybrid_ciphertext(&legacy));
645    }
646
647    #[test]
648    fn seed_expansion_deterministic() {
649        let seed = [0x42u8; SEED_LEN];
650        let expanded = expand_seed(&seed, 96);
651        let expanded2 = expand_seed(&seed, 96);
652        assert_eq!(expanded, expanded2);
653    }
654
655    #[test]
656    fn combiner_uses_label() {
657        let ss_mlkem = [0xAAu8; 32];
658        let ss_x25519 = [0xBBu8; 32];
659        let ct_x25519 = [0xCCu8; 32];
660        let pk_x25519 = [0xDDu8; 32];
661        let result = combine(&ss_mlkem, &ss_x25519, &ct_x25519, &pk_x25519);
662        assert_eq!(result.len(), 32);
663
664        let ss_mlkem2 = [0xEEu8; 32];
665        let result2 = combine(&ss_mlkem2, &ss_x25519, &ct_x25519, &pk_x25519);
666        assert_ne!(result, result2);
667    }
668}