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.