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_b64is the file path encrypted with the client'senc_key, so even path names stay private.ciphertext_refis 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/metadataonly.