Skip to content

Group Management and Federation

fn create_group(
founder: &DeviceIdentity,
initial_members: &[LocalContact],
group_name: String,
provider: &impl OpenMlsProvider,
) -> Result<MultiUserGroup> {
// 1. Generate random group ID
let group_id = random_bytes(32);
// 2. Create permission extension
let mut device_permissions = HashMap::new();
device_permissions.insert(
founder.device_id,
Permissions::ADMINISTRATOR
);
let perm_ext = CryptidPermissionExtension {
device_permissions,
founder: founder.device_id,
version: 0,
last_updated_at: current_unix_timestamp(),
last_updated_by: founder.device_id,
};
// 3. Create group metadata extension
let metadata_ext = CryptidGroupMetadata {
group_name,
group_picture: None,
group_description: None,
threads: HashMap::new(),
version: 0,
last_updated_at: current_unix_timestamp(),
last_updated_by: founder.device_id,
};
// 4. Serialize both extensions
let perm_data = serde_json::to_vec(&perm_ext)?;
let metadata_data = serde_json::to_vec(&metadata_ext)?;
let perm_extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
extension_data.into()
);
let metadata_extension = Extension::new(
ExtensionType::Unknown(CRYPTID_GROUP_METADATA_EXT),
extension_data.into()
);
// 5. Create MLS group with founder's credential, RatchetTreeExtension, and Extensions
let mut extensions = Extensions::empty();
extensions.add(perm_extension)?;
extensions.add(metadata_extension)?;
let config = MlsGroupCreateConfig::builder()
.with_ratchet_tree_extension(true)
.with_group_context_extensions(extensions)?
.build();
let mut mls_group = MlsGroup::new(
provider,
&founder.keypair,
&config,
founder.create_mls_credential(),
)?;
// 6. Fetch KeyPackages for ALL devices of each user
let mut all_keypackages = Vec::new();
let mut device_to_user_map = HashMap::new();
for user_contact in initial_members {
// Fetch KeyPackages for all of this user's devices
for device in &user_contact.devices {
match fetch_keypackage(&device.keypackage_server, &device.device_id, token) {
Ok(keypackage) => {
all_keypackages.push((device.clone(), keypackage));
device_to_user_map.insert(device.device_id, user_contact.user_id);
}
Err(e) => {
log::warn!("Failed to fetch KeyPackage for device {}: {}",
hex::encode(&device.device_id), e);
// Continue with other devices (partial addition)
}
}
}
}
if all_keypackages.is_empty() {
return Err("No KeyPackages available for any invited user");
}
// Extract just the KeyPackages for MLS
let keypackage: Vec<KeyPackage> = all_keypackages.iter()
.map(|(_, kp)| kp.clone())
.collect();
// 7. Add all devices in one operation (OpenMLS API)
let (commit, welcomes) = mls_group.add_members(
provider,
&founder.keypair,
&keypackages,
)?;
// Commit the changes
mls_group.merge_pending_commit(provider)?;
// 8. Update permission extension to include new members
// (Give them default member permissions)
let mut updated_perm_ext = perm_ext.clone();
for (device, _) in &all_keypackages {
updated_perm_ext.device_permissions.insert(
device.device_id,
Permissions::default_member()
);
}
updated_perm_ext.version += 1;
// Create updated extension
let updated_extension_data = serde_json::to_vec(&updated_perm_ext)?;
let updated_extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
updated_extension_data.into()
);
// Update group context with new permissions
let (perm_commit, _) = mls_group.update_group_context_extensions(
provider,
Extensions::single(updated_extension),
&founder.signer,
)?;
mls_group.merge_pending_commit(provider)?;
// 9. Send Welcome messages to each added device, followed by the group's address book.
// MLS carries credentials but not addresses, so without the snapshot a joiner
// knows every device_id and no addresses at all.
for ((device, _), welcome) in all_keypackages.iter().zip(welcomes) {
send_to_contact_mailbox(&device.contact_addresses[0], welcome)?;
send_to_contact_mailbox(
&device.contact_addresses[0],
SystemOperationInner::AddressBookSnapshot {
addresses: group.address_book.iter()
.map(|(d, a)| (*d, a.clone()))
.collect(),
},
)?;
}
// 10. Build member list (users, not individual devices)
let members = build_member_list_from_mls(&mls_group, &device_to_user_map);
Ok(MultiUserGroup {
group_id,
founder_device: founder.device_id,
members,
device_to_user: device_to_user_map,
mls_state: mls_group,
created_at: current_unix_timestamp(),
})
}

Members can add people they have as contacts. When adding a member, KeyPackages for all their devices must be fetched from their designated server.

Multi-Device Invitation:

When inviting a user to a group:

  1. Fetch KeyPackages for ALL of the user’s devices
  2. Add all devices to the MLS group in a single commit
  3. Send Welcome messages to each device’s delivery address
  4. Handle partial failures (some devices may not have KeyPackages available)

See Contact Exchange and Trust for details on KeyPackage management.

fn add_member_to_group(
group: &mut MultiUserGroup,
user_contact: &LocalContact, // Represents a User
inviting_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// 1. Check permissions from MLS group extension
let perm_ext = group.get_permission_extension()?;
let inviter_perms = perm_ext.device_permissions
.get(&inviting_device.device_id)
.ok_or("Inviting device not in group")?;
if !inviter_perms.contains(Permissions::INVITE_MEMBERS) &&
!inviter_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to invite new members".into());
}
// 2. Fetch KeyPackages for ALL of the user's devices
let mut successful_devices = Vec::new();
let mut failed_devices = Vec::new();
for device in &user_contact.devices {
match fetch_keypackage(&device.keypackage_server, &device.deviceid, token) {
Ok(keypackage) => {
successful_devices.push((device.clone(), keypackage));
}
Err(e) => {
failed_devices.push((device.deviceid, e.to_string()));
log::warn!("Failed to fetch KeyPackage for device {}: {}",
hex::encode(&device.deviceid), e);
}
}
}
// 3. Proceed if at least ONE device has a KeyPackage
if successful_devices.is_empty() {
return Err("No KeyPackages available for any of the user's devices");
}
// Extract KeyPackages for MLS
let keypackages: Vec<KeyPackage> = successful_devices.iter()
.map(|(_, kp)| kp.clone())
.collect();
// 4. Add all successful devices to MLS group in a single commit
let (commit, welcomes) = group.mls_state.add_members(
provider,
&inviting_device.keypair,
&keypackages,
)?;
// 5. Merge pending commit to update local group state
group.mls_state.merge_pending_commit(provider)?;
// 6. Update permission extension to grant default permissions to new devices
let mut perm_ext = group.get_permission_extension()?;
for (device, _) in &successful_devices {
perm_ext.device_permissions.insert(
device.device_id,
Permissions::default_member()
);
}
perm_ext.version += 1;
perm_ext.last_updated_at = current_unix_timestamp();
perm_ext.last_updated_by = inviting_device.device_id;
// Update group context with new permissions
let extension_data = serde_json::to_vec(&perm_ext)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
extension_data.into()
);
let (perm_commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&inviting_device.signer,
)?;
group.mls_state.merge_pending_commit(provider)?;
// 7. Serialize commit for distribution
let commit_message = commit.tls_serialize_detached()?;
let perm_commit_message = perm_commit.tls_serialize_detached()?;
// 8. Send commits to all current members (updates their group state)
for member in &group.members {
// Get all devices for this member
for device_id in &member.devices_in_group {
// Send both the add commit and permission update commit
for msg in [&commit_message, &perm_commit_message] {
// MLS authenticates the sender via its credential. There is no
// separate signature, and group_id never leaves the envelope
let inner = InnerEnvelope {
sender_device_id: inviting_device.device_id,
group_id: Some(group.group_id),
mls_ciphertext: msg.clone(),
};
let inner_bytes = serialize(&inner)?;
// Seal to this device's mailbox for THIS group
if let Some(addrs) = group.address_book.get(device_id) {
send_envelope(seal_for(&addrs[0], &inner_bytes))?;
}
}
}
}
// 9. Send Welcome to each successfully added device
for ((device, _), welcome) in successful_devices.iter().zip(welcomes) {
send_to_contact_mailbox(&device.contact_addresses[0], welcome)?;
send_to_contact_mailbox(
&device.contact_addresses[0],
SystemOperationInner::AddressBookSnapshot {
addresses: group.address_book.iter()
.map(|(d, a)| (*d, a.clone()))
.collect(),
},
)?;
}
// 10. Update local group state
// Check if user is already a member (adding new devices to existing user)
if let Some(existing_member) = group.members.iter_mut()
.find(|m| m.user_id == user_contact.user_id) {
// Add new devices to existing member
for (device, _) in &successful_devices {
if !existing_member.devices_in_group.contains(&device.device_id) {
existing_member.devices_in_group.insert(device.device_id);
group.device_to_user.insert(device.device_id, user_contact.user_id);
}
}
} else {
// Add as new member
let device_ids: HashSet<_> = successful_devices.iter()
.map(|(d, _)| d.device_id)
.collect();
for device_id in &device_ids {
group.device_to_user.insert(*device_id, user_contact.user_id);
}
group.members.push(GroupMember {
user_id: user_contact.user_id,
default_persona: user_contact.default_persona.clone(),
personas: user_contact.personas.clone(),
devices_in_group: device_ids,
devices_pending: failed_devices.iter()
.filter_map(|(device_id, _)| {
user_contact.devices.iter()
.find(|d| &d.device_id == device_id)
.cloned()
})
.collect(),
added_by: inviting_device.device_id,
added_at: current_unix_timestamp(),
});
}
Ok(InvitationResult {
invited_user: user_contact.user_id,
successful_devices: successful_devices.iter()
.map(|(d, _)| d.device_id)
.collect(),
failed_devices,
})
}
struct InvitationResult {
invited_user: UserId,
successful_devices: Vec<DeviceId>, // device_ids that were added
failed_devices: Vec<(DeviceId, String)>, // device_ids that failed + reason
}

The GroupMember struct now represents users (not individual devices):

struct GroupMember {
// User identity
user_id: UserId,
// Personas/identities for this user
// See the Crypitd's Identity System doc for more info
default_persona: Persona,
personas: Vec<Persona>,
// Device membership tracking
devices_in_group: HashSet<DeviceId>, // Device IDs
devices_pending: Vec<DevicePublicInfo>, // Failed during initial invite
// Who invited this member to the group
added_by: DeviceId,
added_at: u64,
}
struct MultiUserGroup {
// Cryptographically random
group_id: GroupId,
// Who created the group (device-level)
founder_device: DeviceId,
// Members are users, not devices
members: Vec<GroupMember>,
// Fast lookup: device_id -> user_id
device_to_user: HashMap<DeviceId, UserId>,
// Each member's mailboxes for THIS group, in the owner's preference order.
// Index 0 is the primary; later entries are failover targets, not
// additional recipients. Seeded via AddressBookSnapshot, updated by
// AddressRotation
address_book: HashMap<DeviceId, Vec<DeliveryAddress>>,
// Current MLS epoch and keys (contains permission extension)
mls_state: MlsGroup,
created_at: u64,
}
impl MultiUserGroup {
/// Extract permissions from MLS group context
fn get_permission_extension(&self) -> Result<CryptidPermissionExtension> {
let extensions = self.mls_state.group_context().extensions();
let perm_ext = extensions
.iter()
.find(|ext| matches!(
ext.extension_type(),
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT)
))
.ok_or("Permission extension missing from group")?;
let permissions: CryptidPermissionExtension =
serde_json::from_slice(perm_ext.extension_data())?;
Ok(permissions)
}
/// Check if device has specific permission
fn has_permission(
&self,
device_id: &DeviceId,
perm: Permissions
) -> Result<bool> {
let perm_ext = self.get_permission_extension()?;
if let Some(device_perms) = perm_ext.device_permissions.get(device_id) {
Ok(device_perms.contains(perm))
} else {
Ok(false)
}
}
/// Fast lookup to get user info from device_id
fn get_user_for_device(&self, device_id: &DeviceId) -> Option<&GroupMember> {
let user_id = self.device_to_user.get(device_id)?;
self.members.iter().find(|m| &m.user_id == user_id)
}
}

Why track devices within users?

  • UI displays “Alice (3 devices in group, 2 pending)”
  • Enables per-device operations (e.g., removing a compromising device)
  • Maintains user-centric view while preserving device-level MLS membership

Users can retry adding devices that failed during initial invitation:

fn add_pending_devices_to_group(
group: &mut MultiUserGroup,
member_user_id: &UserId,
inviting_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<Vec<DeviceId>> {
// 1. Check permissions
let perm_ext = group.get_permission_extension()?;
let inviter_perms = perm_ext.device_permissions
.get(&inviting_device.device_id)
.ok_or("Inviting device not in group")?;
if !inviter_perms.contains(Permissions::INVITE_MEMBERS) &&
!inviter_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to add devices".into());
}
// 2. Find member
let member = group.members.iter_mut()
.find(|m| &m.user_id == member_user_id)
.ok_or("User not in group")?;
let mut added_devices = Vec::new();
// 3. Try to add each pending device
for pending_device in &member.devices_pending.clone() {
match fetch_keypackage(&pending_device.keypackage_server, &pending_device.deviceid, token) {
Ok(keypackage) => {
// Add this single device
let (commit, welcome) = group.mls_state.add_members(
provider,
&inviting_device.keypair,
&[keypackage],
)?;
group.mls_state.merge_pending_commit(provider)?;
// Update permissions for the new device
let mut perm_ext = group.get_permission_extension()?;
perm_ext.device_permissions.insert(
pending_device.device_id,
Permissions::default_member()
);
perm_ext.version += 1;
let extension_data = serde_json::to_vec(&perm_ext)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
extension_data.into()
);
let (perm_commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&inviting_device.signer,
)?;
group.mls_state.merge_pending_commit(provider)?;
send_to_contact_mailbox(welcome, &pending_device.contact_addresses[0])?;
added_devices.push(pending_device.deviceid);
member.devices_in_group.insert(pending_device.deviceid);
group.device_to_user.insert(pending_device.device_id, *member_user_id);
}
Err(_) => continue, // Still no KeyPackage, leave pending
}
}
// Remove successfully added devices from pending list
member.devices_pending.retain(|d| !added_devices.contains(&d.deviceid));
Ok(added_devices)
}

Devices with MANAGE_ROLES or ADMINISTRATOR permission can grant permissions to other group members:

async fn grant_permission_to_member(
group: &mut MultiUserGroup,
target_device: DeviceId,
new_permissions: Permissions,
granting_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// 1. Verify granting device has authority
let perm_ext = group.get_permission_extension()?;
let granter_perms = perm_ext.device_permissions
.get(&granting_device.device_id)
.ok_or("Granting device not in group")?;
if !granter_perms.contains(Permissions::MANAGE_ROLES) &&
!granter_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to manage roles".into());
}
// 2. Update permission extension
let mut updated_perm_ext = perm_ext.clone();
updated_perm_ext.device_permissions.insert(target_device, new_permissions);
updated_perm_ext.version += 1;
updated_perm_ext.last_updated_at = current_unix_timestamp();
updated_perm_ext.last_updated_by = granting_device.device_id;
// 3. Create MLS commit with updated permissions
let extension_data = serde_json::to_vec(&updated_perm_ext)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
extension_data.into()
);
let (commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&granting_device.signer,
)?;
// 4. Merge and distribute
group.mls_state.merge_pending_commit(provider)?;
let commit_message = commit.tls_serialize_detached()?;
for member in &group.members {
for device_id in &member.devices_in_group {
let inner = InnerEnvelope {
sender_device_id: granting_device.device_id,
group_id: Some(group.group_id),
mls_ciphertext: commit_message.clone(),
};
if let Some(addrs) = group.address_book.get(device_id) {
send_envelope(seal_for(&addrs[0], &inner_bytes))?;
}
}
}
Ok(())
}
async fn revoke_permission_from_member(
group: &mut MultiUserGroup,
target_device: DeviceId,
permission_to_revoke: Permissions,
revoking_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// Get current permissions
let mut perm_ext = group.get_permission_extension()?;
// Verify authority
let revoker_perms = perm_ext.device_permissions
.get(&revoking_device.device_id)
.ok_or("Revoking device not in group")?;
if !revoker_perms.contains(Permissions::MANAGE_ROLES) &&
!revoker_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to manage roles".into());
}
// Cannot revoke founder's permissions
if target_device == perm_ext.founder {
return Err("Cannot revoke founder's permissions".into());
}
// Update target's permissions
if let Some(target_perms) = perm_ext.device_permissions.get_mut(&target_device) {
*target_perms = target_perms.difference(permission_to_revoke);
}
perm_ext.version += 1;
perm_ext.last_updated_at = current_unix_timestamp();
perm_ext.last_updated_by = revoking_device.device_id;
// Same commit/merge/distribute process as granting...
// (See grant_permission_to_member for full implementation)
Ok(())
}

Permission Rules:

  • Only MANAGE_ROLES or ADMINISTRATOR can change permissions
  • Founder’s ADMINISTRATOR permission is immutable
  • Permission changes are logged in group history
  • All clients must verify permission changes before accepting

See Moderation Architecture for the complete permission flag list and presets.

Group metadata (name, picture, description, and thread organization) is stored in an MLS group context extension, ensuring that all cosmetic and organizational data is cryptographically protected and automatically synchronized across members.

const CRYPTID_GROUP_METADATA_EXT: u16 = 0xF001;
struct CryptidGroupMetadata {
// Group cosmetics
group_name: Option<String>,
group_picture: Option<GroupPicture>,
group_description: Option<String>,
// Thread organization
threads: HashMap<ThreadId, ThreadMetadata>,
// Version tracking
version: u64,
last_updated_at: u64,
last_updated_by: DeviceId,
}
struct GroupPicture {
// Small images stored inline (< 100KB)
inline_data: Option<InlineMedia>,
// Large images via file transfer reference
file_ref: Option<FileRef>,
}
struct ThreadMetadata {
id: ThreadId,
name: String,
created_at: u64,
created_by: DeviceId,
pinned: bool,
archived: bool,
}
impl MultiUserGroup {
/// Extract metadata from MLS group context
fn get_group_metadata_extension(&self) -> Result<CryptidGroupMetadata> {
let extensions = self.mls_state.group_context().extensions();
let metadata_ext = extensions
.iter()
.find(|ext| matches!(
ext.extension_type(),
ExtensionType::Unknown(CRYPTID_GROUP_METADATA_EXT)
))
.ok_or("Group metadata extension missing")?;
let metadata: CryptidGroupMetadata =
serde_json::from_slice(metadata_ext.extension_data())?;
Ok(metadata)
}
}
async fn update_group_name(
group: &mut MultiUserGroup,
new_name: String,
updating_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// 1. Check permissions
let perm_ext = group.get_permission_extension()?;
let updater_perms = perm_ext.device_permissions
.get(&updating_device.device_id)
.ok_or("Updating device not in group")?;
if !updater_perms.contains(Permissions::CHANGE_GROUP_NAME) &&
!updater_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to change group name".into());
}
// 2. Update metadata
let mut metadata = group.get_group_metadata_extension()?;
metadata.group_name = Some(new_name);
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = updating_device.device_id;
// 3. Create MLS commit
let extension_data = serde_json::to_vec(&metadata)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_GROUP_METADATA_EXT),
extension_data.into(),
);
let (commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&updating_device.signer,
)?;
// 4. Merge and distribute
group.mls_state.merge_pending_commit(provider)?;
let commit_message = commit.tls_serialize_detached()?;
for member in &group.members {
for device_id in &member.devices_in_group {
let inner = InnerEnvelope {
sender_device_id: updating_device.device_id,
group_id: Some(group.group_id),
mls_ciphertext: commit_message.clone(),
};
if let Some(addrs) = group.address_book.get(device_id) {
send_envelope(seal_for(&addrs[0], &inner_bytes))?;
}
}
}
Ok(())
}
async fn update_group_picture(
group: &mut MultiUserGroup,
picture: GroupPicture,
updating_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// Check permissions
let perm_ext = group.get_permission_extension()?;
let updater_perms = perm_ext.device_permissions
.get(&updating_device.device_id)
.ok_or("Updating device not in group")?;
if !updater_perms.contains(Permissions::CHANGE_GROUP_ICON) &&
!updater_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to change group picture".into());
}
// Update metadata (same pattern as update_group_name)
let mut metadata = group.get_group_metadata_extension()?;
metadata.group_picture = Some(picture);
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = updating_device.device_id;
// Create commit and distribute (same stuff as update_group_name)
}

Threads organize messages within a group. Each thread has a name, creation metadata, and status flags.

async fn create_thread(
group: &mut MultiUserGroup,
thread_name: String,
creating_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<ThreadId> {
// Check permissions
let perm_ext = group.get_permission_extension()?;
let creator_perms = perm_ext.device_permissions
.get(&creating_device.device_id)
.ok_or("Creating device not in group")?;
if !creator_perms.contains(Permissions::MANAGE_THREADS) &&
!creator_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to create threads".into());
}
// Generate thread ID
let thread_id = Uuid::new_v4();
// Update metadata
let mut metadata = group.get_group_metadata_extension()?;
metadata.threads.insert(
thread_id,
ThreadMetadata {
id: thread_id,
name: thread_name,
created_at: current_unix_timestamp(),
created_by: creating_device.device_id,
pinned: false,
archived: false,
}
);
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = creating_device.device_id;
// Create commit and distribute (same pattern as other metadata updates)
let extension_data = serde_json::to_vec(&metadata)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_GROUP_METADATA_EXT),
extension_data.into(),
);
let (commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&creating_device.signer,
)?;
group.mls_state.merge_pending_commit(provider)?;
// Distribute commit to all members
Ok(thread_id)
}
async fn rename_thread(
group: &mut MultiUserGroup,
thread_id: ThreadId,
new_name: String,
renaming_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// Check permissions
let perm_ext = group.get_permission_extension()?;
let renamer_perms = perm_ext.device_permissions
.get(&renaming_device.device_id)
.ok_or("Renaming device not in group")?;
if !renamer_perms.contains(Permissions::MANAGE_THREADS) &&
!renamer_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to rename threads".into());
}
// Update thread name
let mut metadata = group.get_group_metadata_extension()?;
let thread = metadata.threads.get_mut(&thread_id)
.ok_or("Thread not found")?;
thread.name = new_name;
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = renaming_device.device_id;
// Create commit and distribute
}
async fn update_thread_status(
group: &mut MultiUserGroup,
thread_id: ThreadId,
pinned: Option<bool>,
archived: Option<bool>,
updating_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// Permission check
let perm_ext = group.get_permission_extension()?;
let updater_perms = perm_ext.device_permissions
.get(&updating_device.device_id)
.ok_or("Updating device not in group")?;
if !updater_perms.contains(Permissions::MANAGE_THREADS) &&
!updater_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to update thread status".into());
}
let mut metadata = group.get_group_metadata_extension()?;
let thread = metadata.threads.get_mut(&thread_id)
.ok_or("Thread not found")?;
if let Some(pin) = pinned {
thread.pinned = pin;
}
if let Some(arch) = archived {
thread.archived = arch;
}
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = updating_device.device_id;
// Create commit and distribute...
}

When a member rotates a mailbox, they distribute the new mailbox_public to the group. This is key distribution, not a routing hint. A member who misses it cannot reach the rotating device at all.

fn rotate_address_in_group(
group: &mut MultiUserGroup,
device: &DeviceIdentity,
new_address: String,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// 1. Announce the new address, carrying its mailbox public key
let rotation = SystemOperationInner::AddressRotation {
old_addresses: Some(group.address_book[&device.device_id].clone()),
new_addresses: vec![new_address.clone()],
};
// 2. Encrypt with MLS. MLS authenticates the sender, so no signature
let encrypted = group.mls_state.encrypt_application_message(
provider,
&device.keypair,
&serialize(&rotation)?,
)?;
let inner = InnerEnvelope {
sender_device_id: device.device_id,
group_id: Some(group.group_id),
mls_ciphertext: encrypted,
};
let inner_bytes = serialize(&inner)?;
// 3. Seal separately per recipient. Each member has a distinct mailbox key,
// so one blob cannot serve several
for (device_id, addr) in &group.address_book {
if *device_id == device.device_id {
continue;
}
send_envelope(seal_for(&addrs[0], &inner_bytes))?;
}
// 4. The old mailbox stays registered through the overlap window
group.address_book.insert(device.device_id, new_address);
Ok(())
}

When members receive an address rotation message:

fn handle_address_rotation(
group: &mut MultiUserGroup,
sender: DeviceId, // From the MLS credential, never InnerEnvelope
rotation: &SystemOperation::AddressRotation,
) -> Result<()> {
if rotation.new_addresses.is_empty() {
return Err("AddressRotation carried no addresses");
}
// Every prefix must derive from its accompanying mailbox public key
for addr in &rotation.new_addresses {
if !addr.verify_binding() {
return Err("Address prefix does not bind to mailbox_public");
}
}
// Replaces wholesale, preserving the sender's preference order
group.address_book.insert(sender, rotation.new_addresses.clone());
Ok(())
}

Address rotation properties:

  • The rotating device keeps its previous mailbox registered through an overlap window, so members who missed the rotation still reach a live mailbox
  • Members hold the sender’s full address list for the group, replaced wholesale on each rotation
  • Senders use index 0 and fail over to later entries; see Failover
  • Recipients MUST verify verify_binding() before accepting an address
  • The sender MUST be taken from the MLS credential; InnerEnvelope.sender_device_id is outside the MLS ciphertext and unauthenticated
GET /.well-known/cryptid
Host: server.example.com
{
"cryptid_version": "0.11",
"server_name": "server.example.com",
"federation_enabled": true,
"supported_features": [
"mls_messaging",
"cryptid_envelope_v1",
"privacypass_0x0001",
"blind_keypackages",
"blob_store_v1"
],
"rate_limits": {
"federated_messages_per_minute": 1000,
"max_message_size": 10485760
},
"certificate_fingerprint": "sha256:a1b2c3d4..."
}

cryptid_version is the Cryptid specification version a server implements, as major.minor. It is unrelated to the MLS ProtocolVersion carried inside KeyPackages.

  • Servers MUST reject federation from a peer whose cryptid_version they cannot parse
  • Before 1.0, a differing minor SHOULD be logged but MUST NOT block federation
  • From 1.0 onward, servers MUST federate with peers sharing the same major, and MUST tolerate a differing minor
  • A differing major MUST block federation

Because the version number does not gate compatibility, supported_features carries the real signal. Any change affecting the wire format MUST introduce or bump a feature flag, and a server MUST NOT send a construct its peer has not advertised.

Current flags:

FlagCovers
mls_messagingBase MLS group messaging
cryptid_envelope_v1Double-encrypted envelope relay and its federation payload
privacypass_0x0001Token type 0x0001 issuance and redemption
blind_keypackagesDerived-handle KeyPackage storage and fetch
blob_store_v1Blob upload, capability-handle fetch, and reports

Federation relays CryptidEnvelope values unchanged. A receiving server learns exactly what a local server learns: a routing string and an opaque blob.

PUT /federation/v1/deliver/{transaction_id}
Content-Type: application/json
X-Origin-Server: server1.com
X-Origin-Signature: <ed25519 signature over the canonical request>
{
"origin_server": "server1.com",
"transaction_id": "508afbb2-01a3-445c-a5b5-1e08fac35e03",
"envelopes": [
{
"message_id": "01a0433a-0984-7fd3-b074-650672069214",
"recipient_address": "GHJ2R2ETMA5DXE32UDMJIFNR@server2.com",
"encrypted_blob": "base64_hpke_sealed_inner_envelope",
"timestamp": 1787834787
}
]
}

Fields:

  • origin_server: domain of the originating server; MUST match X-Origin-Server
  • transaction_id: UUIDv4 for idempotent retry; MUST match the URL path
  • message_id: UUIDv7, time-ordered, for deduplication
  • recipient_address: the routing string only
  • encrypted_blob: the HPKE-sealed InnerEnvelope, opaque to both servers
  • timestamp: when the origin server accepted the envelope

PrivacyPass tokens are server-local and MUST NOT cross the federation boundary. A token minted by one server is meaningless to another, and forwarding one would tie a user’s action to a specific remote request.

Federation authenticates servers, not users. The origin server signs the canonical request with its server key; the receiving server verifies against the key published through server discovery.

  • Request MUST be signed by the origin server’s key
  • Receiving servers MUST verify the signature before queueing anything
  • Receiving servers MUST verify the TLS certificate against the certificate_fingerprint from discovery
  • Receiving servers MUST NOT infer any user-level authorization from a valid server signature

Because this is per-server rather than per-user, it introduces no linkable identifier for senders.

Envelopes bound for different recipients SHOULD NOT share a transaction.

Batching co-delivered envelopes is worse than the timing correlation documented elsewhere. Timing merely suggests that mailboxes are related, while a shared transaction asserts it. A receiving server that sees five mailboxes in one transaction learns that those five are in a group together, directly.

  • Servers SHOULD dispatch envelopes for distinct recipients in separate transactions
  • Servers MAY batch retries of the same envelope
  • Servers SHOULD introduce jitter between transactions derived from a single fan-out

recipient_address is a routing string, prefix@server. The receiving server resolves it against its mailbox registry and queues the blob. It performs no cryptographic validation, and learns nothing about which device owns the mailbox.

A device holding several mailboxes does NOT present interchangeable addresses to the server. Each mailbox is derived from its own keypair, so:

  • A sender MUST seal a separate envelope per delivery attempt. The AAD and mailbox key differ per address, so one blob cannot be retried against a different mailbox
  • Servers MUST NOT treat two addresses as equivalent, and cannot tell they belong to one device
  • An envelope MUST be delivered only to the exact address it names

In situations where a recipient publishes several addresses for a group, they are ordered by preference and only the primary receives traffic under normal operation. The rest are failover targets, not additional recipients.

Example: Bob is in two groups on server2.com, so Alice sends two envelopes, each sealed to a different mailbox key:

{
"origin_server": "server1.com",
"transaction_id": "87f34374-8c09-4f6a-887f-48362a45b417",
"envelopes": [
{
"recipient_address": "CXURXMUCRMWGMKQPGK7A7XK3@server2.com",
"encrypted_blob": "..." // sealed to Bob's mailbox for Group A
}
]
}

The envelope for Bob’s group B mailbox travels in a separate transaction, per the batching guidance above. server2.com cannot determine that the two are the same device.