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
InteractiveThis 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.
Anatomy of an envelope
InteractiveStep 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.
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.
Signing a request
InteractiveAuthenticated 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.
The 7 fields joined by \n, signed with the device's ML-DSA-65 key. body-sha256 is computed live in your browser.
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:
| Scheme | Standard | Job in Cipher Station | Implementation |
|---|---|---|---|
| ML-KEM-768 | FIPS 203 | Wrap per-post content keys (content envelopes) | cipher_station/pqcrypto.py |
| ML-DSA-65 | FIPS 204 | Sign device-auth API requests | cipher_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:
- KEM —
ML-KEM-768.encaps()produces a random 32-byteshared_secretplus akem_ctthat the holder of the decapsulation key can turn back into that same secret. - KDF — the shared secret is run through BLAKE2b (
digest_size=32, personalizationb"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-DSA | Not 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 paths | Body 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:
| Backend | Library | Constant-time? | When |
|---|---|---|---|
liboqs | Open Quantum Safe C library via the oqs Python binding | Yes — hardened, preferred | Production / side-channel-sensitive hosts |
python | pure-Python kyber-py (ML-KEM-768) + dilithium-py (ML-DSA-65) | No | Always 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 bytesmldsa.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
| Step | Operation | Scheme |
|---|---|---|
| 1. Encrypt | Fresh 32-byte key seals the file with SecretBox, ciphertext uploads to IPFS | XSalsa20-Poly1305 |
| 2. Wrap | KEM-DEM-wrap that key to each recipient's public key → one hex envelope each | ML-KEM-768 |
| 3. Read | Rewrap to the device's key, open the envelope, decrypt the content | ML-KEM-768 + SecretBox |
| 4. Authenticate | Every API call above is signed and replay-protected | ML-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.