Add group description (#161)

Update config

Update constructor calls

Enable groupContext for GV2

Remove test group type

Add test

temporary dep; waiting for upstream

Remove owner

Doc MLS extension type

Add ConvoMetaInfoVersion

Update infallable function docs

strongly typed metadata

Cleanup tests

Pin de-mls revision

full remove owner
This commit is contained in:
Jazz Turner-Baggs 2026-07-10 23:26:05 -07:00 committed by GitHub
parent 8da9e4da18
commit c089144dc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 273 additions and 16 deletions

2
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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<S: ExternalServices>: Convo<S> + std::fmt::Debug + S
/// Each current member's MLS leaf-credential content (hex-encoded), self
/// included.
fn members(&self) -> Result<Vec<Vec<u8>>, ChatError>;
// All GroupConvos MUST return ConvoMetadata
// the return type is Option<_> to support legacy ConvoTypes which
// are being phased out.
fn metadata(&self) -> Option<ConvoMetadata>;
}
pub(crate) trait Identified {

View File

@ -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<S: ExternalServices> GroupConvo<S> for GroupV1Convo {
.map(|m| m.credential.serialized_content().to_vec())
.collect())
}
fn metadata(&self) -> Option<ConvoMetadata> {
None
}
}

View File

@ -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<S: ExternalServices>(
cx: &mut ServiceContext<S>,
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<S: ExternalServices>(
service_ctx: &mut ServiceContext<S>,
name: &str,
desc: &str,
) -> Result<Self, ChatError> {
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<ConvoMetadata> {
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<ConversationState, ChatError> {
// Ok(self
// .conversation

View File

@ -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 (0xF0000xFFFF) 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<u16> for ConvoMetaInfoVersion {
type Error = TlsError;
fn try_from(value: u16) -> Result<Self, Self::Error> {
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<String>, desc: impl Into<String>) -> Self {
Self {
version: ConvoMetaInfoVersion::V1,
name: name.into(),
desc: desc.into(),
}
}
pub fn to_extension_bytes(&self) -> Vec<u8> {
// 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, TlsError> {
Self::tls_deserialize(&mut &bytes[..])
}
}
// Each field is encoded as a variable-length opaque (`opaque <V>`); `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<W: Write>(&self, writer: &mut W) -> Result<usize, TlsError> {
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<R: Read>(bytes: &mut R) -> Result<Self, TlsError> {
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<R: Read>(bytes: &mut R) -> Result<String, TlsError> {
let raw = VLBytes::tls_deserialize(bytes)?;
String::from_utf8(raw.into())
.map_err(|_| TlsError::DecodingError("invalid utf-8 in ConvoMetaInfo".into()))
}
impl From<ConvoMetaInfo> for ConvoMetadata {
fn from(value: ConvoMetaInfo) -> Self {
Self {
name: value.name,
desc: value.desc,
}
}
}

View File

@ -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<S> {
&mut self,
participants: &[IdentIdRef],
) -> Result<ConversationId, ChatError> {
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<S> {
pub fn create_group_convo_v2(
&mut self,
participants: &[IdentIdRef],
name: &str,
desc: &str,
) -> Result<ConversationId, ChatError> {
// 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<S> {
.load_conversation(convo_id)?
.ok_or_else(|| ChatError::NoConvo(convo_id.into()))
}
pub fn convo_metadata(&self, convo_id: ConversationIdRef) -> Result<ConvoMetadata, ChatError> {
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<S: ExternalServices> {

View File

@ -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<InviteType>,
}

View File

@ -66,3 +66,9 @@ impl AddressedEncryptedPayload {
)
}
}
#[derive(Debug)]
pub struct ConvoMetadata {
pub name: String,
pub desc: String,
}

View File

@ -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);