Client Media & CSAM Implementation Guide¶
Reference
Rendered from Server/docs/client-media.md on main at build time. Kept current with the code it describes, but not a protocol specification.
This document describes everything the client needs to implement file upload, download, and CSAM protection against the Zipp-Chat media service.
1. File Upload Flow¶
Media upload is a two-step process. Step 1 obtains a short-lived token that authorises the upload. Step 2 sends the actual file with that token.
Step 1 — Request an upload token¶
POST /media/upload/request
Authorization: Bearer <user_jwt>
Content-Type: application/json
{
"content_type": "image/jpeg"
}
Response 200:
upload_tokenis valid for 5 minutes.max_bytesis the server-enforced size limit for the given content type.- The token is non-transferable — it is tied to the requesting session.
Supported content types and their size limits:
| Type | Limit |
|---|---|
| image/jpeg, png, gif, webp | 8 MB |
| video/mp4, video/webm | 100 MB |
| audio/mpeg, ogg, wav | 50 MB |
| application/pdf, text/plain | 10 MB |
Step 2 — Finalize the upload¶
POST /media/upload/finalize
Authorization: Bearer <user_jwt>
Content-Type: multipart/form-data
upload_token=<token from step 1>
file=<binary file data>
The file field must contain the plaintext (unencrypted) bytes. The server
handles all encryption before writing to storage. The content type and size
limits are re-validated from the token server-side; the client cannot bypass them.
Response 201:
{
"attachment_id": "a1b2c3d4-...",
"url": "/media/attachments/a1b2c3d4-...",
"content_type": "image/jpeg",
"original_name": "photo.jpg",
"size_bytes": 1234567,
"width": 1920,
"height": 1080,
"created_at": "2024-01-15T10:00:00Z"
}
Store the attachment_id in your message draft so you can reference it when
sending the message. The server holds the encryption key; you do not need to
store it.
2. File Download Flow¶
Download an attachment¶
The server decrypts the file and returns the plaintext bytes directly:
200 OK
Content-Type: image/jpeg
Content-Disposition: inline; filename="photo.jpg"
<binary plaintext>
Caching recommendations¶
- Cache the decrypted bytes keyed on
attachment_id. The content of an attachment never changes once uploaded (no in-place edits). - Respect standard HTTP cache headers if set.
- On iOS/Android, store in a disk cache (e.g. URLCache / OkHttp DiskLruCache) with a maximum of ~200 MB to avoid repeated downloads.
- Evict entries when the associated message is deleted.
- Do not cache the
upload_tokenacross app restarts — it expires in 5 min.
3. Content Protection (CSAM)¶
Zipp-Chat uses a client-side perceptual hash matching approach. The server distributes an encrypted hash database; clients download it and check received media locally before displaying it. Nothing is uploaded to the server for scanning — only a report (with user consent) if a match is found.
3.1 — Fetching the hash DB manifest¶
Call this endpoint on app startup and then weekly to check for updates.
Response 200:
{
"version": "2024-01-stub",
"hash_count": 0,
"size_bytes": 128,
"updated_at": "2024-01-15T00:00:00Z",
"download_url": "/media/csam/hashdb",
"download_token": "<jwt, 15-min TTL>",
"client_key": "<base64-encoded 32 bytes>"
}
download_tokenauthorises one download and expires in 15 minutes.client_keyis a per-request decryption key derived server-side via HKDF from the Vault CSAM master key and a random nonce embedded in the token. The master key itself is never sent to clients.- Cache the
versionlocally. Only re-download the full hash DB whenversionchanges.
3.2 — Downloading and decrypting the hash DB¶
Append the download token as a query parameter:
The response body is the hash DB encrypted with AES-256-GCM using client_key.
Decryption (all platforms):
key = base64_decode(manifest.client_key) // 32 bytes
blob = response body bytes
// AES-256-GCM: first 12 bytes are the nonce (prepended by server)
nonce = blob[0:12]
ct = blob[12:]
aad = b"csam-hashdb-client" // additional data
plain = AES_256_GCM_Decrypt(key, nonce, ct, aad)
The decrypted plain is a list of 32-byte SHA-256 hashes concatenated without
delimiters. Parse them in 32-byte chunks:
Store the hash list in a sorted array or hash set for O(1) or O(log n) lookup.
Stub note: Until Zipp-Chat obtains NCMEC Technology Coalition membership,
the hash DB will be empty (hash_count: 0). Build the full client-side pipeline
now so it works automatically when real hashes are loaded.
3.3 — Recommended perceptual hash libraries¶
| Platform | Library | Notes |
|---|---|---|
| Android | com.github.nivance:phash-java or OpenCV perceptual hash |
pHash (DCT-based) |
| iOS | CIFilter + custom DCT, or opencv2/imgproc |
Use pHash, not aHash |
| Web | blockhash-js or phash.js |
Run in a Web Worker |
| Desktop (Flutter) | image package + custom pHash |
Compute a pHash (perceptual hash) — not a cryptographic hash — on received images. pHash is robust to minor re-encoding, resizing, and compression artefacts.
3.4 — When to run the hash check¶
- Run the check on receive, not on send.
- Check immediately before displaying an image or video thumbnail in the message list.
- Do not run for audio files, PDFs, or text — only image/jpeg, image/png, image/gif, image/webp, video/mp4, video/webm.
- Run in a background queue to avoid blocking the UI.
Never block on network when checking — always use the locally cached hash DB.
3.5 — What to do on a match¶
If a computed pHash (or a SHA-256 exact match) appears in the hash DB:
- Do not display the content. Show a blurred placeholder with a warning icon.
- Show a warning banner: "This image may contain illegal content."
- Offer two actions:
- Report (primary action, red/destructive) — opens the explicit consent flow
- Dismiss (secondary) — marks the message as seen without reporting
3.6 — Explicit consent flow before reporting¶
Before calling the report endpoint the client must present a consent screen:
┌──────────────────────────────────────────────────────────────────┐
│ Report illegal content │
│ │
│ You are about to send the content you received to our │
│ Trust & Safety team and to the National Center for │
│ Missing & Exploited Children (NCMEC). │
│ │
│ This will include: │
│ • A copy of the image/video │
│ • The message context (channel, sender) │
│ • Your account details │
│ │
│ Reports are irreversible. The sender's account will be │
│ immediately suspended pending review. │
│ │
│ [ Cancel ] [ Report to NCMEC ] │
└──────────────────────────────────────────────────────────────────┘
Only proceed to POST /api/reports/csam after the user taps "Report to NCMEC".
3.7 — Settings toggle¶
CSAM scanning is on by default and should be presented in Settings as:
Safety
[✓] Scan received images for illegal content
Uses a local database — images are not uploaded to the server
unless you choose to report them.
- When the toggle is off, display a permanent warning banner in the chat UI: "Content scanning is disabled. You may be exposed to illegal content."
- The toggle state must be stored server-side (user preference) so it follows
the user across devices. Use
PATCH /api/users/@mewith{"csam_scan": false}. - The toggle controls display only — the server-side evidence pipeline always processes reports when submitted; it does not respect this toggle.
4. API Reference¶
POST /media/upload/request¶
Auth: Required Rate limit: 10 req/min per user
| Field | Type | Required | Description |
|---|---|---|---|
| content_type | string | yes | MIME type of the file to upload |
Success: 200 OK — UploadRequestResponse
Errors:
| Code | Meaning |
|---|---|
| 415 | Content type not supported |
| 401 | Not authenticated |
POST /media/upload/finalize¶
Auth: Required
Content-Type: multipart/form-data
Rate limit: 20 req/min per user
| Field | Type | Required | Description |
|---|---|---|---|
| upload_token | string | yes | Token from /media/upload/request |
| file | binary | yes | Plaintext file bytes |
Success: 201 Created — FinalizeUploadResponse
Errors:
| Code | Meaning |
|---|---|
| 400 | Missing fields or unreadable file |
| 401 | Upload token invalid or expired |
| 413 | File exceeds token's max_bytes |
| 415 | Magic bytes don't match content_type in token |
| 400 | Content policy violation (CSAM detected server-side) |
GET /media/attachments/{attachmentID}¶
Auth: Required Rate limit: 60 req/min per user
Returns plaintext file bytes with correct Content-Type header.
Errors:
| Code | Meaning |
|---|---|
| 404 | Attachment not found or deleted |
| 403 | Forbidden |
DELETE /media/attachments/{attachmentID}¶
Auth: Required — must be the uploader
Success: 204 No Content
Errors:
| Code | Meaning |
|---|---|
| 404 | Not found or already deleted |
GET /media/csam/manifest¶
Auth: Required Rate limit: 5 req/min per user
Success: 200 OK — HashDBManifest
| Field | Type | Description |
|---|---|---|
| version | string | Opaque version string |
| hash_count | int | Number of hashes in the DB |
| size_bytes | int | Size of the encrypted blob in bytes |
| updated_at | string | RFC 3339 timestamp |
| download_url | string | Base path for the hash DB download |
| download_token | string | JWT, 15-min TTL |
| client_key | string | base64 AES-256 key for decrypting the blob |
GET /media/csam/hashdb¶
Auth: Required
Query param: token=<download_token from manifest>
Rate limit: 2 req/min per user (hash DB is large — cache aggressively)
Returns the encrypted hash DB blob as application/octet-stream.
Errors:
| Code | Meaning |
|---|---|
| 400 | Missing or expired download token |
POST /api/reports/csam¶
Auth: Required Rate limit: 1 active report per attachment per user (returns 409 on duplicate) Max body: 100 MB (evidence blob)
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
| reported_user_id | string (UUID) | yes | The sender of the flagged content |
| channel_id | string (UUID) | no | Channel where content was seen |
| message_id | string (UUID) | no | Message containing the attachment |
| attachment_id | string (UUID) | no | Specific attachment ID |
| content_hash | string | yes | pHash of the flagged content (hex) |
| ncmec_hash_match | string | no | The matching hash from the DB |
| decrypted_blob | bytes (base64 in JSON) | yes | Plaintext content bytes |
| reported_at | string (RFC 3339) | no | Client timestamp; defaults to now |
Success: 202 Accepted
Errors:
| Code | Meaning |
|---|---|
| 400 | Missing required fields |
| 401 | Not authenticated |
| 409 | Duplicate report for this attachment |
| 500 | Internal error storing evidence |
Note: A 202 response means the report was received and stored. The
reported user's account will be suspended asynchronously within seconds.
The client should reflect this by hiding or flagging the reported content
immediately after a successful response.
Link previews¶
A link preview is an ordinary sealed attachment with content type
application/vnd.zippchat.link-preview+json and the name link-preview.json.
Its plaintext is a small JSON document (title, description, site name, URL and
an inline JPEG thumbnail) built by the sender's client; receivers decrypt
it like any file and never fetch the URL. Older clients show it as a file chip
under the message text. Browsers cannot fetch other sites, so a web client may
opt in to POST /api/link-preview (see api.md §15), which shows this
instance the URL. The format is specified in ProjectInfo e2ee.md.
Appendix: AES-256-GCM wire format¶
All encrypted blobs produced by the server use this layout:
The nonce is randomly generated per encryption. The GCM tag is appended by the
standard AEAD Seal call and validated by Open. There is no additional framing.
Additional authenticated data (AAD) strings used per context:
| Context | AAD string |
|---|---|
| Media file (server storage) | media-file |
| Media thumbnail | media-thumb |
| Media DEK wrapped by KEK | media-dek |
| CSAM hash DB (server storage) | csam-hashdb |
| CSAM hash DB (client re-encrypted) | csam-hashdb-client |
| CSAM evidence blob | csam-evidence |
| CSAM evidence DEK wrapped by KEK | csam-evidence-dek |