Klef 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), 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:
- A DEK (data encryption key): 32 random bytes, generated once per account. Every env blob is encrypted with this.
- A KEK (key encryption key): derived from your master passphrase. Its only job is to wrap the DEK.
- 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, 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. That's half the reason it's open source.