use ml_kem::{Decapsulate, MlKem512, MlKem768, MlKem1024};
use ml_kem::{DecapsulationKey, EncapsulationKey, KeyExport};
use sha3::Shake256;
use sha3::digest::{ExtendableOutput, Update, XofReader};
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret};
use zeroize::Zeroize;
use crypto_secretbox::aead::Aead;
use crypto_secretbox::aead::generic_array::GenericArray;
use crypto_secretbox::{KeyInit, XSalsa20Poly1305};
use crate::CryptoError;
use crate::b64;
const VERSION_HYBRID_512: u8 = 0x01;
const VERSION_HYBRID_768: u8 = 0x02;
const VERSION_HYBRID_1024: u8 = 0x03;
const NONCE_LEN: usize = 24;
const X25519_LEN: usize = 32;
const SEED_LEN: usize = 32;
const MAC_LEN: usize = 16;
const LABEL: &[u8] = b"\\.//^\\";
const MLKEM512_EK_LEN: usize = 800;
const MLKEM512_CT_LEN: usize = 768;
const MLKEM512_SEED_LEN: usize = 64;
const EXPANDED_SEED_512_LEN: usize = 96;
const COMBINED_PK_512_LEN: usize = MLKEM512_EK_LEN + X25519_LEN;
const COMBINED_CT_512_LEN: usize = MLKEM512_CT_LEN + X25519_LEN;
const MIN_HYBRID_512_LEN: usize = 1 + COMBINED_CT_512_LEN + NONCE_LEN + MAC_LEN;
const MLKEM768_EK_LEN: usize = 1184;
const MLKEM768_CT_LEN: usize = 1088;
const MLKEM768_SEED_LEN: usize = 64;
const EXPANDED_SEED_768_LEN: usize = 96;
const COMBINED_PK_768_LEN: usize = MLKEM768_EK_LEN + X25519_LEN;
const COMBINED_CT_768_LEN: usize = MLKEM768_CT_LEN + X25519_LEN;
const MIN_HYBRID_768_LEN: usize = 1 + COMBINED_CT_768_LEN + NONCE_LEN + MAC_LEN;
const MLKEM1024_EK_LEN: usize = 1568;
const MLKEM1024_CT_LEN: usize = 1568;
const MLKEM1024_SEED_LEN: usize = 64;
const EXPANDED_SEED_1024_LEN: usize = 96;
const COMBINED_PK_1024_LEN: usize = MLKEM1024_EK_LEN + X25519_LEN;
const COMBINED_CT_1024_LEN: usize = MLKEM1024_CT_LEN + X25519_LEN;
const MIN_HYBRID_1024_LEN: usize = 1 + COMBINED_CT_1024_LEN + NONCE_LEN + MAC_LEN;
#[derive(Debug, Clone)]
pub struct HybridKeyPair {
pub public_key: String,
pub secret_key: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SecurityLevel {
Cat1,
#[default]
Cat3,
Cat5,
}
#[inline]
fn random_bytes(buf: &mut [u8]) {
getrandom::getrandom(buf).expect("OS CSPRNG unavailable");
}
fn expand_seed(seed: &[u8; SEED_LEN], output_len: usize) -> Vec<u8> {
let mut hasher = Shake256::default();
hasher.update(seed);
let mut reader = hasher.finalize_xof();
let mut out = vec![0u8; output_len];
reader.read(&mut out);
out
}
fn combine(
ss_mlkem: &[u8],
ss_x25519: &[u8],
ct_x25519: &[u8; X25519_LEN],
pk_x25519: &[u8; X25519_LEN],
) -> [u8; 32] {
use sha3::Digest;
let mut hasher = sha3::Sha3_256::new();
Digest::update(&mut hasher, ss_mlkem);
Digest::update(&mut hasher, ss_x25519);
Digest::update(&mut hasher, ct_x25519);
Digest::update(&mut hasher, pk_x25519);
Digest::update(&mut hasher, LABEL);
hasher.finalize().into()
}
fn secretbox_encrypt(
shared_secret: &[u8; 32],
plaintext: &[u8],
) -> Result<(Vec<u8>, [u8; NONCE_LEN]), CryptoError> {
let cipher = XSalsa20Poly1305::new(GenericArray::from_slice(shared_secret));
let mut nonce_buf = [0u8; NONCE_LEN];
random_bytes(&mut nonce_buf);
let nonce = GenericArray::from_slice(&nonce_buf);
let ct = cipher
.encrypt(nonce, plaintext)
.map_err(|_| CryptoError::Hybrid("secretbox encrypt failed".into()))?;
Ok((ct, nonce_buf))
}
fn secretbox_decrypt(
shared_secret: &[u8; 32],
nonce: &[u8],
ciphertext: &[u8],
) -> Result<Vec<u8>, CryptoError> {
let cipher = XSalsa20Poly1305::new(GenericArray::from_slice(shared_secret));
let nonce = GenericArray::from_slice(nonce);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| CryptoError::Decryption)
}
pub fn generate_hybrid_keypair_512() -> HybridKeyPair {
generate_hybrid_keypair_with_level(SecurityLevel::Cat1)
}
pub fn hybrid_seal_512(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat1)
}
pub fn generate_hybrid_keypair() -> HybridKeyPair {
generate_hybrid_keypair_with_level(SecurityLevel::Cat3)
}
pub fn hybrid_seal(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat3)
}
pub fn hybrid_open(ct_b64: &str, seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
let combined = b64::decode(ct_b64)?;
match combined.first() {
Some(&VERSION_HYBRID_512) => hybrid_open_512(&combined, seed_b64),
Some(&VERSION_HYBRID_768) => hybrid_open_768(&combined, seed_b64),
Some(&VERSION_HYBRID_1024) => hybrid_open_1024(&combined, seed_b64),
_ => Err(CryptoError::Hybrid(
"not a hybrid ciphertext (bad version tag)".into(),
)),
}
}
pub fn is_hybrid_ciphertext(ct_b64: &str) -> bool {
let Ok(bytes) = b64::decode(ct_b64) else {
return false;
};
match bytes.first() {
Some(&VERSION_HYBRID_512) => bytes.len() >= MIN_HYBRID_512_LEN,
Some(&VERSION_HYBRID_768) => bytes.len() >= MIN_HYBRID_768_LEN,
Some(&VERSION_HYBRID_1024) => bytes.len() >= MIN_HYBRID_1024_LEN,
_ => false,
}
}
pub fn generate_hybrid_keypair_1024() -> HybridKeyPair {
generate_hybrid_keypair_with_level(SecurityLevel::Cat5)
}
pub fn hybrid_seal_1024(plaintext: &[u8], combined_pk_b64: &str) -> Result<String, CryptoError> {
hybrid_seal_with_level(plaintext, combined_pk_b64, SecurityLevel::Cat5)
}
pub fn generate_hybrid_keypair_with_level(level: SecurityLevel) -> HybridKeyPair {
let mut seed = [0u8; SEED_LEN];
random_bytes(&mut seed);
let expanded_len = match level {
SecurityLevel::Cat1 => EXPANDED_SEED_512_LEN,
SecurityLevel::Cat3 => EXPANDED_SEED_768_LEN,
SecurityLevel::Cat5 => EXPANDED_SEED_1024_LEN,
};
let mlkem_seed_len = match level {
SecurityLevel::Cat1 => MLKEM512_SEED_LEN,
SecurityLevel::Cat3 => MLKEM768_SEED_LEN,
SecurityLevel::Cat5 => MLKEM1024_SEED_LEN,
};
let mut expanded = expand_seed(&seed, expanded_len);
let x25519_sk_bytes: [u8; X25519_LEN] = expanded[mlkem_seed_len..].try_into().unwrap();
let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
let x25519_pk = X25519PublicKey::from(&x25519_sk);
let combined_pk = match level {
SecurityLevel::Cat1 => {
let mlkem_seed: [u8; MLKEM512_SEED_LEN] =
expanded[..MLKEM512_SEED_LEN].try_into().unwrap();
let dk = DecapsulationKey::<MlKem512>::from_seed(mlkem_seed.into());
let ek = dk.encapsulation_key();
let ek_bytes = ek.to_bytes();
let mut pk = Vec::with_capacity(COMBINED_PK_512_LEN);
pk.extend_from_slice(&ek_bytes);
pk.extend_from_slice(x25519_pk.as_bytes());
pk
}
SecurityLevel::Cat3 => {
let mlkem_seed: [u8; MLKEM768_SEED_LEN] =
expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
let dk = DecapsulationKey::<MlKem768>::from_seed(mlkem_seed.into());
let ek = dk.encapsulation_key();
let ek_bytes = ek.to_bytes();
let mut pk = Vec::with_capacity(COMBINED_PK_768_LEN);
pk.extend_from_slice(&ek_bytes);
pk.extend_from_slice(x25519_pk.as_bytes());
pk
}
SecurityLevel::Cat5 => {
let mlkem_seed: [u8; MLKEM1024_SEED_LEN] =
expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
let dk = DecapsulationKey::<MlKem1024>::from_seed(mlkem_seed.into());
let ek = dk.encapsulation_key();
let ek_bytes = ek.to_bytes();
let mut pk = Vec::with_capacity(COMBINED_PK_1024_LEN);
pk.extend_from_slice(&ek_bytes);
pk.extend_from_slice(x25519_pk.as_bytes());
pk
}
};
let pair = HybridKeyPair {
public_key: b64::encode(&combined_pk),
secret_key: b64::encode(&seed),
};
seed.zeroize();
expanded.zeroize();
pair
}
pub fn hybrid_seal_with_level(
plaintext: &[u8],
combined_pk_b64: &str,
level: SecurityLevel,
) -> Result<String, CryptoError> {
let pk_bytes = b64::decode(combined_pk_b64)?;
let (expected_pk_len, mlkem_ek_len, version_tag) = match level {
SecurityLevel::Cat1 => (COMBINED_PK_512_LEN, MLKEM512_EK_LEN, VERSION_HYBRID_512),
SecurityLevel::Cat3 => (COMBINED_PK_768_LEN, MLKEM768_EK_LEN, VERSION_HYBRID_768),
SecurityLevel::Cat5 => (COMBINED_PK_1024_LEN, MLKEM1024_EK_LEN, VERSION_HYBRID_1024),
};
if pk_bytes.len() != expected_pk_len {
return Err(CryptoError::InvalidLength {
expected: expected_pk_len,
got: pk_bytes.len(),
});
}
let mlkem_ek_bytes = &pk_bytes[..mlkem_ek_len];
let x25519_pk_bytes: [u8; X25519_LEN] = pk_bytes[mlkem_ek_len..].try_into().unwrap();
let mut mlkem_coins = [0u8; 32];
random_bytes(&mut mlkem_coins);
let (mlkem_ct_bytes, ss_mlkem_bytes) = match level {
SecurityLevel::Cat1 => {
let ek = EncapsulationKey::<MlKem512>::new(
mlkem_ek_bytes
.try_into()
.map_err(|_| CryptoError::Hybrid("invalid ML-KEM-512 ek".into()))?,
)
.map_err(|_| CryptoError::Hybrid("invalid ML-KEM-512 encapsulation key".into()))?;
let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
(ct.as_slice().to_vec(), ss.as_slice().to_vec())
}
SecurityLevel::Cat3 => {
let ek = EncapsulationKey::<MlKem768>::new(
mlkem_ek_bytes
.try_into()
.map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ek".into()))?,
)
.map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 encapsulation key".into()))?;
let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
(ct.as_slice().to_vec(), ss.as_slice().to_vec())
}
SecurityLevel::Cat5 => {
let ek = EncapsulationKey::<MlKem1024>::new(
mlkem_ek_bytes
.try_into()
.map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ek".into()))?,
)
.map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 encapsulation key".into()))?;
let (ct, ss) = ek.encapsulate_deterministic(&mlkem_coins.into());
(ct.as_slice().to_vec(), ss.as_slice().to_vec())
}
};
mlkem_coins.zeroize();
let mut x25519_eph_bytes = [0u8; X25519_LEN];
random_bytes(&mut x25519_eph_bytes);
let x25519_eph_sk = X25519StaticSecret::from(x25519_eph_bytes);
let x25519_eph_pk = X25519PublicKey::from(&x25519_eph_sk);
let x25519_recipient_pk = X25519PublicKey::from(x25519_pk_bytes);
let ss_x25519 = x25519_eph_sk.diffie_hellman(&x25519_recipient_pk);
x25519_eph_bytes.zeroize();
let ct_x25519: [u8; X25519_LEN] = *x25519_eph_pk.as_bytes();
let mut shared_secret = combine(
&ss_mlkem_bytes,
ss_x25519.as_bytes(),
&ct_x25519,
&x25519_pk_bytes,
);
let (secretbox_ct, nonce_buf) = secretbox_encrypt(&shared_secret, plaintext)?;
shared_secret.zeroize();
let combined_ct_len = mlkem_ct_bytes.len() + X25519_LEN;
let mut out = Vec::with_capacity(1 + combined_ct_len + NONCE_LEN + secretbox_ct.len());
out.push(version_tag);
out.extend_from_slice(&mlkem_ct_bytes);
out.extend_from_slice(&ct_x25519);
out.extend_from_slice(&nonce_buf);
out.extend_from_slice(&secretbox_ct);
Ok(b64::encode(&out))
}
fn hybrid_open_512(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
let seed_bytes = b64::decode(seed_b64)?;
if seed_bytes.len() != SEED_LEN {
return Err(CryptoError::InvalidLength {
expected: SEED_LEN,
got: seed_bytes.len(),
});
}
if combined.len() < MIN_HYBRID_512_LEN {
return Err(CryptoError::TooShort);
}
let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
let mut expanded = expand_seed(&seed, EXPANDED_SEED_512_LEN);
let mlkem_seed: [u8; MLKEM512_SEED_LEN] = expanded[..MLKEM512_SEED_LEN].try_into().unwrap();
let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM512_SEED_LEN..].try_into().unwrap();
expanded.zeroize();
let mlkem_ct = &combined[1..1 + MLKEM512_CT_LEN];
let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
[1 + MLKEM512_CT_LEN..1 + COMBINED_CT_512_LEN]
.try_into()
.unwrap();
let nonce_slice = &combined[1 + COMBINED_CT_512_LEN..1 + COMBINED_CT_512_LEN + NONCE_LEN];
let encrypted = &combined[1 + COMBINED_CT_512_LEN + NONCE_LEN..];
let dk = DecapsulationKey::<MlKem512>::from_seed(mlkem_seed.into());
let kem_ct = mlkem_ct
.try_into()
.map_err(|_| CryptoError::Hybrid("invalid ML-KEM-512 ciphertext".into()))?;
let ss_mlkem = dk.decapsulate(kem_ct);
let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
let x25519_pk = X25519PublicKey::from(&x25519_sk);
let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
let mut shared_secret = combine(
ss_mlkem.as_slice(),
ss_x25519.as_bytes(),
&x25519_eph_pk_bytes,
&pk_x25519,
);
let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
shared_secret.zeroize();
result
}
fn hybrid_open_768(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
let seed_bytes = b64::decode(seed_b64)?;
if seed_bytes.len() != SEED_LEN {
return Err(CryptoError::InvalidLength {
expected: SEED_LEN,
got: seed_bytes.len(),
});
}
if combined.len() < MIN_HYBRID_768_LEN {
return Err(CryptoError::TooShort);
}
let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
let mut expanded = expand_seed(&seed, EXPANDED_SEED_768_LEN);
let mlkem_seed: [u8; MLKEM768_SEED_LEN] = expanded[..MLKEM768_SEED_LEN].try_into().unwrap();
let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM768_SEED_LEN..].try_into().unwrap();
expanded.zeroize();
let mlkem_ct = &combined[1..1 + MLKEM768_CT_LEN];
let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
[1 + MLKEM768_CT_LEN..1 + COMBINED_CT_768_LEN]
.try_into()
.unwrap();
let nonce_slice = &combined[1 + COMBINED_CT_768_LEN..1 + COMBINED_CT_768_LEN + NONCE_LEN];
let encrypted = &combined[1 + COMBINED_CT_768_LEN + NONCE_LEN..];
let dk = DecapsulationKey::<MlKem768>::from_seed(mlkem_seed.into());
let kem_ct = mlkem_ct
.try_into()
.map_err(|_| CryptoError::Hybrid("invalid ML-KEM-768 ciphertext".into()))?;
let ss_mlkem = dk.decapsulate(kem_ct);
let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
let x25519_pk = X25519PublicKey::from(&x25519_sk);
let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
let mut shared_secret = combine(
ss_mlkem.as_slice(),
ss_x25519.as_bytes(),
&x25519_eph_pk_bytes,
&pk_x25519,
);
let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
shared_secret.zeroize();
result
}
fn hybrid_open_1024(combined: &[u8], seed_b64: &str) -> Result<Vec<u8>, CryptoError> {
let seed_bytes = b64::decode(seed_b64)?;
if seed_bytes.len() != SEED_LEN {
return Err(CryptoError::InvalidLength {
expected: SEED_LEN,
got: seed_bytes.len(),
});
}
if combined.len() < MIN_HYBRID_1024_LEN {
return Err(CryptoError::TooShort);
}
let seed: [u8; SEED_LEN] = seed_bytes.try_into().unwrap();
let mut expanded = expand_seed(&seed, EXPANDED_SEED_1024_LEN);
let mlkem_seed: [u8; MLKEM1024_SEED_LEN] = expanded[..MLKEM1024_SEED_LEN].try_into().unwrap();
let x25519_sk_bytes: [u8; X25519_LEN] = expanded[MLKEM1024_SEED_LEN..].try_into().unwrap();
expanded.zeroize();
let mlkem_ct = &combined[1..1 + MLKEM1024_CT_LEN];
let x25519_eph_pk_bytes: [u8; X25519_LEN] = combined
[1 + MLKEM1024_CT_LEN..1 + COMBINED_CT_1024_LEN]
.try_into()
.unwrap();
let nonce_slice = &combined[1 + COMBINED_CT_1024_LEN..1 + COMBINED_CT_1024_LEN + NONCE_LEN];
let encrypted = &combined[1 + COMBINED_CT_1024_LEN + NONCE_LEN..];
let dk = DecapsulationKey::<MlKem1024>::from_seed(mlkem_seed.into());
let kem_ct = mlkem_ct
.try_into()
.map_err(|_| CryptoError::Hybrid("invalid ML-KEM-1024 ciphertext".into()))?;
let ss_mlkem = dk.decapsulate(kem_ct);
let x25519_sk = X25519StaticSecret::from(x25519_sk_bytes);
let x25519_eph_pk = X25519PublicKey::from(x25519_eph_pk_bytes);
let ss_x25519 = x25519_sk.diffie_hellman(&x25519_eph_pk);
let x25519_pk = X25519PublicKey::from(&x25519_sk);
let pk_x25519: [u8; X25519_LEN] = *x25519_pk.as_bytes();
let mut shared_secret = combine(
ss_mlkem.as_slice(),
ss_x25519.as_bytes(),
&x25519_eph_pk_bytes,
&pk_x25519,
);
let result = secretbox_decrypt(&shared_secret, nonce_slice, encrypted);
shared_secret.zeroize();
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cat3_roundtrip() {
let kp = generate_hybrid_keypair();
let pt = b"32-byte symmetric context key!!!";
let ct = hybrid_seal(pt, &kp.public_key).unwrap();
assert!(is_hybrid_ciphertext(&ct));
let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
assert_eq!(opened, pt);
}
#[test]
fn cat3_wrong_key_fails() {
let kp1 = generate_hybrid_keypair();
let kp2 = generate_hybrid_keypair();
let ct = hybrid_seal(b"x", &kp1.public_key).unwrap();
assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
}
#[test]
fn cat3_version_tag() {
let kp = generate_hybrid_keypair();
let raw = b64::decode(&hybrid_seal(b"x", &kp.public_key).unwrap()).unwrap();
assert_eq!(raw[0], VERSION_HYBRID_768);
}
#[test]
fn cat3_nondeterministic() {
let kp = generate_hybrid_keypair();
let c1 = hybrid_seal(b"x", &kp.public_key).unwrap();
let c2 = hybrid_seal(b"x", &kp.public_key).unwrap();
assert_ne!(c1, c2);
}
#[test]
fn cat3_empty_plaintext() {
let kp = generate_hybrid_keypair();
let ct = hybrid_seal(b"", &kp.public_key).unwrap();
assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
}
#[test]
fn cat3_key_sizes() {
let kp = generate_hybrid_keypair();
let pk = b64::decode(&kp.public_key).unwrap();
let sk = b64::decode(&kp.secret_key).unwrap();
assert_eq!(pk.len(), COMBINED_PK_768_LEN); assert_eq!(sk.len(), SEED_LEN); }
#[test]
fn cat3_ciphertext_size() {
let kp = generate_hybrid_keypair();
let pt = b"exactly 32 bytes of key material";
let raw = b64::decode(&hybrid_seal(pt, &kp.public_key).unwrap()).unwrap();
assert_eq!(
raw.len(),
1 + COMBINED_CT_768_LEN + NONCE_LEN + 32 + MAC_LEN
);
}
#[test]
fn cat1_roundtrip() {
let kp = generate_hybrid_keypair_512();
let pt = b"32-byte symmetric context key!!!";
let ct = hybrid_seal_512(pt, &kp.public_key).unwrap();
assert!(is_hybrid_ciphertext(&ct));
let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
assert_eq!(opened, pt);
}
#[test]
fn cat1_version_tag() {
let kp = generate_hybrid_keypair_512();
let raw = b64::decode(&hybrid_seal_512(b"x", &kp.public_key).unwrap()).unwrap();
assert_eq!(raw[0], VERSION_HYBRID_512);
}
#[test]
fn cat1_wrong_key_fails() {
let kp1 = generate_hybrid_keypair_512();
let kp2 = generate_hybrid_keypair_512();
let ct = hybrid_seal_512(b"x", &kp1.public_key).unwrap();
assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
}
#[test]
fn cat1_key_sizes() {
let kp = generate_hybrid_keypair_512();
let pk = b64::decode(&kp.public_key).unwrap();
let sk = b64::decode(&kp.secret_key).unwrap();
assert_eq!(pk.len(), COMBINED_PK_512_LEN); assert_eq!(sk.len(), SEED_LEN); }
#[test]
fn cat1_ciphertext_size() {
let kp = generate_hybrid_keypair_512();
let pt = b"exactly 32 bytes of key material";
let raw = b64::decode(&hybrid_seal_512(pt, &kp.public_key).unwrap()).unwrap();
assert_eq!(
raw.len(),
1 + COMBINED_CT_512_LEN + NONCE_LEN + 32 + MAC_LEN
);
}
#[test]
fn cat1_nondeterministic() {
let kp = generate_hybrid_keypair_512();
let c1 = hybrid_seal_512(b"x", &kp.public_key).unwrap();
let c2 = hybrid_seal_512(b"x", &kp.public_key).unwrap();
assert_ne!(c1, c2);
}
#[test]
fn cat1_empty_plaintext() {
let kp = generate_hybrid_keypair_512();
let ct = hybrid_seal_512(b"", &kp.public_key).unwrap();
assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
}
#[test]
fn cat5_roundtrip() {
let kp = generate_hybrid_keypair_1024();
let pt = b"32-byte symmetric context key!!!";
let ct = hybrid_seal_1024(pt, &kp.public_key).unwrap();
assert!(is_hybrid_ciphertext(&ct));
let opened = hybrid_open(&ct, &kp.secret_key).unwrap();
assert_eq!(opened, pt);
}
#[test]
fn cat5_version_tag() {
let kp = generate_hybrid_keypair_1024();
let raw = b64::decode(&hybrid_seal_1024(b"x", &kp.public_key).unwrap()).unwrap();
assert_eq!(raw[0], VERSION_HYBRID_1024);
}
#[test]
fn cat5_wrong_key_fails() {
let kp1 = generate_hybrid_keypair_1024();
let kp2 = generate_hybrid_keypair_1024();
let ct = hybrid_seal_1024(b"x", &kp1.public_key).unwrap();
assert!(hybrid_open(&ct, &kp2.secret_key).is_err());
}
#[test]
fn cat5_key_sizes() {
let kp = generate_hybrid_keypair_1024();
let pk = b64::decode(&kp.public_key).unwrap();
let sk = b64::decode(&kp.secret_key).unwrap();
assert_eq!(pk.len(), COMBINED_PK_1024_LEN); assert_eq!(sk.len(), SEED_LEN); }
#[test]
fn cat5_ciphertext_size() {
let kp = generate_hybrid_keypair_1024();
let pt = b"exactly 32 bytes of key material";
let raw = b64::decode(&hybrid_seal_1024(pt, &kp.public_key).unwrap()).unwrap();
assert_eq!(
raw.len(),
1 + COMBINED_CT_1024_LEN + NONCE_LEN + 32 + MAC_LEN
);
}
#[test]
fn cat5_nondeterministic() {
let kp = generate_hybrid_keypair_1024();
let c1 = hybrid_seal_1024(b"x", &kp.public_key).unwrap();
let c2 = hybrid_seal_1024(b"x", &kp.public_key).unwrap();
assert_ne!(c1, c2);
}
#[test]
fn cat5_empty_plaintext() {
let kp = generate_hybrid_keypair_1024();
let ct = hybrid_seal_1024(b"", &kp.public_key).unwrap();
assert_eq!(hybrid_open(&ct, &kp.secret_key).unwrap(), b"");
}
#[test]
fn cat3_ct_cannot_open_with_cat5_key() {
let kp3 = generate_hybrid_keypair();
let kp5 = generate_hybrid_keypair_1024();
let ct = hybrid_seal(b"test", &kp3.public_key).unwrap();
assert!(hybrid_open(&ct, &kp5.secret_key).is_err());
}
#[test]
fn cat5_ct_cannot_open_with_cat3_key() {
let kp3 = generate_hybrid_keypair();
let kp5 = generate_hybrid_keypair_1024();
let ct = hybrid_seal_1024(b"test", &kp5.public_key).unwrap();
assert!(hybrid_open(&ct, &kp3.secret_key).is_err());
}
#[test]
fn cat1_ct_cannot_open_with_cat3_key() {
let kp1 = generate_hybrid_keypair_512();
let kp3 = generate_hybrid_keypair();
let ct = hybrid_seal_512(b"test", &kp1.public_key).unwrap();
assert!(hybrid_open(&ct, &kp3.secret_key).is_err());
}
#[test]
fn cat1_ct_cannot_open_with_cat5_key() {
let kp1 = generate_hybrid_keypair_512();
let kp5 = generate_hybrid_keypair_1024();
let ct = hybrid_seal_512(b"test", &kp1.public_key).unwrap();
assert!(hybrid_open(&ct, &kp5.secret_key).is_err());
}
#[test]
fn legacy_not_hybrid() {
let legacy = b64::encode(&[0x42, 0x02, 0x03]);
assert!(!is_hybrid_ciphertext(&legacy));
}
#[test]
fn legacy_starting_with_0x01_not_misdetected_as_cat1() {
let mut legacy = vec![0x01u8]; legacy.extend_from_slice(&[0u8; 79]); let legacy_b64 = b64::encode(&legacy);
assert!(!is_hybrid_ciphertext(&legacy_b64));
let kp = generate_hybrid_keypair_512();
let err = hybrid_open(&legacy_b64, &kp.secret_key).unwrap_err();
assert!(matches!(err, CryptoError::TooShort));
}
#[test]
fn long_0x01_blob_below_cat1_min_not_hybrid() {
let min_cat1 = MIN_HYBRID_512_LEN; let mut blob = vec![0x01u8];
blob.extend_from_slice(&vec![0u8; min_cat1 - 2]); assert!(!is_hybrid_ciphertext(&b64::encode(&blob)));
let mut at_min = vec![0x01u8];
at_min.extend_from_slice(&vec![0u8; min_cat1 - 1]); assert!(is_hybrid_ciphertext(&b64::encode(&at_min)));
}
#[test]
fn seed_expansion_deterministic() {
let seed = [0x42u8; SEED_LEN];
let expanded = expand_seed(&seed, 96);
let expanded2 = expand_seed(&seed, 96);
assert_eq!(expanded, expanded2);
}
#[test]
fn combiner_uses_label() {
let ss_mlkem = [0xAAu8; 32];
let ss_x25519 = [0xBBu8; 32];
let ct_x25519 = [0xCCu8; 32];
let pk_x25519 = [0xDDu8; 32];
let result = combine(&ss_mlkem, &ss_x25519, &ct_x25519, &pk_x25519);
assert_eq!(result.len(), 32);
let ss_mlkem2 = [0xEEu8; 32];
let result2 = combine(&ss_mlkem2, &ss_x25519, &ct_x25519, &pk_x25519);
assert_ne!(result, result2);
}
}