Skip to content

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 use a hybrid approach: small thumbnails for instant display, with the full resolution image fetched from the blob store on demand.

ProfilePicture 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,
}

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
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),
})
}
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 are announced via MessageAction::AttachFile, carrying a FileRef that locates and decrypts the blob.

FileRef Structure
/// 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 identifier
struct 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 group
enum 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 checks
  • mime_type: Hint only. No validation or enforcement
  • plaintext_hash: Blake3 of unencrypted content. Used for deduplication and verification
  • file_id: Stable identity for this file, targeted by FileAction
  • host: Server holding the ciphertext, chosen by the uploader
  • blob_handle: Unguessable storage key, and the fetch capability
  • key: FileKey::Epoch for group media, FileKey::Direct for 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.

Files are uploaded once to a blob store and fetched by recipients. The sender does not stream bytes to anyone.

Files are encrypted client-side before upload. The server stores opaque bytes and holds no key.

Group-scoped key derivation
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.

  1. Derive the key and encrypt the file
  2. Generate a random 32-byte blob_handle
  3. Upload the ciphertext to the uploader’s own server under that handle, spending one token
  4. Send the caption message, then MessageAction::AttachFile carrying the FileRef
  • Uploads MUST spend one token
  • The blob_handle MUST be generated with a cryptographically secure random source
  • Servers MUST NOT index blobs by anything derived from file_id or device_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_hash after decryption and MUST discard the file on mismatch
  • Servers MUST NOT log fetcher identity or source address against a blob handle
Blob store limits
// Matches message queue retention, keeping one number across the system
const DEFAULT_BLOB_TTL: Duration = Duration::days(30);
// Bounds a host's exposure to becoming general-purpose storage
const MAX_BLOB_SIZE: usize = 100 * 1024 * 1024;
// Matches the InfoPackage bound
const 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_uses per handle, bounded at MAX_BLOB_USES
  • Uploaders MAY re-upload an expired file and announce FileAction::Relocated, preserving the original file_id
  • Recipients SHOULD cache decrypted files locally rather than relying on blob availability

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.
SeesDoes not see
An opaque 32-byte handleWhich device uploaded the blob
Ciphertext and its sizeFile content, name, or type
Fetch counts per handleWho fetched it, or which group it belongs to
expires_at, for garbage collectionThe two blobs are the same file in different groups

When MLS epoch changes (member added/removed):

  • Files remain encrypted under the epoch secret current at upload time
  • FileKey::Epoch records 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)
}

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

Groups can have sticker packs stored in MLS group extensions. Stickers are full message reactions (unlike inline custom emoji).

Custom Media Structures
/// Extension type identifier for custom media
const 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 variant
struct CustomEmoji {
id: String,
inline_data: InlineMedia,
}
TierSize RangeStorage MethodUse Case
Small0-50KBInline in group metadataStatic stickers
Medium50-500KBBlob storeAnimated stickers
Large> 500KBRejected from group metadataClient should compress

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

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(())
}
  • 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 are inline in Text messages using the :emoji_id: format. They’re stored in the group’s custom_media extension as small media items.

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.

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

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 = &caps;[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]“

  • 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

Servers MUST enforce a maximum message size: 10 MB

Messages exceeding this limit are rejected with error 4003 MESSAGE_TOO_LARGE.

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

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.


Clients SHOULD implement a download-once, cache-forever strategy for all media:

  1. First fetch: Fetch the blob from FileRelf.host, or read inline media from group metadata
  2. Verify: Check plaintext_hash matches after decryption
  3. Store locally: Save to device storage with metadata
  4. Reuse: All future accesses use the cached copy
  5. 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
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,
}
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)
}

Clients SHOULD implement cache cleanup based on:

  1. Storage quota: Per-group or per-user storage limit
  2. Access patterns: Remove least-recently-used (LRU) items
  3. Age: Remove items older than retention period
  4. User pinning: Never delete user-favorited media

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 entries are invalidated when:

  1. Hash mismatch: Corrupted file detected (never use again)
  2. Manual deletion: User explicitly clears cache or removes item
  3. 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

DO:

  • Cache all media immediately after fetch
  • Check cache before fetching a blob
  • Verify plaintext_hash after 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

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)

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

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_uses per 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

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_handle and 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_uses exhaustion, or uploader deletion
  • Only files uploaded after removal are protected, since the removed member cannot derive the new epoch secret

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

MUST:

  • Compress profile pictures to meet size/format requirements
  • Cache decrypted media locally, keyed by plaintext_hash
  • Verify plaintext_hash after 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

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

Media TypeMax SizeStorage MethodEncryptionAlways Available?
Profile Picture Thumbnail16 KBInline in InfoPackageRandom key✅ Yes
Profile Picture Full200 KBBlob storeFileKey::Direct⏳ Until TTL expiry
File AttachmentsNo limit*Blob storeMLS epoch-derived⏳ Until TTL expiry
Stickers (inline)50 KBGroup metadataMLS group key✅ Yes
Stickers (large)500 KBBlob storeMLS epoch-derived⏳ Until TTL expiry
Custom Emoji50 KBGroup metadataMLS group key✅ Yes

*Practical limit: the host’s configured maximum blob size