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;
98use crate::ecc;
99use crate::suite::{
100    self, GCM_NONCE_LEN, GCM_TAG_LEN, SEAL_CONTEXT_V1, Suite, TAG_KEM_MATCHED_CAT3,
101    TAG_KEM_MATCHED_CAT5, TAG_KEM_PURE_CNSA2,
102};
103
104// === Constants ===
105
106/// Version tag for Cat-1 hybrid ciphertext (ML-KEM-512).
107const VERSION_HYBRID_512: u8 = 0x01;
108/// Version tag for Cat-3 hybrid ciphertext (ML-KEM-768).
109const VERSION_HYBRID_768: u8 = 0x02;
110/// Version tag for Cat-5 hybrid ciphertext (ML-KEM-1024).
111const VERSION_HYBRID_1024: u8 = 0x03;
112/// XSalsa20 nonce length.
113const NONCE_LEN: usize = 24;
114/// X25519 key size.
115const X25519_LEN: usize = 32;
116/// Root seed length.
117const SEED_LEN: usize = 32;
118/// Poly1305 MAC.
119const MAC_LEN: usize = 16;
120/// Noble's domain-separation label.
121const LABEL: &[u8] = b"\\.//^\\";
122
123// ML-KEM-512 (Cat-1)
124/// ML-KEM-512 encapsulation key size.
125const MLKEM512_EK_LEN: usize = 800;
126/// ML-KEM-512 ciphertext size.
127const MLKEM512_CT_LEN: usize = 768;
128/// ML-KEM-512 seed portion (64 bytes).
129const MLKEM512_SEED_LEN: usize = 64;
130/// Expanded seed for Cat-1: ML-KEM seed (64) + X25519 secret (32).
131const EXPANDED_SEED_512_LEN: usize = 96;
132/// Combined public key for Cat-1: ML-KEM ek (800) + X25519 pk (32).
133const COMBINED_PK_512_LEN: usize = MLKEM512_EK_LEN + X25519_LEN;
134/// Combined ciphertext for Cat-1: ML-KEM ct (768) + X25519 ephemeral pk (32).
135const COMBINED_CT_512_LEN: usize = MLKEM512_CT_LEN + X25519_LEN;
136/// Minimum sealed-box length for a Cat-1 hybrid ciphertext (empty plaintext:
137/// version tag + combined ct + nonce + Poly1305 MAC). Single source of truth
138/// shared by the routing check ([`is_hybrid_ciphertext`]) and the open gate
139/// ([`hybrid_open_512`]); keeping them in lockstep is what prevents a legacy
140/// ciphertext from being mis-routed.
141const MIN_HYBRID_512_LEN: usize = 1 + COMBINED_CT_512_LEN + NONCE_LEN + MAC_LEN;
142
143// ML-KEM-768 (Cat-3)
144/// ML-KEM-768 encapsulation key size.
145const MLKEM768_EK_LEN: usize = 1184;
146/// ML-KEM-768 ciphertext size.
147const MLKEM768_CT_LEN: usize = 1088;
148/// ML-KEM-768 seed portion (64 bytes).
149const MLKEM768_SEED_LEN: usize = 64;
150/// Expanded seed for Cat-3: ML-KEM seed (64) + X25519 secret (32).
151const EXPANDED_SEED_768_LEN: usize = 96;
152/// Combined public key for Cat-3: ML-KEM ek (1184) + X25519 pk (32).
153const COMBINED_PK_768_LEN: usize = MLKEM768_EK_LEN + X25519_LEN;
154/// Combined ciphertext for Cat-3: ML-KEM ct (1088) + X25519 ephemeral pk (32).
155const COMBINED_CT_768_LEN: usize = MLKEM768_CT_LEN + X25519_LEN;
156/// Minimum sealed-box length for a Cat-3 hybrid ciphertext (empty plaintext).
157/// Shared by [`is_hybrid_ciphertext`] and [`hybrid_open_768`].
158const MIN_HYBRID_768_LEN: usize = 1 + COMBINED_CT_768_LEN + NONCE_LEN + MAC_LEN;
159
160// ML-KEM-1024 (Cat-5)
161/// ML-KEM-1024 encapsulation key size.
162const MLKEM1024_EK_LEN: usize = 1568;
163/// ML-KEM-1024 ciphertext size.
164const MLKEM1024_CT_LEN: usize = 1568;
165/// ML-KEM-1024 seed portion (64 bytes).
166const MLKEM1024_SEED_LEN: usize = 64;
167/// Expanded seed for Cat-5: ML-KEM seed (64) + X25519 secret (32).
168const EXPANDED_SEED_1024_LEN: usize = 96;
169/// Combined public key for Cat-5: ML-KEM ek (1568) + X25519 pk (32).
170const COMBINED_PK_1024_LEN: usize = MLKEM1024_EK_LEN + X25519_LEN;
171/// Combined ciphertext for Cat-5: ML-KEM ct (1568) + X25519 ephemeral pk (32).
172const COMBINED_CT_1024_LEN: usize = MLKEM1024_CT_LEN + X25519_LEN;
173/// Minimum sealed-box length for a Cat-5 hybrid ciphertext (empty plaintext).
174/// Shared by [`is_hybrid_ciphertext`] and [`hybrid_open_1024`].
175const MIN_HYBRID_1024_LEN: usize = 1 + COMBINED_CT_1024_LEN + NONCE_LEN + MAC_LEN;
176
177// === CNSA 2.0 suites (v0.7.0): AES-256-GCM envelope layouts ===
178//
179// These layouts are produced only by the new `Suite::PureCnsa2` /
180// `Suite::HybridMatched` paths. Layout: `tag(1) || mlkem_ct || [ecc_eph_pk] ||
181// nonce(12) || aes_gcm_ct || gcm_tag(16)`. The `aes_gcm_ct || gcm_tag` portion
182// is the combined AEAD output, so an empty-plaintext minimum is
183// `header || nonce(12) || tag(16)`.
184
185/// Minimum sealed-box length for a PureCnsa2 (`0x10`) ciphertext:
186/// `tag(1) || ML-KEM-1024 ct (1568) || nonce(12) || gcm_tag(16)`.
187const MIN_PURE_CNSA2_LEN: usize = 1 + MLKEM1024_CT_LEN + GCM_NONCE_LEN + GCM_TAG_LEN;
188/// Minimum sealed-box length for a HybridMatched Cat-3 (`0x13`) ciphertext:
189/// `tag(1) || ML-KEM-768 ct (1088) || X448 eph pk (56) || nonce(12) || gcm_tag(16)`.
190const MIN_MATCHED_CAT3_LEN: usize =
191    1 + MLKEM768_CT_LEN + ecc::X448_LEN + GCM_NONCE_LEN + GCM_TAG_LEN;
192/// Minimum sealed-box length for a HybridMatched Cat-5 (`0x14`) ciphertext:
193/// `tag(1) || ML-KEM-1024 ct (1568) || P-521 eph pk (133) || nonce(12) || gcm_tag(16)`.
194const MIN_MATCHED_CAT5_LEN: usize =
195    1 + MLKEM1024_CT_LEN + ecc::P521_PK_LEN + GCM_NONCE_LEN + GCM_TAG_LEN;
196
197/// PureCnsa2 combined public key length (ML-KEM-1024 ek only).
198const PURE_CNSA2_PK_LEN: usize = MLKEM1024_EK_LEN;
199/// HybridMatched Cat-3 combined public key length (ML-KEM-768 ek + X448 pk).
200const MATCHED_CAT3_PK_LEN: usize = MLKEM768_EK_LEN + ecc::X448_LEN;
201/// HybridMatched Cat-5 combined public key length (ML-KEM-1024 ek + P-521 pk).
202const MATCHED_CAT5_PK_LEN: usize = MLKEM1024_EK_LEN + ecc::P521_PK_LEN;
203
204/// Expanded-seed length for HybridMatched Cat-3 (ML-KEM-768 seed + X448 secret).
205const EXPANDED_SEED_MATCHED_CAT3_LEN: usize = MLKEM768_SEED_LEN + ecc::X448_LEN;
206/// Expanded-seed length for HybridMatched Cat-5 (ML-KEM-1024 seed + P-521 secret).
207const EXPANDED_SEED_MATCHED_CAT5_LEN: usize = MLKEM1024_SEED_LEN + ecc::P521_SK_LEN;
208
209// === Types ===
210
211/// A hybrid ML-KEM + X25519 keypair (base64-encoded).
212#[derive(Debug, Clone)]
213pub struct HybridKeyPair {
214    /// Combined public key: ML-KEM ek ‖ X25519 pk. Base64.
215    pub public_key: String,
216    /// Root seed (32 bytes). Base64.
217    pub secret_key: String,
218}
219
220/// Security level for hybrid PQ operations.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub enum SecurityLevel {
223    /// NIST Category 1: ML-KEM-512 + X25519 (~AES-128).
224    Cat1,
225    /// NIST Category 3: ML-KEM-768 + X25519 (~AES-192). Default.
226    #[default]
227    Cat3,
228    /// NIST Category 5: ML-KEM-1024 + X25519 (~AES-256).
229    Cat5,
230}
231
232// === Helpers ===
233
234/// Fill buffer with OS random bytes.
235#[inline]
236fn random_bytes(buf: &mut [u8]) {
237    getrandom::getrandom(buf).expect("OS CSPRNG unavailable");
238}
239
240/// Expand a 32-byte seed using SHAKE256.
241fn expand_seed(seed: &[u8; SEED_LEN], output_len: usize) -> Vec<u8> {
242    let mut hasher = Shake256::default();
243    hasher.update(seed);
244    let mut reader = hasher.finalize_xof();
245    let mut out = vec![0u8; output_len];
246    reader.read(&mut out);
247    out
248}
249
250/// SHA3-256 combiner: `SHA3-256(ss_mlkem || ss_x25519 || ct_x25519 || pk_x25519 || label)`
251fn combine(
252    ss_mlkem: &[u8],
253    ss_x25519: &[u8],
254    ct_x25519: &[u8; X25519_LEN],
255    pk_x25519: &[u8; X25519_LEN],
256) -> [u8; 32] {
257    use sha3::Digest;
258    let mut hasher = sha3::Sha3_256::new();
259    Digest::update(&mut hasher, ss_mlkem);
260    Digest::update(&mut hasher, ss_x25519);
261    Digest::update(&mut hasher, ct_x25519);
262    Digest::update(&mut hasher, pk_x25519);
263    Digest::update(&mut hasher, LABEL);
264    hasher.finalize().into()
265}
266
267/// Encrypt plaintext with a 32-byte shared secret using XSalsa20-Poly1305.
268fn secretbox_encrypt(
269    shared_secret: &[u8; 32],
270    plaintext: &[u8],
271) -> Result<(Vec<u8>, [u8; NONCE_LEN]), CryptoError> {
272    let cipher = XSalsa20Poly1305::new(GenericArray::from_slice(shared_secret));
273    let mut nonce_buf = [0u8; NONCE_LEN];
274    random_bytes(&mut nonce_buf);
275    let nonce = GenericArray::from_slice(&nonce_buf);
276    let ct = cipher
277        .encrypt(nonce, plaintext)
278        .map_err(|_| CryptoError::Hybrid("secretbox encrypt failed".into()))?;
279    Ok((ct, nonce_buf))
280}
281
282/// Decrypt ciphertext with a 32-byte shared secret using XSalsa20-Poly1305.
283fn secretbox_decrypt(
284    shared_secret: &[u8; 32],
285    nonce: &[u8],
286    ciphertext: &[u8],
287) -> Result<Vec<u8>, CryptoError> {
288    let cipher = XSalsa20Poly1305::new(GenericArray::from_slice(shared_secret));
289    let nonce = GenericArray::from_slice(nonce);
290    cipher
291        .decrypt(nonce, ciphertext)
292        .map_err(|_| CryptoError::Decryption)
293}
294
295// === Public API: Cat-1 (ML-KEM-512) ===
296
297/// Generate a hybrid ML-KEM-512 + X25519 keypair (Cat-1).
298pub fn generate_hybrid_keypair_512() -> HybridKeyPair {
299    generate_hybrid_keypair_with_level(SecurityLevel::Cat1)
300}
301
302/// Seal `plaintext` to a Cat-1 hybrid public key (ML-KEM-512).
303///
304/// Returns base64: `0x01 || hybrid_ct (800 B) || nonce (24 B) || secretbox_ct`.
305pub fn hybrid_seal_512(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
306    hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat1)
307}
308
309// === Public API: Cat-3 (ML-KEM-768, default) ===
310
311/// Generate a hybrid ML-KEM-768 + X25519 keypair (Cat-3, default).
312pub fn generate_hybrid_keypair() -> HybridKeyPair {
313    generate_hybrid_keypair_with_level(SecurityLevel::Cat3)
314}
315
316/// Seal `plaintext` to a Cat-3 hybrid public key (ML-KEM-768).
317///
318/// Returns base64: `0x02 || hybrid_ct (1120 B) || nonce (24 B) || secretbox_ct`.
319pub fn hybrid_seal(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
320    hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat3)
321}
322
323/// Open a hybrid-sealed ciphertext. Auto-detects the suite + level from the
324/// version tag.
325///
326/// - `0x01/0x02/0x03` → legacy `Suite::Hybrid` (ML-KEM + X25519, combineKEMS).
327/// - `0x10` → `Suite::PureCnsa2` Cat-5 (ML-KEM-1024 + AES-256-GCM).
328/// - `0x13` → `Suite::HybridMatched` Cat-3 (ML-KEM-768 + X448 + AES-256-GCM).
329/// - `0x14` → `Suite::HybridMatched` Cat-5 (ML-KEM-1024 + P-521 + AES-256-GCM).
330///
331/// The new (`0x10/0x13/0x14`) suites bind the **default** context label
332/// [`SEAL_CONTEXT_V1`]; use [`hybrid_open_with_context`] to supply a custom
333/// per-tenant label.
334pub fn hybrid_open(ct_b64: &str, seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
335    hybrid_open_with_context(ct_b64, seed_b64, SEAL_CONTEXT_V1)
336}
337
338/// Open a hybrid-sealed ciphertext, supplying the context label used at seal
339/// time for the new CNSA-2.0 suites. The label is ignored for the legacy
340/// `0x01/0x02/0x03` tags (which never bind a context).
341pub fn hybrid_open_with_context(
342    ct_b64: &str,
343    seed_b64: &str,
344    context_label: &str,
345) -> Result<Vec<u8>, CryptoError> {
346    let combined = b64::decode(ct_b64)?;
347    match combined.first() {
348        Some(&VERSION_HYBRID_512) => hybrid_open_512(&combined, seed_b64),
349        Some(&VERSION_HYBRID_768) => hybrid_open_768(&combined, seed_b64),
350        Some(&VERSION_HYBRID_1024) => hybrid_open_1024(&combined, seed_b64),
351        Some(&TAG_KEM_PURE_CNSA2) => open_pure_cnsa2(&combined, seed_b64, context_label),
352        Some(&TAG_KEM_MATCHED_CAT3) => open_matched_cat3(&combined, seed_b64, context_label),
353        Some(&TAG_KEM_MATCHED_CAT5) => open_matched_cat5(&combined, seed_b64, context_label),
354        _ => Err(CryptoError::Hybrid(
355            "not a hybrid ciphertext (bad version tag)".into(),
356        )),
357    }
358}
359
360/// Returns `true` if the base64 blob is a hybrid ciphertext: its first byte is a
361/// known version tag (`0x01`, `0x02`, or `0x03`) **and** its total length is at
362/// least the minimum for that tier.
363///
364/// The length check (matching the exact minimum-length gate enforced by
365/// [`hybrid_open`]) is what definitively distinguishes a hybrid ciphertext from a
366/// legacy `box_seal` ciphertext whose random first byte happens to collide with a
367/// tag value: a real hybrid ciphertext is always `>=` its tier minimum, while a
368/// short legacy ciphertext below that bound is correctly rejected here. (A legacy
369/// ciphertext that collides on *both* the first byte *and* a hybrid-matching
370/// length is still handled safely by the fallback in
371/// [`crate::seal::unseal_from_user`].)
372pub fn is_hybrid_ciphertext(ct_b64: &str) -> bool {
373    let Ok(bytes) = b64::decode(ct_b64) else {
374        return false;
375    };
376    match bytes.first() {
377        Some(&VERSION_HYBRID_512) => bytes.len() >= MIN_HYBRID_512_LEN,
378        Some(&VERSION_HYBRID_768) => bytes.len() >= MIN_HYBRID_768_LEN,
379        Some(&VERSION_HYBRID_1024) => bytes.len() >= MIN_HYBRID_1024_LEN,
380        Some(&TAG_KEM_PURE_CNSA2) => bytes.len() >= MIN_PURE_CNSA2_LEN,
381        Some(&TAG_KEM_MATCHED_CAT3) => bytes.len() >= MIN_MATCHED_CAT3_LEN,
382        Some(&TAG_KEM_MATCHED_CAT5) => bytes.len() >= MIN_MATCHED_CAT5_LEN,
383        _ => false,
384    }
385}
386
387// === Public API: Cat-5 (ML-KEM-1024) ===
388
389/// Generate a hybrid ML-KEM-1024 + X25519 keypair (Cat-5).
390pub fn generate_hybrid_keypair_1024() -> HybridKeyPair {
391    generate_hybrid_keypair_with_level(SecurityLevel::Cat5)
392}
393
394/// Seal `plaintext` to a Cat-5 hybrid public key (ML-KEM-1024).
395///
396/// Returns base64: `0x03 || hybrid_ct (1600 B) || nonce (24 B) || secretbox_ct`.
397pub fn hybrid_seal_1024(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
398    hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat5)
399}
400
401// === Public API: Level-parametric ===
402
403/// Generate a hybrid keypair at the specified security level.
404pub fn generate_hybrid_keypair_with_level(level: SecurityLevel) -> HybridKeyPair {
405    let mut seed = [0u8; SEED_LEN];
406    random_bytes(&mut seed);
407
408    let expanded_len = match level {
409        SecurityLevel::Cat1 => EXPANDED_SEED_512_LEN,
410        SecurityLevel::Cat3 => EXPANDED_SEED_768_LEN,
411        SecurityLevel::Cat5 => EXPANDED_SEED_1024_LEN,
412    };
413    let mlkem_seed_len = match level {
414        SecurityLevel::Cat1 => MLKEM512_SEED_LEN,
415        SecurityLevel::Cat3 => MLKEM768_SEED_LEN,
416        SecurityLevel::Cat5 => MLKEM1024_SEED_LEN,
417    };
418
419    let mut expanded = expand_seed(&seed, expanded_len);
420    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[mlkem_seed_len..].try_into().unwrap();
421
422    // X25519 keypair
423    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
424    let x25519_pk = X25519PublicKey::from(&x25519_sk);
425
426    let combined_pk = match level {
427        SecurityLevel::Cat1 => {
428            let mlkem_seed: [u8; MLKEM512_SEED_LEN] =
429                expanded[..MLKEM512_SEED_LEN].try_into().unwrap();
430            let dk = DecapsulationKey::<MlKem512>::from_seed(mlkem_seed.into());
431            let ek = dk.encapsulation_key();
432            let ek_bytes = ek.to_bytes();
433            let mut pk = Vec::with_capacity(COMBINED_PK_512_LEN);
434            pk.extend_from_slice(&ek_bytes);
435            pk.extend_from_slice(x25519_pk.as_bytes());
436            pk
437        }
438        SecurityLevel::Cat3 => {
439            let mlkem_seed: [u8; MLKEM768_SEED_LEN] =
440                expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
441            let dk = DecapsulationKey::<MlKem768>::from_seed(mlkem_seed.into());
442            let ek = dk.encapsulation_key();
443            let ek_bytes = ek.to_bytes();
444            let mut pk = Vec::with_capacity(COMBINED_PK_768_LEN);
445            pk.extend_from_slice(&ek_bytes);
446            pk.extend_from_slice(x25519_pk.as_bytes());
447            pk
448        }
449        SecurityLevel::Cat5 => {
450            let mlkem_seed: [u8; MLKEM1024_SEED_LEN] =
451                expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
452            let dk = DecapsulationKey::<MlKem1024>::from_seed(mlkem_seed.into());
453            let ek = dk.encapsulation_key();
454            let ek_bytes = ek.to_bytes();
455            let mut pk = Vec::with_capacity(COMBINED_PK_1024_LEN);
456            pk.extend_from_slice(&ek_bytes);
457            pk.extend_from_slice(x25519_pk.as_bytes());
458            pk
459        }
460    };
461
462    let pair = HybridKeyPair {
463        public_key: b64::encode(&combined_pk),
464        secret_key: b64::encode(&seed),
465    };
466
467    seed.zeroize();
468    expanded.zeroize();
469    pair
470}
471
472/// Seal plaintext at the specified security level.
473pub fn hybrid_seal_with_level(
474    plaintext: &[u8],
475    combined_pk_b64: &str,
476    level: SecurityLevel,
477) -> Result<String, CryptoError> {
478    let pk_bytes = b64::decode(combined_pk_b64)?;
479
480    let (expected_pk_len, mlkem_ek_len, version_tag) = match level {
481        SecurityLevel::Cat1 => (COMBINED_PK_512_LEN, MLKEM512_EK_LEN, VERSION_HYBRID_512),
482        SecurityLevel::Cat3 => (COMBINED_PK_768_LEN, MLKEM768_EK_LEN, VERSION_HYBRID_768),
483        SecurityLevel::Cat5 => (COMBINED_PK_1024_LEN, MLKEM1024_EK_LEN, VERSION_HYBRID_1024),
484    };
485
486    if pk_bytes.len() != expected_pk_len {
487        return Err(CryptoError::InvalidLength {
488            expected: expected_pk_len,
489            got: pk_bytes.len(),
490        });
491    }
492
493    // Split combined public key
494    let mlkem_ek_bytes = &pk_bytes[..mlkem_ek_len];
495    let x25519_pk_bytes: [u8; X25519_LEN] = pk_bytes[mlkem_ek_len..].try_into().unwrap();
496
497    // ML-KEM encapsulate
498    let mut mlkem_coins = [0u8; 32];
499    random_bytes(&mut mlkem_coins);
500
501    let (mlkem_ct_bytes, ss_mlkem_bytes) = match level {
502        SecurityLevel::Cat1 => {
503            let ek = EncapsulationKey::<MlKem512>::new(
504                mlkem_ek_bytes
505                    .try_into()
506                    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-512 ek".into()))?,
507            )
508            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-512 encapsulation key".into()))?;
509            let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
510            (ct.as_slice().to_vec(), ss.as_slice().to_vec())
511        }
512        SecurityLevel::Cat3 => {
513            let ek = EncapsulationKey::<MlKem768>::new(
514                mlkem_ek_bytes
515                    .try_into()
516                    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ek".into()))?,
517            )
518            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 encapsulation key".into()))?;
519            let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
520            (ct.as_slice().to_vec(), ss.as_slice().to_vec())
521        }
522        SecurityLevel::Cat5 => {
523            let ek = EncapsulationKey::<MlKem1024>::new(
524                mlkem_ek_bytes
525                    .try_into()
526                    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ek".into()))?,
527            )
528            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 encapsulation key".into()))?;
529            let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
530            (ct.as_slice().to_vec(), ss.as_slice().to_vec())
531        }
532    };
533    mlkem_coins.zeroize();
534
535    // X25519 encapsulate (ephemeral DH)
536    let mut x25519_eph_bytes = [0u8; X25519_LEN];
537    random_bytes(&mut x25519_eph_bytes);
538    let x25519_eph_sk = X25519StaticSecret::from(x25519_eph_bytes);
539    let x25519_eph_pk = X25519PublicKey::from(&x25519_eph_sk);
540    let x25519_recipient_pk = X25519PublicKey::from(x25519_pk_bytes);
541    let ss_x25519 = x25519_eph_sk.diffie_hellman(&x25519_recipient_pk);
542    x25519_eph_bytes.zeroize();
543
544    // Combine shared secrets
545    let ct_x25519: [u8; X25519_LEN] = *x25519_eph_pk.as_bytes();
546    let mut shared_secret = combine(
547        &ss_mlkem_bytes,
548        ss_x25519.as_bytes(),
549        &ct_x25519,
550        &x25519_pk_bytes,
551    );
552
553    // Encrypt plaintext
554    let (secretbox_ct, nonce_buf) = secretbox_encrypt(&shared_secret, plaintext)?;
555    shared_secret.zeroize();
556
557    // Assemble: version || mlkem_ct || x25519_eph_pk || nonce || secretbox_ct
558    let combined_ct_len = mlkem_ct_bytes.len() + X25519_LEN;
559    let mut out = Vec::with_capacity(1 + combined_ct_len + NONCE_LEN + secretbox_ct.len());
560    out.push(version_tag);
561    out.extend_from_slice(&mlkem_ct_bytes);
562    out.extend_from_slice(&ct_x25519);
563    out.extend_from_slice(&nonce_buf);
564    out.extend_from_slice(&secretbox_ct);
565
566    Ok(b64::encode(&out))
567}
568
569// === Internal: Cat-1 open ===
570
571fn hybrid_open_512(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
572    let seed_bytes = b64::decode(seed_b64)?;
573    if seed_bytes.len() != SEED_LEN {
574        return Err(CryptoError::InvalidLength {
575            expected: SEED_LEN,
576            got: seed_bytes.len(),
577        });
578    }
579    if combined.len() < MIN_HYBRID_512_LEN {
580        return Err(CryptoError::TooShort);
581    }
582
583    let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
584    let mut expanded = expand_seed(&seed, EXPANDED_SEED_512_LEN);
585    let mlkem_seed: [u8; MLKEM512_SEED_LEN] = expanded[..MLKEM512_SEED_LEN].try_into().unwrap();
586    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM512_SEED_LEN..].try_into().unwrap();
587    expanded.zeroize();
588
589    // Parse ciphertext
590    let mlkem_ct = &combined[1..1 + MLKEM512_CT_LEN];
591    let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
592        [1 + MLKEM512_CT_LEN..1 + COMBINED_CT_512_LEN]
593        .try_into()
594        .unwrap();
595    let nonce_slice = &combined[1 + COMBINED_CT_512_LEN..1 + COMBINED_CT_512_LEN + NONCE_LEN];
596    let encrypted = &combined[1 + COMBINED_CT_512_LEN + NONCE_LEN..];
597
598    // ML-KEM-512 decapsulate
599    let dk = DecapsulationKey::<MlKem512>::from_seed(mlkem_seed.into());
600    let kem_ct = mlkem_ct
601        .try_into()
602        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-512 ciphertext".into()))?;
603    let ss_mlkem = dk.decapsulate(kem_ct);
604
605    // X25519 decapsulate
606    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
607    let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
608    let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
609
610    let x25519_pk = X25519PublicKey::from(&x25519_sk);
611    let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
612
613    let mut shared_secret = combine(
614        ss_mlkem.as_slice(),
615        ss_x25519.as_bytes(),
616        &x25519_eph_pk_bytes,
617        &pk_x25519,
618    );
619
620    let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
621    shared_secret.zeroize();
622    result
623}
624
625// === Internal: Cat-3 open ===
626
627fn hybrid_open_768(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
628    let seed_bytes = b64::decode(seed_b64)?;
629    if seed_bytes.len() != SEED_LEN {
630        return Err(CryptoError::InvalidLength {
631            expected: SEED_LEN,
632            got: seed_bytes.len(),
633        });
634    }
635    if combined.len() < MIN_HYBRID_768_LEN {
636        return Err(CryptoError::TooShort);
637    }
638
639    let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
640    let mut expanded = expand_seed(&seed, EXPANDED_SEED_768_LEN);
641    let mlkem_seed: [u8; MLKEM768_SEED_LEN] = expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
642    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM768_SEED_LEN..].try_into().unwrap();
643    expanded.zeroize();
644
645    // Parse ciphertext
646    let mlkem_ct = &combined[1..1 + MLKEM768_CT_LEN];
647    let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
648        [1 + MLKEM768_CT_LEN..1 + COMBINED_CT_768_LEN]
649        .try_into()
650        .unwrap();
651    let nonce_slice = &combined[1 + COMBINED_CT_768_LEN..1 + COMBINED_CT_768_LEN + NONCE_LEN];
652    let encrypted = &combined[1 + COMBINED_CT_768_LEN + NONCE_LEN..];
653
654    // ML-KEM-768 decapsulate
655    let dk = DecapsulationKey::<MlKem768>::from_seed(mlkem_seed.into());
656    let kem_ct = mlkem_ct
657        .try_into()
658        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ciphertext".into()))?;
659    let ss_mlkem = dk.decapsulate(kem_ct);
660
661    // X25519 decapsulate
662    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
663    let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
664    let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
665
666    let x25519_pk = X25519PublicKey::from(&x25519_sk);
667    let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
668
669    let mut shared_secret = combine(
670        ss_mlkem.as_slice(),
671        ss_x25519.as_bytes(),
672        &x25519_eph_pk_bytes,
673        &pk_x25519,
674    );
675
676    let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
677    shared_secret.zeroize();
678    result
679}
680
681// === Internal: Cat-5 open ===
682
683fn hybrid_open_1024(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
684    let seed_bytes = b64::decode(seed_b64)?;
685    if seed_bytes.len() != SEED_LEN {
686        return Err(CryptoError::InvalidLength {
687            expected: SEED_LEN,
688            got: seed_bytes.len(),
689        });
690    }
691    if combined.len() < MIN_HYBRID_1024_LEN {
692        return Err(CryptoError::TooShort);
693    }
694
695    let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
696    let mut expanded = expand_seed(&seed, EXPANDED_SEED_1024_LEN);
697    let mlkem_seed: [u8; MLKEM1024_SEED_LEN] = expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
698    let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM1024_SEED_LEN..].try_into().unwrap();
699    expanded.zeroize();
700
701    // Parse ciphertext
702    let mlkem_ct = &combined[1..1 + MLKEM1024_CT_LEN];
703    let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
704        [1 + MLKEM1024_CT_LEN..1 + COMBINED_CT_1024_LEN]
705        .try_into()
706        .unwrap();
707    let nonce_slice = &combined[1 + COMBINED_CT_1024_LEN..1 + COMBINED_CT_1024_LEN + NONCE_LEN];
708    let encrypted = &combined[1 + COMBINED_CT_1024_LEN + NONCE_LEN..];
709
710    // ML-KEM-1024 decapsulate
711    let dk = DecapsulationKey::<MlKem1024>::from_seed(mlkem_seed.into());
712    let kem_ct = mlkem_ct
713        .try_into()
714        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ciphertext".into()))?;
715    let ss_mlkem = dk.decapsulate(kem_ct);
716
717    // X25519 decapsulate
718    let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
719    let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
720    let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
721
722    let x25519_pk = X25519PublicKey::from(&x25519_sk);
723    let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
724
725    let mut shared_secret = combine(
726        ss_mlkem.as_slice(),
727        ss_x25519.as_bytes(),
728        &x25519_eph_pk_bytes,
729        &pk_x25519,
730    );
731
732    let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
733    shared_secret.zeroize();
734    result
735}
736
737// === CNSA 2.0 suites: ML-KEM helpers ===
738
739/// ML-KEM-768 encapsulate against `ek_bytes`. Returns `(ct, ss(32))`.
740fn mlkem768_encapsulate(ek_bytes: &[u8]) -> Result<(Vec<u8>, [u8; 32]), CryptoError> {
741    let ek = EncapsulationKey::<MlKem768>::new(
742        ek_bytes
743            .try_into()
744            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ek".into()))?,
745    )
746    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 encapsulation key".into()))?;
747    let mut coins = [0u8; 32];
748    random_bytes(&mut coins);
749    let (ct, ss) = ek.encapsulate_deterministic(&coins.into());
750    coins.zeroize();
751    let ss32: [u8; 32] = ss
752        .as_slice()
753        .try_into()
754        .map_err(|_| CryptoError::Hybrid("unexpected ML-KEM shared-secret length".into()))?;
755    Ok((ct.as_slice().to_vec(), ss32))
756}
757
758/// ML-KEM-1024 encapsulate against `ek_bytes`. Returns `(ct, ss(32))`.
759fn mlkem1024_encapsulate(ek_bytes: &[u8]) -> Result<(Vec<u8>, [u8; 32]), CryptoError> {
760    let ek = EncapsulationKey::<MlKem1024>::new(
761        ek_bytes
762            .try_into()
763            .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ek".into()))?,
764    )
765    .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 encapsulation key".into()))?;
766    let mut coins = [0u8; 32];
767    random_bytes(&mut coins);
768    let (ct, ss) = ek.encapsulate_deterministic(&coins.into());
769    coins.zeroize();
770    let ss32: [u8; 32] = ss
771        .as_slice()
772        .try_into()
773        .map_err(|_| CryptoError::Hybrid("unexpected ML-KEM shared-secret length".into()))?;
774    Ok((ct.as_slice().to_vec(), ss32))
775}
776
777/// ML-KEM-768 decapsulate `ct` with a 64-byte seed. Returns `ss(32)`.
778fn mlkem768_decapsulate(
779    seed64: &[u8; MLKEM768_SEED_LEN],
780    ct: &[u8],
781) -> Result<[u8; 32], CryptoError> {
782    let dk = DecapsulationKey::<MlKem768>::from_seed((*seed64).into());
783    let kem_ct = ct
784        .try_into()
785        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ciphertext".into()))?;
786    let ss = dk.decapsulate(kem_ct);
787    ss.as_slice()
788        .try_into()
789        .map_err(|_| CryptoError::Hybrid("unexpected ML-KEM shared-secret length".into()))
790}
791
792/// ML-KEM-1024 decapsulate `ct` with a 64-byte seed. Returns `ss(32)`.
793fn mlkem1024_decapsulate(
794    seed64: &[u8; MLKEM1024_SEED_LEN],
795    ct: &[u8],
796) -> Result<[u8; 32], CryptoError> {
797    let dk = DecapsulationKey::<MlKem1024>::from_seed((*seed64).into());
798    let kem_ct = ct
799        .try_into()
800        .map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ciphertext".into()))?;
801    let ss = dk.decapsulate(kem_ct);
802    ss.as_slice()
803        .try_into()
804        .map_err(|_| CryptoError::Hybrid("unexpected ML-KEM shared-secret length".into()))
805}
806
807/// Derive the ML-KEM-1024 encapsulation key bytes (1568 B) from a 64-byte seed.
808fn mlkem1024_ek_from_seed(seed64: &[u8; MLKEM1024_SEED_LEN]) -> Vec<u8> {
809    let dk = DecapsulationKey::<MlKem1024>::from_seed((*seed64).into());
810    dk.encapsulation_key().to_bytes().as_slice().to_vec()
811}
812
813/// Derive the ML-KEM-768 encapsulation key bytes (1184 B) from a 64-byte seed.
814fn mlkem768_ek_from_seed(seed64: &[u8; MLKEM768_SEED_LEN]) -> Vec<u8> {
815    let dk = DecapsulationKey::<MlKem768>::from_seed((*seed64).into());
816    dk.encapsulation_key().to_bytes().as_slice().to_vec()
817}
818
819// === CNSA 2.0 suites: keygen ===
820
821/// Generate a keypair for the given [`Suite`] + [`SecurityLevel`].
822///
823/// - `Suite::Hybrid` (any level) and `Suite::HybridMatched` at Cat-1 delegate to
824///   the existing [`generate_hybrid_keypair_with_level`] (identical bytes).
825/// - `Suite::HybridMatched` at Cat-3/Cat-5 and `Suite::PureCnsa2` at Cat-5
826///   produce the new combined-key layouts (ML-KEM ek `||` matched ECC pk, or
827///   ML-KEM-1024 ek alone). The `secret_key` stays a single 32-byte root seed.
828///
829/// Returns an error for unsupported combinations (PureCnsa2 below Cat-5).
830pub fn generate_hybrid_keypair_suite(
831    suite: Suite,
832    level: SecurityLevel,
833) -> Result<HybridKeyPair, CryptoError> {
834    match (suite, level) {
835        (Suite::Hybrid, _) | (Suite::HybridMatched, SecurityLevel::Cat1) => {
836            Ok(generate_hybrid_keypair_with_level(level))
837        }
838        (Suite::HybridMatched, SecurityLevel::Cat3) => Ok(generate_matched_cat3_keypair()),
839        (Suite::HybridMatched, SecurityLevel::Cat5) => Ok(generate_matched_cat5_keypair()),
840        (Suite::PureCnsa2, SecurityLevel::Cat5) => Ok(generate_pure_cnsa2_keypair()),
841        (Suite::PureCnsa2, _) => Err(CryptoError::Hybrid(
842            "PureCnsa2 is Cat-5 only in v0.7.0".into(),
843        )),
844    }
845}
846
847fn generate_pure_cnsa2_keypair() -> HybridKeyPair {
848    let mut seed = [0u8; SEED_LEN];
849    random_bytes(&mut seed);
850    let mut expanded = expand_seed(&seed, MLKEM1024_SEED_LEN);
851    let mlkem_seed: [u8; MLKEM1024_SEED_LEN] = expanded[..].try_into().unwrap();
852    let pk = mlkem1024_ek_from_seed(&mlkem_seed);
853    let pair = HybridKeyPair {
854        public_key: b64::encode(&pk),
855        secret_key: b64::encode(&seed),
856    };
857    seed.zeroize();
858    expanded.zeroize();
859    pair
860}
861
862fn generate_matched_cat3_keypair() -> HybridKeyPair {
863    let mut seed = [0u8; SEED_LEN];
864    random_bytes(&mut seed);
865    let mut expanded = expand_seed(&seed, EXPANDED_SEED_MATCHED_CAT3_LEN);
866    let mlkem_seed: [u8; MLKEM768_SEED_LEN] = expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
867    let x448_secret: [u8; ecc::X448_LEN] = expanded[MLKEM768_SEED_LEN..].try_into().unwrap();
868    let mut pk = mlkem768_ek_from_seed(&mlkem_seed);
869    pk.extend_from_slice(&ecc::x448_public_from_secret(&x448_secret));
870    let pair = HybridKeyPair {
871        public_key: b64::encode(&pk),
872        secret_key: b64::encode(&seed),
873    };
874    seed.zeroize();
875    expanded.zeroize();
876    pair
877}
878
879fn generate_matched_cat5_keypair() -> HybridKeyPair {
880    let mut seed = [0u8; SEED_LEN];
881    random_bytes(&mut seed);
882    let mut expanded = expand_seed(&seed, EXPANDED_SEED_MATCHED_CAT5_LEN);
883    let mlkem_seed: [u8; MLKEM1024_SEED_LEN] = expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
884    let p521_secret: [u8; ecc::P521_SK_LEN] = expanded[MLKEM1024_SEED_LEN..].try_into().unwrap();
885    let mut pk = mlkem1024_ek_from_seed(&mlkem_seed);
886    pk.extend_from_slice(
887        &ecc::p521_public_from_secret(&p521_secret)
888            .expect("deterministic P-521 public-key derivation"),
889    );
890    let pair = HybridKeyPair {
891        public_key: b64::encode(&pk),
892        secret_key: b64::encode(&seed),
893    };
894    seed.zeroize();
895    expanded.zeroize();
896    pair
897}
898
899// === CNSA 2.0 suites: seal ===
900
901/// Seal `plaintext` to a recipient public key under the given [`Suite`] +
902/// [`SecurityLevel`], binding the **default** context label [`SEAL_CONTEXT_V1`].
903///
904/// See [`hybrid_seal_suite_with_context`] for a custom per-tenant label.
905pub fn hybrid_seal_suite(
906    plaintext: &[u8],
907    combined_pk_b64: &str,
908    suite: Suite,
909    level: SecurityLevel,
910) -> Result<String, CryptoError> {
911    hybrid_seal_suite_with_context(plaintext, combined_pk_b64, suite, level, SEAL_CONTEXT_V1)
912}
913
914/// Seal `plaintext` under the given [`Suite`] + [`SecurityLevel`] + context label.
915///
916/// `Suite::Hybrid` (and `HybridMatched` at Cat-1) delegate to the legacy
917/// combineKEMS path and ignore `context_label` (that format binds no context).
918/// The new suites build the HKDF-SHA512 + AES-256-GCM envelope described in
919/// [`crate::suite`], binding `context_label` into both the HKDF `info` and the
920/// GCM AAD.
921pub fn hybrid_seal_suite_with_context(
922    plaintext: &[u8],
923    combined_pk_b64: &str,
924    suite: Suite,
925    level: SecurityLevel,
926    context_label: &str,
927) -> Result<String, CryptoError> {
928    match (suite, level) {
929        (Suite::Hybrid, _) => hybrid_seal_with_level(plaintext, combined_pk_b64, level),
930        (Suite::HybridMatched, SecurityLevel::Cat1) => {
931            hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat1)
932        }
933        (Suite::HybridMatched, SecurityLevel::Cat3) => {
934            seal_matched_cat3(plaintext, combined_pk_b64, context_label)
935        }
936        (Suite::HybridMatched, SecurityLevel::Cat5) => {
937            seal_matched_cat5(plaintext, combined_pk_b64, context_label)
938        }
939        (Suite::PureCnsa2, SecurityLevel::Cat5) => {
940            seal_pure_cnsa2(plaintext, combined_pk_b64, context_label)
941        }
942        (Suite::PureCnsa2, _) => Err(CryptoError::Hybrid(
943            "PureCnsa2 is Cat-5 only in v0.7.0".into(),
944        )),
945    }
946}
947
948fn check_pk_len(pk: &[u8], expected: usize) -> Result<(), CryptoError> {
949    if pk.len() != expected {
950        return Err(CryptoError::InvalidLength {
951            expected,
952            got: pk.len(),
953        });
954    }
955    Ok(())
956}
957
958fn assemble_envelope(
959    tag: u8,
960    kem_ct: &[u8],
961    ecc_eph_pk: Option<&[u8]>,
962    nonce: &[u8; GCM_NONCE_LEN],
963    aead_ct: &[u8],
964) -> String {
965    let ecc_len = ecc_eph_pk.map_or(0, |p| p.len());
966    let mut out = Vec::with_capacity(1 + kem_ct.len() + ecc_len + GCM_NONCE_LEN + aead_ct.len());
967    out.push(tag);
968    out.extend_from_slice(kem_ct);
969    if let Some(p) = ecc_eph_pk {
970        out.extend_from_slice(p);
971    }
972    out.extend_from_slice(nonce);
973    out.extend_from_slice(aead_ct);
974    b64::encode(&out)
975}
976
977fn random_nonce() -> [u8; GCM_NONCE_LEN] {
978    let mut nonce = [0u8; GCM_NONCE_LEN];
979    random_bytes(&mut nonce);
980    nonce
981}
982
983fn seal_pure_cnsa2(
984    plaintext: &[u8],
985    combined_pk_b64: &str,
986    context_label: &str,
987) -> Result<String, CryptoError> {
988    let pk = b64::decode(combined_pk_b64)?;
989    check_pk_len(&pk, PURE_CNSA2_PK_LEN)?;
990    let (kem_ct, ss_mlkem) = mlkem1024_encapsulate(&pk)?;
991    let nonce = random_nonce();
992    let aead_ct = suite::envelope_seal(
993        &ss_mlkem,
994        TAG_KEM_PURE_CNSA2,
995        context_label,
996        &nonce,
997        plaintext,
998    )?;
999    Ok(assemble_envelope(
1000        TAG_KEM_PURE_CNSA2,
1001        &kem_ct,
1002        None,
1003        &nonce,
1004        &aead_ct,
1005    ))
1006}
1007
1008fn seal_matched_cat3(
1009    plaintext: &[u8],
1010    combined_pk_b64: &str,
1011    context_label: &str,
1012) -> Result<String, CryptoError> {
1013    let pk = b64::decode(combined_pk_b64)?;
1014    check_pk_len(&pk, MATCHED_CAT3_PK_LEN)?;
1015    let (mlkem_ek, x448_pk) = pk.split_at(MLKEM768_EK_LEN);
1016    let (kem_ct, ss_mlkem) = mlkem768_encapsulate(mlkem_ek)?;
1017    let (x448_eph_pk, ss_x448) = ecc::x448_encapsulate(x448_pk)?;
1018    let mut ikm = Vec::with_capacity(32 + ecc::X448_LEN);
1019    ikm.extend_from_slice(&ss_mlkem);
1020    ikm.extend_from_slice(&ss_x448);
1021    let nonce = random_nonce();
1022    let aead_ct =
1023        suite::envelope_seal(&ikm, TAG_KEM_MATCHED_CAT3, context_label, &nonce, plaintext)?;
1024    ikm.zeroize();
1025    Ok(assemble_envelope(
1026        TAG_KEM_MATCHED_CAT3,
1027        &kem_ct,
1028        Some(&x448_eph_pk),
1029        &nonce,
1030        &aead_ct,
1031    ))
1032}
1033
1034fn seal_matched_cat5(
1035    plaintext: &[u8],
1036    combined_pk_b64: &str,
1037    context_label: &str,
1038) -> Result<String, CryptoError> {
1039    let pk = b64::decode(combined_pk_b64)?;
1040    check_pk_len(&pk, MATCHED_CAT5_PK_LEN)?;
1041    let (mlkem_ek, p521_pk) = pk.split_at(MLKEM1024_EK_LEN);
1042    let (kem_ct, ss_mlkem) = mlkem1024_encapsulate(mlkem_ek)?;
1043    let (p521_eph_pk, ss_p521) = ecc::p521_encapsulate(p521_pk)?;
1044    let mut ikm = Vec::with_capacity(32 + ecc::P521_SS_LEN);
1045    ikm.extend_from_slice(&ss_mlkem);
1046    ikm.extend_from_slice(&ss_p521);
1047    let nonce = random_nonce();
1048    let aead_ct =
1049        suite::envelope_seal(&ikm, TAG_KEM_MATCHED_CAT5, context_label, &nonce, plaintext)?;
1050    ikm.zeroize();
1051    Ok(assemble_envelope(
1052        TAG_KEM_MATCHED_CAT5,
1053        &kem_ct,
1054        Some(&p521_eph_pk),
1055        &nonce,
1056        &aead_ct,
1057    ))
1058}
1059
1060// === CNSA 2.0 suites: open ===
1061
1062fn load_seed(seed_b64: &str) -> Result<[u8; SEED_LEN], CryptoError> {
1063    let seed_bytes = b64::decode(seed_b64)?;
1064    if seed_bytes.len() != SEED_LEN {
1065        return Err(CryptoError::InvalidLength {
1066            expected: SEED_LEN,
1067            got: seed_bytes.len(),
1068        });
1069    }
1070    Ok(seed_bytes.try_into().unwrap())
1071}
1072
1073fn open_pure_cnsa2(
1074    combined: &[u8],
1075    seed_b64: &str,
1076    context_label: &str,
1077) -> Result<Vec<u8>, CryptoError> {
1078    if combined.len() < MIN_PURE_CNSA2_LEN {
1079        return Err(CryptoError::TooShort);
1080    }
1081    let seed = load_seed(seed_b64)?;
1082    let mut expanded = expand_seed(&seed, MLKEM1024_SEED_LEN);
1083    let mlkem_seed: [u8; MLKEM1024_SEED_LEN] = expanded[..].try_into().unwrap();
1084
1085    let kem_ct = &combined[1..1 + MLKEM1024_CT_LEN];
1086    let nonce: [u8; GCM_NONCE_LEN] = combined
1087        [1 + MLKEM1024_CT_LEN..1 + MLKEM1024_CT_LEN + GCM_NONCE_LEN]
1088        .try_into()
1089        .unwrap();
1090    let aead_ct = &combined[1 + MLKEM1024_CT_LEN + GCM_NONCE_LEN..];
1091
1092    let ss_mlkem = mlkem1024_decapsulate(&mlkem_seed, kem_ct)?;
1093    expanded.zeroize();
1094    suite::envelope_open(
1095        &ss_mlkem,
1096        TAG_KEM_PURE_CNSA2,
1097        context_label,
1098        &nonce,
1099        aead_ct,
1100    )
1101}
1102
1103fn open_matched_cat3(
1104    combined: &[u8],
1105    seed_b64: &str,
1106    context_label: &str,
1107) -> Result<Vec<u8>, CryptoError> {
1108    if combined.len() < MIN_MATCHED_CAT3_LEN {
1109        return Err(CryptoError::TooShort);
1110    }
1111    let seed = load_seed(seed_b64)?;
1112    let mut expanded = expand_seed(&seed, EXPANDED_SEED_MATCHED_CAT3_LEN);
1113    let mlkem_seed: [u8; MLKEM768_SEED_LEN] = expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
1114    let x448_secret: [u8; ecc::X448_LEN] = expanded[MLKEM768_SEED_LEN..].try_into().unwrap();
1115
1116    let kem_ct = &combined[1..1 + MLKEM768_CT_LEN];
1117    let ecc_start = 1 + MLKEM768_CT_LEN;
1118    let x448_eph_pk = &combined[ecc_start..ecc_start + ecc::X448_LEN];
1119    let nonce_start = ecc_start + ecc::X448_LEN;
1120    let nonce: [u8; GCM_NONCE_LEN] = combined[nonce_start..nonce_start + GCM_NONCE_LEN]
1121        .try_into()
1122        .unwrap();
1123    let aead_ct = &combined[nonce_start + GCM_NONCE_LEN..];
1124
1125    let ss_mlkem = mlkem768_decapsulate(&mlkem_seed, kem_ct)?;
1126    let ss_x448 = ecc::x448_decapsulate(&x448_secret, x448_eph_pk)?;
1127    expanded.zeroize();
1128    let mut ikm = Vec::with_capacity(32 + ecc::X448_LEN);
1129    ikm.extend_from_slice(&ss_mlkem);
1130    ikm.extend_from_slice(&ss_x448);
1131    let out = suite::envelope_open(&ikm, TAG_KEM_MATCHED_CAT3, context_label, &nonce, aead_ct);
1132    ikm.zeroize();
1133    out
1134}
1135
1136fn open_matched_cat5(
1137    combined: &[u8],
1138    seed_b64: &str,
1139    context_label: &str,
1140) -> Result<Vec<u8>, CryptoError> {
1141    if combined.len() < MIN_MATCHED_CAT5_LEN {
1142        return Err(CryptoError::TooShort);
1143    }
1144    let seed = load_seed(seed_b64)?;
1145    let mut expanded = expand_seed(&seed, EXPANDED_SEED_MATCHED_CAT5_LEN);
1146    let mlkem_seed: [u8; MLKEM1024_SEED_LEN] = expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
1147    let p521_secret: [u8; ecc::P521_SK_LEN] = expanded[MLKEM1024_SEED_LEN..].try_into().unwrap();
1148
1149    let kem_ct = &combined[1..1 + MLKEM1024_CT_LEN];
1150    let ecc_start = 1 + MLKEM1024_CT_LEN;
1151    let p521_eph_pk = &combined[ecc_start..ecc_start + ecc::P521_PK_LEN];
1152    let nonce_start = ecc_start + ecc::P521_PK_LEN;
1153    let nonce: [u8; GCM_NONCE_LEN] = combined[nonce_start..nonce_start + GCM_NONCE_LEN]
1154        .try_into()
1155        .unwrap();
1156    let aead_ct = &combined[nonce_start + GCM_NONCE_LEN..];
1157
1158    let ss_mlkem = mlkem1024_decapsulate(&mlkem_seed, kem_ct)?;
1159    let ss_p521 = ecc::p521_decapsulate(&p521_secret, p521_eph_pk)?;
1160    expanded.zeroize();
1161    let mut ikm = Vec::with_capacity(32 + ecc::P521_SS_LEN);
1162    ikm.extend_from_slice(&ss_mlkem);
1163    ikm.extend_from_slice(&ss_p521);
1164    let out = suite::envelope_open(&ikm, TAG_KEM_MATCHED_CAT5, context_label, &nonce, aead_ct);
1165    ikm.zeroize();
1166    out
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use super::*;
1172
1173    // === Cat-3 (existing behavior) ===
1174
1175    #[test]
1176    fn cat3_roundtrip() {
1177        let kp = generate_hybrid_keypair();
1178        let pt = b"32-byte symmetric context key!!!";
1179        let ct = hybrid_seal(pt, &kp.public_key).unwrap();
1180        assert!(is_hybrid_ciphertext(&ct));
1181        let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
1182        assert_eq!(opened, pt);
1183    }
1184
1185    #[test]
1186    fn cat3_wrong_key_fails() {
1187        let kp1 = generate_hybrid_keypair();
1188        let kp2 = generate_hybrid_keypair();
1189        let ct = hybrid_seal(b"x", &kp1.public_key).unwrap();
1190        assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
1191    }
1192
1193    #[test]
1194    fn cat3_version_tag() {
1195        let kp = generate_hybrid_keypair();
1196        let raw = b64::decode(&hybrid_seal(b"x", &kp.public_key).unwrap()).unwrap();
1197        assert_eq!(raw[0], VERSION_HYBRID_768);
1198    }
1199
1200    #[test]
1201    fn cat3_nondeterministic() {
1202        let kp = generate_hybrid_keypair();
1203        let c1 = hybrid_seal(b"x", &kp.public_key).unwrap();
1204        let c2 = hybrid_seal(b"x", &kp.public_key).unwrap();
1205        assert_ne!(c1, c2);
1206    }
1207
1208    #[test]
1209    fn cat3_empty_plaintext() {
1210        let kp = generate_hybrid_keypair();
1211        let ct = hybrid_seal(b"", &kp.public_key).unwrap();
1212        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
1213    }
1214
1215    #[test]
1216    fn cat3_key_sizes() {
1217        let kp = generate_hybrid_keypair();
1218        let pk = b64::decode(&kp.public_key).unwrap();
1219        let sk = b64::decode(&kp.secret_key).unwrap();
1220        assert_eq!(pk.len(), COMBINED_PK_768_LEN); // 1216
1221        assert_eq!(sk.len(), SEED_LEN); // 32
1222    }
1223
1224    #[test]
1225    fn cat3_ciphertext_size() {
1226        let kp = generate_hybrid_keypair();
1227        let pt = b"exactly 32 bytes of key material";
1228        let raw = b64::decode(&hybrid_seal(pt, &kp.public_key).unwrap()).unwrap();
1229        // 1 + 1120 + 24 + 32 + 16 = 1193
1230        assert_eq!(
1231            raw.len(),
1232            1 + COMBINED_CT_768_LEN + NONCE_LEN + 32 + MAC_LEN
1233        );
1234    }
1235
1236    // === Cat-1 (new) ===
1237
1238    #[test]
1239    fn cat1_roundtrip() {
1240        let kp = generate_hybrid_keypair_512();
1241        let pt = b"32-byte symmetric context key!!!";
1242        let ct = hybrid_seal_512(pt, &kp.public_key).unwrap();
1243        assert!(is_hybrid_ciphertext(&ct));
1244        let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
1245        assert_eq!(opened, pt);
1246    }
1247
1248    #[test]
1249    fn cat1_version_tag() {
1250        let kp = generate_hybrid_keypair_512();
1251        let raw = b64::decode(&hybrid_seal_512(b"x", &kp.public_key).unwrap()).unwrap();
1252        assert_eq!(raw[0], VERSION_HYBRID_512);
1253    }
1254
1255    #[test]
1256    fn cat1_wrong_key_fails() {
1257        let kp1 = generate_hybrid_keypair_512();
1258        let kp2 = generate_hybrid_keypair_512();
1259        let ct = hybrid_seal_512(b"x", &kp1.public_key).unwrap();
1260        assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
1261    }
1262
1263    #[test]
1264    fn cat1_key_sizes() {
1265        let kp = generate_hybrid_keypair_512();
1266        let pk = b64::decode(&kp.public_key).unwrap();
1267        let sk = b64::decode(&kp.secret_key).unwrap();
1268        assert_eq!(pk.len(), COMBINED_PK_512_LEN); // 832
1269        assert_eq!(sk.len(), SEED_LEN); // 32
1270    }
1271
1272    #[test]
1273    fn cat1_ciphertext_size() {
1274        let kp = generate_hybrid_keypair_512();
1275        let pt = b"exactly 32 bytes of key material";
1276        let raw = b64::decode(&hybrid_seal_512(pt, &kp.public_key).unwrap()).unwrap();
1277        // 1 + 800 + 24 + 32 + 16 = 873
1278        assert_eq!(
1279            raw.len(),
1280            1 + COMBINED_CT_512_LEN + NONCE_LEN + 32 + MAC_LEN
1281        );
1282    }
1283
1284    #[test]
1285    fn cat1_nondeterministic() {
1286        let kp = generate_hybrid_keypair_512();
1287        let c1 = hybrid_seal_512(b"x", &kp.public_key).unwrap();
1288        let c2 = hybrid_seal_512(b"x", &kp.public_key).unwrap();
1289        assert_ne!(c1, c2);
1290    }
1291
1292    #[test]
1293    fn cat1_empty_plaintext() {
1294        let kp = generate_hybrid_keypair_512();
1295        let ct = hybrid_seal_512(b"", &kp.public_key).unwrap();
1296        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
1297    }
1298
1299    // === Cat-5 (new) ===
1300
1301    #[test]
1302    fn cat5_roundtrip() {
1303        let kp = generate_hybrid_keypair_1024();
1304        let pt = b"32-byte symmetric context key!!!";
1305        let ct = hybrid_seal_1024(pt, &kp.public_key).unwrap();
1306        assert!(is_hybrid_ciphertext(&ct));
1307        let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
1308        assert_eq!(opened, pt);
1309    }
1310
1311    #[test]
1312    fn cat5_version_tag() {
1313        let kp = generate_hybrid_keypair_1024();
1314        let raw = b64::decode(&hybrid_seal_1024(b"x", &kp.public_key).unwrap()).unwrap();
1315        assert_eq!(raw[0], VERSION_HYBRID_1024);
1316    }
1317
1318    #[test]
1319    fn cat5_wrong_key_fails() {
1320        let kp1 = generate_hybrid_keypair_1024();
1321        let kp2 = generate_hybrid_keypair_1024();
1322        let ct = hybrid_seal_1024(b"x", &kp1.public_key).unwrap();
1323        assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
1324    }
1325
1326    #[test]
1327    fn cat5_key_sizes() {
1328        let kp = generate_hybrid_keypair_1024();
1329        let pk = b64::decode(&kp.public_key).unwrap();
1330        let sk = b64::decode(&kp.secret_key).unwrap();
1331        assert_eq!(pk.len(), COMBINED_PK_1024_LEN); // 1600
1332        assert_eq!(sk.len(), SEED_LEN); // 32
1333    }
1334
1335    #[test]
1336    fn cat5_ciphertext_size() {
1337        let kp = generate_hybrid_keypair_1024();
1338        let pt = b"exactly 32 bytes of key material";
1339        let raw = b64::decode(&hybrid_seal_1024(pt, &kp.public_key).unwrap()).unwrap();
1340        // 1 + 1600 + 24 + 32 + 16 = 1673
1341        assert_eq!(
1342            raw.len(),
1343            1 + COMBINED_CT_1024_LEN + NONCE_LEN + 32 + MAC_LEN
1344        );
1345    }
1346
1347    #[test]
1348    fn cat5_nondeterministic() {
1349        let kp = generate_hybrid_keypair_1024();
1350        let c1 = hybrid_seal_1024(b"x", &kp.public_key).unwrap();
1351        let c2 = hybrid_seal_1024(b"x", &kp.public_key).unwrap();
1352        assert_ne!(c1, c2);
1353    }
1354
1355    #[test]
1356    fn cat5_empty_plaintext() {
1357        let kp = generate_hybrid_keypair_1024();
1358        let ct = hybrid_seal_1024(b"", &kp.public_key).unwrap();
1359        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
1360    }
1361
1362    // === Cross-level ===
1363
1364    #[test]
1365    fn cat3_ct_cannot_open_with_cat5_key() {
1366        let kp3 = generate_hybrid_keypair();
1367        let kp5 = generate_hybrid_keypair_1024();
1368        let ct = hybrid_seal(b"test", &kp3.public_key).unwrap();
1369        assert!(hybrid_open(&ct, &kp5.secret_key).is_err());
1370    }
1371
1372    #[test]
1373    fn cat5_ct_cannot_open_with_cat3_key() {
1374        let kp3 = generate_hybrid_keypair();
1375        let kp5 = generate_hybrid_keypair_1024();
1376        let ct = hybrid_seal_1024(b"test", &kp5.public_key).unwrap();
1377        assert!(hybrid_open(&ct, &kp3.secret_key).is_err());
1378    }
1379
1380    #[test]
1381    fn cat1_ct_cannot_open_with_cat3_key() {
1382        let kp1 = generate_hybrid_keypair_512();
1383        let kp3 = generate_hybrid_keypair();
1384        let ct = hybrid_seal_512(b"test", &kp1.public_key).unwrap();
1385        assert!(hybrid_open(&ct, &kp3.secret_key).is_err());
1386    }
1387
1388    #[test]
1389    fn cat1_ct_cannot_open_with_cat5_key() {
1390        let kp1 = generate_hybrid_keypair_512();
1391        let kp5 = generate_hybrid_keypair_1024();
1392        let ct = hybrid_seal_512(b"test", &kp1.public_key).unwrap();
1393        assert!(hybrid_open(&ct, &kp5.secret_key).is_err());
1394    }
1395
1396    #[test]
1397    fn legacy_not_hybrid() {
1398        // A short blob whose first byte is not any hybrid tag.
1399        let legacy = b64::encode(&[0x42, 0x02, 0x03]);
1400        assert!(!is_hybrid_ciphertext(&legacy));
1401    }
1402
1403    #[test]
1404    fn legacy_starting_with_0x01_not_misdetected_as_cat1() {
1405        // `is_hybrid_ciphertext` is length-aware: a legacy-sized (~80B) blob whose
1406        // first byte collides with the Cat-1 tag (0x01) is NOT classified as
1407        // hybrid, because it is far below the Cat-1 minimum length
1408        // (1 + 768 + 32 + 24 + 16 = 841B). In practice Mosslet legacy cts (~80B
1409        // sealing a 32B key) can never reach a hybrid length.
1410        let mut legacy = vec![0x01u8]; // collides with the Cat-1 tag
1411        legacy.extend_from_slice(&[0u8; 79]); // total 80 bytes, legacy-sized
1412        let legacy_b64 = b64::encode(&legacy);
1413        assert!(!is_hybrid_ciphertext(&legacy_b64));
1414        // And `hybrid_open`'s own length gate independently rejects it.
1415        let kp = generate_hybrid_keypair_512();
1416        let err = hybrid_open(&legacy_b64, &kp.secret_key).unwrap_err();
1417        assert!(matches!(err, CryptoError::TooShort));
1418    }
1419
1420    #[test]
1421    fn long_0x01_blob_below_cat1_min_not_hybrid() {
1422        // A 0x01-leading blob just under the Cat-1 minimum is still not hybrid.
1423        let min_cat1 = MIN_HYBRID_512_LEN; // 841
1424        let mut blob = vec![0x01u8];
1425        blob.extend_from_slice(&vec![0u8; min_cat1 - 2]); // total = min - 1
1426        assert!(!is_hybrid_ciphertext(&b64::encode(&blob)));
1427        // At exactly the minimum, the tag+length check classifies it as hybrid
1428        // (disambiguation past this point is handled by unseal_from_user's
1429        // fallback to the legacy opener).
1430        let mut at_min = vec![0x01u8];
1431        at_min.extend_from_slice(&vec![0u8; min_cat1 - 1]); // total = min
1432        assert!(is_hybrid_ciphertext(&b64::encode(&at_min)));
1433    }
1434
1435    #[test]
1436    fn seed_expansion_deterministic() {
1437        let seed = [0x42u8; SEED_LEN];
1438        let expanded = expand_seed(&seed, 96);
1439        let expanded2 = expand_seed(&seed, 96);
1440        assert_eq!(expanded, expanded2);
1441    }
1442
1443    #[test]
1444    fn combiner_uses_label() {
1445        let ss_mlkem = [0xAAu8; 32];
1446        let ss_x25519 = [0xBBu8; 32];
1447        let ct_x25519 = [0xCCu8; 32];
1448        let pk_x25519 = [0xDDu8; 32];
1449        let result = combine(&ss_mlkem, &ss_x25519, &ct_x25519, &pk_x25519);
1450        assert_eq!(result.len(), 32);
1451
1452        let ss_mlkem2 = [0xEEu8; 32];
1453        let result2 = combine(&ss_mlkem2, &ss_x25519, &ct_x25519, &pk_x25519);
1454        assert_ne!(result, result2);
1455    }
1456
1457    // === CNSA 2.0 suites (v0.7.0) ===
1458
1459    #[test]
1460    fn pure_cnsa2_roundtrip() {
1461        let kp = generate_hybrid_keypair_suite(Suite::PureCnsa2, SecurityLevel::Cat5).unwrap();
1462        let pt = b"32-byte symmetric context key!!!";
1463        let ct =
1464            hybrid_seal_suite(pt, &kp.public_key, Suite::PureCnsa2, SecurityLevel::Cat5).unwrap();
1465        assert!(is_hybrid_ciphertext(&ct));
1466        assert_eq!(b64::decode(&ct).unwrap()[0], TAG_KEM_PURE_CNSA2);
1467        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), pt);
1468    }
1469
1470    #[test]
1471    fn pure_cnsa2_pk_len_and_no_classical_half() {
1472        let kp = generate_hybrid_keypair_suite(Suite::PureCnsa2, SecurityLevel::Cat5).unwrap();
1473        assert_eq!(
1474            b64::decode(&kp.public_key).unwrap().len(),
1475            PURE_CNSA2_PK_LEN
1476        );
1477        assert_eq!(b64::decode(&kp.secret_key).unwrap().len(), SEED_LEN);
1478    }
1479
1480    #[test]
1481    fn pure_cnsa2_only_cat5() {
1482        assert!(generate_hybrid_keypair_suite(Suite::PureCnsa2, SecurityLevel::Cat3).is_err());
1483        assert!(generate_hybrid_keypair_suite(Suite::PureCnsa2, SecurityLevel::Cat1).is_err());
1484    }
1485
1486    #[test]
1487    fn matched_cat3_roundtrip() {
1488        let kp = generate_hybrid_keypair_suite(Suite::HybridMatched, SecurityLevel::Cat3).unwrap();
1489        assert_eq!(
1490            b64::decode(&kp.public_key).unwrap().len(),
1491            MATCHED_CAT3_PK_LEN
1492        );
1493        let pt = b"matched cat-3 (ML-KEM-768 + X448)";
1494        let ct = hybrid_seal_suite(
1495            pt,
1496            &kp.public_key,
1497            Suite::HybridMatched,
1498            SecurityLevel::Cat3,
1499        )
1500        .unwrap();
1501        assert!(is_hybrid_ciphertext(&ct));
1502        assert_eq!(b64::decode(&ct).unwrap()[0], TAG_KEM_MATCHED_CAT3);
1503        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), pt);
1504    }
1505
1506    #[test]
1507    fn matched_cat5_roundtrip() {
1508        let kp = generate_hybrid_keypair_suite(Suite::HybridMatched, SecurityLevel::Cat5).unwrap();
1509        assert_eq!(
1510            b64::decode(&kp.public_key).unwrap().len(),
1511            MATCHED_CAT5_PK_LEN
1512        );
1513        let pt = b"matched cat-5 (ML-KEM-1024 + P-521)";
1514        let ct = hybrid_seal_suite(
1515            pt,
1516            &kp.public_key,
1517            Suite::HybridMatched,
1518            SecurityLevel::Cat5,
1519        )
1520        .unwrap();
1521        assert!(is_hybrid_ciphertext(&ct));
1522        assert_eq!(b64::decode(&ct).unwrap()[0], TAG_KEM_MATCHED_CAT5);
1523        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), pt);
1524    }
1525
1526    #[test]
1527    fn matched_cat1_is_plain_hybrid() {
1528        // HybridMatched at Cat-1 must be byte-format-identical to Hybrid Cat-1
1529        // (X25519, tag 0x01) — no new format at the lowest rung.
1530        let kp = generate_hybrid_keypair_suite(Suite::HybridMatched, SecurityLevel::Cat1).unwrap();
1531        let ct = hybrid_seal_suite(
1532            b"x",
1533            &kp.public_key,
1534            Suite::HybridMatched,
1535            SecurityLevel::Cat1,
1536        )
1537        .unwrap();
1538        assert_eq!(b64::decode(&ct).unwrap()[0], VERSION_HYBRID_512);
1539        assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"x");
1540    }
1541
1542    #[test]
1543    fn hybrid_suite_is_unchanged_legacy_format() {
1544        // Suite::Hybrid must keep emitting the legacy tags/bytes at every level.
1545        for (level, tag) in [
1546            (SecurityLevel::Cat1, VERSION_HYBRID_512),
1547            (SecurityLevel::Cat3, VERSION_HYBRID_768),
1548            (SecurityLevel::Cat5, VERSION_HYBRID_1024),
1549        ] {
1550            let kp = generate_hybrid_keypair_suite(Suite::Hybrid, level).unwrap();
1551            let ct = hybrid_seal_suite(b"x", &kp.public_key, Suite::Hybrid, level).unwrap();
1552            assert_eq!(b64::decode(&ct).unwrap()[0], tag);
1553            assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"x");
1554        }
1555    }
1556
1557    #[test]
1558    fn new_suites_empty_plaintext() {
1559        for (suite, level) in [
1560            (Suite::PureCnsa2, SecurityLevel::Cat5),
1561            (Suite::HybridMatched, SecurityLevel::Cat3),
1562            (Suite::HybridMatched, SecurityLevel::Cat5),
1563        ] {
1564            let kp = generate_hybrid_keypair_suite(suite, level).unwrap();
1565            let ct = hybrid_seal_suite(b"", &kp.public_key, suite, level).unwrap();
1566            assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
1567        }
1568    }
1569
1570    #[test]
1571    fn new_suites_nondeterministic_and_wrong_key_fails() {
1572        for (suite, level) in [
1573            (Suite::PureCnsa2, SecurityLevel::Cat5),
1574            (Suite::HybridMatched, SecurityLevel::Cat3),
1575            (Suite::HybridMatched, SecurityLevel::Cat5),
1576        ] {
1577            let kp = generate_hybrid_keypair_suite(suite, level).unwrap();
1578            let kp2 = generate_hybrid_keypair_suite(suite, level).unwrap();
1579            let c1 = hybrid_seal_suite(b"x", &kp.public_key, suite, level).unwrap();
1580            let c2 = hybrid_seal_suite(b"x", &kp.public_key, suite, level).unwrap();
1581            assert_ne!(c1, c2, "fresh nonce + KEM => non-deterministic");
1582            assert!(
1583                hybrid_open(&c1, &kp2.secret_key).is_err(),
1584                "wrong key fails"
1585            );
1586        }
1587    }
1588
1589    #[test]
1590    fn context_label_is_bound() {
1591        let kp = generate_hybrid_keypair_suite(Suite::PureCnsa2, SecurityLevel::Cat5).unwrap();
1592        let ct = hybrid_seal_suite_with_context(
1593            b"secret",
1594            &kp.public_key,
1595            Suite::PureCnsa2,
1596            SecurityLevel::Cat5,
1597            "mosslet/seal/v1",
1598        )
1599        .unwrap();
1600        // Opening with the wrong context label fails (AAD + HKDF info mismatch).
1601        assert!(hybrid_open_with_context(&ct, &kp.secret_key, "metamorphic/seal/v1").is_err());
1602        // Opening with the correct label succeeds.
1603        assert_eq!(
1604            hybrid_open_with_context(&ct, &kp.secret_key, "mosslet/seal/v1").unwrap(),
1605            b"secret"
1606        );
1607    }
1608
1609    #[test]
1610    fn new_suite_tampered_ciphertext_fails() {
1611        let kp = generate_hybrid_keypair_suite(Suite::HybridMatched, SecurityLevel::Cat5).unwrap();
1612        let ct = hybrid_seal_suite(
1613            b"data",
1614            &kp.public_key,
1615            Suite::HybridMatched,
1616            SecurityLevel::Cat5,
1617        )
1618        .unwrap();
1619        let mut raw = b64::decode(&ct).unwrap();
1620        let last = raw.len() - 1;
1621        raw[last] ^= 0xFF; // corrupt the GCM tag
1622        assert!(hybrid_open(&b64::encode(&raw), &kp.secret_key).is_err());
1623    }
1624
1625    /// ML-KEM-1024 deterministic known-answer test (fixed seed + fixed coins).
1626    /// The `ml-kem` crate is validated against the NIST FIPS-203 KATs, so these
1627    /// pinned digests anchor byte-equality of the raw ML-KEM-1024 encapsulation
1628    /// key / ciphertext / shared secret with `@noble/post-quantum` (ml_kem1024)
1629    /// and any other FIPS-203 implementation, across Rust / WASM / NIF.
1630    #[test]
1631    fn mlkem1024_fips203_kat() {
1632        use crate::hash::sha3_512;
1633        fn hx(b: &[u8]) -> String {
1634            b.iter().map(|x| format!("{x:02x}")).collect()
1635        }
1636        let seed = [0x07u8; MLKEM1024_SEED_LEN];
1637        let dk = DecapsulationKey::<MlKem1024>::from_seed(seed.into());
1638        let ek = dk.encapsulation_key();
1639        let ek_bytes = ek.to_bytes();
1640        assert_eq!(ek_bytes.len(), MLKEM1024_EK_LEN);
1641        assert_eq!(
1642            hx(&sha3_512(ek_bytes.as_slice())),
1643            "21d44f22f8467cde9040b3e6161c9353f9dd48e6854d3125c2690826a06ad707\
1644             8fa79245d715430afcca6bbd94a352e95081bd0b65aa210661f4deafdfc2fee4"
1645        );
1646        let coins = [0x09u8; 32];
1647        let (ct, ss) = ek.encapsulate_deterministic(&coins.into());
1648        assert_eq!(ct.as_slice().len(), MLKEM1024_CT_LEN);
1649        assert_eq!(
1650            hx(&sha3_512(ct.as_slice())),
1651            "79ca73f654930548ecedd30019fcd19f4ca6b653aef0bc647df8389945d04f81\
1652             47d5c45c8c8b93b679f3c15a4424c6c38c57e23d3383fd1e72964e98c1f19475"
1653        );
1654        assert_eq!(
1655            hx(ss.as_slice()),
1656            "a6b0741c68de147722d30abc60415c846f7130a51611c0de65cfe019cd9913f4"
1657        );
1658    }
1659}