Reference

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

Interactive

Cipher 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.

Each app defines its own metadata schema and posts under its own namespace in the manifest. One identity, many clients.

Audience modes

Interactive

Every 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.

LayerResponsibilityKey artifacts
ClientApp-specific metadata and namespacing (cipherframe, drive)manifest.clients["<client>"]
ManifestPost index and envelope pointersmanifest.json
Social GraphEncrypted followers / following listsfollowing_cid, followers_cid
Content EncryptionPer-post symmetric key + per-recipient envelopespost sym_key, envelope hex
IdentityKeypairs and UIDsML-KEM-768 + ML-DSA-65 keypairs, uid
DiscoveryPermanent, location-independent addressesIPNS under the IPFS Peer ID
Cryptographic PrimitivesThe mathML-KEM-768, ML-DSA-65, NaCl SecretBox, BLAKE2b
TransportContent storage and the station APIIPFS + the HTTPS station API

A few load-bearing facts about this stack:

  • One identity, many clients. A single station hosts many client namespaces. cipherframe photos and drive files live side by side under the same identity, the same keys, and the same follower graph — isolated only by their key in the manifest's clients map.
  • 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.

SchemeRolePublicSecretCiphertext / shared secret
ML-KEM-768content envelopes (KEM-DEM)1184 B (ek)2400 B (dk)1088 B ct → 32 B shared secret
ML-DSA-65device-auth signatures1952 B4032 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:

  1. Generate a fresh 32-byte symmetric key per post (nacl.utils.random(SecretBox.KEY_SIZE)).
  2. SecretBox-encrypt the file bytes and upload the ciphertext to IPFS → post_cid.
  3. 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_uids is present only when audience_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, count 0) and is uploaded to IPFS unencrypted.
  • Here metadata, when present, is a plaintext dictnot a hex blob. Same field name, opposite handling. The encrypted: false flag 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.

ModeWho can readEncrypted?Envelopes
selfonly youyesself-envelope only
specificyou + the listed follower UIDsyesself + each listed UID
allyou + every Allowed followeryesself + each Allowed UID
publicanyonenonone

Implementation notes that follow from the facts:

  • The encrypted modes (self, specific, all) flow through one manifest path; public flows through a separate path — the encrypted path rejects public with a ValueError. 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 public is 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". Pending followers 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 random uuid4 hex, 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 to Allowed and grants envelopes for every all-audience post. It is a full access-control bypass — anyone who can reach the station reads your all content 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_delegate authenticates any Allowed device of any UID the station knows — including a follower's own delegate device. Only /inbox (for non-follow-request messages) uses this bare.
  • require_owner layers a station-ownership check on top: the authenticated uid must equal the station's own UID, else 403 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.