Skip to content

Blobs

The blob store holds encrypted files a host cannot read, addressed by a handle it cannot guess. Everything a recipient needs to find and decrypt a file travels inside MLS, so holding a blob_handle is itself evidence of group membership.

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.

Stores an encrypted file and returns its handle.

  • Input: BlobUpload { ciphertext, max_uses, ttl_secs }
  • Output: BlobReceipt { blob_handle, revocation_token, expires_at }
  • Auth: PrivacyPass
  • Idempotent: No
  • Errors: TOKEN_INVALID, TOKEN_REPLAYED, BUDGET_EXHAUSTED, BLOB_TOO_LARGE, MAX_USES_OUT_OF_RANGE
  • Upload MUST spend one token, regardless of size
  • Servers MUST reject an upload exceeding their published maximum blob size
  • Servers MUST generate blob_handle with a CSPRNG
  • Servers MUST NOT index a blob by anything derived from file_id or device_id
  • Servers MUST treat contents as opaque and MUST NOT inspect or transcode them
  • Servers MUST return a revocation_token and MUST NOT accept one supplied by the client
  • Servers MUST enforce a TTL, defaulting to 30 days
  • Servers SHOULD enforce max_uses, bounded at MAX_BLOB_USES
POST /api/v1/blobs
Authorization: PrivateToken token="<base64url>"
Content-Type: application/octet-stream

Retrieves a blob by handle

  • Input: None - the handle is the path
  • Output: ciphertext
  • Auth: Capability handle
  • Idempotent: Yes
  • Errors: BLOB_NOT_FOUND, BLOB_EXPIRED, BLOB_USES_EXHAUSTED
  • Fetch MUST NOT require a token
  • Servers MUST NOT log the fetcher’s identity or source address against a handle
  • Servers MUST count a fetch against max_uses
  • Recipients MUST verify plaintext_hash after decryption and discard on mismatch

Every other data-carrying operation costs a token. This one cannot, for the same reason tokens never cross the federation boundary: they redeem only at the issuing server, so a recipient fetching a file from a peer’s host holds nothing that host would accept.

Authorization comes instead from possession of the blob_handle, which travels only inside MLS. Hosts bound abuse with a TTL and max_uses rather than with tokens.

GET /api/v1/blobs/{blob_handle}

Removes a blob before its TTL expires.

  • Input: None - the revocation_token is in a header
  • Output: None
  • Auth: Revocation token
  • Idempotent: Yes
  • Errors: BLOB_NOT_FOUND, REVOCATION_TOKEN_INVALID
  • Servers MUST compare revocation tokens in constant time
  • Servers MUST return REVOCATION_TOKEN_INVALID rather than BLOB_NOT_FOUND when the blob exists but the token is wrong
  • The revocation_token MUST NOT appear in a FileRef
  • Deletion spends no token - it frees resources

The token stays out of the FileRef deliberately. A FileRef reaches every group member, and any of them could otherwise delete everyone else’s files.

DELETE /api/v1/blobs/{blob_handle}
X-Revocation-Token: <32 bytes, base64url>

Flags a blob for operator attention.

  • Input: BlobReport { reason }
  • Output: None
  • Auth: Capability handle
  • Idempotent: Yes
  • Errors: BLOB_NOT_FOUND
  • Reports MUST be advisory. Hosts MUST NOT auto-delete on report.
  • Hosts SHOULD require operator review, or a threshold of independent reports, before removal
  • Hosts MUST NOT log reporter identity or source address against a handle
  • Reporting spends no token

A reporter is necessarily somene who could decrypt the blob, since only group members learn a handle. That is the most a host can establish without breaking the encryption model: not that the content is bad, but that someone with legitimate access objected.

Auto-deletion on a single report would be a griefing vector - every group member holds the handle for every file shared in that group.

POST /api/v1/blobs/{blob_handle}/report

A blob is gone when the first of these happens: its TTL expires, max_uses is reached, or the uploader deletes it. Recipients still holding a FileRef get BLOB_NOT_FOUND or BLOB_EXPIRED thereafter.

  • Recipients SHOULD cache decrypted files locally rather than relying on availability
  • Uploaders MAY re-upload an expired file and announce FileAction::Relocated, preserving file_id