Media Files¶
Partly out of date
Rendered from ProjectInfo/client/media-files.md on docs/encryption-modes-spec. Its protocol content is still useful, but parts of it describe an older client or an earlier API. Where it disagrees with the code, the code wins — see the spec errata.
⚠️ Where this document and Encryption Modes disagree, that document wins. It reconciles the mode names, key distribution and media model across the specs and the running code, which have diverged. Media currently has no client-side encryption path; uploads send plaintext.
All files are encrypted by the client before upload. The server stores only ciphertext and sealed key material. It never holds the plaintext DEK.
Wire format for all encrypted files: nonce (24 bytes) ∥ ciphertext (XChaCha20-Poly1305)
Upload Flow¶
Two steps: get an upload token, then POST the encrypted file.
Step 1 — Request an upload token¶
POST MEDIA_BASE/media/upload/request
Authorization: Bearer <token>
{ "content_type": "image/png" }
→ { "upload_token": "jwt-string" }
Token is valid for 5 minutes and scoped to one upload.
Step 2 — Upload the encrypted file¶
POST MEDIA_BASE/media/upload/finalize
Authorization: Bearer <token>
Content-Type: multipart/form-data
upload_token (text) token from step 1
file (file) encrypted ciphertext: nonce ∥ ct
key_type (text) "public" | "dm" | "server_encrypted"
sha256_plaintext (text) hex SHA-256 of the *plaintext* (for CSAM check)
public_dek (text) base64 32-byte DEK — only for key_type=public
grants (text) JSON array — only for key_type=dm
→ 201 Attachment
Key Types¶
public — avatars, server icons, public assets¶
The DEK is not secret; it only prevents trivial storage-layer access.
const dek = sodium.randombytes_buf(32);
const nonce = sodium.randombytes_buf(24);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, dek);
const wire = concat(nonce, ciphertext);
formData.append("key_type", "public");
formData.append("public_dek", toBase64(dek));
dm — DM channel attachments¶
Seal the DEK for every device that needs to open it (sender's own devices + all recipient devices):
const dek = sodium.randombytes_buf(32);
// encrypt file as above → wire
const grants = allDevices.map(device => {
const deviceIKX25519 = sodium.crypto_sign_ed25519_pk_to_curve25519(
fromBase64(device.identity_key)
);
return {
user_id: device.user_id,
device_id: device.device_id,
encrypted_dek_b64: toBase64(sodium.crypto_box_seal(dek, deviceIKX25519))
};
});
formData.append("key_type", "dm");
formData.append("grants", JSON.stringify(grants));
server_encrypted — legacy only¶
Pre-encrypted using the media Vault KEK. Do not use for new uploads. Only relevant for backward compatibility with pre-Phase-1 attachments.
Full Upload Pseudocode¶
async function uploadFile(file: File, keyType: "public" | "dm", devices?: Device[]): Promise<Attachment> {
// 1. Read plaintext
const plaintext = new Uint8Array(await file.arrayBuffer());
// 2. SHA-256 of plaintext (for CSAM check)
const sha256Buf = await crypto.subtle.digest("SHA-256", plaintext);
const sha256Hex = Array.from(new Uint8Array(sha256Buf))
.map(b => b.toString(16).padStart(2, "0")).join("");
// 3. Encrypt
const dek = sodium.randombytes_buf(32);
const nonce = sodium.randombytes_buf(24);
const ct = sodium.crypto_secretbox_easy(plaintext, nonce, dek);
const wire = concat(nonce, ct);
// 4. Get upload token
const { upload_token } = await postJSON(`${MEDIA_BASE}/media/upload/request`, {
content_type: file.type
});
// 5. Build multipart
const form = new FormData();
form.append("upload_token", upload_token);
form.append("file", new Blob([wire]));
form.append("key_type", keyType);
form.append("sha256_plaintext", sha256Hex);
if (keyType === "public") {
form.append("public_dek", toBase64(dek));
} else if (keyType === "dm" && devices) {
form.append("grants", JSON.stringify(devices.map(d => ({
user_id: d.userId,
device_id: d.deviceId,
encrypted_dek_b64: toBase64(
sodium.crypto_box_seal(dek, sodium.crypto_sign_ed25519_pk_to_curve25519(fromBase64(d.identityKey)))
)
}))));
}
// 6. Upload
const res = await fetch(`${MEDIA_BASE}/media/upload/finalize`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: form
});
return res.json(); // Attachment
}
Download and Decryption¶
Step 1 — Fetch metadata¶
interface AttachmentMeta {
id: string;
filename: string;
content_type: string;
size: number;
key_type: "public" | "dm" | "server_encrypted";
raw_url: string;
dek_b64: string | null; // for key_type=public
encrypted_dek_b64: string | null; // for key_type=dm
}
Step 2 — Resolve the DEK¶
let dek: Uint8Array;
if (meta.key_type === "public") {
dek = fromBase64(meta.dek_b64!);
} else if (meta.key_type === "dm") {
const sealed = fromBase64(meta.encrypted_dek_b64!);
dek = sodium.crypto_box_seal_open(sealed, ownIKX25519Pub, ownIKX25519Priv);
} else {
// server_encrypted: use legacy endpoint (server decrypts)
return `${MEDIA_BASE}${meta.raw_url}?token=${accessToken}`;
}
Step 3 — Fetch and decrypt ciphertext¶
const wireBytes = new Uint8Array(
await fetch(`${MEDIA_BASE}${meta.raw_url}`, {
headers: { Authorization: `Bearer ${token}` }
}).then(r => r.arrayBuffer())
);
const nonce = wireBytes.slice(0, 24);
const ct = wireBytes.slice(24);
const plaintext = sodium.crypto_secretbox_open_easy(ct, nonce, dek);
// Create a displayable blob URL
const blobUrl = URL.createObjectURL(new Blob([plaintext], { type: meta.content_type }));
// Remember to URL.revokeObjectURL(blobUrl) when the component unmounts
Deleting an Attachment¶
Uploader only. Soft delete.
Avatars, Banners, and Icons¶
Profile avatars, server icons, and server banners use key_type=public. When the API returns a relative path like /media/attachments/{id}, fetch the metadata, resolve the DEK from dek_b64, download raw_url, and decrypt.
For <img> tags that cannot set auth headers: use ?token=<access_token> as a query param on raw_url. This only works for server_encrypted legacy downloads. For client-encrypted files, always fetch programmatically and create a blob URL.
CSAM Detection¶
Before displaying any received image or video, optionally scan against a local CSAM hash database:
1. GET MEDIA_BASE/media/csam/manifest → { token, client_key }
2. GET MEDIA_BASE/media/csam/hashdb?token={token} → encrypted hash DB
3. Decrypt with client_key
4. Compute perceptual hash of plaintext image
5. Match against local DB (no network call)
6. Match → blur media + show warning + [Report] button
7. User consents → POST /api/reports/csam { attachment_id, evidence_note }
The server also scans server-side on upload. Client scanning is defense-in-depth only.