Skip to content

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_token": "<jwt>",
  "content_type": "image/jpeg",
  "max_bytes": 8388608
}

  • upload_token is valid for 5 minutes.
  • max_bytes is 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

GET /media/attachments/{attachmentID}
Authorization: Bearer <user_jwt>

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_token across 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.

GET /media/csam/manifest
Authorization: Bearer <user_jwt>

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_token authorises one download and expires in 15 minutes.
  • client_key is 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 version locally. Only re-download the full hash DB when version changes.

3.2 — Downloading and decrypting the hash DB

Append the download token as a query parameter:

GET /media/csam/hashdb?token=<download_token from manifest>
Authorization: Bearer <user_jwt>

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:

hashes = []
for i in range(0, len(plain), 32):
    hashes.append(plain[i:i+32])

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.


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:

  1. Do not display the content. Show a blurred placeholder with a warning icon.
  2. Show a warning banner: "This image may contain illegal content."
  3. Offer two actions:
  4. Report (primary action, red/destructive) — opens the explicit consent flow
  5. Dismiss (secondary) — marks the message as seen without 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/@me with {"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

{
  "report_id": "uuid",
  "status": "pending",
  "received_at": "2024-01-15T10:00:00Z"
}

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.


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:

[ 12-byte GCM nonce ] [ ciphertext + 16-byte GCM tag ]

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