Storage & Discovery
How Cipher Station stores content as encrypted, content-addressed blobs on IPFS and how stations stay findable through a permanent IPNS Peer ID, with the realities of public gateway reachability for self-hosters.
What is IPFS?
The InterPlanetary File System is an open, peer-to-peer protocol for storing and sharing data — the backbone Cipher Station is built on. Instead of fetching a file from a specific server (a location), you fetch it by its content: a cryptographic hash called a CID. Any peer that has those bytes can serve them, and you can verify the result is exactly what you asked for. Cipher Station stores every post on IPFS as ciphertext and uses IPNS to give your station a permanent address.
Content addressing
You ask for data by what it is (a CID — a hash of the content), not where it lives. Identical bytes always have the identical address, and you can verify you got exactly what you asked for.
Peer-to-peer
There's no central server. Any peer that has a block can serve it. Your station and your followers participate in the same global network directly.
Pinning
Nodes keep (“pin”) the content they care about so it stays available. Your station pins your posts; garbage collection reclaims the rest.
Gateways
An HTTP bridge into IPFS. Any gateway can fetch a CID over plain https, which is how a phone or browser reads content without running a full node.
Walk the discovery chain
InteractiveA follower only ever needs your permanent Peer ID. Press resolve to watch it become readable content — Peer ID → IPNS → CID → public.json → manifest → posts. Then toggle “my tunnel URL just changed” and watch only the endpoint move while your Peer ID, manifest, and post CIDs stay byte-identical.
Press Resolve to walk the chain that lets followers always find you — even if your IP or tunnel URL changes.
Endpoint failover ladder
InteractiveThis is exactly how CipherVault heals itself. Kill the tunnel (or the DHT) and hit resolve: the client walks down the three discovery rungs — direct endpoint, IPNS via DHT, public gateway — re-resolving your Peer ID until one answers. Your identity is the constant the whole way down.
Your Peer ID is the constant the whole way down — that's what makes recovery possible.
Public vs. encrypted on IPFS
InteractiveAnyone who learns a CID can fetch it — so what do they actually get? Fetch each post as a stranger: the encrypted post hands back opaque ciphertext (no key, no read), while the public post hands back the real file and plaintext metadata. And remember — IPFS has no delete.
- post_cid
- QmVkN4T4…ExAiGKMp
- envelopes_cid
- QmWY5DnK…Ubt2YzJw
- envelopes_count
- 4
- metadata
- 9796420956…(hex)
- post_cid
- QmZxWTN1…CuC38F4d8
- encrypted
- false
- envelopes
- — none —
- metadata
- { "name": "profile.jpg" }
Storage & discovery: IPFS and IPNS
Cipher Station splits a hard problem in two. Storage answers "where do the bytes live?" — and the answer is IPFS: every post, every envelope, every social graph is a content-addressed blob. Discovery answers "how does a follower find your station?" — and the answer is IPNS: a single permanent Peer ID that always resolves to your latest public data, even after your IP, ISP, or tunnel URL changes underneath it.
Content addressing: a CID is a fingerprint
When a station adds bytes to IPFS, it gets back a CID (content identifier). The CID is derived from the content itself, so it doubles as an integrity check: fetch a CID, hash what you got, and you know you received exactly what was published. Change one byte and the CID changes.
This is why Cipher Station uploads ciphertext, never plaintext. A post's file bytes are sealed with a fresh 32-byte NaCl SecretBox key (XSalsa20-Poly1305) before they touch IPFS, and the encrypted blob is what gets the CID:
# cipher_station/posts.py — one fresh symmetric key per post
sym_key = nacl_random(SecretBox.KEY_SIZE) # 32 bytes
box = SecretBox(sym_key)
encrypted_blob = box.encrypt(file_bytes) # encrypt first...
cid = ipfs_add_bytes(encrypted_blob) # ...then content-address
A CID on a public IPFS network is world-fetchable to anyone who learns it — but for a Cipher Station post, "fetching the blob" gets you ciphertext. The key to open it travels separately, wrapped per-recipient in a post-quantum envelope (see Content encryption). The CID is public; the plaintext is not.
The one exception: public posts are uploaded unencrypted and are world-readable forever — see audience modes.
What Cipher Station actually stores on IPFS
A station's content is not one big file. It is a small tree of content-addressed objects, each independently fetchable by CID:
| Object | What it is | Encrypted? |
|---|---|---|
| Post blob | The sealed file bytes (post_cid) | Yes (per-post SecretBox key) |
| Envelopes file | Per-recipient wrapped keys, keyed by UID (envelopes_cid) | Keys are ML-KEM-wrapped |
| Manifest | The post index: clients → namespace → posts[] | Plaintext index of CIDs |
| Following / followers graph | Your social graph | Yes (ephemeral SecretBox key) |
public.json | Your public identity + pointers | Plaintext (public by design) |
The manifest is itself just bytes with a CID. So is public.json. Discovery is the act of resolving one stable name down to the current manifest CID, then fetching the tree below it.
The discovery chain: from a permanent name to your latest post
Every station publishes public.json to IPNS under its IPFS Peer ID. The Peer ID is the root of trust for discovery and it never changes for the life of the station. IPNS lets that one fixed name point at a mutable target: each time your content changes, the station republishes public.json to IPNS, and the name now resolves to the new version.
The chain a client walks:
Peer ID → IPNS → /ipfs/<CID> → public.json → manifest_pointer → manifest → posts
(forever) (latest) (this version) (your identity) (current index)
public.json is the hinge. The /profile endpoint surfaces it directly, including the current manifest pointer as manifest_cid:
# cipher_station/profile.py — get_public_profile()
return {
"uid": uid,
"mlkem_public_key": ..., # ML-KEM-768, for content envelopes
"mldsa_public_key": ..., # ML-DSA-65, for auth signatures
"endpoint": ..., # current direct HTTP URL (may change)
"ipfs_peer_id": ..., # PERMANENT — your IPNS name
"manifest_cid": manifest_pointer,
"manifest_posts": total,
}
Notice both an endpoint (which can change) and an ipfs_peer_id (which cannot). Self-hosted stations live behind ephemeral infrastructure — a Cloudflare Quick Tunnel hands you a fresh *.trycloudflare.com URL on every restart, home IPs rotate — so followers never bookmark a URL. They bookmark your Peer ID, and clients resolve location at fetch time using a priority ladder:
| Rung | When | Address |
|---|---|---|
| 1. Direct endpoint | Fast path, usually up | HTTP URL from the social graph |
| 2. IPNS via the DHT | Endpoint down | Resolve Peer ID → fresh public.json → new endpoint |
| 3. Public gateway | Last resort | https://ipfs.io/ipns/<peer-id> |
This is exactly how the CipherVault client self-heals. When it sees connection/DNS errors or tunnel 5xx (502/503/520–530), it resolves IPNS using the Peer ID it stored at pairing time, pulls the new public.json, verifies the fresh endpoint with /health, updates its stored credentials, and retries the original request once:
tunnel URL dies (520) → resolve IPNS by stored ipfs_peer_id
→ read new endpoint from public.json
→ GET /health on it → update credentials → retry
Your IP moved, your tunnel got a new name, you reinstalled on new hardware after a USB restore — none of it matters to a follower, because the Peer ID stayed put and IPNS now points at wherever you actually are.
One subtlety for client authors:
public.jsonis mutated on disk after startup (the manifest pointer and endpoint get rewritten as you post). Always re-read it via IPNS//profileto get current values — don't cache a startup snapshot and assume it's still accurate.
Public gateway reachability: the self-hoster's realities
IPNS-over-DHT and the public gateway are your safety nets, but they are not instant or guaranteed. Plan around these facts:
- IPNS publishing is slow. Republishing
public.jsonto IPNS can take 60 seconds or more. The CipherVault client sets deliberately generous timeouts (request 120s, resource 300s) precisely because write operations block on IPNS publish. After you post, there is a propagation window before the new pointer is resolvable everywhere. - Public gateways are best-effort and shared.
https://ipfs.io/ipns/<peer-id>depends on a third-party gateway being healthy, having your content reachable, and resolving your IPNS record in time. It is the last rung of the ladder for a reason — treat it as a fallback for discovery, not your primary path.
(Tunnel vs. port-forwarding and firewall specifics are covered on Run a Station.)
The mental model: content is permanent and addressed by hash; your station is mobile and addressed by Peer ID. IPFS guarantees you can verify and re-fetch any blob you have a CID for. IPNS guarantees followers can always find the current set of CIDs, no matter where on the network your box has wandered.
Examples
Share the permanent IPNS address, never the tunnel URL
ipfs id -f='<id>' # your permanent Peer ID
# Followers discover you at: https://ipfs.io/ipns/<PeerID>