The Protocol
Reference-grade walkthrough of Cipher Station's layered architecture (transport/IPFS up to the client layer), the client-namespaced manifest schema with exact JSON shapes for encrypted and public posts, the four audience modes, the encrypted social graph with its device-level decoder envelopes, and the unauthenticated-inbox plus owner-approval follow model.
The layered model
InteractiveCipher Station is built in layers, from raw IPFS transport up to the client apps. Expand each one to see what it owns. Because higher layers depend only on the contracts below them, many different clients can share one identity and social graph.
Audience modes
InteractiveEvery post declares who is allowed to read it. Switch between the four modes to see who receives an envelope, whether the content is encrypted at all, and how the resulting manifest entry changes.
Broadcast to everyone you've approved. New followers are re-wrapped in on approval.
- Who can read
- You + all allowed followers
- Encrypted
- Yes
- Envelopes
- 1 per allowed follower + you
{ "post_cid": "Qm…", "audience_mode": "all", "encrypted": true }The protocol: architecture & data model
Cipher Station is a protocol, not an app. A personal station — a small process on a Raspberry Pi or any Linux/macOS box — publishes encrypted content to IPFS and grants followers access through post-quantum cryptographic envelopes. There are no central servers. Everything below describes the wire-level contract that any client (the photo app cipherframe, the file app drive, or one you write yourself) must follow — source: github.com/CharliePetch/cipher-station.
The layered architecture
Cipher Station is built in layers. Each one depends only on the layer beneath it, so a client never touches raw crypto and the crypto layer never knows what a "post" is.
| Layer | Responsibility | Key artifacts |
|---|---|---|
| Client | App-specific metadata and namespacing (cipherframe, drive) | manifest.clients["<client>"] |
| Manifest | Post index and envelope pointers | manifest.json |
| Social Graph | Encrypted followers / following lists | following_cid, followers_cid |
| Content Encryption | Per-post symmetric key + per-recipient envelopes | post sym_key, envelope hex |
| Identity | Keypairs and UIDs | ML-KEM-768 + ML-DSA-65 keypairs, uid |
| Discovery | Permanent, location-independent addresses | IPNS under the IPFS Peer ID |
| Cryptographic Primitives | The math | ML-KEM-768, ML-DSA-65, NaCl SecretBox, BLAKE2b |
| Transport | Content storage and the station API | IPFS + the HTTPS station API |
A few load-bearing facts about this stack:
- One identity, many clients. A single station hosts many client namespaces.
cipherframephotos anddrivefiles live side by side under the same identity, the same keys, and the same follower graph — isolated only by their key in the manifest'sclientsmap. - Identity is permanent via IPNS. Your IPFS Peer ID never changes, and discovery/failover (how a client finds a station whose endpoint moved) is covered on the Storage & Discovery page.
- No classical crypto anywhere in the stack — see the Cryptography page for the full ML-KEM-768/ML-DSA-65 picture.
Algorithm parameters
These sizes are fixed by the FIPS standards and are part of the wire contract — keys and ciphertexts are exchanged as lowercase hex strings.
| Scheme | Role | Public | Secret | Ciphertext / shared secret |
|---|---|---|---|---|
| ML-KEM-768 | content envelopes (KEM-DEM) | 1184 B (ek) | 2400 B (dk) | 1088 B ct → 32 B shared secret |
| ML-DSA-65 | device-auth signatures | 1952 B | 4032 B | — |
A recipient "public key" in an envelope context is therefore an ML-KEM-768 encapsulation key as a 2368-character hex string (1184 × 2); an ML-DSA-65 public key is 3904 hex characters.
The PQC math is a pluggable backend (CIPHER_PQC_BACKEND) — see Cryptography: two backends for the liboqs-vs-pure-Python tradeoff.
The content encryption layer
Before the manifest makes sense, you need the encryption flow it points at. Every encrypted post follows the same recipe:
- Generate a fresh 32-byte symmetric key per post (
nacl.utils.random(SecretBox.KEY_SIZE)). - SecretBox-encrypt the file bytes and upload the ciphertext to IPFS →
post_cid. - For each recipient, wrap that symmetric key into an ML-KEM envelope keyed to the recipient's ML-KEM-768 public key.
The envelope is a KEM-DEM construction (ML-KEM can't encrypt a chosen plaintext directly) — the exact wire format and derivation are on the Cryptography page.
Two structural rules matter for readers and re-sharers:
- Envelopes are published as a separate IPFS object, not inlined in the manifest. The manifest stores a pointer (
envelopes_cid) and a count. The envelopes file is keyed by UID — one envelope per UID (see the schema below). - Every post carries a forced "self" envelope sealed to the station's own ML-KEM key, inserted at index 0. This is the recovery root: it lets the station later reopen the symmetric key to reshare or rewrap a post. If a post needs to be re-targeted to a new audience, the station opens its own self-envelope to recover the key, then re-wraps it for the new recipients.
The manifest schema
The manifest is the station's published index of posts. It lives on IPFS; public.json carries a manifest_pointer to its current CID, and /profile exposes that pointer as manifest_cid. Every time you post, the manifest is rebuilt and its pointer republished to IPNS.
The shape is namespaced by client:
{
"clients": {
"cipherframe": { "posts": [ /* … entries … */ ] },
"drive": { "posts": [ /* … entries … */ ] }
}
}
There are two kinds of post entry, and a reader must branch on audience_mode / encrypted to interpret them correctly — especially the overloaded metadata field.
Encrypted post entry (audience_mode is self, specific, or all):
{
"post_cid": "Qm…",
"audience_mode": "all",
"envelopes_cid": "Qm…",
"envelopes_count": 12,
"audience_uids": ["uid-a", "uid-b"],
"metadata": "8f3c…"
}
audience_uidsis present only whenaudience_mode == "specific".metadata, when present, is a hex SecretBox blob — encrypted metadata that opens with the post's symmetric key.
Public post entry (audience_mode == "public"):
{
"post_cid": "Qm…",
"audience_mode": "public",
"encrypted": false,
"envelopes_cid": null,
"envelopes_count": 0,
"metadata": { "title": "…", "tags": ["…"] }
}
- A public post has no envelopes (
envelopes_cid: null, count0) and is uploaded to IPFS unencrypted. - Here
metadata, when present, is a plaintext dict — not a hex blob. Same field name, opposite handling. Theencrypted: falseflag is your signal.
Public is permanent. A public post is world-readable forever. IPFS has no delete, and anyone who learns the CID — including via your public IPNS manifest — can fetch it. Only publish what you mean to share with the world.
The per-post envelopes object
The object that envelopes_cid points at is keyed by UID, one sealed key per UID:
{
"v": 1,
"post_cid": "Qm…",
"envelopes": {
"uid-self": "…hex envelope…",
"uid-alice": "…hex envelope…",
"uid-bob": "…hex envelope…"
}
}
Because the map is keyed by UID, followers are deduplicated to one row per UID before envelopes are built — passing multiple devices for the same UID would simply overwrite. (The social graph's decoder envelopes, below, do the opposite — keep careful track of which is which.)
Audience modes
Every post declares exactly one audience_mode. The type is Literal["self", "specific", "all", "public"]; the legacy alias all_followers normalizes to all.
| Mode | Who can read | Encrypted? | Envelopes |
|---|---|---|---|
self | only you | yes | self-envelope only |
specific | you + the listed follower UIDs | yes | self + each listed UID |
all | you + every Allowed follower | yes | self + each Allowed UID |
public | anyone | no | none |
Implementation notes that follow from the facts:
- The encrypted modes (
self,specific,all) flow through one manifest path;publicflows through a separate path — the encrypted path rejectspublicwith aValueError. A client decides on the mode before the manifest write. - Resharing works for encrypted modes (the station recovers the key via its self-envelope), but resharing to or from
publicis unsupported — a public post has no recoverable symmetric key.
The encrypted social graph
Your follower and following lists are themselves encrypted before they touch IPFS. On each rebuild, the station generates one ephemeral SecretBox key and uses it for both graphs:
// following graph (encrypted to the ephemeral key, then added to IPFS → following_cid)
{ "version": 1, "updated_at": "<uuid4 hex>", "following": [ /* … */ ] }
// followers graph (→ followers_cid) — Allowed followers only
{ "version": 1, "updated_at": "<uuid4 hex>", "followers": [ /* … */ ] }
Two important details:
- The followers graph contains only followers with
allowed == "Allowed".Pendingfollowers appear in neither the graph nor any content envelope, so they receive nothing until approved. - Each rebuild mints a brand-new ephemeral key and a fresh
updated_at(a randomuuid4hex, not a real timestamp), so the CIDs change on every rebuild.
To let Allowed followers decrypt the graph, the station wraps that same ephemeral key for each of them in a decoder-envelopes object. Note the shape difference from post envelopes — this one is device-level, a list per UID:
{
"version": 1,
"envelopes": {
"uid-alice": [
{ "device_uid": "alice-phone", "envelope": "…hex…" },
{ "device_uid": "alice-laptop", "envelope": "…hex…" }
],
"uid-bob": [
{ "device_uid": "bob-root", "envelope": "…hex…" }
]
}
}
The station writes following_cid, followers_cid, and follow_decoder_envelopes_cid into public.json and republishes it to IPNS.
One trap worth flagging: the IPFS copies of the graph are encrypted, but the rebuild also writes plaintext copies to disk (
following.json,followers.json,follow_envelopes.json) for local inspection. Anyone with filesystem access to the station reads the cleartext graph — protect the box.
The inbox and the owner-approval model
Follows are fail-closed. A stranger can ask to follow you, but they get nothing until you, the owner, explicitly approve.
How a follow request arrives
A follow request is delivered as a POST /inbox message of type: "follow_request", and it is unauthenticated by design — a new follower has no relationship with you yet, so there is no key to sign with. (Every other inbox message type requires a valid ML-DSA device signature.)
When a follow request lands, each device is registered with allowed = "Pending" and granted no content access. The response is follow_request_pending (or follow_request_pending_multi_device). Two guards protect this open door:
- A follow request can never target the station's own UID (blocks an attacker registering as a device under your identity).
- A re-request for an already-registered device UID is a no-op — key rotation must go through owner re-approval, not an unauthenticated message.
Owner approval is the only path to access
POST /followers/approve is the only place a follower becomes Allowed. Approval triggers a rewrap of existing posts for the newly-allowed follower and a graph rebuild, so access takes effect. The mirror operations — /followers/pending to list waiting devices, /followers/remove to reject or revoke — round out the model. Removing an Allowed follower rewraps and rebuilds; but already-published content the follower already had keys for cannot be recalled.
Dev-only bypass.
CIPHER_DEV_AUTO_ACCEPT_FOLLOWS(default off) immediately promotes any unauthenticated follow request toAllowedand grants envelopes for everyall-audience post. It is a full access-control bypass — anyone who can reach the station reads yourallcontent with no approval. It logs a startup warning and nothing else blocks it. Never enable it in production.
Owner vs. delegate: the privilege boundary
Authentication has two tiers, and the difference is a real security boundary:
require_delegateauthenticates anyAlloweddevice of any UID the station knows — including a follower's own delegate device. Only/inbox(for non-follow-request messages) uses this bare.require_ownerlayers a station-ownership check on top: the authenticateduidmust equal the station's own UID, else403 owner-only endpoint. Every management endpoint — posting, deleting, sharing, following, dumping the follower list — uses this. It is what stops a mere follower from acting as the owner.
What matters for the data model: owner-scoped writes are cryptographically bound to the station's own identity — signing mechanics are on the API reference.