Skip to content

Backend API

All endpoints are served under /api/ by a single NinjaAPI declared in backend/api/api.py. Authentication is a bearer token (Authorization: Bearer <token>) issued at enrolment. Request and response shapes live in backend/api/schemas.py.

Base URL (production): https://blindproof.co.uk/api/

Endpoints

GET /health

Unauthenticated liveness probe. Returns {"status": "ok"}.

POST /enrol

Create a new user account.

Request (EnrolIn): {"email": "...", "password": "..."}

Response (EnrolOut): {"token": "...", "argon2_salt_hex": "..."}

The salt is generated server-side on enrolment and returned so the client can derive its master key. The password is used for account authentication; it is separate from the author's BlindProof passphrase (which we never see — it is used on the client to derive the encryption keys).

POST /login

Return a fresh bearer token and the user's Argon2 salt for an existing account.

Request (LoginIn): {"email": "...", "password": "..."}

Response (LoginOut): {"token": "...", "argon2_salt_hex": "..."}

Used by the Mac app's Sign-in flow when re-linking to an existing account.

GET /whoami — authenticated

Identity probe. Returns {"email": "..."}. Useful for the client to confirm a stored token is still valid.

POST /snapshots — authenticated

Upload a snapshot: ciphertext plus non-sensitive metadata.

Request (SnapshotIn):

{
  "ciphertext_ref": "uuid-hex-string",
  "ciphertext_b64": "base64-of-AES-GCM-ciphertext",
  "nonce_hex": "24-hex-chars",
  "captured_at": "2026-04-22T14:00:00+00:00",
  "file_type": "md",
  "path_ciphertext_b64": "base64-of-AES-GCM-encrypted-path",
  "path_nonce_hex": "24-hex-chars",
  "plaintext_hmac_hex": "64-hex-chars",
  "ciphertext_size": 1234,
  "word_count": 520,
  "char_count": 3102,
  "commitment_scheme": "v2-per-leaf",
  "extractor_version": "docx-v1"
}

commitment_scheme tells the bundle generator how plaintext_hmac_hex was computed. Modern clients use v2-per-leaf (HMAC-SHA256(HKDF(mac_key, ciphertext_ref), plaintext)); the older v1-mac-key path is retained for backwards compatibility but cannot participate in manuscript-match. Field omitted → defaults to v1-mac-key.

extractor_version records which extractor produced the committed plaintext (text-v1 for .md/.txt, docx-v1 for Word). It's non-sensitive provenance, stored unencrypted alongside file_type, so a synced capture stays independently auditable. Field omitted (older clients, pre-format-tracking captures) → stored as null.

Response (SnapshotOut): the assigned snapshot id and server-stamped uploaded_at.

Notes:

  • The server derives nothing from the manuscript — ciphertext is opaque bytes written to the blob store, plus the HMAC commitment for later Merkle aggregation.
  • path_ciphertext_b64 is the file path encrypted with the client's enc_key, so even path names stay private.
  • ciphertext_ref is a client-chosen UUID-v4; it's used as the blob storage key end-to-end (client filename == server blob key).

GET /snapshots/{snap_id} — authenticated

Fetch a single snapshot: metadata plus the raw ciphertext body. Used for restore flows from another device.

Response (SnapshotFullOut): all metadata fields plus ciphertext_b64.

Only the authenticated user's own snapshots are returned; requesting someone else's snap_id returns 404.

GET /user/metadata — authenticated

List all snapshots for the authenticated user, metadata only — no ciphertext.

Response (UserMetadataOut): {"snapshots": [SnapshotMetadataOut, ...]}.

This is what the dashboard reads.

POST /proof-bundle — authenticated

Assemble and return the signed zip bundle for the authenticated user.

Request (ProofBundleIn):

{
  "author_name": "...",
  "project_name": "...",
  "attestation_text": "...",
  "reveals": {
    "<ciphertext_ref>": "<reveal_key_hex>"
  }
}

reveals is the per-leaf reveal-key map the client computes locally before requesting the bundle. The server embeds eligible entries (v2 snapshots belonging to this user) into bundle.json so a publisher can later run verify.py --manuscript .... Refs that aren't recognised — or that point at v1 snapshots — are dropped silently. Empty / omitted means a structurally-valid bundle that does not support manuscript-match.

Response: a Content-Type: application/zip body. The four files inside are described in Proof bundle format.

On first call, the user's authorship attestation text is persisted — subsequent bundles read that stored text to keep bundles stable and comparable.

Authentication

BearerAuth (in backend/api/api.py) looks up the token against the AuthToken model and attaches the user to request.auth for handlers to use. Tokens are long-lived; there is no refresh flow. Rotating a token means calling /login again (the old token remains valid until manually revoked — no automatic expiry in the POC).

Error responses

django-ninja's default error envelope: HTTP status + {"detail": "..."}. The handlers don't customise this; wrong credentials are 401, missing resources are 404, validation errors are 422.

What the API deliberately does not expose

  • No plaintext-echoing endpoints. Nothing accepts, returns, or logs plaintext, file paths, file names, or titles in the clear. If you find a log statement that could leak plaintext, it's a bug.
  • No cross-user reads. Every authenticated handler scopes queries to request.auth.
  • No aggregate analytics endpoints. The dashboard reads per-user data from /user/metadata only.