Reference

API Reference

Complete reference for every Cipher Station station endpoint, grouped by auth level (public, device-signed, owner-only), plus the 6-header signed-request scheme, the canonical string, and replay protections.

Endpoint catalog

Interactive

Every station endpoint, grouped by how it's authenticated: public, device-signed, or owner-only. Filter by auth level and expand any row for its purpose. The owner-only set is precisely the surface a paired client like CipherVault drives.

16 endpoints

API reference

Every Cipher Station station speaks the same HTTP API — under twenty endpoints, each at a precise privilege level. This is the complete reference: what each endpoint does, what it expects, and how the signed-request scheme protecting most of them works.

Two things to know up front: TLS is self-signed (port 8443; authentication rides on per-request post-quantum signatures, not on TLS), and discovery is out-of-band — followers find a station via its permanent IPNS peer ID, then read /profile (see Storage & Discovery).

Auth levels at a glance

LevelWhat it meansDependency
PublicNo signature. Anyone who can reach the station.
Device-signedA valid ML-DSA-65 signature from an Allowed device of any uid the station knows.require_delegate
Owner-onlyDevice-signed and the authenticated uid equals the station's own uid.require_owner

require_owner is layered directly on top of require_delegate: it runs the full signature check, then additionally asserts ctx["uid"] == get_identity().uid, returning 403 owner-only endpoint otherwise. This is what stops a mere follower's Allowed delegate device from posting, deleting, or dumping your follower list as if it were you.


Public endpoints

No authentication. These exist so strangers, monitors, and brand-new unpaired devices can reach the station.

MethodPathPurpose
GET/healthLiveness/readiness probe.
GET/storageAggregate storage report.
GET/profileThe station's public identity document.
POST/inboxInbound messages — but only follow_request is public (see below).
POST/delegate/startBegin device pairing.
POST/delegate/confirmComplete device pairing with a PIN.

GET /health

Runs three sub-checks — database (SELECT 1), ipfs (ipfs_add_bytes(b"health")), and identity (get_identity()) — and reports each independently. Returns 200 if all three pass, 503 otherwise.

{
  "status": "healthy",
  "checks": { "database": true, "ipfs": true, "identity": true }
}

GET /storage

An aggregate report; each section degrades to {"error": "..."} independently rather than failing the whole request.

{
  "ipfs": { "repo_size_bytes": 0, "storage_max_bytes": 0, "num_objects": 0, "used_percent": 0 },
  "device": { "total_bytes": 0, "used_bytes": 0, "free_bytes": 0, "used_percent": 0 },
  "clients": { "<client_key>": { "size_bytes": 0, "post_count": 0, "percent_of_repo": 0 } },
  "total_content_bytes": 0
}

GET /profile

The public identity document — the root of follower discovery. It carries the station's two public keys, its network location, its permanent IPNS peer id, and a pointer to the current manifest.

{
  "uid": "…",
  "mlkem_public_key": "…",   // ML-KEM-768 (content), 2368 hex chars
  "mldsa_public_key": "…",   // ML-DSA-65 (auth), 3904 hex chars
  "endpoint": "https://….trycloudflare.com",
  "ipfs_peer_id": "…",
  "manifest_cid": "…",        // = manifest_pointer
  "manifest_posts": 0,
  "manifest_posts_by_client": { "…": 0 }
}

POST /inbox — conditional auth

/inbox is the one endpoint whose auth requirement depends on the message body. A follow_request is public — this is the open-follow path, so a stranger can ask to follow you. Every other message type calls require_delegate and must carry a valid device signature.

@app.post("/inbox")
async def inbox(request: Request, msg: InboxMessage):
    if msg.type != "follow_request":
        await require_delegate(request)
    ...

A follow request lands fail-closed: each device is registered as Pending with no content access until the owner explicitly approves it via POST /followers/approve. Body fields: type (required), plus optional uid, mlkem_public_key, mldsa_public_key, payload_hex, devices (list), ipns_id. /inbox is one of the guarded paths, so its body SHA-256 is captured and verified when the request is signed.

POST /delegate/start and POST /delegate/confirm

These are fully public because they're how a brand-new device — one that has no keys the station trusts yet — bootstraps. Security rests on an out-of-band 6-digit PIN (logged server-side, 5-minute expiry) plus throttling, not on the header scheme.

POST /delegate/start
  { "device_uid": "…", "mlkem_public_key": "…", "mldsa_public_key": "…" }
  → { "status": "ok", "pairing_id": "…", "expires_in_seconds": 300 }

POST /delegate/confirm
  { "pairing_id": "…", "pin": "123456" }
  → { "status": "ok", "action": "delegate_added", "uid": "…", "device_uid": "…" }

A throttled pairing returns 429; a bad or expired PIN returns 400. On confirm, the station registers the new device as an owner delegate with allowed="Allowed" and returns the canonical device_uid — clients must sign later requests with that value, not the one they generated locally.


Device-signed endpoint

Authenticated with require_delegate alone — any Allowed device of any uid the station knows. Exactly one endpoint sits here:

MethodPathPurpose
POST/inboxNon-follow_request message types (signed).

Everything else that requires a signature is owner-only. That's deliberate: require_delegate authenticates a follower's own delegate too, so using it on a management endpoint would let a follower act as the owner.


Owner-only endpoints

require_owner — device-signed and the authenticated uid must be the station's own. This is every station-management operation.

MethodPathPurpose
POST/postCreate a new post (multipart upload).
POST/post/deleteRemove a post from the manifest.
POST/post/shareReshare / re-target a post to a new audience.
POST/rewrapRe-wrap a post's envelopes for current allowed followers.
POST/followFollow a remote user.
POST/unfollowUnfollow a user.
GET/followersList allowed followers (deduped to user level).
GET/followers/pendingList devices awaiting approval.
POST/followers/approveApprove a pending follower.
POST/followers/removeReject or revoke a follower.

POST /post

multipart/form-data. The only fields are file (required) plus optional metadata (JSON string), client, audience_mode (default all), and audience_uids.

  • audience_uids accepts either a JSON-array string or a comma-separated string.
  • metadata that fails to parse is silently dropped (logged as a warning) — you get a post with no metadata rather than a 4xx.

POST /post/delete

Body: post_cid (required), client (optional). A not_found result maps to 404. Note this only removes the manifest entry — IPFS content itself is not recallable.

POST /post/share

Body: post_cid (required), audience_mode (default specific), audience_uids (list or null), client. Error mapping: ValueError → 400, not_found → 404, error → 500.

POST /rewrap

Re-encapsulates a post's symmetric key to the requesting device's ML-KEM public key on demand — the read path a client like CipherVault uses to open content. Beyond require_owner, it adds a header-vs-body identity binding:

@app.post("/rewrap")
def rewrap_route(msg: RewrapMessage, delegate=Depends(require_owner)):
    if delegate["uid"] != msg.uid or delegate["device_uid"] != msg.device_uid:
        raise HTTPException(status_code=401, detail="header/body mismatch")

Body: uid (required), device_uid (required), post_cid (required), envelopes_cid (optional).

POST /follow and POST /unfollow

/follow body: uid, endpoint, mlkem_public_key (all required), plus optional mldsa_public_key (for read-only targets) and ipns_id. /unfollow body: just uid. Both rebuild the encrypted social graph and reload the profile, returning the updated graph CIDs.

Follower management

GET /followers and GET /followers/pending take no body. POST /followers/approve is the only place a follower becomes Allowed; omit device_uid to approve all of a uid's devices. POST /followers/remove rejects or revokes — and only re-wraps + rebuilds the graph if an Allowed follower was actually removed.

Approve and remove call rewrap_all_posts and rebuild_graphs_and_envelopes as side effects. A 200 does not guarantee the rewrap succeeded — inspect the rewrap sub-object in the response, which may be {"error": "..."}.


The signed-request scheme

Every authenticated request carries six headers. All six are required — a missing one yields a FastAPI 422 (not a 401), so treat 422 on these routes as "malformed or missing auth headers."

HeaderContents
x-cipher-uidClaimed user id.
x-cipher-deviceDevice uid.
x-cipher-tsUnix seconds (integer).
x-cipher-nonceUnique-per-request token.
x-cipher-body-sha256Lowercase-hex SHA-256 of the exact request body.
x-cipher-sigBase64 ML-DSA-65 signature over the canonical string.

The canonical string

The client signs exactly seven fields, newline-joined, UTF-8 encoded. It must match the server byte-for-byte — METHOD is uppercased, PATH is the request path only (no host, no query string), and BODY_SHA256 is lowercase:

METHOD
PATH
UID
DEVICE_UID
TS
NONCE
BODY_SHA256
def _canonical(method, path, uid, device_uid, ts, nonce, body_sha256) -> bytes:
    s = "\n".join([method.upper(), path, uid, device_uid, ts, nonce, body_sha256])
    return s.encode("utf-8")

BODY_SHA256 is computed over the actual serialized bytes on the wire. For a multipart POST /post that's the hash of the finalized multipart body (with its boundary), not the file. For a GET it's the SHA-256 of an empty body. Any divergence — wrong field order, a trailing newline, a query string in PATH, uppercase hex — makes every signature reject.

Verification order

require_delegate checks, in order, and stops at the first failure:

  1. Timestamp window. abs(now - ts) > 60401 stale request. The window is symmetric, so a client clock skewed more than 60 s in either direction fails. A non-integer ts → 401 bad timestamp.
  2. Device authorization. The (uid, device_uid) must be a known device with allowed == "Allowed" and a non-empty ML-DSA public key → else 403.
  3. Replay. The nonce is looked up in auth_nonces before recording → a repeat is 401 replay (nonces are retained 24h, NONCE_TTL_SECONDS = 86400).
  4. Body hash. Compared with hmac.compare_digest against the middleware-captured hash (constant-time), if one was captured.
  5. Signature. The ML-DSA public key is hex-decoded, length-checked against MLDSA_PUBLIC_BYTES (1952), and pqcrypto.verify runs over the canonical message → else 401 bad auth.
  6. Remember the nonce — only after a fully successful verification, so failed attempts never burn a nonce.

Body-hash caveat — only guarded paths are verified

The body SHA-256 is captured and compared only on the guarded paths: /post, /post/delete, /post/share, /rewrap, /follow, /unfollow, /inbox. For the other signed routes (GET /followers, GET /followers/pending, POST /followers/approve, POST /followers/remove), request.state.raw_body_sha256 is never set, so the comparison in step 4 is skipped (if cached_sha is not None). The x-cipher-body-sha256 value is still bound into the signed canonical string — so it's authenticated — but the server doesn't independently re-derive it from the body on those routes ("assumes TLS / trusted path"). The guarded paths also enforce a 413 size limit before reading the body.


A complete signed request

Putting it together — an owner deleting a post. The client builds the canonical string, signs it with the device's ML-DSA-65 secret key, base64-encodes the signature, and sends the six headers alongside the JSON body whose lowercase-hex SHA-256 is in x-cipher-body-sha256.

POST /post/delete HTTP/1.1
Host: cipherstation
Content-Type: application/json
x-cipher-uid: 7f3c…             # station uid
x-cipher-device: iPhone-a1b2c3d4
x-cipher-ts: 1750118400
x-cipher-nonce: 9f2a…           # 16 random bytes, hex
x-cipher-body-sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-cipher-sig: bXktbWxkc2Etc2ln…   # base64 ML-DSA-65 signature

{"post_cid":"bafy…","client":"drive"}

The signature covers POST\n/post/delete\n7f3c…\niPhone-a1b2c3d4\n1750118400\n9f2a…\ne3b0c4….

Repo: github.com/CharliePetch/cipher-station