// This Implementation is a Quick and Dirty Integration of DeMLS into libchat. // DeMLS and Libchat have different execution models, trait definitions and ownership/lifetimes of objects. // The easies path is to do a Spike to see what it would take, gather the friction points and then iterate. use crate::conversation::mls_extensions::{ ConvoMetaInfo, GROUP_METADATA_EXTENSION_TYPE, capabilities_with_group_metadata, }; use crate::types::{AddressedEncryptedPayload, ConvoMetadata}; use crate::{Content, WakeupService}; use alloy::signers::local::PrivateKeySigner; use blake2::{Blake2b, Digest, digest::consts::U6}; use chat_proto::logoschat::encryption::{EncryptedPayload, Plaintext, encrypted_payload}; use de_mls::protos::de_mls::messages::v1::{ AppMessage as AppMessageProto, MemberWelcome, app_message, }; use de_mls::{ Conversation, ConversationEvent, MockClock, PeerScoringService, ScoringConfig, WallClock, default_score_deltas, defaults::{DefaultConsensusPlugin, DefaultPeerScoring, InMemoryPeerScoreStorage}, }; use hashgraph_like_consensus::signing::EthereumConsensusSigner; use openmls::extensions::{Extension, Extensions, UnknownExtension}; use openmls::group::MlsGroupCreateConfig; use openmls::prelude::tls_codec::Deserialize as _; use openmls::prelude::{KeyPackageIn, OpenMlsProvider as _, ProtocolVersion}; use openmls_traits::crypto::OpenMlsCrypto; use prost::Message; use shared_traits::{IdentId, IdentIdRef}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tracing::{info, instrument}; use crate::IdentityProvider; use crate::conversation::{ConversationIdRef, ExternalServices, ServiceContext}; use crate::{ ConvoOutcome, DeliveryService, RegistrationService, conversation::{ChatError, Convo, GroupConvo, Identified}, }; /// The de-mls time source: every conversation deadline (freeze windows, /// consensus timeouts, auto-votes) and consensus wire timestamp is measured /// against this clock. Production runs on system time; tests share one /// `MockClock` with the harness scheduler so virtual time moves the /// protocol's timers. #[derive(Debug, Clone, Default)] pub enum GroupV2Clock { #[default] System, Mock(MockClock), } impl WallClock for GroupV2Clock { fn now(&self) -> Duration { match self { GroupV2Clock::System => SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(), GroupV2Clock::Mock(clock) => clock.now(), } } } /// Local member id bytes — the account identity the protocol matches on, /// shared with the MLS credential and the consensus member. fn member_id(service_ctx: &ServiceContext) -> Vec { service_ctx.mls_identity.id().as_str().as_bytes().to_vec() } /// `app_id` for outbound packets / echo-dedup — random per conversation. fn rand_app_id() -> Arc<[u8]> { Arc::from(rand_string(5).as_bytes()) } /// Peer-scoring plug-in: the library default over in-memory storage. fn make_scoring() -> DefaultPeerScoring { PeerScoringService::new( InMemoryPeerScoreStorage::default(), default_score_deltas(), ScoringConfig::default(), ) } /// Consensus service: the library default over a fresh in-memory store and a /// random Ethereum consensus signer. fn make_consensus() -> DefaultConsensusPlugin { DefaultConsensusPlugin::new(EthereumConsensusSigner::new(PrivateKeySigner::random())) } pub struct GroupV2Convo { convo_id: String, conversation: Conversation, /// Joiners WE invited, as `(member_id, signer_id)`: the de-mls member id /// (the joiner's leaf credential content, read from its key package) paired /// with the signer id its welcome is delivered to. pending_invites: Vec<(Vec, String)>, } impl std::fmt::Debug for GroupV2Convo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("GroupV2Convo") .field("convo_id", &self.convo_id) .finish_non_exhaustive() } } fn rand_string(n: usize) -> String { let bytes: Vec = (0..n).map(|_| rand::random::()).collect(); hex::encode(bytes) } fn group_config( cx: &mut ServiceContext, name: &str, desc: &str, ) -> MlsGroupCreateConfig { let meta = ConvoMetaInfo::new(name, desc); let extensions = Extensions::from_vec(vec![Extension::Unknown( GROUP_METADATA_EXTENSION_TYPE, UnknownExtension(meta.to_extension_bytes()), )]) .expect("failed to create extensions"); MlsGroupCreateConfig::builder() .ciphersuite(cx.mls_provider.crypto().supported_ciphersuites()[0]) .capabilities(capabilities_with_group_metadata()) .use_ratchet_tree_extension(true) // Embed the ratchet tree in the Welcome so joiners can build the group .with_group_context_extensions(extensions) .build() } impl GroupV2Convo { pub fn new( service_ctx: &mut ServiceContext, name: &str, desc: &str, ) -> Result { let convo_id = rand_string(5); let group_config = group_config(service_ctx, name, desc); let conversation = Conversation::create( &convo_id, &member_id(service_ctx), &service_ctx.mls_provider, service_ctx.mls_identity.get_credential(), &group_config, &service_ctx.mls_identity, &make_consensus(), make_scoring(), service_ctx.demls_clock.clone(), rand_app_id(), service_ctx.demls_config.clone(), )?; let convo = GroupV2Convo { convo_id, conversation, pending_invites: vec![], }; convo.init(service_ctx)?; Ok(convo) } /// Joiner side: ingest a de-mls welcome handed over the InboxV2 1-1 /// channel. `from_welcome` attaches MLS and applies the bundled /// `ConversationSync` in one call; we then subscribe to the /// conversation address and flush the join broadcast. #[instrument(name = "groupv2.new_from_welcome", skip_all, fields(user_id = %service_ctx.mls_identity.display_name()))] pub fn new_from_welcome( service_ctx: &mut ServiceContext, welcome: &MemberWelcome, ) -> Result { let Some(conv) = Conversation::join( &member_id(service_ctx), &service_ctx.mls_provider, &service_ctx.mls_identity, &welcome.welcome_bytes, &welcome.conversation_sync_bytes, &make_consensus(), make_scoring(), service_ctx.demls_clock.clone(), rand_app_id(), service_ctx.demls_config.clone(), )? else { return Err(ChatError::generic("welcome not addressed to this member")); }; let mut convo = GroupV2Convo { convo_id: conv.id().to_string(), conversation: conv, pending_invites: vec![], }; convo.init(service_ctx)?; // subscribe convo.after_op(service_ctx)?; // flush join broadcast + schedule wakeup Ok(convo) } fn delivery_address_from_id(convo_id: &str) -> String { let hash = Blake2b::::new() .chain_update("delivery_addr|") .chain_update(convo_id) .finalize(); hex::encode(hash) } fn init( &self, service_ctx: &mut ServiceContext, ) -> Result<(), ChatError> { // Configure the delivery service to listen for the required delivery addresses. service_ctx .ds .subscribe(&Self::delivery_address_from_id(&self.convo_id)) .map_err(ChatError::generic)?; Ok(()) } pub fn id(&self) -> ConversationIdRef<'_> { &self.convo_id } } impl Identified for GroupV2Convo { fn id(&self) -> ConversationIdRef<'_> { &self.convo_id } } impl Convo for GroupV2Convo where S: ExternalServices, { #[instrument(name = "groupv2.send_content", skip_all, fields(user_id = %service_ctx.mls_identity.display_name(), content))] fn send_content( &mut self, service_ctx: &mut super::ServiceContext, content: &[u8], ) -> Result<(), ChatError> { self.conversation.send_message( &service_ctx.mls_provider, &service_ctx.mls_identity, content.to_vec(), )?; self.after_op(service_ctx)?; Ok(()) } #[instrument(name = "groupv2.handle_frame", skip_all, fields(user_id = %service_ctx.mls_identity.display_name()))] fn handle_frame( &mut self, service_ctx: &mut super::ServiceContext, encoded_payload: EncryptedPayload, ) -> Result { let bytes = match encoded_payload.encryption { Some(encrypted_payload::Encryption::Plaintext(pt)) => pt.payload, _ => { return Err(ChatError::generic("Expected plaintext")); } }; let frame = GroupV2Frame::decode(bytes.as_ref()).map_err(ChatError::generic)?; let inner = match frame.payload { Some(GroupV2Payload::DeMlsWrapper(b)) => b.to_vec(), _ => return Ok(ConvoOutcome::empty(self.convo_id.clone())), }; self.conversation.process_inbound( &service_ctx.mls_provider, &service_ctx.mls_identity, &frame.sender_app_id, &inner, )?; self.conversation .poll(&service_ctx.mls_provider, &service_ctx.mls_identity); let events = self.after_op(service_ctx)?; // route + publish + re-arm, returns events Ok(self.outcome_from_events(&events)) } #[instrument(name = "groupv2.wakeup", skip_all, fields(user_id = %ctx.mls_identity.display_name()))] fn wakeup(&mut self, ctx: &mut ServiceContext) -> Result { info!(convo = %self.convo_id, "Wakeup"); let poll_outcome = self.conversation.poll(&ctx.mls_provider, &ctx.mls_identity); if poll_outcome.leave_requested { // Commit ejected us (or join expired). Real handling - drops // this convo from its map; tracing::warn!(convo = %self.convo_id, "conversation requested teardown"); } let events = self.after_op(ctx)?; // publish what poll produced + re-arm alarm Ok(self.outcome_from_events(&events)) } fn members(&self) -> Result>, ChatError> { // Guarantee the local member is listed so callers see the full roster. let mut members = self.conversation.members()?; let self_id = self.conversation.member_id_bytes().to_vec(); if !members.contains(&self_id) { members.push(self_id); } Ok(members) } } impl GroupConvo for GroupV2Convo where S: ExternalServices, { #[instrument(name = "groupv2.add_member", skip_all, fields(user_id = %service_ctx.mls_identity.display_name()))] fn add_member( &mut self, service_ctx: &mut ServiceContext, members: &[IdentIdRef], ) -> Result<(), ChatError> { // Dedup the requested signers up front: an account can resolve to the // same signer twice, or a caller can repeat one, and a duplicate would // otherwise cost a redundant key-package fetch here and a second Add // proposal for a member already being added in this batch. let mut seen = std::collections::HashSet::new(); let members: Vec = members .iter() .copied() .filter(|m| seen.insert(m.as_str().to_string())) .collect(); // Fetch and validate every key package before proposing any add, so a // member with no key package fails the call before it opens proposals // for the others. Members are signer ids; the de-mls member id must // match the id of the IdentityProvider that generated the key package // (its MLS leaf credential content — de-mls matches members by // credential), so it is read from the fetched key package rather than // assumed equal to the signer id. let mut invites = Vec::with_capacity(members.len()); for member in &members { let kp_bytes = service_ctx .registry .retrieve(member.as_str()) .map_err(ChatError::generic)? .ok_or_else(|| ChatError::generic("No key package"))?; let key_package_in = KeyPackageIn::tls_deserialize(&mut kp_bytes.as_slice())?; let keypkg = key_package_in .validate(service_ctx.mls_provider.crypto(), ProtocolVersion::Mls10)?; let member_id = keypkg .leaf_node() .credential() .serialized_content() .to_vec(); invites.push((member_id, member.to_string(), kp_bytes)); } // pending_invites drives welcome delivery: after_op forwards a welcome // only to a joiner recorded here. Record a member only if de-mls will // actually propose its add — recording one it silently drops strands an // entry that a later re-join can match, firing a spurious duplicate // welcome. de-mls drops self and members already in the group; and since // add_member only opens a proposal, the committed roster won't reflect a // member added earlier in this same loop, so the set tracks those too. // Seed it with the roster and self, insert as we go, and one check // covers all three. let mut roster: std::collections::HashSet> = self.conversation.members()?.into_iter().collect(); roster.insert(self.conversation.member_id_bytes().to_vec()); let mut result = Ok(()); for (member_id, signer_id, kp_bytes) in invites { if !roster.insert(member_id.clone()) { continue; } self.pending_invites.push((member_id.clone(), signer_id)); if let Err(e) = self.conversation.add_member( &service_ctx.mls_provider, &service_ctx.mls_identity, &member_id, &kp_bytes, ) { self.pending_invites.pop(); result = Err(e.into()); break; } } // Flush even on a mid-loop failure: proposals already opened must be // published and the wakeup re-armed, or they sit dormant until an // unrelated frame drives the conversation. let flushed = self.after_op(service_ctx).map(drop); result.and(flushed) } fn pending_members(&self) -> Result>, ChatError> { Ok(self .pending_invites .iter() .map(|(member_id, _)| member_id.clone()) .collect()) } fn metadata(&self) -> Option { let res = self.conversation.extensions().iter().find_map(|ext| { if let Extension::Unknown(ext_type, UnknownExtension(bytes)) = ext && *ext_type == GROUP_METADATA_EXTENSION_TYPE { return ConvoMetaInfo::from_extension_bytes(bytes).ok(); }; None }); res.map(Into::into) } // fn conversation_state(&self) -> Result { // Ok(self // .conversation // .as_ref() // .map(|c| c.state()) // .unwrap_or(ConversationState::PendingJoin)) // } } impl GroupV2Convo { fn after_op( &mut self, service_ctx: &mut ServiceContext, ) -> Result, ChatError> { // Pull everything first (these are &self, take-all): let events = self.conversation.drain_events(); let outbound = self.conversation.drain_outbound(); // Vec let wakeup = self.conversation.next_wakeup_in(); // 1. Route welcomes for joiners WE invited (event fires on every member // now). The welcome travels to the joiner's signer id (where its // InboxV2 listens), not its de-mls member id. for evt in &events { if let ConversationEvent::WelcomeReady { welcome, .. } = evt { for joiner in &welcome.joiner_identities { if let Some(i) = self.pending_invites.iter().position(|(p, _)| p == joiner) { let (_, signer_id) = self.pending_invites.remove(i); crate::inbox_v2::invite_user_v2( &mut service_ctx.ds, &IdentId::new(signer_id), welcome, )?; } } } } // 2. Publish for out in outbound { let frame = GroupV2Frame { payload: Some(GroupV2Payload::DeMlsWrapper(out.payload.into())), sender_app_id: out.sender, // was pkt.app_id }; let payload = AddressedEncryptedPayload { delivery_address: Self::delivery_address_from_id(&out.conversation_id), data: EncryptedPayload { encryption: Some(encrypted_payload::Encryption::Plaintext(Plaintext { payload: frame.encode_to_vec().into(), })), }, }; service_ctx .ds .publish(payload.into_envelope(out.conversation_id)) .map_err(ChatError::generic)?; } // 3. Re-arm the alarm with the conversation's earliest deadline. if let Some(d) = wakeup { service_ctx .wakeup_service .wakeup_in(d, self.convo_id.clone()); } Ok(events) } fn outcome_from_events(&self, events: &[ConversationEvent]) -> ConvoOutcome { let content = events.iter().find_map(|evt| match evt { ConversationEvent::ConversationMessage(AppMessageProto { payload: Some(app_message::Payload::ConversationMessage(cm)), }) => Some(Content { bytes: cm.message.clone(), encoded_credential: cm.sender.clone(), }), _ => None, }); let members_changed = events.iter().any(|evt| { matches!( evt, ConversationEvent::CommitApplied(_) | ConversationEvent::WelcomeReady { .. } ) }); ConvoOutcome { convo_id: self.convo_id.clone(), content, members_changed, } } } use prost::{Oneof, bytes::Bytes}; #[derive(Clone, PartialEq, Message)] pub struct GroupV2Frame { #[prost(oneof = "GroupV2Payload", tags = "2, 3")] pub payload: Option, #[prost(bytes = "vec", tag = "4")] pub sender_app_id: Vec, } #[derive(Clone, PartialEq, Oneof)] pub enum GroupV2Payload { #[prost(message, tag = "2")] DeMlsWrapper(Bytes), #[prost(message, tag = "3")] MlsCommitMessage(Bytes), }