metamorphic_crypto/suite.rs
1//! CNSA 2.0 **suite axis** — shared scaffolding for the post-quantum suites
2//! added in v0.7.0 (additive, non-breaking).
3//!
4//! The [`Suite`] axis is **orthogonal** to [`crate::hybrid::SecurityLevel`] /
5//! [`crate::sign::SignatureLevel`]: `SecurityLevel` selects the ML-* parameter
6//! set (Cat-1/3/5), while `Suite` selects the *composition posture* (classical
7//! hybrid vs. matched-strength hybrid vs. pure post-quantum). Both the KEM /
8//! seal layer ([`crate::hybrid`]) and the signature layer ([`sign`](mod@crate::sign))
9//! consume the same axis, so a developer flips between postures with a single
10//! one-argument change while the rest of the API surface stays identical.
11//!
12//! ## Suites
13//!
14//! - [`Suite::Hybrid`] — **default & recommended.** The existing strict-AND
15//! classical+PQ construction. Byte-for-byte unchanged; the new wire formats
16//! in this module are never produced for `Hybrid`.
17//! - [`Suite::HybridMatched`] — opt-in. The classical partner is chosen *by the
18//! PQ category* so it is never the weak link (KEM: Cat-3→X448, Cat-5→P-521
19//! ECDH; signatures: Cat-3→Ed448, Cat-5→ECDSA-P-521). At the lowest shared
20//! rung it is identical to `Hybrid` (no new format), so nothing breaks.
21//! - [`Suite::PureCnsa2`] — opt-in. The NSA CNSA-2.0 box: pure ML-KEM-1024 /
22//! AES-256-GCM (KEM) and pure ML-DSA-87 (signatures), with no classical half.
23//! Standards-compliant but, until the lattice implementation is independently
24//! audited, it lacks the classical backstop the default `Hybrid` keeps — this
25//! is documented plainly rather than hidden.
26//!
27//! ## Seal envelope (KEM, new suites only)
28//!
29//! The matched / pure KEM suites do **not** reuse the legacy `combineKEMS`
30//! (SHA3-256) + XSalsa20-Poly1305 construction (that stays byte-identical for
31//! `Suite::Hybrid`). Instead they use a CNSA-correct envelope built only from
32//! standardized pieces:
33//!
34//! ```text
35//! KEM encapsulate -> ss(s)
36//! ikm = ss_mlkem (PureCnsa2) | ss_mlkem || ss_ecc (HybridMatched)
37//! key = HKDF-SHA512(ikm, info = suite_tag || context_label) -> 32-byte AES-256 key
38//! out = AES-256-GCM(key, 96-bit random nonce, AAD = suite_tag || context_label)
39//! wire = tag(1) || kem_ct || [ecc_eph_pk] || nonce(12) || ct || gcm_tag(16)
40//! ```
41//!
42//! Because every encapsulation yields a fresh KEM secret, the derived AES-256
43//! key is single-use, so the 96-bit random nonce can never repeat — SIV-grade
44//! misuse resistance without leaving the CNSA-approved set (no AES-GCM-SIV).
45//! HKDF-SHA512 is the SP 800-56C-approved KDF for KEM-derived keys and is
46//! byte-identical across RustCrypto, WebCrypto, and `@noble/hashes`.
47//!
48//! Note the deliberate hash split: HKDF-**SHA512** for *key derivation* here;
49//! SHA3-512 stays the choice for *leaf/transcript* hashing elsewhere
50//! ([`crate::hash`]). Different roles, both standardized.
51
52use aes_gcm::aead::array::Array;
53use aes_gcm::aead::{Aead, Payload};
54use aes_gcm::{Aes256Gcm, KeyInit};
55use hkdf::Hkdf;
56use sha2::Sha512;
57use zeroize::Zeroize;
58
59use crate::CryptoError;
60
61// === Suite axis ===
62
63/// Composition posture for the post-quantum suites (orthogonal to the ML-*
64/// parameter set chosen by `SecurityLevel` / `SignatureLevel`).
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum Suite {
67 /// Default & recommended: existing classical+PQ strict-AND construction.
68 /// Byte-for-byte unchanged; never emits the new envelope formats.
69 #[default]
70 Hybrid,
71 /// Opt-in: classical partner matched to the PQ category (Cat-3 / Cat-5
72 /// diverge from `Hybrid`; the lowest shared rung is identical to `Hybrid`).
73 HybridMatched,
74 /// Opt-in: pure post-quantum, no classical half (the NSA CNSA-2.0 box).
75 PureCnsa2,
76}
77
78// === Context labels ===
79
80/// Default versioned context label for sealing (KEM envelope).
81///
82/// Grammar: `"<namespace>/<purpose>/v<major>"`. The **namespace** is the one
83/// per-tenant knob — Mosslet / Metamorphic / mosskeys pass their own (e.g.
84/// `"mosslet/seal/v1"`) while the protocol shape stays fixed. Bound into both
85/// the HKDF `info` and the GCM AAD.
86pub const SEAL_CONTEXT_V1: &str = "metamorphic/seal/v1";
87
88// === Wire-format tags (new KEM/seal suites) ===
89
90/// `0x10` — PureCnsa2 seal (ML-KEM-1024 + AES-256-GCM). Headline CNSA-5 box.
91pub const TAG_KEM_PURE_CNSA2: u8 = 0x10;
92/// `0x13` — HybridMatched Cat-3 seal (ML-KEM-768 + X448, AES-256-GCM).
93pub const TAG_KEM_MATCHED_CAT3: u8 = 0x13;
94/// `0x14` — HybridMatched Cat-5 seal (ML-KEM-1024 + P-521 ECDH, AES-256-GCM).
95pub const TAG_KEM_MATCHED_CAT5: u8 = 0x14;
96
97/// AES-256-GCM nonce length (96-bit, per SP 800-38D).
98pub const GCM_NONCE_LEN: usize = 12;
99/// AES-256-GCM authentication tag length (full 128-bit).
100pub const GCM_TAG_LEN: usize = 16;
101/// Derived AES-256 key length.
102pub const AES256_KEY_LEN: usize = 32;
103
104// === Domain-separation bytes ===
105
106/// Build the domain-separation bytes bound into both the HKDF `info` and the
107/// GCM AAD: `suite_tag || context_label_utf8`.
108pub(crate) fn domain_bytes(tag: u8, context_label: &str) -> Vec<u8> {
109 let mut v = Vec::with_capacity(1 + context_label.len());
110 v.push(tag);
111 v.extend_from_slice(context_label.as_bytes());
112 v
113}
114
115// === HKDF-SHA512 ===
116
117/// Derive a single-use 32-byte AES-256 key from KEM-derived `ikm` using
118/// HKDF-SHA512 (RFC 5869) with no salt and `info = suite_tag || context_label`.
119pub(crate) fn derive_aes256_key(
120 ikm: &[u8],
121 info: &[u8],
122) -> Result<[u8; AES256_KEY_LEN], CryptoError> {
123 let hk = Hkdf::<Sha512>::new(None, ikm);
124 let mut okm = [0u8; AES256_KEY_LEN];
125 hk.expand(info, &mut okm)
126 .map_err(|_| CryptoError::Hybrid("HKDF-SHA512 expand failed".into()))?;
127 Ok(okm)
128}
129
130// === AES-256-GCM ===
131
132/// AES-256-GCM seal. Returns the combined `ciphertext || tag(16)` (the AEAD
133/// output); the caller supplies the 96-bit `nonce` and the `aad`.
134pub(crate) fn aes256gcm_seal(
135 key: &[u8; AES256_KEY_LEN],
136 nonce: &[u8; GCM_NONCE_LEN],
137 aad: &[u8],
138 plaintext: &[u8],
139) -> Result<Vec<u8>, CryptoError> {
140 let cipher = Aes256Gcm::new(&Array(*key));
141 let nonce_arr: Array<u8, _> = Array(*nonce);
142 cipher
143 .encrypt(
144 &nonce_arr,
145 Payload {
146 msg: plaintext,
147 aad,
148 },
149 )
150 .map_err(|_| CryptoError::Hybrid("AES-256-GCM encrypt failed".into()))
151}
152
153/// AES-256-GCM open. `ciphertext` is the combined `ciphertext || tag(16)`.
154pub(crate) fn aes256gcm_open(
155 key: &[u8; AES256_KEY_LEN],
156 nonce: &[u8; GCM_NONCE_LEN],
157 aad: &[u8],
158 ciphertext: &[u8],
159) -> Result<Vec<u8>, CryptoError> {
160 let cipher = Aes256Gcm::new(&Array(*key));
161 let nonce_arr: Array<u8, _> = Array(*nonce);
162 cipher
163 .decrypt(
164 &nonce_arr,
165 Payload {
166 msg: ciphertext,
167 aad,
168 },
169 )
170 .map_err(|_| CryptoError::Decryption)
171}
172
173/// Convenience: seal `plaintext` for the new envelope. Derives the single-use
174/// AES key from `ikm` + domain bytes, encrypts under a caller-provided 96-bit
175/// `nonce`, and zeroizes the derived key. Returns `ciphertext || tag(16)`.
176pub(crate) fn envelope_seal(
177 ikm: &[u8],
178 tag: u8,
179 context_label: &str,
180 nonce: &[u8; GCM_NONCE_LEN],
181 plaintext: &[u8],
182) -> Result<Vec<u8>, CryptoError> {
183 let domain = domain_bytes(tag, context_label);
184 let mut key = derive_aes256_key(ikm, &domain)?;
185 let out = aes256gcm_seal(&key, nonce, &domain, plaintext);
186 key.zeroize();
187 out
188}
189
190/// Convenience: open a new-envelope `ciphertext || tag(16)` produced by
191/// [`envelope_seal`] with the same `ikm`, `tag`, `context_label`, and `nonce`.
192pub(crate) fn envelope_open(
193 ikm: &[u8],
194 tag: u8,
195 context_label: &str,
196 nonce: &[u8; GCM_NONCE_LEN],
197 ciphertext: &[u8],
198) -> Result<Vec<u8>, CryptoError> {
199 let domain = domain_bytes(tag, context_label);
200 let mut key = derive_aes256_key(ikm, &domain)?;
201 let out = aes256gcm_open(&key, nonce, &domain, ciphertext);
202 key.zeroize();
203 out
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn hex(bytes: &[u8]) -> String {
211 bytes.iter().map(|b| format!("{b:02x}")).collect()
212 }
213
214 fn unhex(s: &str) -> Vec<u8> {
215 (0..s.len())
216 .step_by(2)
217 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
218 .collect()
219 }
220
221 // --- Known-answer tests (KATs) ---
222
223 /// AES-256-GCM NIST CAVP vectors (all-zero key + 96-bit zero IV). Pins the
224 /// AEAD output (`ciphertext || tag`) for cross-language parity.
225 #[test]
226 fn aes256gcm_nist_kat() {
227 let key = [0u8; 32];
228 let nonce = [0u8; 12];
229 // Empty plaintext / empty AAD => 16-byte tag only.
230 let out = aes256gcm_seal(&key, &nonce, &[], &[]).unwrap();
231 assert_eq!(hex(&out), "530f8afbc74536b9a963b4f1c4cb738b");
232 // 16 zero bytes plaintext => ct(16) || tag(16).
233 let out16 = aes256gcm_seal(&key, &nonce, &[], &[0u8; 16]).unwrap();
234 assert_eq!(
235 hex(&out16),
236 "cea7403d4d606b6e074ec5d3baf39d18d0d1c8a799996bf0265b98b5d48ab919"
237 );
238 // Round-trips.
239 assert_eq!(aes256gcm_open(&key, &nonce, &[], &out).unwrap(), b"");
240 assert_eq!(
241 aes256gcm_open(&key, &nonce, &[], &out16).unwrap(),
242 [0u8; 16]
243 );
244 }
245
246 /// HKDF-SHA512 known-answer vector (RFC 5869 Test Case 1 inputs computed
247 /// with SHA-512, L=42). Pins the HKDF-SHA512 primitive used for the seal
248 /// envelope's key derivation; byte-identical to `@noble/hashes` and
249 /// WebCrypto.
250 #[test]
251 fn hkdf_sha512_kat() {
252 use hkdf::Hkdf;
253 use sha2::Sha512;
254 let ikm = [0x0bu8; 22];
255 let salt: Vec<u8> = (0u8..=0x0c).collect();
256 let info = unhex("f0f1f2f3f4f5f6f7f8f9");
257 let hk = Hkdf::<Sha512>::new(Some(&salt), &ikm);
258 let mut okm = [0u8; 42];
259 hk.expand(&info, &mut okm).unwrap();
260 assert_eq!(
261 hex(&okm),
262 "832390086cda71fb47625bb5ceb168e4c8e26a1a16ed34d9fc7fe92c1481579338da362cb8d9f925d7cb"
263 );
264 }
265
266 #[test]
267 fn hkdf_sha512_deterministic_and_domain_separated() {
268 let ikm = [7u8; 32];
269 let k1 =
270 derive_aes256_key(&ikm, &domain_bytes(TAG_KEM_PURE_CNSA2, SEAL_CONTEXT_V1)).unwrap();
271 let k2 =
272 derive_aes256_key(&ikm, &domain_bytes(TAG_KEM_PURE_CNSA2, SEAL_CONTEXT_V1)).unwrap();
273 assert_eq!(k1, k2);
274 // Different tag => different key.
275 let k3 =
276 derive_aes256_key(&ikm, &domain_bytes(TAG_KEM_MATCHED_CAT5, SEAL_CONTEXT_V1)).unwrap();
277 assert_ne!(k1, k3);
278 // Different context label => different key.
279 let k4 =
280 derive_aes256_key(&ikm, &domain_bytes(TAG_KEM_PURE_CNSA2, "mosslet/seal/v1")).unwrap();
281 assert_ne!(k1, k4);
282 }
283
284 #[test]
285 fn aes256gcm_roundtrip_and_aad_binding() {
286 let key = [9u8; 32];
287 let nonce = [3u8; 12];
288 let aad = b"metamorphic/seal/v1";
289 let ct = aes256gcm_seal(&key, &nonce, aad, b"hello cnsa").unwrap();
290 // ct includes the 16-byte tag.
291 assert_eq!(ct.len(), "hello cnsa".len() + GCM_TAG_LEN);
292 assert_eq!(
293 aes256gcm_open(&key, &nonce, aad, &ct).unwrap(),
294 b"hello cnsa"
295 );
296 // Wrong AAD fails.
297 assert!(aes256gcm_open(&key, &nonce, b"other", &ct).is_err());
298 // Tampered ciphertext fails.
299 let mut bad = ct.clone();
300 bad[0] ^= 0xFF;
301 assert!(aes256gcm_open(&key, &nonce, aad, &bad).is_err());
302 }
303
304 #[test]
305 fn envelope_roundtrip() {
306 let ikm = [1u8; 64];
307 let nonce = [5u8; 12];
308 let ct = envelope_seal(
309 &ikm,
310 TAG_KEM_PURE_CNSA2,
311 SEAL_CONTEXT_V1,
312 &nonce,
313 b"key material",
314 )
315 .unwrap();
316 let pt = envelope_open(&ikm, TAG_KEM_PURE_CNSA2, SEAL_CONTEXT_V1, &nonce, &ct).unwrap();
317 assert_eq!(pt, b"key material");
318 // Different ikm fails to open.
319 let other = [2u8; 64];
320 assert!(envelope_open(&other, TAG_KEM_PURE_CNSA2, SEAL_CONTEXT_V1, &nonce, &ct).is_err());
321 }
322}