Skip to main content

metamorphic_crypto/
hybrid.rs

1//! Hybrid post-quantum KEM: ML-KEM-512 + X25519 (Cat-1), ML-KEM-768 + X25519
2//! (Cat-3, default), and ML-KEM-1024 + X25519 (Cat-5).
3//!
4//! This module implements hybrid KEMs combining ML-KEM with X25519, ensuring
5//! byte-level compatibility with existing production ciphertext. The three tiers
6//! span the **full standardized ML-KEM range** — NIST (FIPS 203) defines ML-KEM
7//! only at categories 1, 3, and 5 (512 / 768 / 1024). There is **no** category-2
8//! or category-4 ML-KEM parameter set, so none is offered here.
9//!
10//! ## Security Levels
11//!
12//! | Level | ML-KEM | NIST Category | Equivalent | Version Tag |
13//! |-------|--------|---------------|------------|-------------|
14//! | Cat-1 | 512    | 1             | ~AES-128   | `0x01`      |
15//! | Cat-3 | 768    | 3             | ~AES-192   | `0x02`      |
16//! | Cat-5 | 1024   | 5             | ~AES-256   | `0x03`      |
17//!
18//! ### About the version tags
19//!
20//! The version tag is a **per-artifact-type wire-format version**, *not* a
21//! global NIST-category code. A KEM tag only ever appears as the first byte of a
22//! hybrid-KEM ciphertext produced by this module and is parsed only by
23//! [`hybrid_open`] / [`is_hybrid_ciphertext`]; it is never handed to the
24//! signature code. The KEM tags form a dense ordered sequence — Cat-1 = `0x01`,
25//! Cat-3 = `0x02`, Cat-5 = `0x03`.
26//!
27//! By design these tags **agree with the signature tags in [`crate::sign`] on
28//! every level the two families share**: Cat-3 = `0x02` and Cat-5 = `0x03` in
29//! both. The single divergence is at `0x01`, which here denotes Cat-1
30//! (ML-KEM-512) while on the signature side `0x01` denotes Cat-2 (ML-DSA-44).
31//! This is unavoidable: NIST standardizes ML-KEM at categories {1, 3, 5} but
32//! ML-DSA at {2, 3, 5}, so the two families have different lowest rungs and
33//! "tag == category" cannot hold for both.
34//!
35//! These bytes are **not legacy sentinels**, either. The pre-PQ `box_seal`
36//! ciphertext format is *unversioned* — its output is `ephemeral_pk (32) ||
37//! box_ct`, so its first byte is a random X25519 public-key byte, not a reserved
38//! tag — so there is no `0x00`/`0x01` legacy marker anywhere for these values to
39//! clash with. Both [`is_hybrid_ciphertext`] and [`hybrid_open`] additionally
40//! enforce a minimum-length gate per tier, so a legacy ciphertext whose random
41//! first byte happens to be `0x01` cannot be mis-routed as a Cat-1 hybrid
42//! ciphertext; [`crate::seal::unseal_from_user`] also falls back to the legacy
43//! opener if a hybrid open fails, closing the residual length-collision case.
44//!
45//! ## Construction (from noble source)
46//!
47//! ```text
48//! combineKEMS(
49//!   seedLen = 32,
50//!   outputLen = 32,
51//!   expandSeed = SHAKE256(seed, dkLen=96),
52//!   combiner = SHA3-256(ss_mlkem || ss_x25519 || ct_x25519 || pk_x25519 || b"\\.//^\\"),
53//!   ml_kem{512|768|1024},
54//!   ecdhKem(x25519)
55//! )
56//! ```
57//!
58//! ## Key layout (Cat-1 / Cat-3 / Cat-5)
59//!
60//! | Component | Cat-1 (512) | Cat-3 (768) | Cat-5 (1024) | Description |
61//! |-----------|-------------|-------------|--------------|-------------|
62//! | Secret key (seed) | 32 bytes | 32 bytes | 32 bytes | Root seed expanded via SHAKE256 |
63//! | Public key | 832 bytes | 1216 bytes | 1600 bytes | ML-KEM ek ‖ X25519 pk (32) |
64//! | Ciphertext | 800 bytes | 1120 bytes | 1600 bytes | ML-KEM ct ‖ X25519 ephemeral pk (32) |
65//! | Shared secret | 32 bytes | 32 bytes | 32 bytes | SHA3-256 combiner output |
66//!
67//! ## Classical partner caveat
68//!
69//! The classical half is **X25519 (~Cat-1 classical) at every tier** — it does
70//! not scale up with the ML-KEM parameter set. At Cat-3 and Cat-5 the
71//! post-quantum half dominates and X25519 is the classical floor; this is
72//! standard hybrid-KEM practice (the hybrid is at least as strong as its
73//! strongest component, and a break requires defeating *both* halves). If you
74//! need a higher classical margin, that is a separate (currently non-standard
75//! for X25519) concern.
76//!
77//! ## Sealed-box ciphertext format
78//!
79//! ```text
80//! v1: 0x01 || hybrid_ciphertext_512  (800 B)  || nonce (24 B) || secretbox_ct
81//! v2: 0x02 || hybrid_ciphertext_768  (1120 B) || nonce (24 B) || secretbox_ct
82//! v3: 0x03 || hybrid_ciphertext_1024 (1600 B) || nonce (24 B) || secretbox_ct
83//! ```
84
85use ml_kem::{Decapsulate, MlKem512, MlKem768, MlKem1024};
86use ml_kem::{DecapsulationKey, EncapsulationKey, KeyExport};
87use sha3::Shake256;
88use sha3::digest::{ExtendableOutput, Update, XofReader};
89use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret};
90use zeroize::Zeroize;
91
92use crypto_secretbox::aead::Aead;
93use crypto_secretbox::aead::generic_array::GenericArray;
94use crypto_secretbox::{KeyInit, XSalsa20Poly1305};
95
96use crate::CryptoError;
97use crate::b64;
98
99// === Constants ===
100
101/// Version tag for Cat-1 hybrid ciphertext (ML-KEM-512).
102const VERSION_HYBRID_512: u8 = 0x01;
103/// Version tag for Cat-3 hybrid ciphertext (ML-KEM-768).
104const VERSION_HYBRID_768: u8 = 0x02;
105/// Version tag for Cat-5 hybrid ciphertext (ML-KEM-1024).
106const VERSION_HYBRID_1024: u8 = 0x03;
107/// XSalsa20 nonce length.
108const NONCE_LEN: usize = 24;
109/// X25519 key size.
110const X25519_LEN: usize = 32;
111/// Root seed length.
112const SEED_LEN: usize = 32;
113/// Poly1305 MAC.
114const MAC_LEN: usize = 16;
115/// Noble's domain-separation label.
116const LABEL: &[u8] = b"\\.//^\\";
117
118// ML-KEM-512 (Cat-1)
119/// ML-KEM-512 encapsulation key size.
120const MLKEM512_EK_LEN: usize = 800;
121/// ML-KEM-512 ciphertext size.
122const MLKEM512_CT_LEN: usize = 768;
123/// ML-KEM-512 seed portion (64 bytes).
124const MLKEM512_SEED_LEN: usize = 64;
125/// Expanded seed for Cat-1: ML-KEM seed (64) + X25519 secret (32).
126const EXPANDED_SEED_512_LEN: usize = 96;
127/// Combined public key for Cat-1: ML-KEM ek (800) + X25519 pk (32).
128const COMBINED_PK_512_LEN: usize = MLKEM512_EK_LEN + X25519_LEN;
129/// Combined ciphertext for Cat-1: ML-KEM ct (768) + X25519 ephemeral pk (32).
130const COMBINED_CT_512_LEN: usize = MLKEM512_CT_LEN + X25519_LEN;
131/// Minimum sealed-box length for a Cat-1 hybrid ciphertext (empty plaintext:
132/// version tag + combined ct + nonce + Poly1305 MAC). Single source of truth
133/// shared by the routing check ([`is_hybrid_ciphertext`]) and the open gate
134/// ([`hybrid_open_512`]); keeping them in lockstep is what prevents a legacy
135/// ciphertext from being mis-routed.
136const MIN_HYBRID_512_LEN: usize = 1 + COMBINED_CT_512_LEN + NONCE_LEN + MAC_LEN;
137
138// ML-KEM-768 (Cat-3)
139/// ML-KEM-768 encapsulation key size.
140const MLKEM768_EK_LEN: usize = 1184;
141/// ML-KEM-768 ciphertext size.
142const MLKEM768_CT_LEN: usize = 1088;
143/// ML-KEM-768 seed portion (64 bytes).
144const MLKEM768_SEED_LEN: usize = 64;
145/// Expanded seed for Cat-3: ML-KEM seed (64) + X25519 secret (32).
146const EXPANDED_SEED_768_LEN: usize = 96;
147/// Combined public key for Cat-3: ML-KEM ek (1184) + X25519 pk (32).
148const COMBINED_PK_768_LEN: usize = MLKEM768_EK_LEN + X25519_LEN;
149/// Combined ciphertext for Cat-3: ML-KEM ct (1088) + X25519 ephemeral pk (32).
150const COMBINED_CT_768_LEN: usize = MLKEM768_CT_LEN + X25519_LEN;
151/// Minimum sealed-box length for a Cat-3 hybrid ciphertext (empty plaintext).
152/// Shared by [`is_hybrid_ciphertext`] and [`hybrid_open_768`].
153const MIN_HYBRID_768_LEN: usize = 1 + COMBINED_CT_768_LEN + NONCE_LEN + MAC_LEN;
154
155// ML-KEM-1024 (Cat-5)
156/// ML-KEM-1024 encapsulation key size.
157const MLKEM1024_EK_LEN: usize = 1568;
158/// ML-KEM-1024 ciphertext size.
159const MLKEM1024_CT_LEN: usize = 1568;
160/// ML-KEM-1024 seed portion (64 bytes).
161const MLKEM1024_SEED_LEN: usize = 64;
162/// Expanded seed for Cat-5: ML-KEM seed (64) + X25519 secret (32).
163const EXPANDED_SEED_1024_LEN: usize = 96;
164/// Combined public key for Cat-5: ML-KEM ek (1568) + X25519 pk (32).
165const COMBINED_PK_1024_LEN: usize = MLKEM1024_EK_LEN + X25519_LEN;
166/// Combined ciphertext for Cat-5: ML-KEM ct (1568) + X25519 ephemeral pk (32).
167const COMBINED_CT_1024_LEN: usize = MLKEM1024_CT_LEN + X25519_LEN;
168/// Minimum sealed-box length for a Cat-5 hybrid ciphertext (empty plaintext).
169/// Shared by [`is_hybrid_ciphertext`] and [`hybrid_open_1024`].
170const MIN_HYBRID_1024_LEN: usize = 1 + COMBINED_CT_1024_LEN + NONCE_LEN + MAC_LEN;
171
172// === Types ===
173
174/// A hybrid ML-KEM + X25519 keypair (base64-encoded).
175#[derive(Debug, Clone)]
176pub struct HybridKeyPair {
177    /// Combined public key: ML-KEM ek ‖ X25519 pk. Base64.
178    pub public_key: String,
179    /// Root seed (32 bytes). Base64.
180    pub secret_key: String,
181}
182
183/// Security level for hybrid PQ operations.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
185pub enum SecurityLevel {
186    /// NIST Category 1: ML-KEM-512 + X25519 (~AES-128).
187    Cat1,
188    /// NIST Category 3: ML-KEM-768 + X25519 (~AES-192). Default.
189    #[default]
190    Cat3,
191    /// NIST Category 5: ML-KEM-1024 + X25519 (~AES-256).
192    Cat5,
193}
194
195// === Helpers ===
196
197/// Fill buffer with OS random bytes.
198#[inline]
199fn random_bytes(buf: &mut [u8]) {
200    getrandom::getrandom(buf).expect("OS CSPRNG unavailable");
201}
202
203/// Expand a 32-byte seed using SHAKE256.
204fn expand_seed(seed: &[u8; SEED_LEN], output_len: usize) -> Vec<u8> {
205    let mut hasher = Shake256::default();
206    hasher.update(seed);
207    let mut reader = hasher.finalize_xof();
208    let mut out = vec![0u8; output_len];
209    reader.read(&mut out);
210    out
211}
212
213/// SHA3-256 combiner: `SHA3-256(ss_mlkem || ss_x25519 || ct_x25519 || pk_x25519 || label)`
214fn combine(
215    ss_mlkem: &[u8],
216    ss_x25519: &[u8],
217    ct_x25519: &[u8; X25519_LEN],
218    pk_x25519: &[u8; X25519_LEN],
219) -> [u8; 32] {
220    use sha3::Digest;
221    let mut hasher = sha3::Sha3_256::new();
222    Digest::update(&mut hasher, ss_mlkem);
223    Digest::update(&mut hasher, ss_x25519);
224    Digest::update(&mut hasher, ct_x25519);
225    Digest::update(&mut hasher, pk_x25519);
226    Digest::update(&mut hasher, LABEL);
227    hasher.finalize().into()
228}
229
230/// Encrypt plaintext with a 32-byte shared secret using XSalsa20-Poly1305.
231fn secretbox_encrypt(
232    shared_secret: &[u8; 32],
233    plaintext: &[u8],
234) -> Result<(Vec<u8>, [u8; NONCE_LEN]), CryptoError> {
235    let cipher = XSalsa20Poly1305::new(GenericArray::from_slice(shared_secret));
236    let mut nonce_buf = [0u8; NONCE_LEN];
237    random_bytes(&mut nonce_buf);
238    let nonce = GenericArray::from_slice(&nonce_buf);
239    let ct = cipher
240        .encrypt(nonce, plaintext)
241        .map_err(|_| CryptoError::Hybrid("secretbox encrypt failed".into()))?;
242    Ok((ct, nonce_buf))
243}
244
245/// Decrypt ciphertext with a 32-byte shared secret using XSalsa20-Poly1305.
246fn secretbox_decrypt(
247    shared_secret: &[u8; 32],
248    nonce: &[u8],
249    ciphertext: &[u8],
250) -> Result<Vec<u8>, CryptoError> {
251    let cipher = XSalsa20Poly1305::new(GenericArray::from_slice(shared_secret));
252    let nonce = GenericArray::from_slice(nonce);
253    cipher
254        .decrypt(nonce, ciphertext)
255        .map_err(|_| CryptoError::Decryption)
256}
257
258// === Public API: Cat-1 (ML-KEM-512) ===
259
260/// Generate a hybrid ML-KEM-512 + X25519 keypair (Cat-1).
261pub fn generate_hybrid_keypair_512() -> HybridKeyPair {
262    generate_hybrid_keypair_with_level(SecurityLevel::Cat1)
263}
264
265/// Seal `plaintext` to a Cat-1 hybrid public key (ML-KEM-512).
266///
267/// Returns base64: `0x01 || hybrid_ct (800 B) || nonce (24 B) || secretbox_ct`.
268pub fn hybrid_seal_512(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
269    hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat1)
270}
271
272// === Public API: Cat-3 (ML-KEM-768, default) ===
273
274/// Generate a hybrid ML-KEM-768 + X25519 keypair (Cat-3, default).
275pub fn generate_hybrid_keypair() -> HybridKeyPair {
276    generate_hybrid_keypair_with_level(SecurityLevel::Cat3)
277}
278
279/// Seal `plaintext` to a Cat-3 hybrid public key (ML-KEM-768).
280///
281/// Returns base64: `0x02 || hybrid_ct (1120 B) || nonce (24 B) || secretbox_ct`.
282pub fn hybrid_seal(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
283    hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat3)
284}
285
286/// Open a Cat-1, Cat-3, or Cat-5 hybrid-sealed ciphertext. Auto-detects from version tag.
287pub fn hybrid_open(ct_b64: &str, seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
288    let combined = b64::decode(ct_b64)?;
289    match combined.first() {
290        Some(&VERSION_HYBRID_512) => hybrid_open_512(&combined, seed_b64),
291        Some(&VERSION_HYBRID_768) => hybrid_open_768(&combined, seed_b64),
292        Some(&VERSION_HYBRID_1024) => hybrid_open_1024(&combined, seed_b64),
293        _ => Err(CryptoError::Hybrid(
294            "not a hybrid ciphertext (bad version tag)".into(),
295        )),
296    }
297}
298
299/// Returns `true` if the base64 blob is a hybrid ciphertext: its first byte is a
300/// known version tag (`0x01`, `0x02`, or `0x03`) **and** its total length is at
301/// least the minimum for that tier.
302///
303/// The length check (matching the exact minimum-length gate enforced by
304/// [`hybrid_open`]) is what definitively distinguishes a hybrid ciphertext from a
305/// legacy `box_seal` ciphertext whose random first byte happens to collide with a
306/// tag value: a real hybrid ciphertext is always `>=` its tier minimum, while a
307/// short legacy ciphertext below that bound is correctly rejected here. (A legacy
308/// ciphertext that collides on *both* the first byte *and* a hybrid-matching
309/// length is still handled safely by the fallback in
310/// [`crate::seal::unseal_from_user`].)
311pub fn is_hybrid_ciphertext(ct_b64: &str) -> bool {
312    let Ok(bytes) = b64::decode(ct_b64) else {
313        return false;
314    };
315    match bytes.first() {
316        Some(&VERSION_HYBRID_512) => bytes.len() >= MIN_HYBRID_512_LEN,
317        Some(&VERSION_HYBRID_768) => bytes.len() >= MIN_HYBRID_768_LEN,
318        Some(&VERSION_HYBRID_1024) => bytes.len() >= MIN_HYBRID_1024_LEN,
319        _ => false,
320    }
321}
322
323// === Public API: Cat-5 (ML-KEM-1024) ===
324
325/// Generate a hybrid ML-KEM-1024 + X25519 keypair (Cat-5).
326pub fn generate_hybrid_keypair_1024() -> HybridKeyPair {
327    generate_hybrid_keypair_with_level(SecurityLevel::Cat5)
328}
329
330/// Seal `plaintext` to a Cat-5 hybrid public key (ML-KEM-1024).
331///
332/// Returns base64: `0x03 || hybrid_ct (1600 B) || nonce (24 B) || secretbox_ct`.
333pub fn hybrid_seal_1024(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
334    hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat5)
335}
336
337// === Public API: Level-parametric ===
338
339/// Generate a hybrid keypair at the specified security level.
340pub fn generate_hybrid_keypair_with_level(level: SecurityLevel) -> HybridKeyPair {
341    let mut seed = [0u8; SEED_LEN];
342    random_bytes(&mut seed);
343
344    let expanded_len = match level {
345        SecurityLevel::Cat1 => EXPANDED_SEED_512_LEN,
346        SecurityLevel::Cat3 => EXPANDED_SEED_768_LEN,
347        SecurityLevel::Cat5 => EXPANDED_SEED_1024_LEN,
348    };
349    let mlkem_seed_len = match level {
350        SecurityLevel::Cat1 => MLKEM512_SEED_LEN,
351        SecurityLevel::Cat3 => MLKEM768_SEED_LEN,
352        SecurityLevel::Cat5 => MLKEM1024_SEED_LEN,
353    };
354
355    let mut expanded = expand_seed(&seed, expanded_len);
356    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[mlkem_seed_len..].try_into().unwrap();
357
358    // X25519 keypair
359    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
360    let x25519_pk = X25519PublicKey::from(&x25519_sk);
361
362    let combined_pk = match level {
363        SecurityLevel::Cat1 => {
364            let mlkem_seed: [u8; MLKEM512_SEED_LEN] =
365                expanded[..MLKEM512_SEED_LEN].try_into().unwrap();
366            let dk = DecapsulationKey::<MlKem512>::from_seed(mlkem_seed.into());
367            let ek = dk.encapsulation_key();
368            let ek_bytes = ek.to_bytes();
369            let mut pk = Vec::with_capacity(COMBINED_PK_512_LEN);
370            pk.extend_from_slice(&ek_bytes);
371            pk.extend_from_slice(x25519_pk.as_bytes());
372            pk
373        }
374        SecurityLevel::Cat3 => {
375            let mlkem_seed: [u8; MLKEM768_SEED_LEN] =
376                expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
377            let dk = DecapsulationKey::<MlKem768>::from_seed(mlkem_seed.into());
378            let ek = dk.encapsulation_key();
379            let ek_bytes = ek.to_bytes();
380            let mut pk = Vec::with_capacity(COMBINED_PK_768_LEN);
381            pk.extend_from_slice(&ek_bytes);
382            pk.extend_from_slice(x25519_pk.as_bytes());
383            pk
384        }
385        SecurityLevel::Cat5 => {
386            let mlkem_seed: [u8; MLKEM1024_SEED_LEN] =
387                expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
388            let dk = DecapsulationKey::<MlKem1024>::from_seed(mlkem_seed.into());
389            let ek = dk.encapsulation_key();
390            let ek_bytes = ek.to_bytes();
391            let mut pk = Vec::with_capacity(COMBINED_PK_1024_LEN);
392            pk.extend_from_slice(&ek_bytes);
393            pk.extend_from_slice(x25519_pk.as_bytes());
394            pk
395        }
396    };
397
398    let pair = HybridKeyPair {
399        public_key: b64::encode(&combined_pk),
400        secret_key: b64::encode(&seed),
401    };
402
403    seed.zeroize();
404    expanded.zeroize();
405    pair
406}
407
408/// Seal plaintext at the specified security level.
409pub fn hybrid_seal_with_level(
410    plaintext: &[u8],
411    combined_pk_b64: &str,
412    level: SecurityLevel,
413) -> Result<String, CryptoError> {
414    let pk_bytes = b64::decode(combined_pk_b64)?;
415
416    let (expected_pk_len, mlkem_ek_len, version_tag) = match level {
417        SecurityLevel::Cat1 => (COMBINED_PK_512_LEN, MLKEM512_EK_LEN, VERSION_HYBRID_512),
418        SecurityLevel::Cat3 => (COMBINED_PK_768_LEN, MLKEM768_EK_LEN, VERSION_HYBRID_768),
419        SecurityLevel::Cat5 => (COMBINED_PK_1024_LEN, MLKEM1024_EK_LEN, VERSION_HYBRID_1024),
420    };
421
422    if pk_bytes.len() != expected_pk_len {
423        return Err(CryptoError::InvalidLength {
424            expected: expected_pk_len,
425            got: pk_bytes.len(),
426        });
427    }
428
429    // Split combined public key
430    let mlkem_ek_bytes = &pk_bytes[..mlkem_ek_len];
431    let x25519_pk_bytes: [u8; X25519_LEN] = pk_bytes[mlkem_ek_len..].try_into().unwrap();
432
433    // ML-KEM encapsulate
434    let mut mlkem_coins = [0u8; 32];
435    random_bytes(&mut mlkem_coins);
436
437    let (mlkem_ct_bytes, ss_mlkem_bytes) = match level {
438        SecurityLevel::Cat1 => {
439            let ek = EncapsulationKey::<MlKem512>::new(
440                mlkem_ek_bytes
441                    .try_into()
442                    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-512 ek".into()))?,
443            )
444            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-512 encapsulation key".into()))?;
445            let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
446            (ct.as_slice().to_vec(), ss.as_slice().to_vec())
447        }
448        SecurityLevel::Cat3 => {
449            let ek = EncapsulationKey::<MlKem768>::new(
450                mlkem_ek_bytes
451                    .try_into()
452                    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ek".into()))?,
453            )
454            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 encapsulation key".into()))?;
455            let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
456            (ct.as_slice().to_vec(), ss.as_slice().to_vec())
457        }
458        SecurityLevel::Cat5 => {
459            let ek = EncapsulationKey::<MlKem1024>::new(
460                mlkem_ek_bytes
461                    .try_into()
462                    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ek".into()))?,
463            )
464            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 encapsulation key".into()))?;
465            let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
466            (ct.as_slice().to_vec(), ss.as_slice().to_vec())
467        }
468    };
469    mlkem_coins.zeroize();
470
471    // X25519 encapsulate (ephemeral DH)
472    let mut x25519_eph_bytes = [0u8; X25519_LEN];
473    random_bytes(&mut x25519_eph_bytes);
474    let x25519_eph_sk = X25519StaticSecret::from(x25519_eph_bytes);
475    let x25519_eph_pk = X25519PublicKey::from(&x25519_eph_sk);
476    let x25519_recipient_pk = X25519PublicKey::from(x25519_pk_bytes);
477    let ss_x25519 = x25519_eph_sk.diffie_hellman(&x25519_recipient_pk);
478    x25519_eph_bytes.zeroize();
479
480    // Combine shared secrets
481    let ct_x25519: [u8; X25519_LEN] = *x25519_eph_pk.as_bytes();
482    let mut shared_secret = combine(
483        &ss_mlkem_bytes,
484        ss_x25519.as_bytes(),
485        &ct_x25519,
486        &x25519_pk_bytes,
487    );
488
489    // Encrypt plaintext
490    let (secretbox_ct, nonce_buf) = secretbox_encrypt(&shared_secret, plaintext)?;
491    shared_secret.zeroize();
492
493    // Assemble: version || mlkem_ct || x25519_eph_pk || nonce || secretbox_ct
494    let combined_ct_len = mlkem_ct_bytes.len() + X25519_LEN;
495    let mut out = Vec::with_capacity(1 + combined_ct_len + NONCE_LEN + secretbox_ct.len());
496    out.push(version_tag);
497    out.extend_from_slice(&mlkem_ct_bytes);
498    out.extend_from_slice(&ct_x25519);
499    out.extend_from_slice(&nonce_buf);
500    out.extend_from_slice(&secretbox_ct);
501
502    Ok(b64::encode(&out))
503}
504
505// === Internal: Cat-1 open ===
506
507fn hybrid_open_512(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
508    let seed_bytes = b64::decode(seed_b64)?;
509    if seed_bytes.len() != SEED_LEN {
510        return Err(CryptoError::InvalidLength {
511            expected: SEED_LEN,
512            got: seed_bytes.len(),
513        });
514    }
515    if combined.len() < MIN_HYBRID_512_LEN {
516        return Err(CryptoError::TooShort);
517    }
518
519    let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
520    let mut expanded = expand_seed(&seed, EXPANDED_SEED_512_LEN);
521    let mlkem_seed: [u8; MLKEM512_SEED_LEN] = expanded[..MLKEM512_SEED_LEN].try_into().unwrap();
522    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM512_SEED_LEN..].try_into().unwrap();
523    expanded.zeroize();
524
525    // Parse ciphertext
526    let mlkem_ct = &combined[1..1 + MLKEM512_CT_LEN];
527    let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
528        [1 + MLKEM512_CT_LEN..1 + COMBINED_CT_512_LEN]
529        .try_into()
530        .unwrap();
531    let nonce_slice = &combined[1 + COMBINED_CT_512_LEN..1 + COMBINED_CT_512_LEN + NONCE_LEN];
532    let encrypted = &combined[1 + COMBINED_CT_512_LEN + NONCE_LEN..];
533
534    // ML-KEM-512 decapsulate
535    let dk = DecapsulationKey::<MlKem512>::from_seed(mlkem_seed.into());
536    let kem_ct = mlkem_ct
537        .try_into()
538        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-512 ciphertext".into()))?;
539    let ss_mlkem = dk.decapsulate(kem_ct);
540
541    // X25519 decapsulate
542    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
543    let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
544    let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
545
546    let x25519_pk = X25519PublicKey::from(&x25519_sk);
547    let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
548
549    let mut shared_secret = combine(
550        ss_mlkem.as_slice(),
551        ss_x25519.as_bytes(),
552        &x25519_eph_pk_bytes,
553        &pk_x25519,
554    );
555
556    let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
557    shared_secret.zeroize();
558    result
559}
560
561// === Internal: Cat-3 open ===
562
563fn hybrid_open_768(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
564    let seed_bytes = b64::decode(seed_b64)?;
565    if seed_bytes.len() != SEED_LEN {
566        return Err(CryptoError::InvalidLength {
567            expected: SEED_LEN,
568            got: seed_bytes.len(),
569        });
570    }
571    if combined.len() < MIN_HYBRID_768_LEN {
572        return Err(CryptoError::TooShort);
573    }
574
575    let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
576    let mut expanded = expand_seed(&seed, EXPANDED_SEED_768_LEN);
577    let mlkem_seed: [u8; MLKEM768_SEED_LEN] = expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
578    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM768_SEED_LEN..].try_into().unwrap();
579    expanded.zeroize();
580
581    // Parse ciphertext
582    let mlkem_ct = &combined[1..1 + MLKEM768_CT_LEN];
583    let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
584        [1 + MLKEM768_CT_LEN..1 + COMBINED_CT_768_LEN]
585        .try_into()
586        .unwrap();
587    let nonce_slice = &combined[1 + COMBINED_CT_768_LEN..1 + COMBINED_CT_768_LEN + NONCE_LEN];
588    let encrypted = &combined[1 + COMBINED_CT_768_LEN + NONCE_LEN..];
589
590    // ML-KEM-768 decapsulate
591    let dk = DecapsulationKey::<MlKem768>::from_seed(mlkem_seed.into());
592    let kem_ct = mlkem_ct
593        .try_into()
594        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ciphertext".into()))?;
595    let ss_mlkem = dk.decapsulate(kem_ct);
596
597    // X25519 decapsulate
598    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
599    let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
600    let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
601
602    let x25519_pk = X25519PublicKey::from(&x25519_sk);
603    let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
604
605    let mut shared_secret = combine(
606        ss_mlkem.as_slice(),
607        ss_x25519.as_bytes(),
608        &x25519_eph_pk_bytes,
609        &pk_x25519,
610    );
611
612    let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
613    shared_secret.zeroize();
614    result
615}
616
617// === Internal: Cat-5 open ===
618
619fn hybrid_open_1024(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
620    let seed_bytes = b64::decode(seed_b64)?;
621    if seed_bytes.len() != SEED_LEN {
622        return Err(CryptoError::InvalidLength {
623            expected: SEED_LEN,
624            got: seed_bytes.len(),
625        });
626    }
627    if combined.len() < MIN_HYBRID_1024_LEN {
628        return Err(CryptoError::TooShort);
629    }
630
631    let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
632    let mut expanded = expand_seed(&seed, EXPANDED_SEED_1024_LEN);
633    let mlkem_seed: [u8; MLKEM1024_SEED_LEN] = expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
634    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM1024_SEED_LEN..].try_into().unwrap();
635    expanded.zeroize();
636
637    // Parse ciphertext
638    let mlkem_ct = &combined[1..1 + MLKEM1024_CT_LEN];
639    let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
640        [1 + MLKEM1024_CT_LEN..1 + COMBINED_CT_1024_LEN]
641        .try_into()
642        .unwrap();
643    let nonce_slice = &combined[1 + COMBINED_CT_1024_LEN..1 + COMBINED_CT_1024_LEN + NONCE_LEN];
644    let encrypted = &combined[1 + COMBINED_CT_1024_LEN + NONCE_LEN..];
645
646    // ML-KEM-1024 decapsulate
647    let dk = DecapsulationKey::<MlKem1024>::from_seed(mlkem_seed.into());
648    let kem_ct = mlkem_ct
649        .try_into()
650        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ciphertext".into()))?;
651    let ss_mlkem = dk.decapsulate(kem_ct);
652
653    // X25519 decapsulate
654    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
655    let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
656    let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
657
658    let x25519_pk = X25519PublicKey::from(&x25519_sk);
659    let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
660
661    let mut shared_secret = combine(
662        ss_mlkem.as_slice(),
663        ss_x25519.as_bytes(),
664        &x25519_eph_pk_bytes,
665        &pk_x25519,
666    );
667
668    let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
669    shared_secret.zeroize();
670    result
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    // === Cat-3 (existing behavior) ===
678
679    #[test]
680    fn cat3_roundtrip() {
681        let kp = generate_hybrid_keypair();
682        let pt = b"32-byte symmetric context key!!!";
683        let ct = hybrid_seal(pt, &kp.public_key).unwrap();
684        assert!(is_hybrid_ciphertext(&ct));
685        let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
686        assert_eq!(opened, pt);
687    }
688
689    #[test]
690    fn cat3_wrong_key_fails() {
691        let kp1 = generate_hybrid_keypair();
692        let kp2 = generate_hybrid_keypair();
693        let ct = hybrid_seal(b"x", &kp1.public_key).unwrap();
694        assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
695    }
696
697    #[test]
698    fn cat3_version_tag() {
699        let kp = generate_hybrid_keypair();
700        let raw = b64::decode(&hybrid_seal(b"x", &kp.public_key).unwrap()).unwrap();
701        assert_eq!(raw[0], VERSION_HYBRID_768);
702    }
703
704    #[test]
705    fn cat3_nondeterministic() {
706        let kp = generate_hybrid_keypair();
707        let c1 = hybrid_seal(b"x", &kp.public_key).unwrap();
708        let c2 = hybrid_seal(b"x", &kp.public_key).unwrap();
709        assert_ne!(c1, c2);
710    }
711
712    #[test]
713    fn cat3_empty_plaintext() {
714        let kp = generate_hybrid_keypair();
715        let ct = hybrid_seal(b"", &kp.public_key).unwrap();
716        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
717    }
718
719    #[test]
720    fn cat3_key_sizes() {
721        let kp = generate_hybrid_keypair();
722        let pk = b64::decode(&kp.public_key).unwrap();
723        let sk = b64::decode(&kp.secret_key).unwrap();
724        assert_eq!(pk.len(), COMBINED_PK_768_LEN); // 1216
725        assert_eq!(sk.len(), SEED_LEN); // 32
726    }
727
728    #[test]
729    fn cat3_ciphertext_size() {
730        let kp = generate_hybrid_keypair();
731        let pt = b"exactly 32 bytes of key material";
732        let raw = b64::decode(&hybrid_seal(pt, &kp.public_key).unwrap()).unwrap();
733        // 1 + 1120 + 24 + 32 + 16 = 1193
734        assert_eq!(
735            raw.len(),
736            1 + COMBINED_CT_768_LEN + NONCE_LEN + 32 + MAC_LEN
737        );
738    }
739
740    // === Cat-1 (new) ===
741
742    #[test]
743    fn cat1_roundtrip() {
744        let kp = generate_hybrid_keypair_512();
745        let pt = b"32-byte symmetric context key!!!";
746        let ct = hybrid_seal_512(pt, &kp.public_key).unwrap();
747        assert!(is_hybrid_ciphertext(&ct));
748        let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
749        assert_eq!(opened, pt);
750    }
751
752    #[test]
753    fn cat1_version_tag() {
754        let kp = generate_hybrid_keypair_512();
755        let raw = b64::decode(&hybrid_seal_512(b"x", &kp.public_key).unwrap()).unwrap();
756        assert_eq!(raw[0], VERSION_HYBRID_512);
757    }
758
759    #[test]
760    fn cat1_wrong_key_fails() {
761        let kp1 = generate_hybrid_keypair_512();
762        let kp2 = generate_hybrid_keypair_512();
763        let ct = hybrid_seal_512(b"x", &kp1.public_key).unwrap();
764        assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
765    }
766
767    #[test]
768    fn cat1_key_sizes() {
769        let kp = generate_hybrid_keypair_512();
770        let pk = b64::decode(&kp.public_key).unwrap();
771        let sk = b64::decode(&kp.secret_key).unwrap();
772        assert_eq!(pk.len(), COMBINED_PK_512_LEN); // 832
773        assert_eq!(sk.len(), SEED_LEN); // 32
774    }
775
776    #[test]
777    fn cat1_ciphertext_size() {
778        let kp = generate_hybrid_keypair_512();
779        let pt = b"exactly 32 bytes of key material";
780        let raw = b64::decode(&hybrid_seal_512(pt, &kp.public_key).unwrap()).unwrap();
781        // 1 + 800 + 24 + 32 + 16 = 873
782        assert_eq!(
783            raw.len(),
784            1 + COMBINED_CT_512_LEN + NONCE_LEN + 32 + MAC_LEN
785        );
786    }
787
788    #[test]
789    fn cat1_nondeterministic() {
790        let kp = generate_hybrid_keypair_512();
791        let c1 = hybrid_seal_512(b"x", &kp.public_key).unwrap();
792        let c2 = hybrid_seal_512(b"x", &kp.public_key).unwrap();
793        assert_ne!(c1, c2);
794    }
795
796    #[test]
797    fn cat1_empty_plaintext() {
798        let kp = generate_hybrid_keypair_512();
799        let ct = hybrid_seal_512(b"", &kp.public_key).unwrap();
800        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
801    }
802
803    // === Cat-5 (new) ===
804
805    #[test]
806    fn cat5_roundtrip() {
807        let kp = generate_hybrid_keypair_1024();
808        let pt = b"32-byte symmetric context key!!!";
809        let ct = hybrid_seal_1024(pt, &kp.public_key).unwrap();
810        assert!(is_hybrid_ciphertext(&ct));
811        let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
812        assert_eq!(opened, pt);
813    }
814
815    #[test]
816    fn cat5_version_tag() {
817        let kp = generate_hybrid_keypair_1024();
818        let raw = b64::decode(&hybrid_seal_1024(b"x", &kp.public_key).unwrap()).unwrap();
819        assert_eq!(raw[0], VERSION_HYBRID_1024);
820    }
821
822    #[test]
823    fn cat5_wrong_key_fails() {
824        let kp1 = generate_hybrid_keypair_1024();
825        let kp2 = generate_hybrid_keypair_1024();
826        let ct = hybrid_seal_1024(b"x", &kp1.public_key).unwrap();
827        assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
828    }
829
830    #[test]
831    fn cat5_key_sizes() {
832        let kp = generate_hybrid_keypair_1024();
833        let pk = b64::decode(&kp.public_key).unwrap();
834        let sk = b64::decode(&kp.secret_key).unwrap();
835        assert_eq!(pk.len(), COMBINED_PK_1024_LEN); // 1600
836        assert_eq!(sk.len(), SEED_LEN); // 32
837    }
838
839    #[test]
840    fn cat5_ciphertext_size() {
841        let kp = generate_hybrid_keypair_1024();
842        let pt = b"exactly 32 bytes of key material";
843        let raw = b64::decode(&hybrid_seal_1024(pt, &kp.public_key).unwrap()).unwrap();
844        // 1 + 1600 + 24 + 32 + 16 = 1673
845        assert_eq!(
846            raw.len(),
847            1 + COMBINED_CT_1024_LEN + NONCE_LEN + 32 + MAC_LEN
848        );
849    }
850
851    #[test]
852    fn cat5_nondeterministic() {
853        let kp = generate_hybrid_keypair_1024();
854        let c1 = hybrid_seal_1024(b"x", &kp.public_key).unwrap();
855        let c2 = hybrid_seal_1024(b"x", &kp.public_key).unwrap();
856        assert_ne!(c1, c2);
857    }
858
859    #[test]
860    fn cat5_empty_plaintext() {
861        let kp = generate_hybrid_keypair_1024();
862        let ct = hybrid_seal_1024(b"", &kp.public_key).unwrap();
863        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
864    }
865
866    // === Cross-level ===
867
868    #[test]
869    fn cat3_ct_cannot_open_with_cat5_key() {
870        let kp3 = generate_hybrid_keypair();
871        let kp5 = generate_hybrid_keypair_1024();
872        let ct = hybrid_seal(b"test", &kp3.public_key).unwrap();
873        assert!(hybrid_open(&ct, &kp5.secret_key).is_err());
874    }
875
876    #[test]
877    fn cat5_ct_cannot_open_with_cat3_key() {
878        let kp3 = generate_hybrid_keypair();
879        let kp5 = generate_hybrid_keypair_1024();
880        let ct = hybrid_seal_1024(b"test", &kp5.public_key).unwrap();
881        assert!(hybrid_open(&ct, &kp3.secret_key).is_err());
882    }
883
884    #[test]
885    fn cat1_ct_cannot_open_with_cat3_key() {
886        let kp1 = generate_hybrid_keypair_512();
887        let kp3 = generate_hybrid_keypair();
888        let ct = hybrid_seal_512(b"test", &kp1.public_key).unwrap();
889        assert!(hybrid_open(&ct, &kp3.secret_key).is_err());
890    }
891
892    #[test]
893    fn cat1_ct_cannot_open_with_cat5_key() {
894        let kp1 = generate_hybrid_keypair_512();
895        let kp5 = generate_hybrid_keypair_1024();
896        let ct = hybrid_seal_512(b"test", &kp1.public_key).unwrap();
897        assert!(hybrid_open(&ct, &kp5.secret_key).is_err());
898    }
899
900    #[test]
901    fn legacy_not_hybrid() {
902        // A short blob whose first byte is not any hybrid tag.
903        let legacy = b64::encode(&[0x42, 0x02, 0x03]);
904        assert!(!is_hybrid_ciphertext(&legacy));
905    }
906
907    #[test]
908    fn legacy_starting_with_0x01_not_misdetected_as_cat1() {
909        // `is_hybrid_ciphertext` is length-aware: a legacy-sized (~80B) blob whose
910        // first byte collides with the Cat-1 tag (0x01) is NOT classified as
911        // hybrid, because it is far below the Cat-1 minimum length
912        // (1 + 768 + 32 + 24 + 16 = 841B). In practice Mosslet legacy cts (~80B
913        // sealing a 32B key) can never reach a hybrid length.
914        let mut legacy = vec![0x01u8]; // collides with the Cat-1 tag
915        legacy.extend_from_slice(&[0u8; 79]); // total 80 bytes, legacy-sized
916        let legacy_b64 = b64::encode(&legacy);
917        assert!(!is_hybrid_ciphertext(&legacy_b64));
918        // And `hybrid_open`'s own length gate independently rejects it.
919        let kp = generate_hybrid_keypair_512();
920        let err = hybrid_open(&legacy_b64, &kp.secret_key).unwrap_err();
921        assert!(matches!(err, CryptoError::TooShort));
922    }
923
924    #[test]
925    fn long_0x01_blob_below_cat1_min_not_hybrid() {
926        // A 0x01-leading blob just under the Cat-1 minimum is still not hybrid.
927        let min_cat1 = MIN_HYBRID_512_LEN; // 841
928        let mut blob = vec![0x01u8];
929        blob.extend_from_slice(&vec![0u8; min_cat1 - 2]); // total = min - 1
930        assert!(!is_hybrid_ciphertext(&b64::encode(&blob)));
931        // At exactly the minimum, the tag+length check classifies it as hybrid
932        // (disambiguation past this point is handled by unseal_from_user's
933        // fallback to the legacy opener).
934        let mut at_min = vec![0x01u8];
935        at_min.extend_from_slice(&vec![0u8; min_cat1 - 1]); // total = min
936        assert!(is_hybrid_ciphertext(&b64::encode(&at_min)));
937    }
938
939    #[test]
940    fn seed_expansion_deterministic() {
941        let seed = [0x42u8; SEED_LEN];
942        let expanded = expand_seed(&seed, 96);
943        let expanded2 = expand_seed(&seed, 96);
944        assert_eq!(expanded, expanded2);
945    }
946
947    #[test]
948    fn combiner_uses_label() {
949        let ss_mlkem = [0xAAu8; 32];
950        let ss_x25519 = [0xBBu8; 32];
951        let ct_x25519 = [0xCCu8; 32];
952        let pk_x25519 = [0xDDu8; 32];
953        let result = combine(&ss_mlkem, &ss_x25519, &ct_x25519, &pk_x25519);
954        assert_eq!(result.len(), 32);
955
956        let ss_mlkem2 = [0xEEu8; 32];
957        let result2 = combine(&ss_mlkem2, &ss_x25519, &ct_x25519, &pk_x25519);
958        assert_ne!(result, result2);
959    }
960}