Cryptography
Audited primitives only, from the RustCrypto stack. Nothing here is hand-rolled and no construction is composed ad hoc.
The standing rule: any change is a new version, never a reinterpretation. Each
construction below is frozen and pinned by a named test vector, so changing what
v1 means cannot happen quietly — it turns a test red.
Primitives
Section titled “Primitives”| Purpose | Primitive |
|---|---|
| Key derivation | Argon2id |
| Symmetric encryption | XChaCha20-Poly1305 |
| Key wrapping | X25519 sealed box |
| Subkey derivation | HKDF-SHA-256 |
| Digests | SHA-256 |
| Randomness | the OS CSPRNG, exclusively |
Known-answer tests cross-validate against external sources rather than against ourselves: libsodium where it has the surface, RFC 9106 §5.3 for keyed Argon2id (libsodium exposes no keyed mode), PyNaCl’s published vectors and RFC 7748 §6.1 for the sealed box, and RFC 5869 A.1–A.3 for HKDF.
Master Unlock Key
Section titled “Master Unlock Key”MUK = Argon2id(version = 0x13, P = NFC(master password) as UTF-8, S = kdf_salt (16 random bytes, public), K = Secret Key (32 random bytes), <- RFC 9106 §3.1 keyed mode X = "cendarum/muk/v1", <- associated data m/t/p from kdf_params, taglen = 32)Defaults: m = 65536 (64 MiB), t = 3, p = 4 — RFC 9106 §4’s second recommended
option. The first option’s 2 GiB is wrong for a desktop app that re-derives on every
unlock.
The Secret Key uses Argon2’s native keyed input rather than being mixed in by hand: one audited primitive, no home-made composition.
The client refuses out-of-policy parameters, in both directions. Because
kdf_params arrives from a server the client does not trust, it is checked against
m between 19456 and 4194304 KiB (floor: the OWASP minimum; ceiling: a denial-of-service
guard), t between 2 and 64, and p between 1 and 8. So a compromised server can neither
downgrade your key derivation — which is exactly the protection that matters if your
laptop is stolen — nor trigger an absurd allocation.
Passwords are NFC-normalised before derivation, so a composed and a decomposed spelling of the same password derive the same key. Compatibility variants stay deliberately distinct: NFC, not NFKC.
Payload encryption
Section titled “Payload encryption”ct||tag = XChaCha20-Poly1305(key = the environment key, nonce = 24 random bytes, fresh per encryption, aad = "cendarum/payload/v1" || env_id || key_epoch || version, msg = the payload)The associated data binds the payload’s public sync coordinates, which stops a compromised server relabelling one environment’s older payload as a different version or epoch — a rollback attempt fails authentication instead of resurrecting pre-revocation values.
What associated data cannot catch is omission: a server hiding newer versions and presenting an old one honestly, as itself. That is the client’s version high-water mark’s job — see sync and conflicts.
Nonces are 192-bit random and generated inside the encryption function. No API accepts a caller-supplied nonce, so keystream reuse is not representable.
Private key at rest
Section titled “Private key at rest”ct||tag = XChaCha20-Poly1305(key = the Master Unlock Key, nonce = 24 random bytes, aad = "cendarum/private-key/v1", msg = your X25519 private key)Normative for any implementation: decrypt, derive, compare. After decryption, derive the public key from the private key and compare it against the expected value. A mismatch is a distinct local error, and the decrypted pair is destroyed.
Binding the public key into the associated data was considered and rejected, because derive-and-compare is strictly stronger: it verifies the actual mathematical relation, so it also catches an honestly-tagged envelope containing the wrong pair-mate — which associated data stamped by the same emitter structurally cannot.
Auth verifier
Section titled “Auth verifier”auth_verifier = HKDF-SHA-256(salt = ABSENT, IKM = MUK, info = "cendarum/auth/v1", L = 32)The Master Unlock Key has exactly two consumers: the private-key encryption above,
and this. Any future consumer must derive through HKDF with its own info label —
never a third raw use — and each gets its own test vector chained to the Master
Unlock Key’s, so a violation goes red in CI rather than shipping.
Honest limitation: the verifier is one-way from the Master Unlock Key, but it is a full account-API credential until you change your password. Someone who captures one gets durable API access — to ciphertext, never to decryption. A PAKE was considered and rejected for now: heavy new cryptography on both sides to protect an asset that is already only ciphertext access.
Recovery backup
Section titled “Recovery backup”ct||tag = XChaCha20-Poly1305(key = the Recovery Key, used directly, nonce = 24 random bytes, aad = "cendarum/recovery/v1", msg = your X25519 private key)The Recovery Key is full-entropy CSPRNG output — already a 256-bit key, with nothing to stretch — so there is no KDF, and the wire format carries no salt or cost parameters, which makes a passphrase-derived key unrepresentable.
Same mandatory decrypt-derive-compare, with its own distinct mismatch error.
Recovery Kit encoding
Section titled “Recovery Kit encoding”<PREFIX>-<GGGG-…13 groups…-GGGG>-<CCC>Crockford Base32 of the raw 32 bytes, a per-type prefix (SK1 for the Secret Key,
RK1 for the Recovery Key), and a 3-character checksum derived with a per-type
domain — so a Recovery Key pasted where a Secret Key belongs fails immediately
rather than becoming a mysterious wrong-password.
Decoding normalises first: separators stripped, upper-cased, Crockford confusables
mapped (O becomes 0; I and L become 1). Any re-spelling of the same value passes and a
value-changing typo fails locally, before the 64 MiB derivation runs. It is
typo detection, not adversarial integrity — the format is public and recomputable.
Public-key fingerprint
Section titled “Public-key fingerprint”digest = SHA-256("cendarum/fingerprint/v1" || public key)fingerprint = first 20 chars of Crockford-Base32(digest)rendered = XXXX-XXXX-XXXX-XXXX-XXXX20 characters is the digest’s first 100 bits — the Signal safety-number class.
Stated precisely: surviving the ceremony with a substituted key requires a usable X25519 key pair whose domain-separated digest matches the victim key’s first 100 bits. That is an expected ~2^100 second-preimage search. Birthday collisions do not apply, because the honest key is fixed and honestly generated before an attacker moves.
Small-order points are refused before a fingerprint can exist at all. A substituted key that was additionally a small-order point would make the wrapped key recoverable by anyone rather than only the server, so the client refuses the canonical small-order encodings against a table that is machine-proven by a test rather than hand-trusted.
Transport
Section titled “Transport”TLS via rustls with the aws-lc-rs provider, on all three wire surfaces. The server verifies its database connection with full hostname verification, enforced by a boot-time guard that refuses to start otherwise. Mail relays require STARTTLS.
TLS keys are connection keys, never vault keys. The zero-knowledge guarantee was never “the server links no cryptography at all” — it is that the server never links the vault’s crypto crate and has no code path from a stored blob to plaintext. Transport TLS does not change that.