MLS Integration and Message Security
What is MLS (Messaging Layer Security)?
Section titled “What is MLS (Messaging Layer Security)?”MLS is the gold standard for group messaging security. It has been standardized as RFC 9420. A simple way to think about it is as “TLS for group chats”.
The protocol provides:
-
Forward Secrecy: Compromise of current keys doesn’t affect past messages.
-
Post-Compromise Security: New key material recovers security after a breach.
-
Group Key Agreement: Efficient key management for large groups.
-
Asynchronous Operation: Members can be added while offline.
-
Scalability: Supports groups consisting of thousands of members.
Why choose MLS over the Signal Protocol?
Section titled “Why choose MLS over the Signal Protocol?”| Feature | Signal Protocol | MLS |
|---|---|---|
| Group Size | Efficient for small groups | Scales to 50,000+ members |
| Forward Secrecy | Yes | Yes |
| Post-Compromise Security | Yes | Yes |
| Standardization | Signal-specific | IETF RFC 9420 |
| Asynchronous Operation | Limited | Full support |
| Future-Proofing | Signal controls | Industry standard |
MLS Cipher Suites
Section titled “MLS Cipher Suites”At the moment, Cryptid is planning support for only one MLS ciphersuite:
- MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519:
- Encryption: ChaCha20-Poly1305
- Benefits:
- No hardware requirements: Fast on all devices, especially mobile
- Constant-time operations: Resistant to timing attacks by design
- Larger nonce space: Better security margins than AES-GCM
- Battery efficient: Lower power consumption on mobile devices
- Modern design: Increasingly preferred in new protocols (WireGuard, TLS 1.3, Signal)
- Simpler implementation: Less prone to side-channel vulnerabilities
- Security level: 128-bit (equivalent to 3072-bit RSA)
- Standards: RFC 8439 (ChaCha20-Poly1305), RFC 9420 (MLS)
Message Format
Section titled “Message Format”All communication uses this unified message format:
struct SecureMessage { // This is a Uuidv7 which is a Uuid with a temporal element message_id: Uuid, // MLS group identifier group_id: Uuid, // MLS-encrypted payload mls_ciphertext: Vec<u8>, // Device signature over ciphertext sender_signature: Ed25519Signature, // Creation timestamp timestamp: u64, // Type information message_type: MessageType,}
enum MessageType { // Cryptid's Application Message Envelope // See the Cryptid Message reference document for more information CryptidMessage, // Group management SystemOperation, // Initial contact establishment ContactRequest,}MLS Group Types
Section titled “MLS Group Types”Direct Message Groups (1:1 Chat)
Section titled “Direct Message Groups (1:1 Chat)”struct DirectMessageGroup { // SHA256 of sorted user IDs group_id: GroupId, // Exactly 2 users (application layer) participants: [UserIdentity; 2], // All devices from both users (MLS layer) devices_in_group: HashMap<DeviceId, GroupId>, // Current epoch and keys mls_state: MLSGroupState, created_at: u64,}
// Group ID is deterministic: same for both participantsfn create_dm_group_id(user1: &UserId, user2: &UserId) -> GroupId { let mut sorted = [*user1, *user2]; sorted.sort(); // Ensures same ID regardless of who creates the group sha256(&sorted.concat())}Multi-User Groups
Section titled “Multi-User Groups”struct MultiUserGroup { // Uuidv4 group_id: GroupId, // Who created the group (device-level) founder_device: DeviceId, // Current members (user-level) members: Vec<GroupMember>, // Fast lookup: device_id -> user_id device_to_user: HashMap<DeviceId, UserId>, // Current MLS epoch and keys mls_state: MlsGroup,
// DEPRECATED: Permissions are now stored in MLS group extension // See "Permission Enforcement via Group Extensions" section admin_devices: Vec<Uuid>, // Legacy field
created_at: u64,}
struct GroupMember { // User identity user_id: UserId,
// Personas/identities for this user // See Cryptid's Identity System Reference doc for more information 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,}
impl MultiUserGroup { /// Application layer: 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) }
/// MLS layer: Check if device is in group fn is_device_in_group(&self, device_id: &DeviceId) -> bool { self.device_to_user.contains_key(device_id) }
/// Get all devices for a user fn get_user_devices(&self, user_id: &UserId) -> Option<&HashSet<DeviceId>> { self.members.iter() .find(|m| &m.user_id == user_id) .map(|m| &m.devices_in_group) }}Benefits of this design:
- UI simplicity: Display “Alice” not “Alice’s Phone, Alice’s Laptop, Alice’s Tablet”
- Fast lookups: device_id -> user info for message display
- Device management: Track which devices are pending vs active
- MLS compatibility: Underlying MLS still operates on device-level credentials
MLS KeyPackages
Section titled “MLS KeyPackages”What Are KeyPackages?
Section titled “What Are KeyPackages?”KeyPackages are one-time-use cryptographic credentials that enable asynchronous group additions. They allow someone to add you to a group even when you’re offline.
KeyPackage structure:
struct KeyPackage { protocol_version: ProtocolVersion, cipher_suite: CipherSuite, init_key: HPKEPublicKey, // Ephemeral public key for Welcome encryption leaf_node: LeafNode { encryption_key: HPKEPublicKey, // Ephemeral encryption key signature_key: Ed25519PublicKey, // Device identity key credential: BasicCredential { identity: device_id, // Device identity }, }, signature: Ed25519Signature, // Signed by device's private key}Key properties:
- One-time use: Each KeyPackage can only be used to add to one group
- Pre-generated: Devices upload 50-100 KeyPackages to servers
- Ephemeral keys: Each KeyPackage has unique HPKE keys
- Device identity: All KeyPackages share the same device credential
KeyPackage Lifecycle
Section titled “KeyPackage Lifecycle”1. Generation
Section titled “1. Generation”Devices generate KeyPackages containing:
- Device credential (device_id, signature key)
- Ephemeral HPKE keys (unique per KeyPackage)
- Self-signature proving device ownership
2. Upload
Section titled “2. Upload”Public KeyPackages are uploaded to the device’s primary server
//POST /api/v1/keypackage/upload{ "device_id": "...", "key_packages": [/* 100 public KeyPackages */], "signature": "...", "timestamp": 1758315663}3. Fetching
Section titled “3. Fetching”When adding someone to a group, fetch their KeyPackage:
// GET /api/v1/keypackage/fetch?device_id={device_id}// Server returns one KeyPackage and deletes it (one-time use){ "device_id": "...", "key_package": "...", "remaining": 99}4. Group Addition
Section titled “4. Group Addition”Use the KeyPackage to add the member:
// Add Alice to group using her KeyPackagelet (commit, welcome, _) = group.add_members( provider, &adder_keypair, &[alice_keypackage])?;
// Welcome is encrypted with Alice's KeyPackage public keysend_welcome(&alice.delivery_addresses, welcome)?;5. Welcome Decryption
Section titled “5. Welcome Decryption”Alice uses her stored private key to decrypt the Welcome message
// Alice has the private key from when she generated the KeyPackagelet staged_join = StagedWelcome::new_from_welcome( provider, &config, welcome, None, // Ratchet tree included in Welcome (RatchetTreeExtension))?;
let alice_group = staged_join.into_group(provider)?;6. Rotation
Section titled “6. Rotation”When a KeyPackage count drops below 20, upload more:
// Background taskif device.remaining_keypackages() < 20 { let new_keypackage = device.generate_key_packages(100); upload_keypackages(server, new_kps)?;}Why KeyPackages Are Separate from Contacts
Section titled “Why KeyPackages Are Separate from Contacts”ShareableIdentityBundle (for contact exchange) does NOT include KeyPackages:
- Bundles are long-lived (shared via QR codes)
- KeyPackages are ephemeral (used once)
- Bundles would need constant regeneration
Instead, bundles specify keypackage_server where KeyPackages can be fetched on-demand.
See Contact Exchange and Trust Establishment for complete KeyPackage management details.
MLS Security Properties Explained
Section titled “MLS Security Properties Explained”Forward Secrecy
Section titled “Forward Secrecy”MLS uses something called TreeKEM to continuously rotate encryption keys. What this means is that even if an attacker gets today’s keys, yesterday’s messages still remain secure. Keys are deleted after they’re used, not just replaced.
Post-Compromise Security
Section titled “Post-Compromise Security”Let’s say Alice’s device gets compromised. Since keys are rotated, her security will be recovered during the next rotation. New members joining the group will trigger key rotation and active members regularly update keys to heal from potential compromises.
Ratchet Tree Extension
Section titled “Ratchet Tree Extension”All Cryptid groups MUST enable the RatchetTreeExtension for simpler Welcome handling.
Why this matters:
When joining a group via Welcome, new members need:
- The Welcome message (encrypted with their KeyPackage)
- The ratchet tree (group’s current key state)
Without RatchetTreeExtension:
- Ratchet tree must be sent out-of-band
- Adds complexity and potential failure points
With RatchetTreeExtension:
- Ratchet tree is included in the Welcome message
- Single message contains everything needed to join
- Simpler, more reliable
This is required in the Cryptid protocol to ensure reliable group joins.
Group Authentication
Section titled “Group Authentication”Every MLS operation (adding members, sending messages, etc) is cryptographically authenticated. This makes it impossible to forge group membership or operations. All members can verify all group operations and history.
Device Identity in MLS
Section titled “Device Identity in MLS”Important: MLS operates exclusively on Device Identity, not User Identity.
Two-layer model:
-
Application Layer: Users interact with User Identities
- “Alice wants to send a message to Bob”
- App looks up all of Bob’s devices
-
MLS Layer: Encryption happens at device level
- “Alice’s Phone encrypts message using MLS”
- “Bob’s Phone, Bob’s Laptop, Bob’s Tablet each decrypt independently”
What MLS sees:
// MLS credential contains device identitystruct BasicCredential { identity: device_id, // Permanent device identity (32 bytes)}
// When encrypting a messagefn encrypt_message(plaintext: &[u8], group: &MlsGroup) -> Vec<u8> {// MLS automatically includes:// - Sender's device_id (from credential)// - Message content// - Authentication data// - Epoch information}Permission enforcement:
MLS credentials (device_id) are used for permission verification:
// When processing a commit, extract sender from MLS credentiallet sender_device_id = commit.sender().credential().identity();
// Check permissions from group extensionlet perms = group.get_permission_extension()?;if !perms.device_permissions.get(&sender_device_id)?.contains(required_perm) { return Err("Unauthorized operation");}This ties cryptographic identity (MLS) to authorization (permissions), preventing impersonation and ensuring all operations are authenticated and authorized.
What MLS doesn’t see:
- Delivery addresses (used for routing, not authentication)
- Server domains
- Routing metadata
Privacy implications:
- Group members know each other’s device_ids (MLS requirement)
- Recipients can block by device_id (survives address rotation)
- Servers maintain device_id mappings internally for anti-spam (not exposed via API or persisted to disk)
Separation of concerns:
| Component | Identity | Routing |
|---|---|---|
| MLS | device_id | Not involved |
| Servers | Hidden | delivery_address |
| Contacts | device_id | delivery_addresses |
This separation ensures:
- MLS handles cryptographic identity
- Servers handle message routing
- Neither learns more than necessary
Implementation: OpenMLS
Section titled “Implementation: OpenMLS”Cryptid uses and recommends OpenMLS for MLS protocol implementation.
OpenMLS features leveraged:
- Automatic key rotation (TreeKEM)
- RatchetTreeExtension support
- Staged operations for validation
- Multiple cipher suite support
- TLS serialization for wire format
See OpenMLS documentation for implementation details.
Permission Enforcement via Group Extensions
Section titled “Permission Enforcement via Group Extensions”Cryptid implements cryptographically enforced permissions using MLS group context extensions. This prevents modified clients from bypassing permission checks and performing unauthorized group operations.
The Problem with Client-Side Permissions
Section titled “The Problem with Client-Side Permissions”Previous Approach (Insecure)
Section titled “Previous Approach (Insecure)”fn add_member_to_group(group: &mut Group, new_member: KeyPackage) -> Result<()> { if !group.admin_devices.contains(&self.device_id) { return Err("Not authorized"); // Modified client just removes this check }
// Perform MLS add operation group.mls_state.add_members(...)?;}The vulnerability:
- Modified client skips the permission check
- Creates valid MLS commit to add/remove members
- Other clients receive the commit and process it (MLS authenticated it)
- Unauthorized operation succeeds across the group
Solution: MLS Group Extensions
Section titled “Solution: MLS Group Extensions”MLS supports group context extensions, data that is:
- Cryptographically bound to the group state
- Automatically synchronized across all members
- Part of the authenticated group context
- Updated only through valid MLS commits
Cryptid stores permissions in a group extension, making them tamper-proof.
Permission Extension Structure
Section titled “Permission Extension Structure”use serde::{Deserialize, Serialize};use std::collections::HashMap;use bitflags::bitflags;
/// Extension type identifier (registered with Cryptid protocol)const CRYPTID_PERMISSIONS_EXT: u16 = 0xF000;
/// Stored in MLS group context extension#[derive(Serialize, Deserialize, Clone, Debug)]struct CryptidPermissionExtension { /// Map device IDs to their permissions device_permissions: HashMap<DeviceId, Permissions>,
/// Group founder (immutable) founder: DeviceId,
/// Version tracking for updates version: u64, last_updated_at: u64, last_updated_by: DeviceId,}
bitflags! { /// Permission flags (see Moderation Architecture for complete list) #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] pub struct Permissions: u64 { const SEND_MESSAGES = 1 << 0; const INVITE_MEMBERS = 1 << 10; const REMOVE_MEMBERS = 1 << 11; const MANAGE_ROLES = 1 << 20; const ADMINISTRATOR = 1 << 63; // ... see moderation-architecture.mdx for full list }}Creating a Group with Permissions
Section titled “Creating a Group with Permissions”use openmls::prelude::*;use openmls::group::config::CryptoConfig;
fn create_group_with_permissions( founder: &DeviceIdentity, provider: &impl OpenMlsProvider,) -> Result<MlsGroup> { // 1. Initialize 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, };
// 2. Serialize and create extension let extension_data = serde_json::to_vec(&perm_ext)?; let extension = Extension::new( ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT), extension_data.into() );
// 3. Configure group with extension let group_config = MlsGroupCreateConfig::builder() .with_group_context_extensions(Extensions::single(extension))? .with_ratchet_tree_extension(true) .build();
// 4. Create MLS group let group = MlsGroup::new( provider, &founder.signer, &group_config, founder.create_mls_credential()? )?;
Ok(group)}Reading Permissions from Extension
Section titled “Reading Permissions from Extension”impl Group { /// Extract permissions from MLS group context fn get_permission_extension(&self) -> Result<CryptidPermissionExtension> { // Get group context extensions let extensions = self.mls_state.group_context().extensions();
// Find Cryptid permission extension let perm_ext = extensions .iter() .find(|ext| matches!( ext.extension_type(), ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT) )) .ok_or("Permission extension missing from group")?;
// Deserialize 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) } }}Updating Permissions
Section titled “Updating Permissions”Permission changes require creating an MLS commit with an updated extension:
impl Group { async fn grant_permission( &mut self, target_device: DeviceId, new_perms: Permissions, updater: &DeviceIdentity, provider: &impl OpenMlsProvider, ) -> Result<MlsMessageOut> { // 1. Get current permissions let mut perm_ext = self.get_permission_extension()?;
// 2. Verify updater has MANAGE_ROLES permission let updater_perms = perm_ext.device_permissions .get(&updater.device_id) .ok_or("Updater not in group")?;
if !updater_perms.contains(Permissions::MANAGE_ROLES) && !updater_perms.contains(Permissions::ADMINISTRATOR) { return Err("Unauthorized: lacks MANAGE_ROLES permission".into()); }
// 3. Update permissions perm_ext.device_permissions.insert(target_device, new_perms); perm_ext.version += 1; perm_ext.last_updated_at = current_unix_timestamp(); perm_ext.last_updated_by = updater.device_id;
// 4. Create new extension let extension_data = serde_json::to_vec(&perm_ext)?; let extension = Extension::new( ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT), extension_data.into() );
// 5. Propose group context extension update let proposal = self.mls_state.propose_group_context_extensions( provider, Extensions::single(extension), &updater.signer, )?;
// 6. Commit the proposal let (commit, welcome, _) = self.mls_state.commit_to_pending_proposals( provider, &updater.signer, )?;
// 7. Merge locally self.mls_state.merge_pending_commit(provider)?;
// 8. Send to group self.send_commit_to_group(commit).await?;
Ok(commit) }}Verifying Commits (Critical)
Section titled “Verifying Commits (Critical)”All clients MUST verify permissions BEFORE accepting commits:
impl CryptidClient { async fn process_incoming_commit( &mut self, commit: MlsMessageIn, group_id: &GroupId, ) -> Result<()> { let group = self.groups.get_mut(group_id).ok_or("Unknown group")?;
// 1. Process the commit (but don't merge yet) let processed_message = group.mls_state .process_message(self.provider(), commit)?;
let staged_commit = match processed_message.into_content() { ProcessedMessageContent::StagedCommitMessage(staged) => staged, _ => return Err("Not a commit message".into()), };
// 2. Extract sender's device ID from MLS credential let sender_device_id = staged_commit .commit() .committer() .credential() .identity();
// 3. Get current permissions from group extension let perm_ext = group.get_permission_extension()?; let sender_perms = perm_ext.device_permissions .get(&sender_device_id) .ok_or("Unknown device attempting operation")?;
// 4. Verify permission based on commit operation for proposal in staged_commit.queued_proposals() { match proposal.proposal() { Proposal::Add(_) => { if !sender_perms.contains(Permissions::INVITE_MEMBERS) && !sender_perms.contains(Permissions::ADMINISTRATOR) { return Err("Unauthorized: lacks INVITE_MEMBERS".into()); } } Proposal::Remove(_) => { if !sender_perms.contains(Permissions::REMOVE_MEMBERS) && !sender_perms.contains(Permissions::ADMINISTRATOR) { return Err("Unauthorized: lacks REMOVE_MEMBERS".into()); } } Proposal::GroupContextExtensions(_) => { if !sender_perms.contains(Permissions::MANAGE_ROLES) && !sender_perms.contains(Permissions::ADMINISTRATOR) { return Err("Unauthorized: lacks MANAGE_ROLES".into()); } } _ => {} // Other proposals don't require special permissions } }
// 5. Only merge if all permissions verified group.mls_state.merge_staged_commit(self.provider(), staged_commit)?;
Ok(()) }}Modified Client Isolation
Section titled “Modified Client Isolation”What happens when a modified client bypasses permission checks?
// Modified client (malicious)fn malicious_add_member(group: &mut Group, new_member: KeyPackage) { // Skips permission check entirely let (commit, welcome, _) = group.mls_state.add_members( provider, &self.signer, &[new_member] ).unwrap();
send_commit_to_group(commit); group.mls_state.merge_pending_commit(provider).unwrap();}Timeline of isolation:
- Modified client creates unauthorized commit
- Adds member without having INVITE_MEMBERS permission
- MLS authenticates the commit (proves it came from that device)
- Sends commit to other group members
- Honest clients receive commit
- Extract sender device ID from MLS credential
- Check permissions from group extension
- Reject commit and don’t merge into group state
- Modified client is now isolated
- Modified client’s local group state: includes unauthorized member
- Honest clients’ group state: does not include unauthorized member
- Epoch numbers diverge between modified and honest clients
- Future messages fail
- Modified client sends messages encrypted for epoch N+1
- Honest clients are still on epoch N
- Decryption fails and epoch mismatch prevents decryption
- Modified client effectively ejected itself from the group
Result:
- Modified client can only corrupt its own group state
- Cannot affect honest clients’ view of the group
- Effectively quarantined automatically
- Must rejoin group to resynchronize (if allowed)
Security Properties
Section titled “Security Properties”What this system guarantees:
- Tamper-proof permissions: Stored in MLS-authenticated group state
- Automatic synchronization: All clients have identical permission state
- Cryptographic enforcement: Cannot forge permissions without breaking MLS
- Modified client isolation: Unauthorized operations fail for honest clients
- Transparent auditing: Permission changes visible in group history
What this system does NOT prevent:
- Modified clients from attempting unauthorized operations
- Modified clients from lying to their local user
- Social engineering attacks on permission holders
Trade-offs:
| Aspect | Before Extensions | After Extensions |
|---|---|---|
| Permission storage | Client-side only | MLS group context |
| Enforcement | Trust client behavior | Cryptographic verification |
| Modified client impact | Can affect entire group | Isolated to local state |
| Complexity | Lower | Higher (MLS extensions) |
| Security | Weak (social only) | Strong (cryptographic) |
Best Practices
Section titled “Best Practices”When implementing permission verification:
- Always verify before merging commits. Never trust sender identity alone.
- Check ADMINISTRATOR flag. It bypasses all permission requirements.
- Handle missing extensions gracefully. Old groups may not have them yet.
- Log permission violations. Help detect modified clients.
- Use staged commits. Allows verification before merging.
- Test with modified client scenarios. Ensure isolation works.
When designing permission policies:
- Principle of least privilege. Grant the absolute minimum necessary permissions required.
- Founder is immutable. Cannot be changed after group creation.
- Version all permission changes. This enables conflict resolution.
- Audit permission grants. Track who granted what to whom.
- Consider revocation paths - i.e., How to remove compromised admins.
Custom Media Group Extension
Section titled “Custom Media Group Extension”Cryptid supports custom emoji and sticker packs stored in MLS group context extensions. This allows groups to have shared, tamper-proof media collections.
Custom Media Extension Structure
Section titled “Custom Media Extension Structure”use serde::{Deserialize, Serialize};use std::collections::HashMap;
/// Extension type identifier for custom mediaconst CRYPTID_CUSTOM_MEDIA_EXT: u16 = 0xF001;
#[derive(Serialize, Deserialize, Clone, Debug)]struct GroupCustomMedia { /// Sticker packs (keyed by pack_id) packs: HashMap<String, MediaPack>,
/// Custom emoji (keyed by emoji_id) emojis: HashMap<String, CustomEmoji>,}
#[derive(Serialize, Deserialize, Clone, Debug)]struct MediaPack { pack_id: String, // Unique identifier name: String, // Display name uploaded_by: DeviceId, // Creator device uploaded_at: u64, // Unix timestamp items: HashMap<String, MediaItem>, // Stickers in pack}
#[derive(Serialize, Deserialize, Clone, Debug)]struct MediaItem { // Item identifier within pack id: String,
// Small items (≤ 50 KB): stored inline inline_data: Option<InlineMedia>,
// Large items (> 50 KB): device-to-device transfer file_reference: Option<FileReference>,}
#[derive(Serialize, Deserialize, Clone, Debug)]struct InlineMedia { mime_type: String, // "image/avif" or "image/webp" data: Vec<u8>, // Compressed media (base64 in JSON)}
#[derive(Serialize, Deserialize, Clone, Debug)]struct FileReference { file_id: String, // Device-to-device file ID size: u64, // File size in bytes mime_type: String,}
#[derive(Serialize, Deserialize, Clone, Debug)]struct CustomEmoji { id: String, // Emoji identifier inline_data: InlineMedia, // Always inline (≤ 50 KB)}Creating Groups with Custom Media Support
Section titled “Creating Groups with Custom Media Support”fn create_group_with_custom_media( founder: &DeviceIdentity, provider: &impl OpenMlsProvider,) -> Result<MlsGroup> { // Initialize empty custom media extension let custom_media = GroupCustomMedia { packs: HashMap::new(), emojis: HashMap::new(), };
let extension_data = serde_json::to_vec(&custom_media)?; let extension = Extension::new( ExtensionType::Unknown(CRYPTID_CUSTOM_MEDIA_EXT), extension_data.into() );
// Create group with custom media extension let group_config = MlsGroupCreateConfig::builder() .with_group_context_extensions(Extensions::single(extension))? .with_ratchet_tree_extension(true) .build();
let group = MlsGroup::new( provider, &founder.signer, &group_config, founder.create_mls_credential()? )?;
Ok(group)}Reading Custom Media from Extension
Section titled “Reading Custom Media from Extension”impl Group { /// Extract custom media from MLS group context fn get_custom_media_extension(&self) -> Result<GroupCustomMedia> { let extensions = self.mls_state.group_context().extensions();
let media_ext = extensions .iter() .find(|ext| matches!( ext.extension_type(), ExtensionType::Unknown(CRYPTID_CUSTOM_MEDIA_EXT) )) .ok_or("Custom media extension not found")?;
let media: GroupCustomMedia = serde_json::from_slice(media_ext.extension_data())?; Ok(media) }
/// Get a specific sticker pack fn get_sticker_pack(&self, pack_id: &str) -> Result<Option<MediaPack>> { let media = self.get_custom_media_extension()?; Ok(media.packs.get(pack_id).cloned()) }
/// Get a specific emoji fn get_emoji(&self, emoji_id: &str) -> Result<Option<CustomEmoji>> { let media = self.get_custom_media_extension()?; Ok(media.emojis.get(emoji_id).cloned()) }}Adding a Sticker Pack
Section titled “Adding a Sticker Pack”impl Group { async fn add_sticker_pack( &mut self, pack: MediaPack, updater: &DeviceIdentity, provider: &impl OpenMlsProvider, ) -> Result<()> { // 1. Get current custom media let mut custom_media = self.get_custom_media_extension()?;
// 2. Verify pack_id doesn't exist if custom_media.packs.contains_key(&pack.pack_id) { return Err("Pack already exists".into()); }
// 3. Validate pack contents for item in pack.items.values() { if let Some(inline) = &item.inline_data { if inline.data.len() > 50_000 { return Err("Inline item exceeds 50 KB".into()); } }
if let Some(file_ref) = &item.file_reference { if file_ref.size > 500_000 { return Err("File item exceeds 500 KB".into()); } } }
// 4. Add pack to media custom_media.packs.insert(pack.pack_id.clone(), pack);
// 5. Create updated extension let extension_data = serde_json::to_vec(&custom_media)?; let extension = Extension::new( ExtensionType::Unknown(CRYPTID_CUSTOM_MEDIA_EXT), extension_data.into() );
// 6. Propose update let proposal = self.mls_state.propose_group_context_extensions( provider, Extensions::single(extension), &updater.signer, )?;
// 7. Commit let (commit, _, _) = self.mls_state.commit_to_pending_proposals( provider, &updater.signer, )?;
self.mls_state.merge_pending_commit(provider)?; self.send_commit_to_group(commit).await?;
Ok(()) }}Adding Custom Emoji
Section titled “Adding Custom Emoji”impl Group { async fn add_emoji( &mut self, emoji_id: String, inline_data: InlineMedia, updater: &DeviceIdentity, provider: &impl OpenMlsProvider, ) -> Result<()> { // Validate size if inline_data.data.len() > 50_000 { return Err("Emoji exceeds 50 KB".into()); }
// Get current media let mut custom_media = self.get_custom_media_extension()?;
// Add emoji custom_media.emojis.insert( emoji_id.clone(), CustomEmoji { id: emoji_id, inline_data, } );
// Propose and commit (same as sticker pack) let extension_data = serde_json::to_vec(&custom_media)?; let extension = Extension::new( ExtensionType::Unknown(CRYPTID_CUSTOM_MEDIA_EXT), extension_data.into() );
let proposal = self.mls_state.propose_group_context_extensions( provider, Extensions::single(extension), &updater.signer, )?;
let (commit, _, _) = self.mls_state.commit_to_pending_proposals( provider, &updater.signer, )?;
self.mls_state.merge_pending_commit(provider)?; self.send_commit_to_group(commit).await?;
Ok(()) }}Privacy and Security
Section titled “Privacy and Security”What’s stored in the group context:
- Sticker/emoji metadata (names, IDs)
- Inline media content (compressed images)
- Uploader information (device_id, timestamp)
What’s NOT stored:
- Device-to-device file content (referenced but stored on uploader’s device)
- User identity (only device_id)
- Message content
Privacy properties:
- All group members see custom media (it’s in group state)
- Server sees encrypted group extensions (cannot decrypt)
- Sticker/emoji usage visible to group members
- File-referenced stickers require device-to-device fetch (optional privacy boost)
Storage efficiency:
- Inline items (≤ 50 KB) keep groups reasonably sized
- Large stickers use device-to-device to avoid bloating group state
- Clients cache fetched stickers locally
See Media Handling for complete sticker/emoji specifications.