Skip to content

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

GET MEDIA_BASE/media/attachments/{id}
Authorization: Bearer <token>
→ AttachmentMeta
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

DELETE MEDIA_BASE/media/attachments/{id}
→ 204

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.