Everything OxiMail stores — message blobs, attachments, chat content, calendar payloads, and the SQLite database itself — is encrypted on disk. The model to hold in mind is LUKS, not end-to-end: like an encrypted disk, the server can decrypt content at read time for an authenticated user; unlike end-to-end encryption, losing a user’s password never makes their mail unreadable, and IMAP/CalDAV/webmail all keep working.
What the model protects — and what it does not
Protected: a stolen disk, a stolen backup of the blob directory or database file, a decommissioned drive, an attacker with offline access to the storage. Without the key material, blobs are ciphertext and the database is a SQLCipher-encrypted file.
Not protected: a fully compromised running server (it holds the keys to do its job), or a malicious administrator with access to the key material. Those threats belong to other layers — access control, the security hardening, and physical custody.
The key hierarchy
Keys wrap keys, so rotation and erasure act on small objects, never on terabytes of content:
- Tenant master key — one per organization. It wraps everything below it.
- Per-account keypairs (X25519) — each account’s content keys are wrapped for its keypair; blob content is encrypted with XSalsa20-Poly1305.
- Tenant-wide symmetric keys (AES-256-GCM) — for content that is shared organization-wide by nature (room attachments, shared blobs) rather than owned by one account.
Two properties are deliberate:
- Account keys are wrapped with the tenant master key, never with the user’s password. A password-derived wrap would make blobs undecryptable whenever the server does not hold the password at read time — which is exactly the IMAP, CalDAV, and background-worker case. Password changes therefore never re-encrypt anything.
- Blobs are self-describing (ADR-096): each carries an encryption header naming its scheme, so the store never guesses how to decrypt.
Every blob is content-addressed by the SHA-256 of its cleartext (deduplication happens before encryption, within the organization only — cross-organization dedup does not exist, by design).
In a shared mailbox, the key follows the effective account, not the caller. A secret key only ever decrypts its own account’s blobs, so anchoring the choice on the authenticated principal broke sharing in both directions: a grantee could list a shared mailbox and read subjects but never open a body, and a message a grantee filed into someone else’s mailbox was encrypted for the grantee, leaving the owner unable to read it in their own mailbox. Both now resolve on the effective account — the one a validated grant produced, which differs from the caller only after resolve_shared_context has accepted the grant. This widens no authorization: impersonation is not representable, and the per-object gate in the handlers remains the authority on what is served. Capabilities stay resolved on the authenticated caller.
The read path enforces one more discriminator: a cross-account grantee or an anonymous share link can only decrypt a blob referenced by its own projection (files vs mail vs avatar) under the resolved account — so even if a route’s authorization check were bypassed by a bug, the crypto layer refuses a blob from another projection. Defense in depth at the key layer, not just the ACL layer.
The database: SQLCipher
The structured metadata (folders, headers, flags, the change log) lives in SQLite, encrypted with SQLCipher:
The database key lives in the key material under /etc/oximail; the wizard provisions it. A copied data.db without that key is noise.
Which SQLCipher, and how to check. The current line ships SQLCipher 4.14.0 over SQLite 3.51.3, statically linked into the binary. That matters more than it looks: the engine’s own CVEs only reach an encrypted database through a SQLCipher release, so a build that pins an old one is quietly carrying every SQLite fix it has missed. Ask the running binary rather than a manifest:
The shipped binary depends on no system crypto library. It carries its own, and the deploy path now proves it instead of assuming it: a build whose ldd output mentions a system TLS, crypto or SQLite library is rejected before it reaches a host. The property used to hold by an accident of link order, which is exactly the kind of thing that breaks silently on an unrelated dependency change.
Backups carry no keys
By design (ADR-102), a backup of the database and blob directory does not include the key material — a stolen backup is therefore not a data breach. The corollary is operational, and absolute:
Back up /etc/oximail (config, DKIM keys, key material) separately from the data, and store it with the same care as a disk-encryption recovery key. Data backups without the key backup are ciphertext forever. oximail verify-backup checks your backups before you need them.
Key operations
- Rotation re-wraps the small key objects under a new master key; content blobs are untouched, so rotation is cheap at any mailbox size (key/content separation).
- Escrow produces a sealed copy of the key material for custody outside the machine. Both
rotate and escrow hard-gate on an escrow drill: the command refuses to proceed until the on-disk escrow has been proven restorable — an escrow you never tested is not an escrow.
account regenerate-key mints a fresh keypair for one account and makes every blob encrypted under the old one permanently unreadable. It exists for deliberate recovery scenarios; the audit phase counts and shows the affected blobs before asking for confirmation. Missing-key conditions at runtime always fail loud (ADR-050) and point the operator here — the server never silently serves or stores cleartext in their place.
Crypto-erasure
Deleting an account drops its key material: every blob of the account becomes undecryptable at that instant, without touching the blob files themselves. This is what makes GDPR erasure tractable at mailbox scale — the sweep deletes rows and index documents, and the ciphertext left on disk is noise. The one store with no keys to drop, the full-text search index, is swept explicitly.
What this is not
- Not end-to-end encryption. The server decrypts for authenticated reads. E2E for specific surfaces is a separate concern with separate trade-offs (searchability, DAV bridges, server-side filtering all stop working under E2E).
- Not filesystem encryption. It composes fine with LUKS/dm-crypt underneath, but does not require it: the application-level scheme protects backups and copied files even off the encrypted volume.