Build a Client
A practical guide to building a Cipher Station client, using the CipherVault iOS app as the worked example: generate the two post-quantum keypairs and pair a device with a PIN, sign every request with ML-DSA-65 over a canonical string, fetch and read a client-namespaced manifest, rewrap and open ML-KEM envelopes to recover post keys, publish a post, and keep your client's posts isolated from other apps sharing the same station.
Sign like a client
InteractiveBuilding a client means reproducing this signature exactly. Fill in a request and watch the canonical string and headers a real device would send — match these byte-for-byte and the station accepts your calls; get one field wrong and it returns 401.
The 7 fields joined by \n, signed with the device's ML-DSA-65 key. body-sha256 is computed live in your browser.
Build on Cipher Station: write your own client
Cipher Station is a protocol, not an app. A station runs on your own hardware, publishes encrypted content to IPFS, and exposes a small HTTP API. A client is anything that pairs with a station, signs requests, and reads or writes content — a photo app, a notes app, a file drive. This guide builds one from the ground up.
We'll use CipherVault — a Google-Drive-style end-to-end-encrypted file app — as the worked example: pair a device, sign requests, read a manifest, rewrap and open envelopes, publish a post, and namespace it alongside other clients. Everything interoperates at the wire level — CipherVault's pure-Swift SwiftKyber/SwiftDilithium packages produce the same FIPS encodings as the station's Python kyber-py/dilithium-py.
The two keypairs
Every Cipher Station device holds two post-quantum keypairs, used for completely different jobs. Don't mix them up.
| Keypair | FIPS | Job | Public size | Secret size |
|---|---|---|---|---|
| ML-KEM-768 | 203 | Content envelopes (wrap/unwrap the per-post key) | 1184 B (ek) | 2400 B (dk) |
| ML-DSA-65 | 204 | Request-auth signatures | 1952 B | 4032 B |
All keys are stored and transmitted as lowercase hex strings. There is no classical X25519 anywhere in the stack — Cipher Station is built to resist "harvest now, decrypt later." (SwiftKyber.Kyber.GenerateKeyPair and SwiftDilithium.Dilithium.GenerateKeyPair produce these on iOS; the station uses kyber-py/dilithium-py.)
Keep the secret keys safe. The ML-DSA secret grants full device authentication; the ML-KEM secret decrypts all content wrapped to this device. After pairing, persist them in a secure store (the iOS Keychain) and wipe the in-memory copies.
Step 1 — Pair a device
Pairing is how a brand-new, key-less device earns the right to act on a station. The two pairing endpoints are public (no signature) — security instead comes from an out-of-band 6-digit PIN; see POST /delegate/start/confirm for the full request/response shapes and error codes.
The flow, in language-neutral pseudocode:
GET /health → station is alive
GET /profile → fetch StationProfile (uid, ML-KEM key, peer id, endpoint)
generate ML-KEM-768 + ML-DSA-65 keypairs
device_uid = "<sanitized-name-prefix20>-<uuid-prefix8>"
POST /delegate/start { device_uid, mlkem_public_key, mldsa_public_key }
→ { pairing_id, expires_in_seconds: 300 }
# operator reads the PIN from the station and types it into the client
POST /delegate/confirm { pairing_id, pin }
→ { device_uid } # station-assigned, canonical
persist DeviceCredentials; wipe in-memory secret keys
Use the
device_uidfrom/delegate/confirm, not your locally generated one. The station may canonicalize it, and every later signature carries it inx-cipher-device— a mismatch fails auth. Persist the returnedDeviceCredentials(both keypairs, the canonicaldevice_uid, the station's ownstationUid/stationMlkemPkHex, andipfsPeerId— see Operational resilience for why) and wipe in-memory secret keys once stored.
Confirm errors are status-code-specific, and the distinction is user-facing: 429 = throttled, 400 with expired/locked in the body = session gone, other 400 = wrong PIN.
Step 2 — Sign every request
After pairing, all owner-scoped endpoints require an ML-DSA-65 signature over a canonical seven-field string, carried as six x-cipher-* headers. The full scheme — canonical string, header table, verification order, body-hash rules — is on the API reference; reproduce it byte-for-byte or every signature is rejected.
Three things that silently break signing:
BODY_SHA256is over the bytes on the wire, not the source file. For a multipartPOST /postit's the SHA-256 of the finalized multipart body (exact boundary included). For aGETit's the SHA-256 of emptyData().PATHis the path only — no query string, no host, no trailing newline games. Field order and the seven-field count must match byte-for-byte.x-cipher-body-sha256must be lowercase hex. The station lowercases before comparing and signs over the lowercased value.
ML-DSA signing here uses empty context (FIPS 204 "pure") and is hedged (randomize: true), so the signature differs on every call even for identical input. That's fine — replay protection comes from the timestamp + nonce, never from the signature. Never use the signature as a cache key.
Step 3 — Read the manifest
A station publishes one manifest that many clients share. Its shape is namespaced by client:
{
"clients": {
"drive": { "posts": [ /* CipherVault's posts */ ] },
"cipherframe": { "posts": [ /* a social client's posts */ ] }
}
}
Your client reads and writes only its own namespace. CipherVault's is "drive" (Constants.clientName). The discovery chain is: GET /profile → current manifest CID → fetch manifest JSON from an IPFS gateway → index into clients["drive"].
// 1. Profile gives the current manifest CID
let profile = try await api.profile(stationURL: credentials.stationEndpoint)
guard let manifestCid = profile.resolvedManifestCid else { /* empty */ return }
// 2. Fetch the manifest from an IPFS gateway by CID
let manifest = try await ipfsGateway.fetchJSON(cid: manifestCid, as: Manifest.self)
// 3. Read ONLY this client's namespace
guard let driveManifest = manifest.clients[Constants.clientName] else { return }
let posts = driveManifest.posts
Each encrypted post entry looks like this:
| Field | Meaning |
|---|---|
post_cid | IPFS CID of the encrypted content blob |
audience_mode | self, specific, or all |
envelopes_cid | CID of the separate envelopes JSON (key wraps) |
envelopes_count | number of recipients |
metadata | optional hex SecretBox blob (encrypted) |
A public post is different: audience_mode: "public", encrypted: false, envelopes_cid: null, and its metadata is a plaintext dict, not a hex blob. Always branch on audience_mode / encrypted before deciding how to read metadata.
Iterate only your own namespace. Other clients' posts share the same manifest object. Trying to decrypt foreign posts wastes round-trips and you may not be authorized for them anyway.
Step 4 — Rewrap, then open the envelope
This is the heart of the read path. A post's symmetric key is wrapped to a set of authorized device KEM keys — not necessarily including yours, and not directly fetchable in bulk. So before you can decrypt a post you ask the station to rewrap that post's key to your ML-KEM public key, on demand.
func rewrap(postCid: String, envelopesCid: String) async throws -> RewrapResponse {
let payload: [String: String] = [
"uid": credentials.stationUid,
"device_uid": credentials.deviceUid,
"post_cid": postCid,
"envelopes_cid": envelopesCid, // always send both so the station finds the set
]
let bodyData = try JSONSerialization.data(withJSONObject: payload)
return try await authenticatedPost(path: "/rewrap", body: bodyData)
}
The response is a single KEM-DEM envelope wrapped to your device. The wire format is:
┌────────────┬───────────────┬───────────────────────────────┐
│ 2 bytes BE │ kem_ct │ SecretBox(wrap_key).encrypt(sym_key) │
│ len(kem_ct)│ (ML-KEM ct) │ (nonce prepended) │
└────────────┴───────────────┴───────────────────────────────┘ hex-encoded
To open it: parse the 2-byte big-endian length prefix, ML-KEM-768-decapsulate the ciphertext to a 32-byte shared secret, derive the wrap key with a domain-separated BLAKE2b, then SecretBox-open the trailing sealed blob to recover the 32-byte post key.
func openEnvelope(envelopeHex: String, mlkemSecretKeyHex: String) -> Data? {
guard let raw = Data(hexString: envelopeHex), raw.count >= 2,
let dkData = Data(hexString: mlkemSecretKeyHex) else { return nil }
// 2-byte BIG-ENDIAN ciphertext length, then kem_ct, then sealed key.
let ctLen = (Int(raw[0]) << 8) | Int(raw[1])
guard raw.count >= 2 + ctLen else { return nil }
let kemCt = Array(raw[2 ..< (2 + ctLen)])
let sealed = Data(raw[(2 + ctLen)...])
// ML-KEM-768 decapsulation → 32-byte shared secret.
guard let decap = try? SwiftKyber.DecapsulationKey(keyBytes: Array(dkData)),
let sharedSecret = try? decap.Decapsulate(ct: kemCt),
sharedSecret.count == 32 else { return nil }
// wrap_key = BLAKE2b(shared_secret, person="orbit-kem"); then SecretBox-open.
guard let wrapKey = blake2bPersonal(sharedSecret, personal: "orbit-kem") else { return nil }
return secretBoxDecrypt(ciphertext: sealed, key: wrapKey)
}
The KDF detail matters and is easy to get subtly wrong. The wrap key is BLAKE2b-256, personalized with the string orbit-kem zero-padded to exactly 16 bytes, with a 16-byte zero salt — byte-compatible with Python's hashlib.blake2b(data, digest_size=32, person=b"orbit-kem"):
private func blake2bPersonal(_ input: [UInt8], personal: String) -> Data? {
var out = [UInt8](repeating: 0, count: 32)
// Personal must be exactly 16 bytes (UTF-8, zero-padded).
var personalBytes = [UInt8](personal.utf8)
guard personalBytes.count <= 16 else { return nil }
personalBytes.append(contentsOf: [UInt8](repeating: 0, count: 16 - personalBytes.count))
let salt = [UInt8](repeating: 0, count: 16)
let rc = crypto_generichash_blake2b_salt_personal(
&out, 32, input, UInt64(input.count), nil, 0, salt, personalBytes)
guard rc == 0 else { return nil }
return Data(out)
}
With the 32-byte post key in hand, decrypt the metadata (and later the content) with SecretBox:
let rewrapResponse = try await api.rewrap(
postCid: post.postCid, envelopesCid: post.envelopesCid)
guard let symKey = crypto.openEnvelope(
envelopeHex: rewrapResponse.result.envelope,
mlkemSecretKeyHex: credentials.mlkemSkHex) else { return nil }
symmetricKeyCache[post.postCid] = symKey // cache aggressively
if let metadataHex = post.metadata, !metadataHex.isEmpty {
guard let metadataBytes = crypto.secretBoxDecryptHex(
ciphertextHex: metadataHex, key: symKey) else { return nil }
let metadata = try JSONDecoder().decode(FileMetadata.self, from: metadataBytes)
driveFile = metadata.toDriveFile(postCid: post.postCid, envelopesCid: post.envelopesCid)
}
Read-path rules of thumb:
- Call
/rewrapper post before opening it — the stored envelope set isn't openable by an arbitrary device.- Cache the recovered key by
post_cid; subsequent loads skip the rewrap + decapsulate round-trip.nil= skip this post, never a crash — one bad envelope must not abort the whole listing.
Step 5 — Publish a post
Writing is a single authenticated multipart POST /post. CipherVault defaults to the drive namespace and to audience_mode: "self" — owner plus the owner's own delegate devices, the privacy model a personal drive wants.
func createPost(fileData: Data, filename: String, mimeType: String,
metadata: [String: Any]?,
client: String = Constants.clientName, // "drive"
audienceMode: String = "self",
audienceUids: [String]? = nil) async throws -> PostUploadResponse {
// ...build multipart body, sign over SHA-256 of the FINALIZED body, send...
}
The four audience modes:
| Mode | Who can read | Encrypted? |
|---|---|---|
self | only you + your delegate devices | yes |
specific | you + listed follower UIDs (audience_uids) | yes |
all | you + all Allowed followers | yes |
public | anyone | no — plaintext, no envelopes, permanent |
Server-side, the station generates a fresh 32-byte key per post, SecretBox-encrypts your file, uploads the ciphertext for a post_cid, and wraps the key into one ML-KEM envelope per recipient (published as a separate envelopes JSON, keyed by uid). Crucially, every post also seals a self envelope to the station's own ML-KEM key — that's the recovery root that lets the station later rewrap to your device, or reshare to a new audience.
publicis forever. A public post is uploaded unencrypted with plaintext metadata, and IPFS has no delete. Anyone who learns the CID — including via your public IPNS manifest — can fetch it permanently. Treat it as truly public.
Step 6 — Multi-client namespacing
One station identity hosts many client namespaces in a single manifest. This is what lets a file app and a social app coexist on the same hardware, same keys, same followers — without seeing each other's content.
station identity (one ML-KEM + one ML-DSA keypair)
└── manifest
├── clients["drive"] ← CipherVault reads/writes ONLY here
├── clients["cipherframe"] ← a social client's namespace
└── clients["<your-app>"] ← pick a stable, unique key
Pick one stable client key and use it for both the client multipart field on POST /post and your manifest.clients[...] lookups — CipherVault uses "drive" everywhere via Constants.clientName.
Operational resilience
Two things a production client must handle, both visible in CipherVault:
- Endpoint mobility. Stations sit behind ephemeral tunnel URLs. On connection/DNS/5xx-tunnel failures, resolve the station's IPNS record (using the
ipfsPeerIdyou stored at pairing) to a freshpublic.json, extract the new endpoint, verify it withGET /health, update your credential store, and retry the original request once. Guard this against reentrancy or it loops. - Generous timeouts. Set request ~120s and resource ~300s. Write endpoints block on IPNS publish, which routinely exceeds 60s — the default 60s timeout will spuriously fail uploads. And when the station is unreachable on read, fall back to your decrypted-metadata cache and render an offline view rather than erroring.
Cheat sheet
| You want to… | Call | Auth |
|---|---|---|
| Check liveness | GET /health | none |
| Get station identity + manifest CID | GET /profile | none |
| Begin pairing | POST /delegate/start | none (PIN) |
| Complete pairing | POST /delegate/confirm | none (PIN) |
| Rewrap a post key to your device | POST /rewrap | ML-DSA (owner) |
| Publish a post | POST /post (multipart) | ML-DSA (owner) |
| Reshare to a new audience | POST /post/share | ML-DSA (owner) |
| Remove a post from the manifest | POST /post/delete | ML-DSA (owner) |
That's a complete client: pair once, sign every write, read your namespace, rewrap and open to decrypt. The crypto is post-quantum end to end, and the wire formats above are the entire contract.
CipherVault's full source: github.com/CharliePetch/cipher-station