use crate::identity::AgentId;
use crate::mls::{MlsError, Result};
use saorsa_mls::{
treekem_group::{ApplicationCiphertext, TreeKemCommit, TreeKemGroup, TreeKemWelcome},
CipherSuite, KeyPackage, MemberIdentity,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[must_use]
pub fn derive_identity_seed(agent_secret: &[u8], group_id: &[u8]) -> [u8; 32] {
let mut hasher = blake3::Hasher::new_derive_key("x0x treekem identity seed v1");
hasher.update(&(agent_secret.len() as u64).to_le_bytes());
hasher.update(agent_secret);
hasher.update(&(group_id.len() as u64).to_le_bytes());
hasher.update(group_id);
*hasher.finalize().as_bytes()
}
fn member_id_from_seed(seed: &[u8; 32]) -> saorsa_mls::MemberId {
let mut hasher = blake3::Hasher::new_derive_key("x0x treekem member-id v1");
hasher.update(seed);
let digest = hasher.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest.as_bytes()[..16]);
saorsa_mls::MemberId::from_bytes(bytes)
}
fn encode<T: Serialize>(value: &T, what: &str) -> Result<Vec<u8>> {
postcard::to_stdvec(value).map_err(|e| MlsError::MlsOperation(format!("encode {what}: {e}")))
}
fn decode<T: for<'de> Deserialize<'de>>(bytes: &[u8], what: &str) -> Result<T> {
postcard::from_bytes(bytes).map_err(|e| MlsError::MlsOperation(format!("decode {what}: {e}")))
}
#[derive(Debug, Serialize, Deserialize)]
struct PersistedTreeKemSnapshot {
version: u8,
inner_snapshot: Vec<u8>,
agent_to_leaf: Vec<([u8; 32], u32)>,
}
const PERSISTED_SNAPSHOT_VERSION: u8 = 1;
const PERSISTED_SNAPSHOT_MAGIC: &[u8; 4] = b"XTK1";
pub struct PreparedMember {
agent: AgentId,
identity: MemberIdentity,
key_package_bytes: Vec<u8>,
}
impl PreparedMember {
#[must_use]
pub fn key_package_bytes(&self) -> &[u8] {
&self.key_package_bytes
}
#[must_use]
pub fn agent(&self) -> &AgentId {
&self.agent
}
}
impl std::fmt::Debug for PreparedMember {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedMember")
.field("agent", &self.agent)
.field(
"key_package_bytes",
&format!("{} bytes", self.key_package_bytes.len()),
)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct AddMemberOutput {
pub commit: Vec<u8>,
pub welcome: Vec<u8>,
}
pub struct TreeKemMlsGroup {
inner: TreeKemGroup,
agent_to_leaf: HashMap<AgentId, u32>,
}
impl std::fmt::Debug for TreeKemMlsGroup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TreeKemMlsGroup")
.field("inner", &self.inner)
.field("members_tracked", &self.agent_to_leaf.len())
.finish_non_exhaustive()
}
}
impl TreeKemMlsGroup {
fn suite() -> CipherSuite {
CipherSuite::default()
}
fn identity_from_seed(seed: &[u8; 32]) -> Result<MemberIdentity> {
let member_id = member_id_from_seed(seed);
MemberIdentity::from_seed(member_id, Self::suite(), seed)
.map_err(|e| MlsError::Identity(format!("from_seed: {e}")))
}
pub fn create(group_id: Vec<u8>, creator: AgentId, seed: &[u8; 32]) -> Result<Self> {
let identity = Self::identity_from_seed(seed)?;
let inner = TreeKemGroup::create(group_id, identity)
.map_err(|e| MlsError::SaorsaMls(format!("treekem create: {e}")))?;
let mut agent_to_leaf = HashMap::new();
if let Some(leaf) = inner.own_leaf() {
agent_to_leaf.insert(creator, leaf);
}
Ok(Self {
inner,
agent_to_leaf,
})
}
pub fn prepare_member(agent: AgentId, seed: &[u8; 32]) -> Result<PreparedMember> {
let identity = Self::identity_from_seed(seed)?;
let key_package_bytes = encode(&identity.key_package, "key package")?;
Ok(PreparedMember {
agent,
identity,
key_package_bytes,
})
}
pub fn add_member(
&mut self,
joiner: AgentId,
joiner_key_package: &[u8],
) -> Result<AddMemberOutput> {
if self.agent_to_leaf.contains_key(&joiner) {
return Err(MlsError::MlsOperation(format!(
"agent {:?} is already a member of this group",
joiner.as_bytes()
)));
}
let kp: KeyPackage = decode(joiner_key_package, "joiner key package")?;
let (commit, welcome) = self
.inner
.add_member(&kp)
.map_err(|e| MlsError::SaorsaMls(format!("treekem add_member: {e}")))?;
if let Some((leaf, _)) = commit.added.first() {
self.agent_to_leaf.insert(joiner, *leaf);
}
Ok(AddMemberOutput {
commit: encode(&commit, "commit")?,
welcome: encode(&welcome, "welcome")?,
})
}
pub fn join_from_welcome(prepared: PreparedMember, welcome: &[u8]) -> Result<Self> {
let welcome: TreeKemWelcome = decode(welcome, "welcome")?;
let joiner = prepared.agent;
let inner = TreeKemGroup::from_welcome(&welcome, prepared.identity)
.map_err(|e| MlsError::Welcome(format!("treekem from_welcome: {e}")))?;
let mut agent_to_leaf = HashMap::new();
if let Some(leaf) = inner.own_leaf() {
agent_to_leaf.insert(joiner, leaf);
}
Ok(Self {
inner,
agent_to_leaf,
})
}
pub fn process_commit(&mut self, commit: &[u8]) -> Result<()> {
let commit: TreeKemCommit = decode(commit, "commit")?;
let mut next_agent_to_leaf = self.agent_to_leaf.clone();
if !commit.removed_leaves.is_empty() {
let removed: std::collections::HashSet<u32> =
commit.removed_leaves.iter().copied().collect();
next_agent_to_leaf.retain(|_, leaf| !removed.contains(leaf));
}
self.inner
.process_commit(&commit)
.map_err(|e| MlsError::SaorsaMls(format!("treekem process_commit: {e}")))?;
self.agent_to_leaf = next_agent_to_leaf;
Ok(())
}
pub fn remove_member(&mut self, member: AgentId) -> Result<Vec<u8>> {
let leaf = self.agent_to_leaf.get(&member).copied().ok_or_else(|| {
MlsError::MemberNotInGroup(format!("unknown leaf for {:?}", member.as_bytes()))
})?;
self.remove_leaf_for_member(member, leaf)
}
pub fn remove_member_verified(
&mut self,
member: AgentId,
expected_key_package: &[u8],
) -> Result<Vec<u8>> {
let kp: KeyPackage = decode(expected_key_package, "expected member key package")?;
let leaf = self.inner.find_leaf_by_key_package(&kp).ok_or_else(|| {
MlsError::MemberNotInGroup(format!(
"no active TreeKEM leaf matching roster KeyPackage for {:?}",
member.as_bytes()
))
})?;
self.remove_leaf_for_member(member, leaf)
}
fn remove_leaf_for_member(&mut self, member: AgentId, leaf: u32) -> Result<Vec<u8>> {
let commit = self
.inner
.remove_member(leaf)
.map_err(|e| MlsError::SaorsaMls(format!("treekem remove_member: {e}")))?;
self.agent_to_leaf.remove(&member);
encode(&commit, "commit")
}
pub fn update(&mut self) -> Result<Vec<u8>> {
let commit = self
.inner
.update()
.map_err(|e| MlsError::SaorsaMls(format!("treekem update: {e}")))?;
encode(&commit, "commit")
}
pub fn encrypt_message(&mut self, plaintext: &[u8]) -> Result<Vec<u8>> {
let ct = self
.inner
.encrypt_message(plaintext)
.map_err(|e| MlsError::EncryptionError(e.to_string()))?;
encode(&ct, "ciphertext")
}
pub fn decrypt_message(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>> {
let ct: ApplicationCiphertext = decode(ciphertext, "ciphertext")
.map_err(|e| MlsError::DecryptionError(e.to_string()))?;
self.inner
.decrypt_message(&ct)
.map_err(|e| MlsError::DecryptionError(e.to_string()))
}
#[must_use]
pub fn epoch(&self) -> u64 {
self.inner.epoch()
}
#[must_use]
pub fn group_id(&self) -> &[u8] {
self.inner.group_id()
}
#[must_use]
pub fn member_count(&self) -> u32 {
self.inner.member_count()
}
pub fn to_snapshot_bytes(&self) -> Result<Vec<u8>> {
let inner_snapshot = self
.inner
.to_snapshot_bytes()
.map_err(|e| MlsError::MlsOperation(format!("treekem snapshot: {e}")))?;
let agent_to_leaf = self
.agent_to_leaf
.iter()
.map(|(agent, leaf)| (*agent.as_bytes(), *leaf))
.collect();
let mut bytes = PERSISTED_SNAPSHOT_MAGIC.to_vec();
bytes.extend(encode(
&PersistedTreeKemSnapshot {
version: PERSISTED_SNAPSHOT_VERSION,
inner_snapshot,
agent_to_leaf,
},
"persisted treekem snapshot",
)?);
Ok(bytes)
}
pub fn restore(snapshot: &[u8], member: AgentId, seed: &[u8; 32]) -> Result<Self> {
let identity = Self::identity_from_seed(seed)?;
let (inner_snapshot, persisted_map) = if let Some(wrapped) =
snapshot.strip_prefix(PERSISTED_SNAPSHOT_MAGIC)
{
let persisted: PersistedTreeKemSnapshot =
decode(wrapped, "persisted treekem snapshot")?;
if persisted.version != PERSISTED_SNAPSHOT_VERSION {
return Err(MlsError::MlsOperation(format!(
"unsupported treekem snapshot version {}",
persisted.version
)));
}
(persisted.inner_snapshot, Some(persisted.agent_to_leaf))
} else {
match decode::<PersistedTreeKemSnapshot>(snapshot, "legacy persisted treekem snapshot")
{
Ok(persisted) if persisted.version == PERSISTED_SNAPSHOT_VERSION => {
(persisted.inner_snapshot, Some(persisted.agent_to_leaf))
}
_ => (snapshot.to_vec(), None),
}
};
let inner = TreeKemGroup::from_snapshot_bytes(&inner_snapshot, identity)
.map_err(|e| MlsError::MlsOperation(format!("treekem restore: {e}")))?;
let mut agent_to_leaf: HashMap<AgentId, u32> = persisted_map
.unwrap_or_default()
.into_iter()
.map(|(agent, leaf)| (AgentId(agent), leaf))
.collect();
if let Some(leaf) = inner.own_leaf() {
agent_to_leaf.insert(member, leaf);
}
Ok(Self {
inner,
agent_to_leaf,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(id: u8) -> AgentId {
let mut bytes = [0u8; 32];
bytes[0] = id;
bytes[1] = id.wrapping_mul(7).wrapping_add(1);
AgentId(bytes)
}
fn seed(id: u8) -> [u8; 32] {
[id; 32]
}
#[test]
fn create_solo_encrypt_decrypt_roundtrip() {
let mut g = TreeKemMlsGroup::create(b"room".to_vec(), agent(1), &seed(1)).expect("create");
assert_eq!(g.epoch(), 0);
assert_eq!(g.member_count(), 1);
let ct = g.encrypt_message(b"hello self").expect("encrypt");
let pt = g.decrypt_message(&ct).expect("decrypt");
assert_eq!(pt, b"hello self");
}
#[test]
fn cross_instance_join_over_the_wire_then_exchange() {
let alice_id = agent(1);
let bob_id = agent(2);
let mut alice =
TreeKemMlsGroup::create(b"group-A".to_vec(), alice_id, &seed(1)).expect("alice create");
let bob_prepared = TreeKemMlsGroup::prepare_member(bob_id, &seed(2)).expect("bob prepare");
let kp_bytes = bob_prepared.key_package_bytes().to_vec();
let out = alice.add_member(bob_id, &kp_bytes).expect("add_member");
let mut bob =
TreeKemMlsGroup::join_from_welcome(bob_prepared, &out.welcome).expect("bob join");
assert_eq!(alice.epoch(), bob.epoch(), "epochs must converge");
assert_eq!(alice.member_count(), 2);
assert_eq!(bob.member_count(), 2);
let ct = alice
.encrypt_message(b"welcome to the group")
.expect("encrypt");
let pt = bob.decrypt_message(&ct).expect("bob decrypt");
assert_eq!(pt, b"welcome to the group");
let ct2 = bob.encrypt_message(b"glad to be here").expect("encrypt");
let pt2 = alice.decrypt_message(&ct2).expect("alice decrypt");
assert_eq!(pt2, b"glad to be here");
}
#[test]
fn wrong_group_message_does_not_decrypt() {
let mut a = TreeKemMlsGroup::create(b"group-A".to_vec(), agent(1), &seed(1)).expect("a");
let mut b = TreeKemMlsGroup::create(b"group-B".to_vec(), agent(2), &seed(2)).expect("b");
let ct = a.encrypt_message(b"secret A").expect("encrypt");
assert!(
b.decrypt_message(&ct).is_err(),
"a group-A ciphertext must not decrypt in group B"
);
}
#[test]
fn snapshot_bytes_have_magic_header_and_restore() {
let alice_id = agent(1);
let g = TreeKemMlsGroup::create(b"magic".to_vec(), alice_id, &seed(1)).expect("create");
let snap = g.to_snapshot_bytes().expect("snapshot");
assert!(snap.starts_with(PERSISTED_SNAPSHOT_MAGIC));
let restored = TreeKemMlsGroup::restore(&snap, alice_id, &seed(1)).expect("restore");
assert_eq!(restored.epoch(), g.epoch());
}
#[test]
fn restart_persistence_creator_restores_and_decrypts() {
let alice_id = agent(1);
let bob_id = agent(2);
let mut alice =
TreeKemMlsGroup::create(b"persist".to_vec(), alice_id, &seed(1)).expect("create");
let bob_prepared = TreeKemMlsGroup::prepare_member(bob_id, &seed(2)).expect("prepare");
let out = alice
.add_member(bob_id, bob_prepared.key_package_bytes())
.expect("add");
let mut bob = TreeKemMlsGroup::join_from_welcome(bob_prepared, &out.welcome).expect("join");
let snap = alice.to_snapshot_bytes().expect("snapshot");
drop(alice);
let mut alice2 = TreeKemMlsGroup::restore(&snap, alice_id, &seed(1)).expect("restore");
assert_eq!(alice2.member_count(), 2);
let ct = bob.encrypt_message(b"after restart").expect("encrypt");
let pt = alice2.decrypt_message(&ct).expect("restored alice decrypt");
assert_eq!(pt, b"after restart");
}
#[test]
fn owner_restore_preserves_leaf_map_for_remove() {
let alice_id = agent(1);
let bob_id = agent(2);
let mut alice =
TreeKemMlsGroup::create(b"persist-map".to_vec(), alice_id, &seed(1)).expect("create");
let bob_prepared = TreeKemMlsGroup::prepare_member(bob_id, &seed(2)).expect("prepare");
alice
.add_member(bob_id, bob_prepared.key_package_bytes())
.expect("add bob");
let snap = alice.to_snapshot_bytes().expect("snapshot");
let mut restored = TreeKemMlsGroup::restore(&snap, alice_id, &seed(1)).expect("restore");
let remove_commit = restored
.remove_member(bob_id)
.expect("remove after restore");
assert!(!remove_commit.is_empty());
assert_eq!(restored.member_count(), 1);
}
#[test]
fn restore_with_wrong_seed_is_rejected() {
let alice_id = agent(1);
let g = TreeKemMlsGroup::create(b"snap".to_vec(), alice_id, &seed(1)).expect("create");
let snap = g.to_snapshot_bytes().expect("snapshot");
let restored = TreeKemMlsGroup::restore(&snap, alice_id, &seed(9));
assert!(restored.is_err(), "restore with the wrong seed must fail");
}
#[test]
fn from_seed_is_deterministic() {
let a = TreeKemMlsGroup::prepare_member(agent(5), &seed(5)).expect("p1");
let b = TreeKemMlsGroup::prepare_member(agent(5), &seed(5)).expect("p2");
let kp_a: KeyPackage = decode(a.key_package_bytes(), "kp").expect("decode a");
let kp_b: KeyPackage = decode(b.key_package_bytes(), "kp").expect("decode b");
assert_eq!(
kp_a.verifying_key, kp_b.verifying_key,
"same seed must yield the same signing public key"
);
assert_eq!(
kp_a.agreement_key, kp_b.agreement_key,
"same seed must yield the same KEM public key"
);
}
#[test]
fn derive_identity_seed_binds_group() {
let secret = b"agent-secret-key-bytes";
let s1 = derive_identity_seed(secret, b"group-A");
let s2 = derive_identity_seed(secret, b"group-B");
assert_ne!(s1, s2, "different groups must yield different seeds");
let s1b = derive_identity_seed(secret, b"group-A");
assert_eq!(s1, s1b, "derivation must be deterministic");
}
#[test]
fn member_id_is_per_group_and_deterministic() {
let secret = b"agent-secret-key-bytes";
let seed_a = derive_identity_seed(secret, b"group-A");
let seed_b = derive_identity_seed(secret, b"group-B");
assert_ne!(
member_id_from_seed(&seed_a),
member_id_from_seed(&seed_b),
"the same agent in two groups must get distinct MemberIds"
);
assert_eq!(
member_id_from_seed(&seed_a),
member_id_from_seed(&seed_a),
"MemberId derivation must be deterministic"
);
}
#[test]
fn same_agent_in_two_groups_is_cryptographically_unlinkable() {
let agent_id = agent(1);
let secret = b"the-agents-long-term-secret";
let kp_a: KeyPackage = decode(
TreeKemMlsGroup::prepare_member(agent_id, &derive_identity_seed(secret, b"group-A"))
.expect("prepare A")
.key_package_bytes(),
"kp",
)
.expect("decode A");
let kp_b: KeyPackage = decode(
TreeKemMlsGroup::prepare_member(agent_id, &derive_identity_seed(secret, b"group-B"))
.expect("prepare B")
.key_package_bytes(),
"kp",
)
.expect("decode B");
assert_ne!(
kp_a.verifying_key, kp_b.verifying_key,
"signing keys must differ across groups"
);
assert_ne!(
kp_a.agreement_key, kp_b.agreement_key,
"KEM keys must differ across groups"
);
assert_ne!(
kp_a.credential, kp_b.credential,
"credentials (which embed the MemberId) must differ across groups"
);
}
#[test]
fn duplicate_add_is_rejected() {
let mut alice = TreeKemMlsGroup::create(b"g".to_vec(), agent(1), &seed(1)).expect("create");
let bob_id = agent(2);
let bob = TreeKemMlsGroup::prepare_member(bob_id, &seed(2)).expect("prepare");
let kp = bob.key_package_bytes().to_vec();
alice.add_member(bob_id, &kp).expect("first add ok");
let bob2 = TreeKemMlsGroup::prepare_member(bob_id, &seed(2)).expect("prepare again");
let err = alice
.add_member(bob_id, bob2.key_package_bytes())
.unwrap_err();
assert!(matches!(err, MlsError::MlsOperation(_)));
assert_eq!(
alice.member_count(),
2,
"duplicate add must not grow the tree"
);
}
#[test]
fn verified_remove_uses_roster_keypackage_not_stale_leaf_map() {
let alice_id = agent(1);
let bob_id = agent(2);
let carol_id = agent(3);
let mut alice = TreeKemMlsGroup::create(b"verified-remove".to_vec(), alice_id, &seed(1))
.expect("create");
let bob = TreeKemMlsGroup::prepare_member(bob_id, &seed(2)).expect("bob prepare");
let bob_kp = bob.key_package_bytes().to_vec();
alice.add_member(bob_id, &bob_kp).expect("add bob");
let carol = TreeKemMlsGroup::prepare_member(carol_id, &seed(3)).expect("carol prepare");
let carol_kp = carol.key_package_bytes().to_vec();
alice.add_member(carol_id, &carol_kp).expect("add carol");
let bob_leaf = alice.agent_to_leaf[&bob_id];
let carol_leaf = alice.agent_to_leaf[&carol_id];
alice.agent_to_leaf.insert(bob_id, carol_leaf);
alice.agent_to_leaf.insert(carol_id, bob_leaf);
let remove_commit = alice
.remove_member_verified(bob_id, &bob_kp)
.expect("roster KeyPackage must resolve Bob's real leaf despite stale map");
assert!(!remove_commit.is_empty());
assert_eq!(alice.member_count(), 2);
assert!(
alice.remove_member_verified(carol_id, &carol_kp).is_ok(),
"Carol must still be removable after Bob, proving the stale map did not remove her"
);
}
#[test]
fn remove_unknown_member_errors_cleanly() {
let mut g = TreeKemMlsGroup::create(b"room".to_vec(), agent(1), &seed(1)).expect("create");
let err = g.remove_member(agent(9)).unwrap_err();
assert!(matches!(err, MlsError::MemberNotInGroup(_)));
}
}