Two Layers, One Envelope, No Passwords: How LVDR Chose Its Data-at-Rest Crypto
By LumaVista Team
The first draft of our security spec named AES-256-GCM as the data-at-rest cipher and called it done. Six months later the production code shipped XChaCha20-Poly1305 at every wrap and bulk-encrypt site, the spec hadn’t been updated, and a junior engineer pointed at the discrepancy in a code review. The interesting part wasn’t the discrepancy itself. It was that every reason driving the actual code path turned out to be downstream of constraints none of us could have predicted from the threat model alone — ARM SoC support that landed mid-cycle, a libsodium frontend that shipped first, a hardware-portability ceiling that mattered more than peak throughput.
Crypto choices look obvious in the spec doc and almost never look obvious in the code.
This post walks through the eight design decisions that got LVDR’s data-at-rest layer where it is. None of them are exotic — every primitive here is well-known, well-audited, decades-deployed. What’s interesting is the order of the reasoning, and the constraints that drive choices the textbook would pick differently. If you’re building something similar, you’ll make different versions of the same choices. The point is showing how each one falls out of the one before it.
1. Threat model and scope
Before any algorithm, the threats. We’re protecting against:
- Stolen disk image. A backup tape walks out the door, an attacker dumps a Badger directory off a decommissioned server, a cloud provider returns a disk that wasn’t fully wiped. Ciphertext on disk must be useless without keys.
- Hostile insider at the storage layer. A rogue DBA, a compromised cloud admin, a backup-restore pipeline mishandling files. Anyone with read access to the bytes shouldn’t have read access to the data.
- Lost device. A laptop with the user’s session walks out of a coffee shop. The attacker should not be able to read prior project data without the user’s consent.
- Future quantum harvest. An adversary patient enough to record encrypted traffic and storage today, decrypt it later when a quantum computer arrives. We covered this threat in Harvest Now, Decrypt Later; it constrains the asymmetric-wrap choice in §5 and the “what we punted” list in §8.
What we explicitly don’t try to defend at this layer:
- RAM dump of a live process. If an attacker gets
/proc/<pid>/memof a running backend, ciphertext on disk decrypts. That’s a process-isolation problem, not a crypto problem. - Compromised application binary. If the running code is poisoned, no amount of careful key handling helps.
- The server’s own metadata. Sizes, timing, key IDs — we don’t try to obscure those.
This matters more than it looks. The threat model is what tells you the encryption has to happen before data hits Badger, and that the decryption key cannot live in the same place as the ciphertext. Everything else falls out of those two facts. (We made the same point a different way in Data Sovereignty, Not Residency — where bytes physically sit matters less than what an attacker can do once they reach them.)
2. Two layers, not one
Badger ships a WithEncryptionKey option. Hand it a 32-byte key and it encrypts every SSTable, every value-log file, every index page with AES-256-CTR, rotating the data key every seven days. It’s transparent — the application never sees a ciphertext. So why not stop there?
We didn’t, and we ship a second layer of encryption on top of Badger native: application-level XChaCha20-Poly1305 on the wrap chain, on individual secret values, and on redacted spans within larger documents. Two reasons each layer earns its keep:
Badger native is cheap and broad. It defends against disk-image theft and against backup-pipeline leaks, transparently and at full Badger throughput. There’s no per-record code path to get wrong. It also encrypts indexes, which a naive app-only scheme would leak.
App-level is targeted and survives a Badger-key compromise. The wrapped DEKs are app-encrypted before they touch Badger — even if Badger’s encryption key is exfiltrated, the wrap chain stays sealed without the device key (we’ll get to that). It also lets us rotate user-scoped DEKs without rewriting the entire store: rotate the wrap, leave the data alone.
The choice isn’t “Badger AES or app XChaCha.” It’s that they protect different threats and the marginal cost of running both is small. Defense in depth works here because the two layers fail under different conditions: a leaked Badger key doesn’t leak anything app-encrypted, and a leaked app DEK still has to get past Badger’s encryption.
3. The bulk AEAD: XChaCha20-Poly1305 over AES-256-GCM
Here’s the big one. The textbook AEAD answer for new systems is AES-256-GCM. We ship XChaCha20-Poly1305 everywhere outside the Badger native layer. Four reasons, ranked by how much weight each carried in the actual decision:
1. Nonce length. GCM uses a 96-bit nonce. Random-nonce generation has a birthday-bound collision probability around 2^-32 — at billions of records over a long-running deployment, that gets uncomfortable. XChaCha’s 192-bit nonce makes random generation safe at any practical scale. No counter discipline, no per-context state, no failure mode that turns a sloppy random source into a key-recovery attack. The nonce-misuse surface is just smaller.
2. Hardware portability. AES-NI is universal on x86 servers. It’s not universal on the ARM SoCs we expected to support for on-prem and edge deployments — the cheaper Cortex-A series often ships without ARMv8 crypto extensions. ChaCha20 is constant-time on every platform without hardware support. The difference shows up in published benchmarks: AES-256-GCM with AES-NI typically lands somewhere in the 3–5 GB/s range on modern x86; ChaCha20-Poly1305 on the same hardware lands around 1–2 GB/s. Without AES-NI, AES-GCM drops by an order of magnitude and stops being constant-time. The portability ceiling matters more than the peak ceiling for a system that ships into hardware we don’t control.
3. libsodium parity. The frontend already uses libsodium for the device-key handshake. Picking the same primitive both sides means no impedance mismatch — what the frontend produces is what the backend consumes, byte-for-byte, with the same test vectors and the same well-understood failure modes. Adding a second AEAD library would have meant a second pile of test vectors, a second set of timing-channel concerns, and a second place to make a mistake.
4. Misuse resistance. Both ciphers are brittle under nonce reuse — a single repeated nonce leaks the auth key in either case. With a 192-bit random nonce, the attack vector is almost impossible to hit in practice. With a 96-bit random nonce, you have to think about it.
What the spec doc didn’t capture: every one of those reasons was downstream of a constraint that emerged after the spec was written. ARM on-prem support and the libsodium frontend choice both landed mid-cycle. The cipher choice followed.

4. Envelope encryption: device → KEK → DEK → data
Three keys deep:
device X25519 keypair (per device, private key never exported)
│
▼ wraps
masterKEK (per user, regenerated on rotation)
│
▼ wraps
DEK (per scope: user, project)
│
▼ encrypts
data (Badger values, secret fields)
Why not just one key encrypting everything? Four reasons:
Rotation cost. A flat scheme rotates by re-encrypting every byte. Envelope rotation rewrites only the wrapped DEKs — kilobytes, not gigabytes. We rotate masterKEK on a schedule and on demand (post-incident, on user request). The data layer doesn’t move.
Per-scope DEKs. Each user has their own DEK. Each project has its own DEK. Deleting a user means deleting their DEK; after that, their Badger ciphertext is just noise on disk, even though the bytes are still there. This makes GDPR erasure tractable without rewriting the store.
HSM swap point. The wrap/unwrap operations sit behind a KeyProvider Go interface. The default SoftwareKeyProvider does everything in process memory; an enterprise PKCS#11 implementation does it in a hardware module. The data path doesn’t change — only the wrap chain ever touches the HSM, and the wrap chain is small.
Multi-device support is essentially free. Each new device gets the masterKEK rewrapped to its X25519 public key. The DEKs and data stay where they are — only the wrap is per-device. Adding a phone to an account that has a laptop is a single rewrap operation, not a re-encryption pass.
What envelope encryption deliberately doesn’t defend: live-process compromise. If an attacker gets the unwrapped DEK out of process memory, ciphertext on disk decrypts. We covered this in §1; it’s a process-isolation problem (or an HSM problem), not a key-hierarchy problem.
5. Asymmetric wrap: why NaCl box
The masterKEK has to be wrapped to each device’s public key so the device can unwrap it locally. Four candidates were on the table:
| Choice | What it is | Why it was a candidate |
|---|---|---|
| NaCl box | X25519 + XSalsa20-Poly1305, libsodium API | Mature, audited, libsodium-default |
| HPKE (RFC 9180) | Modern standardized hybrid public-key encryption | Composable, has a future PQC profile |
| ECIES | Pre-HPKE elliptic-curve answer | Familiar, widely deployed |
| RSA-OAEP | Pre-everything answer | Universally supported |
NaCl box won, in this order:
- Frontend already uses libsodium. Same primitive both sides means the wrap-to-device handshake is byte-compatible — what the frontend produces is what the backend consumes with no glue layer. This is the same libsodium-parity argument from §3, applied to the asymmetric layer.
- One-shot API, no knobs. HPKE has knobs — KEM, KDF, AEAD, info, AAD. Each knob is a way to make the call wrong. Box has no knobs. For a primitive that does one thing (wrap a 32-byte key to a recipient public key), no-knobs wins.
- Maturity. libsodium has been deployed at scale for over a decade. The failure modes are charted. The implementations are audited.
- RSA was never seriously in the running. Keys are large, performance is bad on small devices, and post-quantum is hopeless. We weren’t going to ship a wrap that was worse against quantum than the symmetric layer.
The cost of choosing box over HPKE: no easy upgrade path to PQC hybrid wrapping. HPKE has a clean profile for X25519+ML-KEM hybrid; box doesn’t. We’re going to pay for that when PQC matters. See §8.

6. No password-derived key
Most encrypted-data designs derive a KEK from a user password via Argon2id or scrypt. LVDR doesn’t.
The model: every device has its own X25519 keypair, generated locally and never exported. The masterKEK is randomly generated server-side, then wrapped once to each enrolled device’s public key. To unlock the user’s data, the device unwraps the masterKEK using its own private key. There is no password in the key chain. There is no Argon2 step on every login. There is no offline-crackable password-equivalent secret.
What this buys:
- Login speed. The unwrap is one X25519 operation plus one XChaCha20 decrypt. Argon2id at safe parameters is hundreds of milliseconds; this is microseconds.
- No offline cracking. A stolen wrapped masterKEK is useless without a device private key. Password-derived schemes leak a password-equivalent if someone exfiltrates the wrapped key plus your KDF parameters; the device-keypair model has nothing equivalent to grind on.
- Auditable enrollment. Logging in on a new device requires a human-in-the-loop step — an existing device unwraps the masterKEK and rewraps it to the new device’s public key. That’s an explicit, consented enrollment event with a clear log trail, not a silent password reuse.
What this costs:
- Lose all your devices, lose your data. There is no “I forgot my password” recovery path. The mitigations are a printed recovery KEK held by the user, an admin-held escrow KEK for enterprise tenants, or both. Pick one before you need it.
- Account sharing is harder by design. There’s no shared password. Two humans wanting to use the same account need two device enrollments. For LVDR’s model this is a feature; for a different system it might not be.
The decision was that LVDR’s user model — workstations with persistent local state, multi-device by default, security-conscious users — fits the device-keypair model better than the password model. A different system (a stateless mobile client, a public terminal, a one-time guest login) might choose differently and reach for Argon2id. The principle generalizes: ask whether your users have devices that can hold a private key, before you assume they need to remember a password.
7. The session keyring
Where do unwrapped DEKs live during a session? Three options:
- Unwrap per request. Every read does an asymmetric op. Too slow.
- Process-global cache. DEKs from many users in one shared map, garbage-collected on a schedule we don’t fully control. Too leaky.
- Per-WebSocket-session keyring. Unwrap on WS authenticate, store in a
map[connID]*KeyEntry, evict on disconnect, zero the bytes on evict.
LVDR runs option 3. Two non-obvious implementation details from internal/crypto/keyring.go worth surfacing:
The Get method returns a deep copy of the key entry, not a pointer. Without the copy, a concurrent Evict zeroing the underlying slice while a reader still holds the slice header reads zeros mid-decryption — a use-after-free race with a corruption symptom that looked like an authentication failure for weeks before we tracked it down. The copy is cheap (32 bytes per DEK plus a tiny map of project DEKs); the bug it prevents is expensive.
Eviction is hooked to WS close, not to a TTL. The DEK is alive exactly as long as the user’s session is alive. Laptop suspend disconnects the WebSocket, evicts the keyring, zeros the bytes. When the user wakes, the next message triggers re-auth and re-unwrap. There’s no “session timeout” sliding in 30-minute increments — there’s a session, or there isn’t. The mental model is simpler and the failure modes are easier to reason about.

8. What we punted, and when we’d revisit
Four deliberate non-decisions worth naming:
Post-quantum. No PQC anywhere in the stack today. The X25519 device-key wrap is exactly the kind of thing a future quantum computer breaks first, and the data we’re protecting today has a longer useful lifetime than the time-to-CRQC. The migration we have in mind: move the device-key wrap from NaCl box to an HPKE hybrid profile (X25519 + ML-KEM). The bulk-encryption layer doesn’t need to change — symmetric ciphers are fine against quantum adversaries with double key length. We haven’t shipped this yet because the libsodium-compatibility argument that made box win in §5 holds us back; we’d be the first to break the both-sides-libsodium invariant. See Harvest Now, Decrypt Later for why “we’ll add it when CRQC arrives” is the wrong plan.
HSM by default. The KeyProvider interface is the swap point, but the default SoftwareKeyProvider does everything in process memory. We’d reach for an HSM-backed implementation when (a) a customer requires FIPS-validated key handling, or (b) we’re operating in an environment where process-memory extraction is a real threat. Until then the marginal security gain doesn’t justify the operational cost, the latency of an HSM round-trip per wrap, or the dependency on a specific vendor’s PKCS#11 driver.
AAD binding. The XChaCha20-Poly1305 calls in provider.go pass nil for associated data. We’d ideally bind each ciphertext to its purpose (e.g., “DEK wrap for user X, scope project”) so a wrap can’t be replayed in a different context. The mitigation today is that wraps are scoped by where they’re stored — the key into Badger encodes the context — but a stricter design would use AAD as well. This is a “next time we touch the wrap chain” item, not an emergency.
Automatic DEK rotation cadence. Badger rotates its data key weekly. App-level DEKs rotate only on explicit triggers — a rekey event, a user request, an incident response. A sensible upgrade is automatic per-N-days rotation for app-level DEKs, but rotation has costs (re-wrap and synchronize across all devices) and we’ve punted until we see real key-exposure exposure in the field. The opt-in path exists; the always-on schedule doesn’t.
Each of these has a clear trigger. The point of writing them down — even on a public blog — is that they become assertions rather than oversights. If a future incident makes one of these load-bearing, we already know what we’d do.
Crypto choices look obvious in the spec doc and almost never look obvious in the code. The interesting decisions are the ones the spec doesn’t have language for — the ARM SoCs in the on-prem fleet, the libsodium that already shipped on the frontend, the constant-time guarantee on the hardware you couldn’t control. Reason from those forward, and the algorithm picks itself.