Group Management and Federation
Group Creation Process
Section titled “Group Creation Process”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(), })
}Adding Members to Groups
Section titled “Adding Members to Groups”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:
- Fetch KeyPackages for ALL of the user’s devices
- Add all devices to the MLS group in a single commit
- Send Welcome messages to each device’s delivery address
- 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}Group Member Structure
Section titled “Group Member Structure”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
Handling Failed Devices
Section titled “Handling Failed Devices”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)}Permission Management
Section titled “Permission Management”Granting Permissions
Section titled “Granting Permissions”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(())}Revoking Permissions
Section titled “Revoking Permissions”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_ROLESorADMINISTRATORcan change permissions - Founder’s
ADMINISTRATORpermission 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 Extension
Section titled “Group Metadata Extension”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.
Extension Structure
Section titled “Extension Structure”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) }}Updating Group Name
Section titled “Updating Group Name”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(())}Updating Group Picture
Section titled “Updating Group Picture”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)}Managing Threads
Section titled “Managing Threads”Threads organize messages within a group. Each thread has a name, creation metadata, and status flags.
Creating a Thread
Section titled “Creating a Thread”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)}Renaming a Thread
Section titled “Renaming a Thread”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}Pinning/Archiving Threads
Section titled “Pinning/Archiving Threads”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...}Address Rotation in Groups
Section titled “Address Rotation in Groups”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.
Address Rotation Protocol
Section titled “Address Rotation Protocol”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(())}Receiving Address Rotation
Section titled “Receiving Address Rotation”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_idis outside the MLS ciphertext and unauthenticated
Federation Protocol
Section titled “Federation Protocol”Server Discovery
Section titled “Server Discovery”GET /.well-known/cryptidHost: 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..."}Version Compatibility
Section titled “Version Compatibility”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_versionthey cannot parse - Before 1.0, a differing
minorSHOULD be logged but MUST NOT block federation - From 1.0 onward, servers MUST federate with peers sharing the same
major, and MUST tolerate a differingminor - A differing
majorMUST 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:
| Flag | Covers |
|---|---|
mls_messaging | Base MLS group messaging |
cryptid_envelope_v1 | Double-encrypted envelope relay and its federation payload |
privacypass_0x0001 | Token type 0x0001 issuance and redemption |
blind_keypackages | Derived-handle KeyPackage storage and fetch |
blob_store_v1 | Blob upload, capability-handle fetch, and reports |
Cross-Server Message Delivery
Section titled “Cross-Server Message Delivery”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/jsonX-Origin-Server: server1.comX-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 matchX-Origin-Servertransaction_id: UUIDv4 for idempotent retry; MUST match the URL pathmessage_id: UUIDv7, time-ordered, for deduplicationrecipient_address: the routing string onlyencrypted_blob: the HPKE-sealedInnerEnvelope, opaque to both serverstimestamp: when the origin server accepted the envelope
Server-to-Server Authentication
Section titled “Server-to-Server Authentication”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_fingerprintfrom 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.
Batching
Section titled “Batching”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 Handling
Section titled “Recipient Address Handling”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.