# How Klef encrypts your .env files

The zero-knowledge security model behind Klef, from envelope encryption and Argon2id to why the recovery key skips the slow KDF.

[Klef](https://klef.sh) is a personal, zero-knowledge vault for `.env` files. Paste your env files in, sync them across machines, pull them back down, all end-to-end encrypted. The server only ever stores ciphertext. It's open source ([AGPL, on GitHub](https://github.com/BenyD/Klef)), so this post can point at real code instead of hand-waving.

The whole design fits in one sentence: **the server must never be able to read your secrets, even if I turn evil or get breached.** Everything below falls out of taking that sentence seriously.

## Signing in is not unlocking

Klef separates two things most apps merge. Google OAuth or a passkey proves *who you are* and gets you a session. A separate master passphrase, which is never sent anywhere, is what actually decrypts your data.

This means a logged-in but locked client sees only ciphertext. You can lock the vault manually, by keyboard shortcut, or on an inactivity timer, without signing out. And it means a compromised session token gets an attacker exactly nothing readable.

## Envelope encryption: DEK and KEK

Blobs are not encrypted directly with your passphrase-derived key. There's a level of indirection, and it earns its keep:

1. A **DEK** (data encryption key): 32 random bytes, generated once per account. Every env blob is encrypted with this.
2. A **KEK** (key encryption key): derived from your master passphrase. Its only job is to wrap the DEK.
3. The server stores the *wrapped* DEK: the DEK encrypted under the KEK.

Changing your passphrase re-wraps one 32-byte key instead of re-encrypting every blob you've ever saved. The same shape extends naturally to per-workspace keys later.

One WebCrypto subtlety I'm fond of: Klef wraps the DEK by encrypting its raw bytes with `subtle.encrypt` rather than using `subtle.wrapKey`. The `wrapKey` API requires the wrapped key to be marked `extractable`; encrypting raw bytes lets both the KEK and DEK live as **non-extractable** `CryptoKey` objects, so even injected JavaScript can't export them.

## The KDF numbers

The passphrase goes through **Argon2id** with 19 MiB of memory, 2 iterations, parallelism 1, and a 16-byte random salt (the OWASP floor, run in a Web Worker). Where WASM isn't available, it falls back to **PBKDF2-HMAC-SHA-256 at 600,000 iterations**.

The KDF id and parameters are stored per account, next to the ciphertext. That sounds odd until you realize nothing in them is secret: salts, nonces, and cost parameters only need uniqueness, not confidentiality. Storing them means the cost can be raised, or the algorithm swapped entirely, without re-encrypting anything but the wrapped DEK.

Actual encryption is **AES-256-GCM** with a fresh 96-bit CSPRNG nonce per operation and a 128-bit auth tag. An (IV, key) pair is never reused.

## The recovery key is different on purpose

Forget your passphrase and there is no reset flow, because the server has nothing to reset with. The only fallback is a recovery key: 128 bits of CSPRNG entropy shown exactly once at setup, encoded as Crockford Base32 (no I, L, O, or U, so it survives handwriting) with a `KLEF-` prefix.

Here's the detail I enjoy: the recovery key does *not* go through Argon2id. Slow KDFs exist to compensate for low-entropy human passphrases. A 128-bit random key needs no compensation, so its wrapping key is derived with a single fast **HKDF-SHA-256** pass. The server stores a second copy of the DEK wrapped under that key. Same zero-knowledge property, none of the pointless work.

Lose both the passphrase and the recovery key and the data is gone. That's not a bug to apologize for; it's the proof the system works as claimed.

## Honest limits

Zero-knowledge is a claim about the *server*. Klef does not protect against a compromised client device, a keylogger, a malicious browser extension, or a weak passphrase brute-forced offline. Workspace and file *names* are plaintext by design so the UI can navigate without unlocking; only env contents are encrypted.

The full contract lives in [BLOB_FORMAT.md](https://github.com/BenyD/Klef/blob/main/src/shared/BLOB_FORMAT.md), written so the format can be reimplemented in another language and stay wire-compatible. Every blob carries a version field, because crypto parameters should be able to improve without breaking anyone's vault.

If you spot a hole in any of this, [email me](mailto:benydishon@gmail.com). That's half the reason it's open source.

Last updated on Jul 10, 2026.