Where the key lives

May 9, 2026

Most writing about end-to-end encryption stops at the moment the ciphertext is produced. You pick a key derivation function, argue about its parameters, wrap a data key, and the diagram ends there. It reads like the problem is solved.

The part I found genuinely hard came after. Once someone has unlocked a vault in a browser tab, there is a usable key in memory, and every question from that point is about lifetime rather than algorithms. Where does it live. How long does it survive. What does "lock" actually mean when the same account is open in four tabs.

Two gates, and only one of them is defensible

The system has two independent gates. Signing in proves who you are, using ordinary OAuth and passkeys, and the server can verify it. Unlocking proves you know the passphrase, and the server cannot verify it at all, because it has never seen it.

That second point has a consequence I did not appreciate until I went looking for where to put a lockout counter. There is nowhere to put one. A wrong passphrase is not a rejected request. It is an AES-GCM authentication tag that fails to verify in the browser, on ciphertext the client already had. No request is made, so nothing can count the attempt, throttle it, or lock an account after five tries.

The rate limits that do exist, three sign-in attempts per ten seconds, protect the gate that is not guarding the secrets. This is not a flaw so much as an honest consequence of the design: if the server could slow down a guess, the server would be participating in the unlock, and it deliberately does not.

What defends the passphrase instead is cost. Argon2id at 19 MiB of memory, two iterations, single-threaded, producing a 256-bit key from a random 128-bit salt. Where WebAssembly is unavailable it falls back to PBKDF2-HMAC-SHA-256 at 600,000 iterations. Those numbers are the entire defense, because they set the price of each offline guess against ciphertext an attacker already holds.

Which makes the eight-character minimum on the passphrase the weakest number in the system. A carefully chosen memory cost cannot compensate for eight characters. That tension sits unresolved in the code today.

Three doors, one room

There are three ways to arrive at the same data key. The passphrase derives a key-encryption key through the slow KDF. The recovery key derives one through a single HKDF pass. Each enrolled passkey derives one from a WebAuthn PRF output.

Data key32 random bytes
PassphraseArgon2id, 19 MiB, t=2, p=1
Recovery key128 bits, one HKDF pass
PasskeyWebAuthn PRF, one per credential
One key, wrapped three times. Adding a door costs one encryption of 32 bytes; the files are never touched.

The recovery key skipping the slow KDF is the detail people query most, and the reasoning is that a slow KDF exists to compensate for low-entropy human input. A recovery key is 128 random bits. There is nothing to compensate for, so it gets one HKDF pass with a versioned context string instead.

Adding a door never touches the data. Enrolling a passkey re-wraps 32 bytes. Changing a passphrase rewrites the key derivation parameters and one wrapped key, and every encrypted file in the account is left bit-identical.

There is a small irony in the passkey path. Keys are imported as non-extractable, which is the property that makes the whole thing safe to keep around, and it means the live data key cannot be read out to wrap under a new door. So enrolling a passkey requires typing the passphrase again, to re-derive what is already sitting in memory but deliberately unreadable.

What happens after unlock

Here is the part I actually spent time on.

An unlocked data key has to survive a page reload, or the product is unusable. Storing key bytes anywhere persistent is obviously wrong. The resolution is that the key is imported as a non-extractable CryptoKey and that object is put in IndexedDB, which can structured-clone it. The browser will happily use it to decrypt afterwards, and no script can read the bytes back out, including mine.

Then the question becomes how long that record is allowed to live, and the answer I settled on is that it must not exceed the idle auto-lock window. If the vault locks after fifteen minutes of inactivity, a stored key older than fifteen minutes is deleted rather than restored. Closing the browser must not buy a longer unlocked session than leaving the tab open would have.

A freshness stamp is refreshed every sixty seconds while unlocked, and records belonging to a different account are evicted rather than merely ignored, so switching users cannot leave someone else's key sitting in the database.

The auto-lock timer itself checks a wall-clock deadline every thirty seconds rather than setting one long timer, because background tabs get throttled and a throttled timer would let an idle vault stay unlocked well past its window. It also re-checks the moment a tab becomes visible again.

Lock has to mean everywhere

Multiple tabs turned out to be the subtlest part. If someone hits lock in one tab and three others stay unlocked, the button is a lie.

Lock events broadcast across tabs, and every tab clears its key on receipt. There is a related decision I like: auto-lock deliberately bypasses the "you have unsaved changes" confirmation, while the manual keyboard shortcut respects it. An idle vault must lock whether or not there is a dirty draft in it. A deliberate action by a present user is allowed to ask first.

What the server can see

The server stores the key derivation parameters, the salts, the nonces, the wrapped keys, and the ciphertext. It also stores workspace, project, and file names in plaintext, because navigation needs them.

That last one is a real concession rather than an oversight, and it is documented as such. A file named .env.production leaks roughly what its label leaks, but the honest framing is that names are not protected and should not be treated as if they were.

Nothing else crosses. The plaintext stored is the pasted text byte for byte, never a re-serialized key-value form, so comments, blank lines, and multi-line values survive a round trip unchanged.

Reflections

Writing this up made me re-read the code with more suspicion than usual, and I found two things I would not have claimed if I had not.

The first is that there is no additional authenticated data binding a ciphertext to the file it belongs to. Each blob is encrypted under the same account-level key with a fresh nonce, and nothing inside the authenticated data says which file or which version it is. A malicious server could serve one of your own ciphertexts in place of another and the client would decrypt it without complaint. The blast radius is limited, since everything involved belongs to the same user, but it is a real integrity gap in a design that is otherwise careful, and the fix is cheap.

The second is that the threat model names cross-site scripting as in scope, and the application ships no Content-Security-Policy. Non-extractable keys mean injected script cannot steal the key itself, which is worth something, but it can still ask the browser to decrypt with it, or simply read the passphrase out of the input field. Naming a threat and not shipping the cheapest mitigation against it is the kind of gap that only shows up when you audit your own claims.

I also found a documentation error while re-reading: the format document says the key derivation runs in a Web Worker, and it does not. It runs on the main thread. Nobody was harmed by that, but the wrong version has been sitting in a file people are invited to read in order to trust the design, which makes it worse than a normal stale comment.

The broader thing I took from it is that the cryptography was never where the difficulty lived. Choosing Argon2id parameters took an afternoon and is well-covered ground. Deciding how long a decrypted key may exist in a browser, what invalidates it, and how four tabs agree on the answer took considerably longer and has almost nothing written about it.

The vault is called Klef, and the format document is worth reading before you trust it.