Reference

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

Interactive

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

Request
Canonical string
POST
/post
191eaa3a-72b1-4c03-802a-1f5d43e91adf
iPhone-7f3a2b1c
1718658000
3f7a1c9e2b4d6f8a0b2c4d6e8f1a3b5c

The 7 fields joined by \n, signed with the device's ML-DSA-65 key. body-sha256 is computed live in your browser.

Request headers
x-cipher-uid:191eaa3a-72b1-4c03-802a-1f5d43e91adf
x-cipher-device:iPhone-7f3a2b1c
x-cipher-ts:1718658000
x-cipher-nonce:3f7a1c9e2b4d6f8a…
x-cipher-body-sha256:
x-cipher-sig:……

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.

KeypairFIPSJobPublic sizeSecret size
ML-KEM-768203Content envelopes (wrap/unwrap the per-post key)1184 B (ek)2400 B (dk)
ML-DSA-65204Request-auth signatures1952 B4032 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_uid from /delegate/confirm, not your locally generated one. The station may canonicalize it, and every later signature carries it in x-cipher-device — a mismatch fails auth. Persist the returned DeviceCredentials (both keypairs, the canonical device_uid, the station's own stationUid/stationMlkemPkHex, and ipfsPeerId — 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_SHA256 is over the bytes on the wire, not the source file. For a multipart POST /post it's the SHA-256 of the finalized multipart body (exact boundary included). For a GET it's the SHA-256 of empty Data().
  • PATH is 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-sha256 must 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:

FieldMeaning
post_cidIPFS CID of the encrypted content blob
audience_modeself, specific, or all
envelopes_cidCID of the separate envelopes JSON (key wraps)
envelopes_countnumber of recipients
metadataoptional 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 /rewrap per 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:

ModeWho can readEncrypted?
selfonly you + your delegate devicesyes
specificyou + listed follower UIDs (audience_uids)yes
allyou + all Allowed followersyes
publicanyoneno — 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.

public is 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 ipfsPeerId you stored at pairing) to a fresh public.json, extract the new endpoint, verify it with GET /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…CallAuth
Check livenessGET /healthnone
Get station identity + manifest CIDGET /profilenone
Begin pairingPOST /delegate/startnone (PIN)
Complete pairingPOST /delegate/confirmnone (PIN)
Rewrap a post key to your devicePOST /rewrapML-DSA (owner)
Publish a postPOST /post (multipart)ML-DSA (owner)
Reshare to a new audiencePOST /post/shareML-DSA (owner)
Remove a post from the manifestPOST /post/deleteML-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