1use 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
99const VERSION_HYBRID_512: u8 = 0x01;
103const VERSION_HYBRID_768: u8 = 0x02;
105const VERSION_HYBRID_1024: u8 = 0x03;
107const NONCE_LEN: usize = 24;
109const X25519_LEN: usize = 32;
111const SEED_LEN: usize = 32;
113const MAC_LEN: usize = 16;
115const LABEL: &[u8] = b"\\.//^\\";
117
118const MLKEM512_EK_LEN: usize = 800;
121const MLKEM512_CT_LEN: usize = 768;
123const MLKEM512_SEED_LEN: usize = 64;
125const EXPANDED_SEED_512_LEN: usize = 96;
127const COMBINED_PK_512_LEN: usize = MLKEM512_EK_LEN + X25519_LEN;
129const COMBINED_CT_512_LEN: usize = MLKEM512_CT_LEN + X25519_LEN;
131const MIN_HYBRID_512_LEN: usize = 1 + COMBINED_CT_512_LEN + NONCE_LEN + MAC_LEN;
137
138const MLKEM768_EK_LEN: usize = 1184;
141const MLKEM768_CT_LEN: usize = 1088;
143const MLKEM768_SEED_LEN: usize = 64;
145const EXPANDED_SEED_768_LEN: usize = 96;
147const COMBINED_PK_768_LEN: usize = MLKEM768_EK_LEN + X25519_LEN;
149const COMBINED_CT_768_LEN: usize = MLKEM768_CT_LEN + X25519_LEN;
151const MIN_HYBRID_768_LEN: usize = 1 + COMBINED_CT_768_LEN + NONCE_LEN + MAC_LEN;
154
155const MLKEM1024_EK_LEN: usize = 1568;
158const MLKEM1024_CT_LEN: usize = 1568;
160const MLKEM1024_SEED_LEN: usize = 64;
162const EXPANDED_SEED_1024_LEN: usize = 96;
164const COMBINED_PK_1024_LEN: usize = MLKEM1024_EK_LEN + X25519_LEN;
166const COMBINED_CT_1024_LEN: usize = MLKEM1024_CT_LEN + X25519_LEN;
168const MIN_HYBRID_1024_LEN: usize = 1 + COMBINED_CT_1024_LEN + NONCE_LEN + MAC_LEN;
171
172#[derive(Debug, Clone)]
176pub struct HybridKeyPair {
177 pub public_key: String,
179 pub secret_key: String,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
185pub enum SecurityLevel {
186 Cat1,
188 #[default]
190 Cat3,
191 Cat5,
193}
194
195#[inline]
199fn random_bytes(buf: &mut [u8]) {
200 getrandom::getrandom(buf).expect("OS CSPRNG unavailable");
201}
202
203fn 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
213fn 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
230fn 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
245fn 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
258pub fn generate_hybrid_keypair_512() -> HybridKeyPair {
262 generate_hybrid_keypair_with_level(SecurityLevel::Cat1)
263}
264
265pub 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
272pub fn generate_hybrid_keypair() -> HybridKeyPair {
276 generate_hybrid_keypair_with_level(SecurityLevel::Cat3)
277}
278
279pub 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
286pub 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
299pub 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
323pub fn generate_hybrid_keypair_1024() -> HybridKeyPair {
327 generate_hybrid_keypair_with_level(SecurityLevel::Cat5)
328}
329
330pub 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
337pub 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 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
408pub 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 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 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 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 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 let (secretbox_ct, nonce_buf) = secretbox_encrypt(&shared_secret, plaintext)?;
491 shared_secret.zeroize();
492
493 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
505fn 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 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 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 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
561fn 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 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 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 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
617fn 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 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 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 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 #[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); assert_eq!(sk.len(), SEED_LEN); }
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 assert_eq!(
735 raw.len(),
736 1 + COMBINED_CT_768_LEN + NONCE_LEN + 32 + MAC_LEN
737 );
738 }
739
740 #[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); assert_eq!(sk.len(), SEED_LEN); }
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 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 #[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); assert_eq!(sk.len(), SEED_LEN); }
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 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 #[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 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 let mut legacy = vec![0x01u8]; legacy.extend_from_slice(&[0u8; 79]); let legacy_b64 = b64::encode(&legacy);
917 assert!(!is_hybrid_ciphertext(&legacy_b64));
918 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 let min_cat1 = MIN_HYBRID_512_LEN; let mut blob = vec![0x01u8];
929 blob.extend_from_slice(&vec![0u8; min_cat1 - 2]); assert!(!is_hybrid_ciphertext(&b64::encode(&blob)));
931 let mut at_min = vec![0x01u8];
935 at_min.extend_from_slice(&vec![0u8; min_cat1 - 1]); 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}