Skip to content

MLS Integration and Message 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.

FeatureSignal ProtocolMLS
Group SizeEfficient for small groupsScales to 50,000+ members
Forward SecrecyYesYes
Post-Compromise SecurityYesYes
StandardizationSignal-specificIETF RFC 9420
Asynchronous OperationLimitedFull support
Future-ProofingSignal controlsIndustry standard

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)

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,
}
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 participants
fn 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())
}
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

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

Devices generate KeyPackages containing:

  • Device credential (device_id, signature key)
  • Ephemeral HPKE keys (unique per KeyPackage)
  • Self-signature proving device ownership

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
}

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
}

Use the KeyPackage to add the member:

// Add Alice to group using her KeyPackage
let (commit, welcome, _) = group.add_members(
provider,
&adder_keypair,
&[alice_keypackage]
)?;
// Welcome is encrypted with Alice's KeyPackage public key
send_welcome(&alice.delivery_addresses, welcome)?;

Alice uses her stored private key to decrypt the Welcome message

// Alice has the private key from when she generated the KeyPackage
let staged_join = StagedWelcome::new_from_welcome(
provider,
&config,
welcome,
None, // Ratchet tree included in Welcome (RatchetTreeExtension)
)?;
let alice_group = staged_join.into_group(provider)?;

When a KeyPackage count drops below 20, upload more:

// Background task
if 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 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.

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.

All Cryptid groups MUST enable the RatchetTreeExtension for simpler Welcome handling.

Why this matters:

When joining a group via Welcome, new members need:

  1. The Welcome message (encrypted with their KeyPackage)
  2. 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.

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.

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 identity
struct BasicCredential {
identity: device_id, // Permanent device identity (32 bytes)
}
// When encrypting a message
fn 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 credential
let sender_device_id = commit.sender().credential().identity();
// Check permissions from group extension
let 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:

ComponentIdentityRouting
MLSdevice_idNot involved
ServersHiddendelivery_address
Contactsdevice_iddelivery_addresses

This separation ensures:

  • MLS handles cryptographic identity
  • Servers handle message routing
  • Neither learns more than necessary

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”
Critical Security Feature

Cryptid implements cryptographically enforced permissions using MLS group context extensions. This prevents modified clients from bypassing permission checks and performing unauthorized group operations.

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:

  1. Modified client skips the permission check
  2. Creates valid MLS commit to add/remove members
  3. Other clients receive the commit and process it (MLS authenticated it)
  4. Unauthorized operation succeeds across the group

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.

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
}
}
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)
}
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)
}
}
}

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)
}
}

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(())
}
}

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:

  1. 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
  1. 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
  1. 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
  1. 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)

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:

AspectBefore ExtensionsAfter Extensions
Permission storageClient-side onlyMLS group context
EnforcementTrust client behaviorCryptographic verification
Modified client impactCan affect entire groupIsolated to local state
ComplexityLowerHigher (MLS extensions)
SecurityWeak (social only)Strong (cryptographic)

When implementing permission verification:

  1. Always verify before merging commits. Never trust sender identity alone.
  2. Check ADMINISTRATOR flag. It bypasses all permission requirements.
  3. Handle missing extensions gracefully. Old groups may not have them yet.
  4. Log permission violations. Help detect modified clients.
  5. Use staged commits. Allows verification before merging.
  6. Test with modified client scenarios. Ensure isolation works.

When designing permission policies:

  1. Principle of least privilege. Grant the absolute minimum necessary permissions required.
  2. Founder is immutable. Cannot be changed after group creation.
  3. Version all permission changes. This enables conflict resolution.
  4. Audit permission grants. Track who granted what to whom.
  5. Consider revocation paths - i.e., How to remove compromised admins.

Cryptid supports custom emoji and sticker packs stored in MLS group context extensions. This allows groups to have shared, tamper-proof media collections.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Extension type identifier for custom media
const 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)
}
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)
}
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())
}
}
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(())
}
}
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(())
}
}

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.