use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use hkdf::Hkdf;
use ml_kem::array::Array;
use ml_kem::kem::{Decapsulate, Encapsulate};
use ml_kem::{Ciphertext, EncodedSizeUser, KemCore, MlKem768};
use rand::rngs::OsRng;
use sha2::Sha256;
use zeroize::Zeroizing;
use crate::domain_separation::TRANSPORT_HYBRID_INFO;
use crate::error::ProtocolError;
use crate::sas::TransportKey;
use crate::token::KemSlot;
pub const ML_KEM_768_CT_LEN: usize = 1088;
pub const ML_KEM_768_EK_LEN: usize = 1184;
pub const ML_KEM_768_SS_LEN: usize = 32;
pub struct HybridInitiatorKem {
decaps: <MlKem768 as KemCore>::DecapsulationKey,
encaps_bytes: Vec<u8>,
}
impl HybridInitiatorKem {
pub fn generate() -> Self {
let mut rng = OsRng;
let (decaps, encaps) = MlKem768::generate(&mut rng);
let encaps_bytes = encaps.as_bytes().to_vec();
Self {
decaps,
encaps_bytes,
}
}
pub fn as_kem_slot(&self) -> KemSlot {
KemSlot::MlKem768 {
public_key: URL_SAFE_NO_PAD.encode(&self.encaps_bytes),
}
}
pub fn encapsulation_key_bytes(&self) -> &[u8] {
&self.encaps_bytes
}
pub fn decapsulate(&self, ciphertext: &[u8]) -> Result<Zeroizing<[u8; 32]>, ProtocolError> {
let ct = decode_ciphertext(ciphertext)?;
let ss = self.decaps.decapsulate(&ct).map_err(|_| {
ProtocolError::KeyExchangeFailed("ML-KEM-768 decapsulate failed".to_string())
})?;
let bytes: [u8; 32] = ss.as_slice().try_into().map_err(|_| {
ProtocolError::KeyExchangeFailed("ML-KEM-768 shared secret not 32 bytes".to_string())
})?;
Ok(Zeroizing::new(bytes))
}
}
pub fn encapsulate_against_slot(
slot: &KemSlot,
) -> Result<(Vec<u8>, Zeroizing<[u8; 32]>), ProtocolError> {
let KemSlot::MlKem768 { public_key } = slot;
let ek_bytes = URL_SAFE_NO_PAD.decode(public_key.as_bytes()).map_err(|e| {
ProtocolError::KeyExchangeFailed(format!("ML-KEM-768 encaps-key b64 decode: {e}"))
})?;
if ek_bytes.len() != ML_KEM_768_EK_LEN {
return Err(ProtocolError::KeyExchangeFailed(format!(
"ML-KEM-768 encapsulation key must be {ML_KEM_768_EK_LEN} bytes, got {}",
ek_bytes.len()
)));
}
let ek_arr = encaps_key_from_slice(&ek_bytes)?;
let ek = <MlKem768 as KemCore>::EncapsulationKey::from_bytes(&ek_arr);
let mut rng = OsRng;
let (ct, ss) = ek.encapsulate(&mut rng).map_err(|_| {
ProtocolError::KeyExchangeFailed("ML-KEM-768 encapsulate failed".to_string())
})?;
let ss_bytes: [u8; 32] = ss.as_slice().try_into().map_err(|_| {
ProtocolError::KeyExchangeFailed("ML-KEM-768 shared secret not 32 bytes".to_string())
})?;
Ok((ct.as_slice().to_vec(), Zeroizing::new(ss_bytes)))
}
pub fn derive_hybrid_transport_key(
ss_classical: &[u8; 32],
ss_pq: &[u8; 32],
initiator_pub: &[u8],
responder_pub: &[u8],
session_id: &str,
short_code: &str,
) -> TransportKey {
let mut combined = Zeroizing::new(Vec::with_capacity(ss_classical.len() + ss_pq.len()));
combined.extend_from_slice(ss_classical);
combined.extend_from_slice(ss_pq);
let mut salt = Vec::with_capacity(initiator_pub.len() + responder_pub.len());
salt.extend_from_slice(initiator_pub);
salt.extend_from_slice(responder_pub);
let mut info =
Vec::with_capacity(TRANSPORT_HYBRID_INFO.len() + session_id.len() + short_code.len());
info.extend_from_slice(TRANSPORT_HYBRID_INFO);
info.extend_from_slice(session_id.as_bytes());
info.extend_from_slice(short_code.as_bytes());
let hk = Hkdf::<Sha256>::new(Some(&salt), &combined);
let mut key = [0u8; 32];
let _ = hk.expand(&info, &mut key);
TransportKey::new(key)
}
fn encaps_key_from_slice(
bytes: &[u8],
) -> Result<
Array<u8, <<MlKem768 as KemCore>::EncapsulationKey as EncodedSizeUser>::EncodedSize>,
ProtocolError,
> {
Array::try_from(bytes).map_err(|_| {
ProtocolError::KeyExchangeFailed(format!(
"ML-KEM-768 encapsulation key must be {ML_KEM_768_EK_LEN} bytes, got {}",
bytes.len()
))
})
}
fn decode_ciphertext(bytes: &[u8]) -> Result<Ciphertext<MlKem768>, ProtocolError> {
if bytes.len() != ML_KEM_768_CT_LEN {
return Err(ProtocolError::KeyExchangeFailed(format!(
"ML-KEM-768 ciphertext must be {ML_KEM_768_CT_LEN} bytes, got {}",
bytes.len()
)));
}
Array::try_from(bytes).map_err(|_| {
ProtocolError::KeyExchangeFailed("ML-KEM-768 ciphertext length mismatch".to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_INIT_PUB: [u8; 33] = [0x01; 33];
const TEST_RESP_PUB: [u8; 33] = [0x02; 33];
const TEST_SESSION_ID: &str = "pq-session-0000";
const TEST_SHORT_CODE: &str = "ABC234";
#[test]
fn hybrid_kem_round_trip() {
let initiator = HybridInitiatorKem::generate();
let slot = initiator.as_kem_slot();
let (ct_bytes, ss_responder) = encapsulate_against_slot(&slot).unwrap();
let ss_initiator = initiator.decapsulate(&ct_bytes).unwrap();
assert_eq!(&*ss_initiator, &*ss_responder, "PQ shared secret mismatch");
assert_eq!(ct_bytes.len(), ML_KEM_768_CT_LEN);
}
#[test]
fn hybrid_combiner_is_order_sensitive() {
let ss_c = [0x11u8; 32];
let ss_p = [0x22u8; 32];
let tk_correct = derive_hybrid_transport_key(
&ss_c,
&ss_p,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let tk_swapped = derive_hybrid_transport_key(
&ss_p,
&ss_c,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
assert_ne!(
tk_correct.as_bytes(),
tk_swapped.as_bytes(),
"classical-first vs PQ-first must produce different keys"
);
}
#[test]
fn hybrid_vs_classical_label_are_domain_separated() {
use crate::sas::derive_transport_key;
let ss_c = [0x33u8; 32];
let ss_p = [0x44u8; 32];
let tk_hybrid = derive_hybrid_transport_key(
&ss_c,
&ss_p,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let tk_classical = derive_transport_key(
&ss_c,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
assert_ne!(
tk_hybrid.as_bytes(),
tk_classical.as_bytes(),
"TRANSPORT_HYBRID_INFO must not collide with TRANSPORT_INFO"
);
}
#[test]
fn encapsulate_rejects_wrong_size_slot() {
let bad_slot = KemSlot::MlKem768 {
public_key: URL_SAFE_NO_PAD.encode([0u8; 16]),
};
let result = encapsulate_against_slot(&bad_slot);
assert!(matches!(result, Err(ProtocolError::KeyExchangeFailed(_))));
}
#[test]
fn decapsulate_rejects_wrong_size_ciphertext() {
let initiator = HybridInitiatorKem::generate();
let result = initiator.decapsulate(&[0u8; 42]);
assert!(matches!(result, Err(ProtocolError::KeyExchangeFailed(_))));
}
}