Learn

Cryptography

Explains Cipher Station's two post-quantum schemes — ML-KEM-768 for wrapping per-post content keys (KEM-DEM envelopes) and ML-DSA-65 for signing device-auth requests — with the exact envelope wire format, the canonical signing string, what is and isn't protected, the liboqs vs. pure-Python backends, encryption at rest, and an optional constant-time hardened backend for additional security.

Live envelope lab

Interactive

This is the real thing — Cipher Station's KEM-DEM construction running entirely in your browser, nothing uploaded. Generate an ML-KEM-768 keypair, encrypt a file, then recover it from the encrypted bundle and secret key alone. It's the same code path your station runs when it seals a post for a follower.

Make a keypair, encrypt a file (≤ 20 MB), then download the bundle and key — recover it on the Decrypt tab.

1 · Keypair
2 · Choose a file (≤ 20 MB)

Anatomy of an envelope

Interactive

Step through how one post's symmetric key is wrapped for one recipient. Each stage shows the operation and the bytes it produces, ending in the exact on-the-wire format — a 2-byte length, the ML-KEM ciphertext, then the sealed key — that gets published to IPFS as a per-follower envelope.

Step 1 / 4

Generate the post key

post_key = random(32)

Every post gets its own fresh 32-byte symmetric key. The content itself is sealed with XSalsa20-Poly1305 (NaCl SecretBox) — already quantum-resistant.

post_key32 B
Envelope wire format (hex-encoded)len ‖ kem_ct ‖ sealed
len
kem_ct
sealed

Signing a request

Interactive

Authenticated calls to a station are signed, not merely sent. This builds the precise canonical string Cipher Station signs — method, path, ids, timestamp, nonce, and a body hash computed live in your browser — which a device's ML-DSA-65 key turns into the x-cipher-sig header. Edit any field to watch the signed payload change.

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:……

Cryptography: ML-KEM-768 & ML-DSA-65

Cipher Station is built so that nothing you publish today can be quietly decrypted tomorrow — not even by an adversary who records all your traffic now and waits a decade for a quantum computer. There is no classical X25519 anywhere in the protocol. Two NIST-standardized post-quantum schemes do all the heavy lifting, and both live in a single file, cipher_station/pqcrypto.py:

SchemeStandardJob in Cipher StationImplementation
ML-KEM-768FIPS 203Wrap per-post content keys (content envelopes)cipher_station/pqcrypto.py
ML-DSA-65FIPS 204Sign device-auth API requestscipher_station/pqcrypto.py

That's the whole post-quantum surface. ML-KEM keeps your content confidential; ML-DSA proves who is asking when a device talks to your station.

Why post-quantum, and why now

The threat is harvest-now, decrypt-later: a patient adversary just records your encrypted posts today and waits for a cryptographically relevant quantum computer to exist, at which point every classically-encrypted message they hoarded falls open at once. Content on IPFS is permanent and world-distributed, so "rotate to PQ later" doesn't help — so Cipher Station wraps every content key with ML-KEM-768 from the first post, a scheme whose security doesn't rest on factoring or discrete logs (the problems Shor's algorithm breaks).

The shapes of the keys

Post-quantum keys are bigger than the classical ones you may be used to, and every one travels as a lowercase hex string — see the size table on the Protocol page for exact byte counts. A "recipient public key" in the API is an ML-KEM-768 encapsulation key as a 2368-character hex string; the shared secret a KEM operation produces is always 32 bytes (the self-test refuses any backend where len(ss) != 32).

Content encryption: KEM-DEM key wrapping

Here's the subtle part. A KEM is not an encryptor. ML-KEM can't take "my post key" and encrypt it — it can only generate a fresh random shared secret and a ciphertext that recovers it. So to seal a chosen 32-byte symmetric key to a recipient, Cipher Station uses the textbook KEM-DEM construction:

shared_secret, kem_ct = ML-KEM.encaps(recipient_pub)     # KEM: get a random secret
wrap_key              = BLAKE2b(shared_secret, person="orbit-kem")   # KDF
sealed                = SecretBox(wrap_key).encrypt(post_key)        # DEM
envelope              = len(kem_ct) || kem_ct || sealed              # then hex-encode

Three moving parts:

  • KEMML-KEM-768.encaps() produces a random 32-byte shared_secret plus a kem_ct that the holder of the decapsulation key can turn back into that same secret.
  • KDF — the shared secret is run through BLAKE2b (digest_size=32, personalization b"orbit-kem") to derive the actual wrapping key. The personalization string domain-separates this key from any other BLAKE2b use.
  • DEM — the chosen 32-byte post key is sealed under that wrap key with NaCl's SecretBox (XSalsa20-Poly1305).

Importantly, the symmetric layer is already quantum-resistant. XSalsa20-Poly1305 with a 256-bit key is not threatened by quantum computers — Grover's algorithm only halves symmetric security, leaving 128 bits, which is fine. ML-KEM is only needed for the wrapping step, because that's the part that historically used quantum-breakable public-key crypto.

The exact envelope wire format

The envelope is a single hex string with a precise byte layout (seal_key, pqcrypto.py):

┌──────────────┬─────────────────────┬───────────────────────────────┐
│  2 bytes     │   len(kem_ct) bytes │   rest of the bytes           │
│  BE length   │   ML-KEM ciphertext │   SecretBox(wrap_key)(post_key)│
│  of kem_ct   │   (1088 for ML-KEM-768) │  (nonce ‖ XSalsa20-Poly1305)│
└──────────────┴─────────────────────┴───────────────────────────────┘
        then .hex() the whole thing
def _wrap_key_from_shared(shared_secret: bytes) -> bytes:
    # BLAKE2b-256, domain-separated by person=b"orbit-kem".
    return hashlib.blake2b(shared_secret, digest_size=32, person=_KEM_PERSON).digest()

def seal_key(sym_key: bytes, mlkem_pub_hex: str) -> str | None:
    key_hex = mlkem_pub_hex.strip().lower()
    if len(key_hex) != MLKEM_PUBLIC_HEX:        # 2368 chars or it's skipped
        return None
    ek = bytes.fromhex(key_hex)

    shared_secret, kem_ct = _BACKEND.mlkem_encaps(ek)   # KEM
    wrap_key = _wrap_key_from_shared(shared_secret)     # KDF
    sealed   = SecretBox(wrap_key).encrypt(sym_key)     # DEM

    # 2-byte big-endian length prefix, then ciphertext, then sealed key.
    envelope = len(kem_ct).to_bytes(2, "big") + kem_ct + sealed
    return envelope.hex()

def open_key(mlkem_secret: bytes, envelope_hex: str) -> bytes | None:
    try:
        raw    = bytes.fromhex(envelope_hex)
        ct_len = int.from_bytes(raw[:2], "big")     # big-endian!
        kem_ct = raw[2:2 + ct_len]
        sealed = raw[2 + ct_len:]
        if len(kem_ct) != ct_len:
            raise ValueError("truncated KEM ciphertext")

        shared_secret = _BACKEND.mlkem_decaps(mlkem_secret, kem_ct)
        wrap_key      = _wrap_key_from_shared(shared_secret)
        return SecretBox(wrap_key).decrypt(sealed)
    except Exception as e:
        logger.error("ML-KEM envelope open failed: %s", e)
        return None   # callers treat None as "skip this envelope"

Opening never throws — open_key returns None on any failure (malformed hex, truncation, wrong key, MAC mismatch), so callers just skip that envelope. The 2-byte big-endian length prefix caps a KEM ciphertext at 65535 bytes — ML-KEM-768's 1088-byte ciphertext leaves enormous headroom — and the wire format is backend-independent, which is why an iOS client and a Raspberry Pi station interoperate without negotiation.

Request signing: the canonical string

Authenticated calls are signed with the device's ML-DSA-65 key over a canonical seven-field string (METHOD\nPATH\nUID\nDEVICE_UID\nTS\nNONCE\nBODY_SHA256), carried as six x-cipher-* headers with a ±60-second freshness window and one-time nonces. Full header table, verification order, and error codes are on the API reference. One boundary worth knowing here: require_delegate authenticates any Allowed device of any uid the station knows; require_owner (used by every write endpoint) additionally requires the authenticated uid to match the station's own — without it, a follower's device could act as the owner.

What is and isn't protected

Protected by ML-KEM / ML-DSANot protected
Confidentiality of encrypted post content (self / specific / all audiences)Public posts — uploaded unencrypted, no envelopes, world-readable forever
The per-post symmetric key (wrapped per recipient)Metadata of public posts (stored plaintext)
Authenticity of signed requests (/post, /rewrap, /follow, …)Follow requests to /inbox — intentionally unauthenticated so strangers can ask to follow
Body integrity of guarded write pathsBody of non-guarded signed routes if the capture middleware is absent (assumes TLS / trusted path)

Public posts are a deliberate, permanent disclosure — see audience modes.

Two backends: liboqs vs. pure-Python

The ML-KEM / ML-DSA math itself is pluggable, chosen at import time by the CIPHER_PQC_BACKEND env var:

BackendLibraryConstant-time?When
liboqsOpen Quantum Safe C library via the oqs Python bindingYes — hardened, preferredProduction / side-channel-sensitive hosts
pythonpure-Python kyber-py (ML-KEM-768) + dilithium-py (ML-DSA-65)NoAlways available; no native build; piwheels/Raspberry-Pi friendly

Both backends are FIPS-interoperable — identical key, ciphertext, and signature encodings — so you can switch backends, or have a station on liboqs talking to a Pi on pure-Python, without re-bootstrapping.

Before a backend goes live, _self_test round-trips both schemes: ML-KEM keygen → encaps → decaps with a shared-secret-equality and len == 32 check, and ML-DSA keygen → sign → verify, requiring verify(msg) true and verify(msg + b"x") false.

Encryption at rest

The keys on disk are protected by a separate, algorithm-agnostic layer in cipher_station/crypto.py. When CIPHER_PASSWORD is set, secret-key bundles are sealed with an Argon2i-derived key:

def encrypt_private_keys(private_bytes: bytes, password: str) -> bytes:
    salt = nacl.utils.random(16)
    key  = argon2i.kdf(32, password.encode(), salt)   # NaCl Argon2i -> 32-byte key
    return salt + SecretBox(key).encrypt(private_bytes)

def decrypt_private_keys(bundle: bytes, password: str) -> bytes:
    salt, ciphertext = bundle[:16], bundle[16:]
    key = argon2i.kdf(32, password.encode(), salt)
    return SecretBox(key).decrypt(ciphertext)

On disk the station identity is stored as public || secret bundles, so the public key is always recoverable from the file alone even when the secret half is encrypted:

  • mlkem.bin = ek (1184)dk (2400) → 3584 bytes
  • mldsa.bin = pk (1952)sk (4032) → 5984 bytes

One sharp edge: there is no key-rotation or migration path for the password. If CIPHER_PASSWORD changes, the stored bundles can no longer be decrypted and reading them raises a length-mismatch error. Treat the password as permanent for a given identity.

Putting it together

StepOperationScheme
1. EncryptFresh 32-byte key seals the file with SecretBox, ciphertext uploads to IPFSXSalsa20-Poly1305
2. WrapKEM-DEM-wrap that key to each recipient's public key → one hex envelope eachML-KEM-768
3. ReadRewrap to the device's key, open the envelope, decrypt the contentML-KEM-768 + SecretBox
4. AuthenticateEvery API call above is signed and replay-protectedML-DSA-65

Post-quantum confidentiality on the content path, post-quantum authenticity on the control path — a hex wire format simple enough to reimplement in any language.

Repo: github.com/CharliePetch/cipher-station