diff --git a/Cargo.lock b/Cargo.lock index caa1ced..c4f9e75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1817,7 +1817,7 @@ dependencies = [ [[package]] name = "de-mls" version = "4.0.0" -source = "git+https://github.com/vacp2p/de-mls?branch=main#e7d2726fd005e56329f00f91e8c07672a3d3639b" +source = "git+https://github.com/vacp2p/de-mls?rev=2c7a8669c1492c749c02efd2c5ac45e93e4926a3#2c7a8669c1492c749c02efd2c5ac45e93e4926a3" dependencies = [ "hashgraph-like-consensus", "indexmap 2.14.0", diff --git a/core/conversations/Cargo.toml b/core/conversations/Cargo.toml index a2114a2..0b3950d 100644 --- a/core/conversations/Cargo.toml +++ b/core/conversations/Cargo.toml @@ -18,7 +18,7 @@ storage = { workspace = true } alloy = "2.0" base64 = "0.22" chat-proto = { git = "https://github.com/logos-messaging/chat_proto", rev = "37ec98a151f6d50aab2905802ac0a896477e62ea" } -de-mls = { git = "https://github.com/vacp2p/de-mls", branch = "main" } +de-mls = { git = "https://github.com/vacp2p/de-mls", rev = "2c7a8669c1492c749c02efd2c5ac45e93e4926a3"} # Expose Mls Extensions (#131) double-ratchets = { path = "../double-ratchets" } hashgraph-like-consensus = "0.6.0" hex = "0.4.3" diff --git a/core/conversations/src/conversation.rs b/core/conversations/src/conversation.rs index 37edb61..4fa07a7 100644 --- a/core/conversations/src/conversation.rs +++ b/core/conversations/src/conversation.rs @@ -1,12 +1,14 @@ mod direct_v1; pub mod group_v1; mod group_v2; +pub mod mls_extensions; mod privatev1; pub use crate::errors::ChatError; use crate::outcomes::ConvoOutcome; use crate::proto::EncryptedPayload; use crate::service_context::{ExternalServices, ServiceContext}; +use crate::types::ConvoMetadata; pub use direct_v1::DirectV1Convo; pub use group_v1::GroupV1Convo; pub use group_v2::{GroupV2Clock, GroupV2Convo}; @@ -46,6 +48,10 @@ pub(crate) trait GroupConvo: Convo + std::fmt::Debug + S /// Each current member's MLS leaf-credential content (hex-encoded), self /// included. fn members(&self) -> Result>, ChatError>; + // All GroupConvos MUST return ConvoMetadata + // the return type is Option<_> to support legacy ConvoTypes which + // are being phased out. + fn metadata(&self) -> Option; } pub(crate) trait Identified { diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index 8aa7402..dbea42a 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -16,6 +16,7 @@ use crate::conversation::ConversationIdRef; use crate::inbox_v2::MlsProvider; use crate::service_context::{ExternalServices, ServiceContext}; +use crate::types::ConvoMetadata; use crate::utils::{blake2b_hex, hash_size}; use crate::{ DeliveryService, IdentityProvider, @@ -349,4 +350,8 @@ impl GroupConvo for GroupV1Convo { .map(|m| m.credential.serialized_content().to_vec()) .collect()) } + + fn metadata(&self) -> Option { + None + } } diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index 6b42f70..8636167 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -2,7 +2,10 @@ // 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::types::AddressedEncryptedPayload; +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}; @@ -16,9 +19,11 @@ use de_mls::{ 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; @@ -103,23 +108,41 @@ fn rand_string(n: usize) -> String { hex::encode(bytes) } -fn group_config() -> MlsGroupCreateConfig { +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() - .use_ratchet_tree_extension(true) + .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(), + &group_config, &service_ctx.mls_identity, &make_consensus(), make_scoring(), @@ -370,6 +393,19 @@ where Ok(members) } + 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 diff --git a/core/conversations/src/conversation/mls_extensions.rs b/core/conversations/src/conversation/mls_extensions.rs new file mode 100644 index 0000000..a5b4033 --- /dev/null +++ b/core/conversations/src/conversation/mls_extensions.rs @@ -0,0 +1,120 @@ +use openmls::{ + extensions::ExtensionType, + prelude::{ + Capabilities, + tls_codec::{Deserialize, Error as TlsError, Serialize, Size, VLByteSlice, VLBytes}, + }, +}; +use std::io::{Read, Write}; + +use crate::types::ConvoMetadata; + +/// MLS extension type carrying our [`ConvoMetadata`]. In the private-use +/// range (0xF000–0xFFFF) reserved by RFC 9420 for non-registered extensions. +pub const GROUP_METADATA_EXTENSION_TYPE: u16 = 0xFF01; + +pub fn capabilities_with_group_metadata() -> Capabilities { + Capabilities::new( + None, // default protocol versions + None, // default ciphersuites + Some(&[ExtensionType::Unknown(GROUP_METADATA_EXTENSION_TYPE)]), + None, // default proposal types + None, // default credential types + ) +} + +/// Wire-format version of [`ConvoMetaInfo`], encoded as a `u16`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +enum ConvoMetaInfoVersion { + V1 = 1, +} + +impl TryFrom for ConvoMetaInfoVersion { + type Error = TlsError; + + fn try_from(value: u16) -> Result { + match value { + 1 => Ok(Self::V1), + other => Err(TlsError::DecodingError(format!( + "unknown ConvoMetaInfo version {other}" + ))), + } + } +} + +#[derive(Debug, Clone)] +pub struct ConvoMetaInfo { + version: ConvoMetaInfoVersion, + name: String, + desc: String, +} + +impl ConvoMetaInfo { + pub fn new(name: impl Into, desc: impl Into) -> Self { + Self { + version: ConvoMetaInfoVersion::V1, + name: name.into(), + desc: desc.into(), + } + } + + pub fn to_extension_bytes(&self) -> Vec { + // TLS presentation-language encoding — matches the MLS stack's wire + // format. Writing to a Vec is infallible; the only error path is a + // field exceeding tls_codec's ~1 GiB length cap, unreachable here. + self.tls_serialize_detached() + .expect("ConvoMetaInfo serialization to Vec is infallible") + } + + pub fn from_extension_bytes(bytes: &[u8]) -> Result { + Self::tls_deserialize(&mut &bytes[..]) + } +} + +// Each field is encoded as a variable-length opaque (`opaque `); `IdentId` +// and `String` aren't `tls_codec` types, so we encode/decode their UTF-8 bytes. +impl Size for ConvoMetaInfo { + fn tls_serialized_len(&self) -> usize { + (self.version as u16).tls_serialized_len() + + VLByteSlice(self.name.as_bytes()).tls_serialized_len() + + VLByteSlice(self.desc.as_bytes()).tls_serialized_len() + } +} + +impl Serialize for ConvoMetaInfo { + fn tls_serialize(&self, writer: &mut W) -> Result { + let mut written = (self.version as u16).tls_serialize(writer)?; + written += VLByteSlice(self.name.as_bytes()).tls_serialize(writer)?; + written += VLByteSlice(self.desc.as_bytes()).tls_serialize(writer)?; + Ok(written) + } +} + +impl Deserialize for ConvoMetaInfo { + fn tls_deserialize(bytes: &mut R) -> Result { + let version = ConvoMetaInfoVersion::try_from(u16::tls_deserialize(bytes)?)?; + let name = vl_string(bytes)?; + let desc = vl_string(bytes)?; + Ok(Self { + version, + name, + desc, + }) + } +} + +fn vl_string(bytes: &mut R) -> Result { + let raw = VLBytes::tls_deserialize(bytes)?; + String::from_utf8(raw.into()) + .map_err(|_| TlsError::DecodingError("invalid utf-8 in ConvoMetaInfo".into())) +} + +impl From for ConvoMetadata { + fn from(value: ConvoMetaInfo) -> Self { + Self { + name: value.name, + desc: value.desc, + } + } +} diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index f0f48d1..0934f99 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -3,6 +3,7 @@ use crate::conversation::{ ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo, }; use crate::service_context::{ExternalServices, ServiceContext}; +use crate::types::ConvoMetadata; use crate::{ DeliveryService, GroupV2Clock, GroupV2Config, IdentityProvider, RegistrationService, WakeupService, @@ -240,7 +241,7 @@ impl<'a, S: ExternalServices + 'static> Core { &mut self, participants: &[IdentIdRef], ) -> Result { - self.create_group_convo_v2(participants) + self.create_group_convo_v2(participants, "", "") } pub fn create_group_convo_v1( @@ -269,11 +270,13 @@ impl<'a, S: ExternalServices + 'static> Core { pub fn create_group_convo_v2( &mut self, participants: &[IdentIdRef], + name: &str, + desc: &str, ) -> Result { // TODO: (P1) Ensure errors are handled properly. This is a high chance for // desynchronized state: MlsGroup persistence, conversation persistence, and // invite delivery all happen separately. - let mut convo = GroupV2Convo::new(&mut self.services)?; + let mut convo = GroupV2Convo::new(&mut self.services, name, desc)?; convo.add_member(&mut self.services, participants)?; let convo_id = convo.id().to_string(); @@ -524,6 +527,23 @@ impl<'a, S: ExternalServices + 'static> Core { .load_conversation(convo_id)? .ok_or_else(|| ChatError::NoConvo(convo_id.into())) } + + pub fn convo_metadata(&self, convo_id: ConversationIdRef) -> Result { + match self.cached_convos.get(convo_id) { + Some(ConvoTypeOwned::Group(group_convo)) => { + group_convo + .metadata() + .ok_or(ChatError::UnsupportedConvoType( + "metadata is not available for this legacy convo_type".into(), + )) + } + Some(ConvoTypeOwned::Direct(_)) => Err(ChatError::UnsupportedFunction( + convo_id.into(), + "implementation coming".into(), + )), + None => Err(ChatError::NoConvo(convo_id.into())), + } + } } enum ConvoTypeOwned { diff --git a/core/conversations/src/inbox_v2.rs b/core/conversations/src/inbox_v2.rs index b67ceed..cde0ff1 100644 --- a/core/conversations/src/inbox_v2.rs +++ b/core/conversations/src/inbox_v2.rs @@ -20,6 +20,7 @@ use crate::conversation::GroupConvo; use crate::conversation::GroupV1Convo; use crate::conversation::GroupV2Convo; use crate::conversation::Identified as _; +use crate::conversation::mls_extensions::GROUP_METADATA_EXTENSION_TYPE; use crate::outcomes::ConversationClass; use crate::service_context::{ExternalServices, ServiceContext}; use crate::utils::{blake2b_hex, hash_size}; @@ -207,6 +208,7 @@ impl InboxV2 { .extensions(vec![ ExtensionType::ApplicationId, ExtensionType::LastResort, + ExtensionType::Unknown(GROUP_METADATA_EXTENSION_TYPE), ]) .build(); let a = KeyPackage::builder() @@ -226,7 +228,7 @@ impl InboxV2 { #[derive(Clone, PartialEq, Message)] pub struct InboxV2Frame { - #[prost(oneof = "InviteType", tags = "1, 2")] + #[prost(oneof = "InviteType", tags = "1, 2, 3")] pub payload: Option, } diff --git a/core/conversations/src/types.rs b/core/conversations/src/types.rs index 1433425..e3a486d 100644 --- a/core/conversations/src/types.rs +++ b/core/conversations/src/types.rs @@ -66,3 +66,9 @@ impl AddressedEncryptedPayload { ) } } + +#[derive(Debug)] +pub struct ConvoMetadata { + pub name: String, + pub desc: String, +} diff --git a/core/integration_tests_core/tests/test_group_v2.rs b/core/integration_tests_core/tests/test_group_v2.rs index afafd0a..acfe88a 100644 --- a/core/integration_tests_core/tests/test_group_v2.rs +++ b/core/integration_tests_core/tests/test_group_v2.rs @@ -18,7 +18,7 @@ fn groupv2_2way_roundtrip() { let particpants = &[&harness.raya().addr()]; let convo_id = harness .saro() - .create_group_convo_v2(particpants) + .create_group_convo_v2(particpants, "", "") .expect("saro create group"); // Carry the invite through (commit, WelcomeReady, routing to Raya's inbox, @@ -56,7 +56,7 @@ fn core_client() { let particpants = &[&harness.raya().addr()]; let convo_id = harness .saro() - .create_group_convo_v2(particpants) + .create_group_convo_v2(particpants, "", "") .expect("Saro create"); // Carry the invite through (commit, WelcomeReady, routing to Raya's inbox, @@ -114,7 +114,7 @@ fn core_client_batch_add() { let particpants = &[&harness.raya().addr(), &harness.pax().addr()]; harness .saro() - .create_group_convo_v2(particpants) + .create_group_convo_v2(particpants, "", "") .expect("Saro create"); // Carry the invite through (commit, WelcomeReady, routing to Raya's inbox, @@ -144,7 +144,7 @@ fn core_client_four_members_two_epochs() { let particpants = &[&harness.raya().addr(), &harness.pax().addr()]; let convo_id = harness .saro() - .create_group_convo_v2(particpants) + .create_group_convo_v2(particpants, "", "") .expect("Saro create"); // Carry the invite through (commit, WelcomeReady, routing to Raya's inbox, @@ -177,6 +177,68 @@ fn core_client_four_members_two_epochs() { }); } +#[test] +fn group_name_propagation() { + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_test_writer() + .try_init(); + + let name = "Jankiest Friends"; + let desc = "A cool group chat, with cool people"; + + let mut harness = TestHarness::<4>::new(|_, _| {}); + + let members = &[&harness.raya().addr()]; + let convo_id = harness + .saro() + .create_group_convo_v2(members, name, desc) + .expect("Saro create"); + + // Carry the invite through (commit, WelcomeReady, routing to Raya's inbox, + // accept_welcome); settle until Raya has joined. + harness.process_until_label("Raya join", |h| h.raya().convo_count() == 1); + + // Verfiy that Saro's Metadata is correct + assert_eq!( + harness.saro().convo_metadata(&convo_id).expect("meta").name, + name + ); + assert_eq!( + harness.saro().convo_metadata(&convo_id).expect("meta").desc, + desc + ); + + // Verify that Raya has the same MetaData + assert_eq!( + harness.saro().convo_metadata(&convo_id).expect("meta").name, + harness.raya().convo_metadata(&convo_id).expect("meta").name + ); + assert_eq!( + harness.saro().convo_metadata(&convo_id).expect("meta").desc, + harness.raya().convo_metadata(&convo_id).expect("meta").desc + ); + + // Epoch 2: Raya adds the 3rd member; settle until Pax has joined + let members = &[&harness.pax().addr()]; + harness + .raya() + .group_add_member(&convo_id, members) + .expect("Add Pax"); + + harness.process_until_label("Pax join", |h| h.pax().convo_count() == 1); + + // Verify that Pax has the same MetaData + assert_eq!( + harness.saro().convo_metadata(&convo_id).expect("meta").name, + harness.pax().convo_metadata(&convo_id).expect("meta").name + ); + assert_eq!( + harness.saro().convo_metadata(&convo_id).expect("meta").desc, + harness.pax().convo_metadata(&convo_id).expect("meta").desc + ); +} + #[test] fn member_joins_two_groups() { // The same installation is invited to two separate groups. Its single @@ -195,14 +257,14 @@ fn member_joins_two_groups() { // Group 1: Saro invites Raya. harness .saro() - .create_group_convo_v2(&[&raya_addr]) + .create_group_convo_v2(&[&raya_addr], "", "") .expect("saro create group 1"); harness.process_until_label("raya joins group 1", |h| h.raya().convo_count() == 1); // Group 2: Saro invites Raya again, into a fresh group. harness .saro() - .create_group_convo_v2(&[&raya_addr]) + .create_group_convo_v2(&[&raya_addr], "", "") .expect("saro create group 2"); harness.process_until_label("raya joins group 2", |h| h.raya().convo_count() == 2); @@ -238,7 +300,7 @@ fn direct_v1_then_group_v2_reuses_key_package() { // 2. GroupV2 inviting the same Raya. harness .saro() - .create_group_convo_v2(&[&raya_addr]) + .create_group_convo_v2(&[&raya_addr], "", "") .expect("saro create group"); harness.process_until_label("raya joins group", |h| h.raya().convo_count() == 2);