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
InteractiveEvery 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.
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
| Level | What it means | Dependency |
|---|---|---|
| Public | No signature. Anyone who can reach the station. | — |
| Device-signed | A valid ML-DSA-65 signature from an Allowed device of any uid the station knows. | require_delegate |
| Owner-only | Device-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.
| Method | Path | Purpose |
|---|---|---|
GET | /health | Liveness/readiness probe. |
GET | /storage | Aggregate storage report. |
GET | /profile | The station's public identity document. |
POST | /inbox | Inbound messages — but only follow_request is public (see below). |
POST | /delegate/start | Begin device pairing. |
POST | /delegate/confirm | Complete 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:
| Method | Path | Purpose |
|---|---|---|
POST | /inbox | Non-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.
| Method | Path | Purpose |
|---|---|---|
POST | /post | Create a new post (multipart upload). |
POST | /post/delete | Remove a post from the manifest. |
POST | /post/share | Reshare / re-target a post to a new audience. |
POST | /rewrap | Re-wrap a post's envelopes for current allowed followers. |
POST | /follow | Follow a remote user. |
POST | /unfollow | Unfollow a user. |
GET | /followers | List allowed followers (deduped to user level). |
GET | /followers/pending | List devices awaiting approval. |
POST | /followers/approve | Approve a pending follower. |
POST | /followers/remove | Reject 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_uidsaccepts either a JSON-array string or a comma-separated string.metadatathat fails to parse is silently dropped (logged as a warning) — you get a post with no metadata rather than a4xx.
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_postsandrebuild_graphs_and_envelopesas side effects. A200does not guarantee the rewrap succeeded — inspect therewrapsub-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."
| Header | Contents |
|---|---|
x-cipher-uid | Claimed user id. |
x-cipher-device | Device uid. |
x-cipher-ts | Unix seconds (integer). |
x-cipher-nonce | Unique-per-request token. |
x-cipher-body-sha256 | Lowercase-hex SHA-256 of the exact request body. |
x-cipher-sig | Base64 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:
- Timestamp window.
abs(now - ts) > 60→401 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. - Device authorization. The
(uid, device_uid)must be a known device withallowed == "Allowed"and a non-empty ML-DSA public key → else403. - Replay. The nonce is looked up in
auth_noncesbefore recording → a repeat is401 replay(nonces are retained 24h,NONCE_TTL_SECONDS = 86400). - Body hash. Compared with
hmac.compare_digestagainst the middleware-captured hash (constant-time), if one was captured. - Signature. The ML-DSA public key is hex-decoded, length-checked against
MLDSA_PUBLIC_BYTES(1952), andpqcrypto.verifyruns over the canonical message → else401 bad auth. - 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….