Fundamentals
The Cipher Station mental model: a personal Station encrypts each post under a fresh symmetric key, uploads the ciphertext to IPFS, and grants access by wrapping that key into a per-follower post-quantum envelope. Followers find you at your permanent Peer ID via IPNS, request to follow, and get content only after you approve. Defines Station, Identity/uid, Peer ID, manifest, envelope, and the four audience modes, and walks the publish and follow/approve flows step by step.
The publish flow
InteractiveFollow a single post from your device out to your followers. Each step here is a real stage of the protocol — so you can see exactly where the content is encrypted, where IPFS gives it an address, and where your IPNS pointer is updated.
1. Encrypt
A fresh 32-byte key encrypts the file with XSalsa20-Poly1305 before anything leaves your device.
blob = SecretBox(post_key).encrypt(file)The follow flow
InteractiveCipher Station is fail-closed: an inbound follow request grants nothing until you approve it. Walk a stranger through the request → pending → approved lifecycle and watch the exact moment access (and content envelopes) are granted.
A stranger wants to follow your station.
Fundamentals: how Cipher Station works
Cipher Station is a protocol, not an app. The whole system rests on one idea you can hold in your head:
Your Station encrypts content, uploads it to IPFS, and hands out access one follower at a time — as a tiny cryptographic envelope only that follower can open. People find you at a permanent address that never changes, even when your server moves.
Everything else — the manifest, the envelopes, IPNS discovery, the approval flow — is just the machinery that makes that sentence true. There are no central servers. Your Station is the only thing that holds your keys.
The six words you need
Before the flows, six definitions. Keep them nearby.
| Term | What it is |
|---|---|
| Station | Your personal server. A small Python (FastAPI) process plus a local IPFS node, running on a Raspberry Pi or any Linux/macOS box. It holds your secret keys, encrypts your posts, and is the only thing that can grant access. |
| Identity / uid | Your account. Generated once when the Station first boots: a fresh uuid4 string (the uid) plus two post-quantum keypairs. The uid is how every other Station refers to you. |
| Peer ID | Your IPFS node's permanent network address. It never changes — not when you switch ISPs, not when your tunnel URL rotates, not when you reinstall from a backup. This is the anchor of discovery. |
| Manifest | The public index of your posts. A JSON document, namespaced per client (cipherframe, drive, …), listing each post's content address and a pointer to its envelopes. It is public, but it only contains ciphertext addresses — never keys. |
| Envelope | A per-recipient wrapper around one post's key. It seals the 32-byte symmetric key for exactly one follower, using their public key. Only the matching secret key opens it. |
| Audience mode | Who a post is for: self, specific, all, or public. This decides who gets an envelope (and, for public, whether anything is encrypted at all). |
Two keypairs, two jobs
Your Identity holds two post-quantum keypairs, and they do different work. Don't mix them up.
- ML-KEM-768 (FIPS 203) — the content key. It wraps and unwraps the keys that encrypt your posts. Your followers' ML-KEM public keys are what you seal envelopes to.
- ML-DSA-65 (FIPS 204) — the auth key. Each device signs its API requests with this. It proves "this request really came from an authorized device," and is never used to encrypt content.
Both are quantum-resistant, and Cipher Station uses no classical X25519 anywhere — the goal is to resist "harvest now, decrypt later" attacks where an adversary records your ciphertext today and breaks it on a future quantum computer. (Exact key/ciphertext sizes are on the Protocol page.)
The publish flow
You drop a photo into a client app and choose an audience. Here is everything your Station does before it goes quiet again.
1. Generate a fresh key. Every post gets its own brand-new 32-byte symmetric key — nacl.utils.random(SecretBox.KEY_SIZE). No key reuse across posts.
2. Encrypt the content. The file bytes are sealed with NaCl SecretBox (XSalsa20-Poly1305, a 256-bit key). This symmetric layer is already quantum-resistant on its own.
3. Upload the ciphertext to IPFS. IPFS stores the encrypted blob and returns a CID (a content address — a hash of the bytes). That CID is the post's permanent handle. The plaintext never touches the network.
4. Wrap the key for each recipient. For every follower in the audience, the Station takes that post's symmetric key and seals it into an envelope using the follower's ML-KEM-768 public key. One envelope per follower.
5. Always seal a self envelope too. The Station inserts an envelope sealed to its own ML-KEM public key. This is the recovery root: it's how the Station can later reopen the post's key to reshare it to a new audience or re-wrap it for a newly approved follower. If you lose the Station's ML-KEM secret, encrypted posts can no longer be reshared or rewrapped — so it matters.
6. Publish the envelopes as a separate file. All the envelopes for a post are bundled into one JSON document, keyed by uid, and uploaded to IPFS as its own object, yielding an envelopes_cid. Envelopes are not stored inline in the manifest — see the shape in Examples below.
7. Record it in the manifest. Finally, an entry is appended to your manifest under the right client namespace. It carries the addresses, not the secrets:
{
"post_cid": "Qm…content",
"audience_mode": "all",
"envelopes_cid": "Qm…envelopes",
"envelopes_count": 12
}
Audience modes decide who gets an envelope
| Mode | Who can read | Encrypted? | Envelopes |
|---|---|---|---|
self | Only you (and your own delegate devices) | Yes | Sealed to your Station |
specific | You + the listed follower uids | Yes | One per listed uid |
all | You + every Allowed follower | Yes | One per Allowed follower |
public | Anyone | No | None — plaintext on IPFS |
A public post is genuinely different: the content is uploaded unencrypted, there are no envelopes, and the metadata is stored as plaintext. It is also permanent and world-readable — IPFS has no delete, so anyone who learns the CID (including via your public manifest) can fetch it forever. Reshares to or from public aren't supported, because a public post has no recoverable key.
How a follower finds you: IPNS and your Peer ID
Your Station might live behind an ephemeral Cloudflare tunnel whose URL rotates on every restart. So Cipher Station never asks followers to remember a URL. Instead, discovery hangs off your Peer ID, which is permanent.
The chain works top to bottom:
Peer ID (never changes)
│ resolve via IPNS
▼
/ipfs/<CID> ← latest snapshot of your public.json
│
▼
public.json ← endpoint, ipfs_peer_id, uid, manifest pointer
│ manifest_pointer
▼
manifest ← the per-client index of your posts
Your Station publishes public.json to IPNS under your Peer ID. IPNS is a mutable pointer: the Peer ID stays constant while the CID it points to updates every time you post or change followers. This is why you hand people your Peer ID, never your tunnel URL — see Storage & Discovery for how a client actually resolves it when your endpoint moves.
The follow / approve flow
Cipher Station is fail-closed: sending a follow request grants you nothing. Access only happens when the owner explicitly approves.
stranger ──POST /inbox (follow_request, unauthenticated)──▶ Station
(discovers you via Peer ID → public.json → /profile)
│
registers device as "Pending"
(no graph key, no content envelopes)
│
owner ──POST /followers/approve──────────────────────────────▶
flips to "Allowed",
rewraps posts + rebuilds graph
│
follower ◀── manifest + envelopes (now contains an envelope for their uid) ──
opens it with their secret key, decrypts
follow_request is the one unauthenticated inbox message type — a stranger has no keys you trust yet, so requiring a signature would be circular. Two guards protect the Pending step: a request can never target your own uid, and re-requesting an already-registered device is a no-op. POST /followers/approve is the only place a follower flips to Allowed, and it triggers two side effects at once: re-wrapping your all-audience posts (so the new follower gets envelopes for existing content) and rebuilding your encrypted social graph (so they receive the graph key).
CIPHER_DEV_AUTO_ACCEPT_FOLLOWSis a complete access-control bypass — dev-only, off by default, never enable it in production.
Inside an envelope (the KEM-DEM construction)
ML-KEM is a Key Encapsulation Mechanism — it produces a random shared secret, so it can't directly encrypt a chosen 32-byte post key. Cipher Station wraps it with the standard KEM-DEM pattern: encapsulate to get a shared secret, derive a wrap key from it with a domain-separated BLAKE2b, then SecretBox-encrypt the post key under that. Opening reverses the steps and is fail-soft — any error (bad hex, decapsulation failure, MAC mismatch) returns nothing rather than throwing, so one malformed envelope never breaks a whole listing. Both of Cipher Station's PQC backends (pure-Python and the hardened, constant-time liboqs) produce identical wire bytes, so you can switch without re-bootstrapping. Full derivation and wire format: Cryptography.
How a device proves itself
Once a device is Allowed, every write it makes is signed — a canonical seven-field string over ML-DSA-65, carried as six x-cipher-* headers, checked in order (timestamp, device status, replay, body hash, signature). Full scheme: API reference.
One subtlety worth knowing: signing in as an Allowed device (require_delegate) authenticates any Allowed device of any uid the Station knows — including a follower's own delegate. Owner-only actions (posting, deleting, dumping the follower list) use a stricter check (require_owner) that additionally demands the authenticated uid equals the Station's own uid. That boundary is what stops an approved follower from acting as you.
Examples
A post's manifest entry and its envelopes file (what's public)
// manifest.json → clients.cipherframe.posts[i]
{
"post_cid": "Qm…content", // address of the ENCRYPTED blob
"audience_mode": "all",
"envelopes_cid": "Qm…envelopes", // address of the file below
"envelopes_count": 12
}
// the separate envelopes file at envelopes_cid
{
"v": 1,
"post_cid": "Qm…content",
"envelopes": {
"<your-uid>": "…sealed-key-hex…", // the forced self-envelope
"<follower-uid>": "…sealed-key-hex…"
}
}
// Everything here is public, yet leaks nothing: only addresses and
// per-recipient sealed keys, each openable by exactly one secret key.