All advisories

Advisory

Zero Knowledge Is a Schema Decision

Calling a vault end-to-end encrypted is easy. Making it true is mostly about what columns exist. We walk through the Locker's derivation chain, why the server stores a wrapped key it cannot unwrap, why the authentication hash is a fast one on purpose, and the recovery design that follows from all of it.

First published
2026-07-09 00:00 UTC
Last updated
2026-08-22 12:10 UTC
Handling. Hostile hostnames and addresses below are published defanged and are never live links. Do not reconstruct or visit them. This advisory names infrastructure only — no reporter, affected party or account is identified anywhere in it, by design.

Every product that stores secrets says it encrypts them. The claim is close to meaningless on its own, because the interesting question is not whether encryption happened but who can perform the decryption — and that question is answered by the database schema long before it is answered by any cryptographic library.

This is how the Locker's schema answers it.

The derivation chain

Nothing derived from your passphrase leaves your device. The chain runs:

passphrase
   │  PBKDF2, 600,000 iterations, per-user random salt
   ▼
master key  (never transmitted, never stored)
   │  HKDF
   ├──▶ encryption key   → AES-GCM over every item
   └──▶ authentication material

PBKDF2 at 600,000 iterations is the deliberate slow step, and it is slow on purpose: it is what makes an offline guessing attack against a weak passphrase expensive. HKDF then splits that one expensive result into separate keys for separate jobs, so the value that encrypts your data is not the value used to prove you know the passphrase.

What the server actually holds

The row for an account is roughly:

kdf_algo, kdf_iterations, kdf_salt   -- so your device can redo the derivation
protected_key                        -- the item key, wrapped under the master key
auth_hash                            -- proof you know the passphrase
recovery_salt, recovery_protected_key

The important column is protected_key. The key that actually decrypts your items is generated randomly and then wrapped under the master key derived on your device. The server stores the wrapped form. It has never seen the master key, so it holds a key it cannot unwrap, next to data it cannot read.

That indirection is what makes changing your passphrase cheap. Re-wrapping one key is a single small write. Re-encrypting every item would be a migration, and a product that makes changing your passphrase expensive is a product where people do not change it.

Why the authentication hash is a fast hash

This looks wrong the first time you see it. auth_hash is not an expensive password hash — and it should not be.

The expensive work has already happened, on the client, in the 600,000-iteration PBKDF2 step. auth_hash is computed over material derived after that. An attacker who steals the database and wants to brute-force the passphrase does not get to attack auth_hash cheaply, because every candidate passphrase still costs them a full PBKDF2 derivation before they can produce a candidate hash to compare.

Putting a second slow hash on the server would add server cost and no security. The cost that matters is already paid where it has to be paid, and it is paid by the attacker too.

Recovery, stated honestly

recovery_salt and recovery_protected_key are a second wrapping of the same item key, under a secret generated for you at setup. It is a second door to the same room, not a copy of the room.

What follows from that is worth saying plainly, because vendors tend to be vague here: we cannot recover your passphrase, and we cannot reset it for you. There is no support process that ends with us decrypting your items, because there is no code path in which we could. If both the passphrase and the recovery secret are lost, the wrapped key stays wrapped. That is the cost of the guarantee, and a product that offered to rescue you from it would be telling you the guarantee is not real.

Why there is no row-level security on these tables

We use Postgres row-level security elsewhere in the product, on a table where it is a clean fit. The Locker tables are enforced at the application layer instead, and the reason is that RLS protects against the wrong threat here.

RLS keeps tenant A from reading tenant B's rows through a query that forgot its filter. That is valuable when rows contain readable data. These rows do not. An attacker who obtains every ciphertext row for every user has obtained a pile of AES-GCM blobs and a set of wrapped keys, and the property that saves you is that the keys are not present — not that a policy stopped the read.

Adding RLS here would buy defence in depth against a class of bug the encryption already neutralises, in exchange for a policy that must be threaded through every access path or it silently returns zero rows. We spent that complexity budget where readable data lives.

Two implementations, one test vector

The web client and the native apps implement this chain separately, in different languages, against different crypto libraries. There is exactly one way to be sure they agree: known-answer tests, with fixed inputs and fixed expected outputs, that both implementations run.

Without them, a divergence does not announce itself at build time. It announces itself when someone sets up on a phone and cannot unlock on a laptop — with correctly encrypted data that the other implementation derives the wrong key for. The KAT vectors are locked, and changing one is a deliberate act with a migration attached, not an implementation detail somebody tidies.

Step-up before destruction

One last schema-adjacent decision. Erasing or recovering a Locker requires a fresh re-authentication, not merely an unlocked session. An unlocked session proves you unlocked it at some point; it does not prove the person at the keyboard right now is you. For an action that destroys data irreversibly, that difference is the whole thing.

Captured payloads and SHA-256 manifests are available to abuse desks, registrars and CERTs on request: [email protected].

Corrections are welcome and are made in place with the update time shown above, rather than silently.