use ed25519_dalek::VerifyingKey;
use hkdf::Hkdf;
use sha2::{Digest, Sha256, Sha512};
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::{Zeroize, Zeroizing};
use crate::crypto::passphrase::KEY_LEN;
use crate::crypto::pqc::{self, PqKeypair, SS_LEN};
use crate::error::{ProtocolError, Result};
fn x25519_shared(
our_ed25519_seed: &[u8; 32],
partner_ed25519_pubkey: &[u8; 32],
) -> Result<Zeroizing<[u8; 32]>> {
let our_x = ed25519_seed_to_x25519_secret(our_ed25519_seed);
let partner_x = ed25519_pubkey_to_x25519(partner_ed25519_pubkey)?;
let shared = our_x.diffie_hellman(&partner_x);
if !shared.was_contributory() {
return Err(ProtocolError::Session(
"DM key agreement rejected: partner X25519 pubkey is non-contributory \
(small-order point)"
.into(),
));
}
Ok(Zeroizing::new(*shared.as_bytes()))
}
pub fn derive_dm_key(
our_ed25519_seed: &[u8; 32],
partner_ed25519_pubkey: &[u8; 32],
canonical_room_id: &str,
) -> Result<[u8; KEY_LEN]> {
let shared = x25519_shared(our_ed25519_seed, partner_ed25519_pubkey)?;
let salt = b"huddle-dm-key-v1\0";
let h = Hkdf::<Sha256>::new(Some(salt), shared.as_slice());
let mut out = [0u8; KEY_LEN];
h.expand(canonical_room_id.as_bytes(), &mut out)
.map_err(|e| ProtocolError::Session(format!("hkdf expand: {e}")))?;
Ok(out)
}
const DM_ENCAPS_LABEL: &[u8] = b"huddle-dm-mlkem-encaps-v1";
fn derive_encaps_message(
initiator_ed25519_seed: &[u8; 32],
partner_mlkem_ek: &[u8],
canonical_room_id: &str,
) -> Zeroizing<[u8; SS_LEN]> {
let hk = Hkdf::<Sha256>::new(Some(DM_ENCAPS_LABEL), initiator_ed25519_seed);
let mut info = Vec::with_capacity(partner_mlkem_ek.len() + canonical_room_id.len());
info.extend_from_slice(partner_mlkem_ek);
info.extend_from_slice(canonical_room_id.as_bytes());
let mut m = Zeroizing::new([0u8; SS_LEN]);
hk.expand(&info, m.as_mut_slice())
.expect("HKDF expand to 32 bytes is within SHA-256's output limit");
m
}
pub fn derive_dm_key_hybrid_initiator(
our_ed25519_seed: &[u8; 32],
partner_ed25519_pubkey: &[u8; 32],
partner_mlkem_ek: &[u8],
canonical_room_id: &str,
) -> Result<([u8; KEY_LEN], Vec<u8>)> {
let ss_x = x25519_shared(our_ed25519_seed, partner_ed25519_pubkey)?;
let m = derive_encaps_message(our_ed25519_seed, partner_mlkem_ek, canonical_room_id);
let (ct, ss_pq) = pqc::encapsulate_deterministic(partner_mlkem_ek, &m)?;
let key = pqc::combine_hybrid(&ss_x, &ss_pq, &ct, canonical_room_id.as_bytes());
Ok((*key, ct))
}
pub fn derive_dm_key_hybrid_responder(
our_pq: &PqKeypair,
our_ed25519_seed: &[u8; 32],
partner_ed25519_pubkey: &[u8; 32],
kem_ciphertext: &[u8],
canonical_room_id: &str,
) -> Result<[u8; KEY_LEN]> {
let ss_x = x25519_shared(our_ed25519_seed, partner_ed25519_pubkey)?;
let ss_pq = our_pq.decapsulate(kem_ciphertext)?;
let key = pqc::combine_hybrid(&ss_x, &ss_pq, kem_ciphertext, canonical_room_id.as_bytes());
Ok(*key)
}
pub fn must_refuse_classical_fallback(peer_known_pq_capable: bool, have_mlkem_ek: bool) -> bool {
peer_known_pq_capable && !have_mlkem_ek
}
fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> StaticSecret {
let mut h = Sha512::digest(seed);
let mut bytes = Zeroizing::new([0u8; 32]);
bytes.copy_from_slice(&h[..32]);
h.as_mut_slice().zeroize();
StaticSecret::from(*bytes)
}
fn ed25519_pubkey_to_x25519(pubkey_bytes: &[u8; 32]) -> Result<PublicKey> {
let vk = VerifyingKey::from_bytes(pubkey_bytes)
.map_err(|e| ProtocolError::Session(format!("bad ed25519 pubkey: {e}")))?;
Ok(PublicKey::from(vk.to_montgomery().to_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::IdentityKeys;
#[test]
fn dm_key_is_commutative() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let room_id = "deadbeefcafef00d1234567890abcdef";
let k_a = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room_id).unwrap();
let k_b = derive_dm_key(&bob.secret_bytes(), &alice.public_bytes(), room_id).unwrap();
assert_eq!(k_a, k_b, "both peers must derive the same DM key");
}
#[test]
fn dm_key_is_deterministic() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let room_id = "room-1";
let k1 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room_id).unwrap();
let k2 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room_id).unwrap();
assert_eq!(k1, k2);
}
#[test]
fn dm_key_binds_to_room_id() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let k1 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), "room-1").unwrap();
let k2 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), "room-2").unwrap();
assert_ne!(
k1, k2,
"different room_ids must produce different keys (HKDF info parameter)"
);
}
#[test]
fn dm_key_differs_per_pair() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let carol = IdentityKeys::generate().unwrap();
let room = "room";
let k_ab = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room).unwrap();
let k_ac = derive_dm_key(&alice.secret_bytes(), &carol.public_bytes(), room).unwrap();
assert_ne!(k_ab, k_ac);
}
#[test]
fn rejects_invalid_ed25519_pubkey() {
let alice = IdentityKeys::generate().unwrap();
let mut bad = [0u8; 32];
bad[31] = 0xff;
let r = derive_dm_key(&alice.secret_bytes(), &bad, "room");
let _ = r; }
#[test]
fn rejects_small_order_partner_pubkey() {
let alice = IdentityKeys::generate().unwrap();
let mut id_point = [0u8; 32];
id_point[0] = 1;
let r = derive_dm_key(&alice.secret_bytes(), &id_point, "room");
assert!(r.is_err(), "small-order partner pubkey must be rejected");
}
#[test]
fn hybrid_initiator_and_responder_agree() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let room = "deadbeefcafef00d1234567890abcdef";
let (k_init, ct) = derive_dm_key_hybrid_initiator(
&alice.secret_bytes(),
&bob.public_bytes(),
&bob.mlkem_public_bytes(),
room,
)
.unwrap();
let k_resp = derive_dm_key_hybrid_responder(
&bob.pq_keypair(),
&bob.secret_bytes(),
&alice.public_bytes(),
&ct,
room,
)
.unwrap();
assert_eq!(
k_init, k_resp,
"both peers must derive the same hybrid DM key"
);
}
#[test]
fn hybrid_key_differs_from_classical() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let room = "room-x";
let classical = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room).unwrap();
let (hybrid, _ct) = derive_dm_key_hybrid_initiator(
&alice.secret_bytes(),
&bob.public_bytes(),
&bob.mlkem_public_bytes(),
room,
)
.unwrap();
assert_ne!(
classical, hybrid,
"hybrid key must mix in the ML-KEM secret, so it differs from classical"
);
}
#[test]
fn hybrid_is_reproducible_by_initiator() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let room = "room-determinism";
let (k1, ct1) = derive_dm_key_hybrid_initiator(
&alice.secret_bytes(),
&bob.public_bytes(),
&bob.mlkem_public_bytes(),
room,
)
.unwrap();
let (k2, ct2) = derive_dm_key_hybrid_initiator(
&alice.secret_bytes(),
&bob.public_bytes(),
&bob.mlkem_public_bytes(),
room,
)
.unwrap();
assert_eq!(k1, k2);
assert_eq!(ct1, ct2);
}
#[test]
fn hybrid_binds_to_room_id() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let (k1, _) = derive_dm_key_hybrid_initiator(
&alice.secret_bytes(),
&bob.public_bytes(),
&bob.mlkem_public_bytes(),
"room-1",
)
.unwrap();
let (k2, _) = derive_dm_key_hybrid_initiator(
&alice.secret_bytes(),
&bob.public_bytes(),
&bob.mlkem_public_bytes(),
"room-2",
)
.unwrap();
assert_ne!(k1, k2, "different rooms must yield different hybrid keys");
}
#[test]
fn hybrid_responder_rejects_tampered_ciphertext() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let room = "room-tamper";
let (k_init, mut ct) = derive_dm_key_hybrid_initiator(
&alice.secret_bytes(),
&bob.public_bytes(),
&bob.mlkem_public_bytes(),
room,
)
.unwrap();
ct[0] ^= 0x01;
let k_resp = derive_dm_key_hybrid_responder(
&bob.pq_keypair(),
&bob.secret_bytes(),
&alice.public_bytes(),
&ct,
room,
)
.unwrap();
assert_ne!(k_init, k_resp);
}
#[test]
fn hybrid_initiator_rejects_bad_ek_length() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let r = derive_dm_key_hybrid_initiator(
&alice.secret_bytes(),
&bob.public_bytes(),
&[0u8; 16], "room",
);
assert!(r.is_err());
}
#[test]
fn refuses_classical_only_for_known_capable_peer_without_ek() {
assert!(must_refuse_classical_fallback(true, false));
}
#[test]
fn allows_classical_when_peer_not_known_capable() {
assert!(!must_refuse_classical_fallback(false, false));
assert!(!must_refuse_classical_fallback(false, true));
}
#[test]
fn does_not_refuse_when_ek_is_available() {
assert!(!must_refuse_classical_fallback(true, true));
}
}