Media Handling
Cryptid handles media (profile picture, file attachments, stickers) without servers ever seeing plaintext. Small items travel inline inside MLS; larger ones are encrypted client-side and stored as opaque blobs that the host cannot read, attribute, or correlate.
Core Principles:
- Small media: Thumbnails stored inline for instant display
- Large media: Fetched from blob storage on demand
- Blind server storage: Servers never see what media you upload
- End-to-end encrypted: All media encrypted with MLS-derived or per-file keys
- User control: Clients decide retention policies and availability
- Client caching: Media cached locally for instant future access
Profile Pictures
Section titled “Profile Pictures”Profile pictures use a hybrid approach: small thumbnails for instant display, with the full resolution image fetched from the blob store on demand.
Structure
Section titled “Structure”struct ProfilePicture { // Inline thumbnail for instant display. AVIF, 64x64, max 16KB. // Travels inside InfoPackages, so it must stay small thumbnail: Vec<u8>,
// Full resolution image, fetched on demand. Identity-scoped, so it uses // FileKey::Direct because a picture shared via InfoPackage has no group // context from which to derive an epoch key full_image: Option<FileRef>,
// Timestamp uploaded_at: u64,
// Signed by the user keypair for authenticity signature: Ed25519Signature,}Requirements
Section titled “Requirements”Format:
- AVIF only (required for consistency)
- Square aspect ratio (enforced)
- Lossless compression mode
Size Limits:
- Thumbnail: Max 16KB (64x64 pixels)
- Full image: Max 200 KB (512x512 pixels recommended)
Validation: Implementations MUST reject profile pictures that:
- Exceed size limits
- Use non-square aspect ratios
- Use formats other than AVIF
Upload Flow
Section titled “Upload Flow”use image::DynamicImage;
fn set_profile_picture(image: DynamicImage) -> Result<ProfilePicture> { // 1. Enforce square aspect ratio let size = image.width().min(image.height()); let squared = image.crop_imm(0, 0, size, size);
// 2. Generate thumbnail (64x64, AVIF lossless) let thumb = squared.resize_exact(64, 64, FilterType::Lanczos3); let thumb_data = encode_avif_lossless(&thumb)?;
if thumb_data.len() > 16_000 { return Err("Thumbnail exceeds 16 KB"); }
// 3. Encode full image (512x512 max, AVIF lossless) let full = squared.resize_to_fill(512, 512, FilterType::Lanczos3); let full_data = encode_avif_lossless(&full)?;
if full_data.len() > 200_000 { return Err("Profile picture exceeds 200 KB"); }
// 4. Encrypt with a standalone random key. Profile pictures are // identity-scoped, so FileKey::Direct rather than an epoch derivation let key = random_32_bytes(); let ciphertext = aead_seal(&key, &full_data);
// 5. Upload to the owner's own server under a random handle. // Costs one token let blob_handle = random_32_bytes(); upload_blob(own_server, blob_handle, ciphertext, token)?;
Ok(ProfilePicture { thumbnail: thumb_data, full_image: Some(FileRef { size: full_data.len() as u64, mime_type: "image/avif".to_string(), plaintext_hash: blake3::hash(&full_data), file_id: FileId { uploader: device_id, id: next_file_id() }, host: own_server.to_string(), blob_handle, key: FileKey::Direct(key), }), uploaded_at: current_unix_timestamp(), signature: user_keypair.sign(&signable_bytes), })}Download Flow
Section titled “Download Flow”use image::DynamicImage;
async fn fetch_profile_picture(pic: &ProfilePicture) -> Result<DynamicImage> { // 1. Display thumbnail immediately display_image(&pic.thumbnail)?;
let file_ref = pic.full_image.as_ref().ok_or("No full image published")?;
// 2. Fetch the blob. No device addressing, and the owner need not be online let ciphertext = fetch_blob(&file_ref.host, &file_ref.blob_handle).await?;
// 3. Decrypt with the key carried in the FileRef let FileKey::Direct(key) = file_ref.key else { return Err("Profile pictures MUST use FileKey::Direct"); }; let plaintext = aead_open(&key, &ciphertext)?;
// 4. Verify before decoding if blake3::hash(&plaintext) != file_ref.plaintext_hash { return Err("Profile picture hash mismatch"); }
decode_avif(&plaintext)}File Attachments
Section titled “File Attachments”File attachments are announced via MessageAction::AttachFile, carrying a FileRef that locates and decrypts the blob.
Announcement
Section titled “Announcement”/// Everything needed to locate, fetch, and decrypt a file. Travels inside MLS,/// so only group members ever learn a blob's location or key.struct FileRef { // File size in bytes size: u64,
// Hint only. Never validated or enforced by a server mime_type: String,
// Blake3 of unencrypted content. Verified after reassembly plaintext_hash: [u8; 32],
// Stable identity, used by FileAction to target this file file_id: FileId,
// Server hosting the ciphertext. Chosen by the uploader, which is why it // is stated rather than derived. Normally the uploader's home server host: String,
// Unguessable storage key. Acts as the fetch capability: it travels only // inside MLS, so holding it evidences group membership blob_handle: [u8; 32],
// How to decrypt the blob key: FileKey,}
/// Type-safe file identifierstruct FileId { // DeviceId of the device that uploaded this file uploader: DeviceId,
// Local counter maintained by uploader id: u64,}
/// Files come from two contexts, and only one of them has a groupenum FileKey { // Group-scoped. Derived from the MLS epoch secret, so the same file sent // to two groups yields different ciphertext and the host cannot correlate // them. Members who joined later cannot derive it, matching MLS forward // secrecy Epoch { epoch: u64 },
// Identity-scoped. A standalone random key carried inline, for media with // no group context, such as a profile picture shared via InfoPackage Direct([u8; 32]),}file_id MUST NOT be used as the storage key. It contains uploader: DeviceId, and a hosting server that indexed by it would learn which device owns every blob. This is the exact leak that blind KeyPackage handles exist to prevent. blob_handle is random and unrelated to identity.
Field Specification:
size: Informational. Clients use it for progress and storage checksmime_type: Hint only. No validation or enforcementplaintext_hash: Blake3 of unencrypted content. Used for deduplication and verificationfile_id: Stable identity for this file, targeted byFileActionhost: Server holding the ciphertext, chosen by the uploaderblob_handle: Unguessable storage key, and the fetch capabilitykey:FileKey::Epochfor group media,FileKey::Directfor identity-scoped media
Filenames are carried in the accompanying caption message, not in the FileRef. A filename is content, and belongs inside MLS rather than in a structure clients may cache alongside blobs.
File Storage
Section titled “File Storage”Files are uploaded once to a blob store and fetched by recipients. The sender does not stream bytes to anyone.
Encryption
Section titled “Encryption”Files are encrypted client-side before upload. The server stores opaque bytes and holds no key.
fn derive_file_key(epoch_secret: &[u8; 32], plaintext_hash: &[u8; 32]) -> [u8; 32] { blake3::derive_key( &format!("cryptid file key v1 {}", hex(plaintext_hash)), epoch_secret, )}- Cross-group isolation: The same file sent to two groups derives from different epoch secrets, so the ciphertexts differ and a host serving both cannot tell they are the same content.
- Forward secrecy: A removed member cannot derive keys for files uploaded after their removal.
- Deduplication: Within one group and epoch, identical content yields an identical key, so clients can recognize a file they already hold by
plaintext_hash.
Media with no group context - profile pictures shared via InfoPackages, for instance - uses FileKey::Direct with a random key carried in the FileRef instead.
Upload
Section titled “Upload”- Derive the key and encrypt the file
- Generate a random 32-byte
blob_handle - Upload the ciphertext to the uploader’s own server under that handle, spending one token
- Send the caption message, then
MessageAction::AttachFilecarrying theFileRef
- Uploads MUST spend one token
- The
blob_handleMUST be generated with a cryptographically secure random source - Servers MUST NOT index blobs by anything derived from
file_idordevice_id - Servers MUST treat blob contents as opaque and MUST NOT attempt validation
GET https://{host}/blob/{blob_handle}Fetch is unauthenticated, because the handle is the authorization. It appears only inside MLS, so possessing it evidences group membership. This matches the way we do InfoPackages, which are already fetched by unguessable URL segment with no identity attached.
- Recipients MUST verify
plaintext_hashafter decryption and MUST discard the file on mismatch - Servers MUST NOT log fetcher identity or source address against a blob handle
Retention and Removal
Section titled “Retention and Removal”// Matches message queue retention, keeping one number across the systemconst DEFAULT_BLOB_TTL: Duration = Duration::days(30);
// Bounds a host's exposure to becoming general-purpose storageconst MAX_BLOB_SIZE: usize = 100 * 1024 * 1024;
// Matches the InfoPackage boundconst MAX_BLOB_USES: u32 = 1000;Blobs expire. When one does, the file is gone from the network even though recipients still hold the FileRef.
- Servers MUST enforce a TTL, defaulting to 30 days and configurable per deployment
- Servers MUST reject uploads exceeding
MAX_BLOB_SIZE - Servers SHOULD enforce
max_usesper handle, bounded atMAX_BLOB_USES - Uploaders MAY re-upload an expired file and announce
FileAction::Relocated, preserving the originalfile_id - Recipients SHOULD cache decrypted files locally rather than relying on blob availability
Uploader-Initiated Deletion
Section titled “Uploader-Initiated Deletion”FileAction::MarkDeleted tells the group to stop offering a file, but it does not reach the host. The ciphertext would otherwise sit there until TTL. Deletion uses the same capability pattern as InfoPackage revocation:
DELETE https://{host}/blob/{blob_handle}X-Revocation-Token: {32-byte token from the upload response}- Upload responses MUST return a
revocation_token - Uploaders SHOULD retain it for the lifetime of the blob
- Hosts MUST compare the presented token in constant time
- The token MUST NOT be placed in the
FileRef. Recipients receive the handle, not the right to delete.
What the Host Learns
Section titled “What the Host Learns”| Sees | Does not see |
|---|---|
| An opaque 32-byte handle | Which device uploaded the blob |
| Ciphertext and its size | File content, name, or type |
| Fetch counts per handle | Who fetched it, or which group it belongs to |
expires_at, for garbage collection | The two blobs are the same file in different groups |
Epoch Handling
Section titled “Epoch Handling”When MLS epoch changes (member added/removed):
- Files remain encrypted under the epoch secret current at upload time
FileKey::Epochrecords which epoch, so recipients derive directly rather than trial-decrypting- Clients MUST retain epoch secrets long enough to decrypt files they may still fetch
- Recommended retention: 100 epochs
- A member who joined after upload cannot derive the key, matching MLS forward secrecy
fn decrypt_file( ciphertext: &[u8], file_ref: &FileRef, mls_group: &MlsGroup,) -> Result<Vec<u8>> { let key = match file_ref.key { // The epoch is named, so there is no search FileKey::Epoch { epoch } => { let secret = mls_group.epoch_secret(epoch) .ok_or("Epoch secret no longer retained")?; derive_file_key(&secret, &file_ref.plaintext_hash) } FileKey::Direct(k) => k, };
let plaintext = aead_open(&key, ciphertext)?;
if blake3::hash(&plaintext) != file_ref.plaintext_hash { return Err("Plaintext hash mismatch"); }
Ok(plaintext)}Availability
Section titled “Availability”Files remain fetchable until their blob TTL expires, independent of whether the uploader is online.
Retention:
- Server-enforced TTL on the blob, configurable per deployment
- Uploaders MAY re-upload an expired file and announce
FileAction::Relocated - Clients SHOULD cache decrypted files locally rather than relying on blob availability
Offline recipients:
- No special handling needed - blobs are fetched from the store, not from the sender
- A recipient coming online fetches any files still within their TTL
Stickers
Section titled “Stickers”Groups can have sticker packs stored in MLS group extensions. Stickers are full message reactions (unlike inline custom emoji).
Structure
Section titled “Structure”/// Extension type identifier for custom mediaconst CRYPTID_CUSTOM_MEDIA_EXT: u16 = 0xF001;
/// Stored in the group context extension "custom media"struct GroupCustomMedia { packs: HashMap<String, MediaPack>, emojis: HashMap<String, CustomEmoji>,}
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}
struct MediaItem { // Item identifier within pack id: String,
// Small items (<= 50 KB): stored inline in group state inline_data: Option<InlineMedia>,
// Large items (> 50 KB): fetched from the blob store file_ref: Option<FileRef>,}
struct InlineMedia { mime_type: String, // "image/avif" or "image/jxl" data: Vec<u8>, // Compressed media}
/// Emoji are always inline. Group state carries them directly, so there is no/// blob-backed variantstruct CustomEmoji { id: String, inline_data: InlineMedia,}Size Tiers
Section titled “Size Tiers”| Tier | Size Range | Storage Method | Use Case |
|---|---|---|---|
| Small | 0-50KB | Inline in group metadata | Static stickers |
| Medium | 50-500KB | Blob store | Animated stickers |
| Large | > 500KB | Rejected from group metadata | Client should compress |
Adding Packs
Section titled “Adding Packs”Group members with appropriate permissions can add packs:
async fn add_sticker_pack( &self, group_id: &GroupId, pack: MediaPack,) -> Result<()> { // Check permission let group = self.groups.get(&group_id)?; let perm_ext = group.get_permission_extension()?; let my_perms = perm_ext.device_permissions .get(&self.device.device_id) .ok_or("Device not found in group permissions")?;
if !my_perms.contains(Permissions::MANAGE_MEDIA_PACKS) && !my_perms.contains(Permissions::ADMINISTRATOR) { return Err("Insufficient permissions to add shared media"); }
// Validate sizes 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"); } }
if let Some(file_ref) = &item.file_reference { if file_ref.size > 500_000 { return Err("File reference exceeds 500 KB"); } } }
// Update group extension let mut custom_media = group.extensions.custom_media; custom_media.packs.insert(pack.pack_id.clone(), pack);
group.update_extension("custom_media", custom_media).await?; Ok(())}Using Stickers
Section titled “Using Stickers”Sending a stickers:
Stickers are sent as text messages containing a reference marker:
<media_pack/{pack_id}/{sticker_id}>See Sticker References for the resolution and fallback rules.
Display flow:
async fn display_sticker( sticker_msg: &Sticker, group: &Group,) -> Result<()> { // 1. Look up pack in group metadata let pack = group.extensions.custom_media .packs.get(&sticker_msg.pack_id)?;
let item = pack.items.get(&sticker_msg.sticker_id)?;
// 2. Check if inline if let Some(inline) = &item.inline_data { display_image(&inline.data)?; return Ok(()); }
// 3. Show fallback thumbnail while fetching if let Some(fallback) = &sticker_msg.fallback { display_image(&fallback)?; }
// 4. Fetch from blob store. The uploader need not be online if let Some(file_ref) = &item.file_ref { let data = fetch_and_decrypt(file_ref).await?; display_image(&data)?; }
Ok(())}Availability
Section titled “Availability”- Inline stickers: Always available (stored in group metadata)
- Blob-stored stickers: Available until the blob TTL expires; the uploader need not be online
- Clients SHOULD cache fetched stickers locally for future use
Custom Emoji
Section titled “Custom Emoji”Custom emoji are inline in Text messages using the :emoji_id: format. They’re stored in the group’s custom_media extension as small media items.
Structure
Section titled “Structure”Emoji reuse the custom media structure shown under Stickers: CustomEmoji holds an InlineMedia directly. Emoji are always inline. Group state carries the bytes, so there is no blob-backed variant and no FileRef.
Adding Emoji
Section titled “Adding Emoji”async fn add_custom_emoji( group_id: GroupId, emoji_id: String, image_data: Vec<u8>,) -> Result<()> { // Validate size if image_data.len() > 50_000 { return Err("Emoji exceeds 50 KB"); }
// Update group extension let mut custom_media = group.extensions.custom_media; custom_media.emojis.insert(emoji_id.clone(), CustomEmoji { id: emoji_id, inline_data: InlineMedia { mime_type: "image/avif".to_string(), data: image_data, }, });
group.update_extension("custom_media", custom_media).await?; Ok(())}Using in Text
Section titled “Using in Text”Sender sends:
{ "type": "Text", "data": { "text": "Looks great! :thumbs-up: Love it :party-blob:", "reply_to": null }}Client rendering:
fn render_text_with_emojis( text: &str, group: &Group,) -> Result<RichText> { let custom_media = &group.extensions.custom_media;
// Find all :emoji_id: patterns let re = Regex::new(r":([a-z0-9_-]+):")?;
let rendered = re.replace_all(text, |caps: &Captures| { let emoji_id = ∩︀[1]
// Look up in custom emoji if let Some(emoji) = custom_media.emojis.get(emoji_id) { // Render emoji inline return "[emoji]".to_string(); }
// Not found, leave as-is format!(":{emoji_id}:") });
Ok(RichText::from_string(rendered))}This should render as: “Looks great! 👍 Love it [party-blob]“
Benefits
Section titled “Benefits”- Lightweight: Emoji format is just text substitution (
:emoji_id:) - Backward compatible: Unknown emoji shows as
:emoji_id:literally - No separate protocol: Lives entirely in Text messages
- Always available: Stored inline in group metadata
- Client flexibility: Clients render however they want
Server Enforcement
Section titled “Server Enforcement”Maximum Message Size
Section titled “Maximum Message Size”Servers MUST enforce a maximum message size: 10 MB
Messages exceeding this limit are rejected with error 4003 MESSAGE_TOO_LARGE.
What Servers See
Section titled “What Servers See”Observable:
- Message size (possibly padded to 10 MB)
- Routing metadata (recipient addresses)
- Timestamp
- Blob uploads and fetches, as opaque ciphertext under an opaque handle
Not observable:
- Sender information
- Media content (encrypted)
- File types or names
- Sticker/emoji usage
- Which device a blob belongs to, or which group it was shared in
- That two blobs are the same file shared in different groups
Server Storage
Section titled “Server Storage”Servers store encrypted blobs and nothing else. All media is either:
- Inline in messages or group metadata, encrypted under MLS
- In the blob store, as ciphertext under an opaque handle with no index linking it to a device
Servers hold no key for either form, and cannot determine what a blob contains, who uploaded it, or which group it belongs to.
Media Caching and Local Storage
Section titled “Media Caching and Local Storage”Download-Once Strategy
Section titled “Download-Once Strategy”Clients SHOULD implement a download-once, cache-forever strategy for all media:
- First fetch: Fetch the blob from
FileRelf.host, or read inline media from group metadata - Verify: Check
plaintext_hashmatches after decryption - Store locally: Save to device storage with metadata
- Reuse: All future accesses use the cached copy
- Retry only if missing: Re-fetch the blob only if the local cache was deleted or corrupted
Benefits:
- Reduces load on sender devices (popular media fetched once)
- Improves UX (instant display from cache)
- Resilient to sender going offline
- Reduces bandwidth usage significantly
- Works fully offline after initial fetch
Cache Storage Structure
Section titled “Cache Storage Structure”struct MediaCache { files: HashMap<[u8; 32], CachedFile>,
// Sticker packs for quick access sticker_packs: HashMap<String, CachedStickerPack>,
// Emoji cache (small, always loaded) emojis: HashMap<String, CachedEmoji>,}
struct CachedFile { plaintext_hash: [u8; 32], plaintext: Vec<u8>, // Decrypted content mime_type: String, size: u64,
// Metadata for cache management cached_at: u64, last_accessed: u64, access_count: u32,
// For cleanup/verification verified: bool, // Hash verified is_favorite: bool, // User-pinned (don't delete)}
struct CachedStickerPack { pack_id: String, items: HashMap<String, CachedStickerItem>, cached_at: u64,}
struct CachedStickerItem { sticker_id: String, data: Vec<u8>, mime_type: String, last_accessed: u64,}
struct CachedEmoji { emoji_id: String, data: Vec<u8>, mime_type: String,}Downloading with Caching
Section titled “Downloading with Caching”async fn fetch_file_with_cache( file_ref: &FileAttachment, mls_group: &MlsGroup,) -> Result<Vec<u8>> { // 1. Check cache first if let Some(cached) = cache.files.get(&file_ref.plaintext_hash) { cached.last_accessed = now(); cached.access_count += 1; return Ok(cached.plaintext.clone()); }
// 2. Fetch from the blob store. No device addressing, no sender online let ciphertext = fetch_blob(&file_ref.host, &file_ref.blob_handle).await?;
// 3. Decrypt and verify let plaintext = decrypt_file(&ciphertext, file_ref, mls_group)?;
// 4. Store in cache cache.files.insert(file_ref.plaintext_hash, CachedFile { plaintext_hash: file_ref.plaintext_hash, plaintext: plaintext.clone(), mime_type: file_ref.mime_type.clone(), size: plaintext.len() as u64, cached_at: now(), last_accessed: now(), access_count: 1, verified: true, is_favorite: false, });
Ok(plaintext)}Cache Eviction Policy
Section titled “Cache Eviction Policy”Clients SHOULD implement cache cleanup based on:
- Storage quota: Per-group or per-user storage limit
- Access patterns: Remove least-recently-used (LRU) items
- Age: Remove items older than retention period
- User pinning: Never delete user-favorited media
Offline Access
Section titled “Offline Access”Once media is cached locally:
- Profile pictures display instantly
- Stickers available in picker
- Previously viewed files/images accessible
- Custom emoji always available (inline)
- No network request needed
Scenarios:
- Device offline: Use cache for all media
- Sender offline: Use cache (sender unreachable for fetch)
- Network slow: Use cache immediately, background refresh if enabled
Cache Invalidation
Section titled “Cache Invalidation”Cache entries are invalidated when:
- Hash mismatch: Corrupted file detected (never use again)
- Manual deletion: User explicitly clears cache or removes item
- Retention expired: Age exceeds policy retention period
Sender-initiated updates:
- Sender deletes old profile picture and uploads new one
- New file_id and plaintext_hash prevent cache collision
- Clients fetch new version automatically
Best Practices
Section titled “Best Practices”DO:
- Cache all media immediately after fetch
- Check cache before fetching a blob
- Verify
plaintext_hashafter decryption - Store with access_count and last_accessed for analytics
- Allow users to pin favorite media (never evict)
- Show cache stats in settings (used storage, item count)
DON’T:
- Re-fetch media if cached copy exists
- Delete cache without user consent
- Store unverified (hash-mismatched) media
- Ignore corrupted cache entries
User Controls
Section titled “User Controls”Clients SHOULD expose cache management UI:
Settings options:
- Cache storage quota
- Retention period (7 days to indefinitely)
- Eviction strategy preference
- “Clear cache” button
- “Pin/favorite” toggle per media item
- View cache statistics (size, item count, oldest/newest)
Security Considerations
Section titled “Security Considerations”Privacy Properties
Section titled “Privacy Properties”End-to-end encryption:
- All media encrypted before transmission
- Profile pictures: random encryption keys
- File attachments: MLS-derived keys per group
- Stickers/emoji: encrypted with MLS group key
Server blindness:
- Server cannot see media content
- Server cannot correlate files across groups
- Server sees fetch counts per blob handle, but not who fetched
Cross-group isolation:
- Same file in different groups = different ciphertext
- Prevents correlation attacks
- Server cannot build social graph from shared media
Denial of Service Mitigation
Section titled “Denial of Service Mitigation”Per-device limits (client-enforced):
- File retention: 30 days recommended
- Storage quota: User-configurable
- Auto-cleanup of old files
Message size limits (server-enforced):
- Max 10 MB per message
- Prevents bandwidth exhaustion
Blob store limits (server-enforced):
- TTL on every blob, configurable per deployment
max_usesper handle, bounding abuse of an unauthenticated fetch- Uploads cost one token, bounding storage exhaustion
Message size limits (server-enforced):
- Max 10 MB per message
- Prevents bandwidth exhaustion
Client-side:
- Storage quota: user-configurable
- Auto-cleanup of cached media
Forward Secrecy
Section titled “Forward Secrecy”MLS epoch-derived encryption:
- Files encrypted with current epoch secret
- Removed members cannot decrypt files uploaded after removal
- Clients retain epoch history for decrypting old files
- Recommended retention: 100 epochs
What forward secrecy does not cover:
- A removed member keeps the
blob_handleand the epoch key for every file uploaded while they were a member, and the host cannot know they were removed - Their access to those files ends only at TTL expiry,
max_usesexhaustion, or uploader deletion - Only files uploaded after removal are protected, since the removed member cannot derive the new epoch secret
Availability Tradeoffs
Section titled “Availability Tradeoffs”Blobs expire:
- A file becomes unfetchable once its TTL passes, even though recipients still hold the
FileRef - Uploaders can re-upload and announce
FileAction::Relocated - Clients caching decrypted media locally are unaffected
- Thumbnails and inline media remain available regardless
Implementation Guidelines
Section titled “Implementation Guidelines”Client Responsibilities
Section titled “Client Responsibilities”MUST:
- Compress profile pictures to meet size/format requirements
- Cache decrypted media locally, keyed by
plaintext_hash - Verify
plaintext_hashafter fetching, and discard on mismatch - Validate media sizes and formats before accepting
SHOULD:
- Check cache before requesting from server
- Implement progress indicators for chunked transfers
- Provide retry logic for failed transfers
- Auto-cleanup old files based on retention policy
- Show cache statistics in settings
- Pre-load stickers when group is opened
MAY:
- Implement message padding for traffic analysis resistance
- Allow user-configurable retention policies
- Provide bandwidth/storage usage statistics
- Sync cache across user’s devices
Server Responsibilities
Section titled “Server Responsibilities”MUST:
- Enforce 10 MB max message size
- Serve blobs by handle, and enforce TTL and
max_uses
MUST NOT:
- Decrypt media
- Store file content
- Track file downloads
- Correlate files across groups
- Inspect message content
- Store media files
Summary
Section titled “Summary”| Media Type | Max Size | Storage Method | Encryption | Always Available? |
|---|---|---|---|---|
| Profile Picture Thumbnail | 16 KB | Inline in InfoPackage | Random key | ✅ Yes |
| Profile Picture Full | 200 KB | Blob store | FileKey::Direct | ⏳ Until TTL expiry |
| File Attachments | No limit* | Blob store | MLS epoch-derived | ⏳ Until TTL expiry |
| Stickers (inline) | 50 KB | Group metadata | MLS group key | ✅ Yes |
| Stickers (large) | 500 KB | Blob store | MLS epoch-derived | ⏳ Until TTL expiry |
| Custom Emoji | 50 KB | Group metadata | MLS group key | ✅ Yes |
*Practical limit: the host’s configured maximum blob size