Writing

How we encrypt your balance sheet

A description of the actual construction, including the parts that are not encrypted and the situations where this protects nobody. Written for readers who intend to check.

· 8 min

The requirement

One sentence: the server must never hold anything that lets it compute a user's figures, and the product must still open on a phone the user has not used before. The second half is what makes this harder than a local-only design, and it is the reason the key material has to be recoverable from something the user carries in their head rather than in a file on one laptop.

The key schedule

Four steps, all standard, none of them ours to invent. Inventing a key derivation is how you end up with an interesting CVE.

  • A twelve-word BIP-39 phrase, 128 bits of entropy from the browser's cryptographic random source, with a real checksum so a mistyped word is caught rather than silently producing a different key.
  • The phrase derives a seed with PBKDF2-HMAC-SHA512 at 2048 rounds, which then derives a non-extractable AES-GCM-256 wrapping key via HKDF-SHA256.
  • The wrapping key wraps a per-account data encryption key. Only the wrapped form is ever transmitted or stored.
  • The data key encrypts each row's values individually. What is stored per value is a version byte, an initialisation vector, and ciphertext.

Why per-row and not per-blob

Encrypting the whole account as one document would be simpler and would make the product unusable: every edit would rewrite everything, two tabs would clobber each other, and a partial read would be impossible. Per-row means the database still behaves like a database — rows can be inserted, updated and deleted individually — and it means a single corrupted value loses one holding rather than a balance sheet.

What is deliberately in plaintext

Row identifiers, foreign keys, dates, record types, and the dashboard layout. Structure has to be readable for queries to work at all. This is the part that most 'end-to-end encrypted' marketing pages omit, and omitting it is what makes the rest of the page untrustworthy. The practical consequence is specific and worth stating: we can see that an account holds seven crypto positions and two properties and that one changed last Tuesday. We cannot see a single number.

Where the unwrapped key lives

In the memory of one tab, and nowhere else. It is never written to disk, never sent to the server, and never shared with another tab. Close the tab and the vault re-locks. This is deliberately inconvenient: a key persisted to local storage would survive a refresh and would also survive every piece of code that can read local storage.

The passkey path, and what it does not weaken

Typing twelve words every time a tab closes is how a product gets abandoned, so a second copy of the data key can be wrapped under a secret derived during an ordinary passkey check — Touch ID, Windows Hello, a hardware key. Enrolment is only possible from an already-unlocked vault, which means a passkey can never be a way in for somebody who does not already have the key. Where the browser cannot do it, the phrase still works and nothing errors.

The server side, and why it is boring

Postgres with row-level security, and an application role that owns nothing, holds no schema rights, and cannot bypass those policies. Every request runs in a transaction scoped to a single user id; a request that fails to set it sees zero rows rather than everyone's. Queries also filter by user explicitly — the policy is the backstop, not the only guard. None of this protects the figures, because the figures are already ciphertext. It protects the structure.

The failure modes

Three, stated in the order they are likely. If you lose the phrase and have no enrolled passkey, the figures are unrecoverable by anyone including us; the account can be reset, which destroys the data and keeps the login. If your device or browser is compromised while the vault is unlocked, the attacker reads what you can read — encryption at rest on our side does nothing about a keylogger on yours. And if you keep the phrase in a note on a synced phone, its privacy is that phone's privacy, not ours.

What we would need to change to read your data

Ship browser code that exfiltrates the key. That is the honest answer, and it is true of every client-side-encrypted web application, including the well-regarded ones. The mitigation is that it would be a deliberate, detectable act rather than a query someone could run — and that it is the kind of thing which, once done, is found. We would rather write that sentence than let the architecture imply a guarantee it does not make.

If something here is wrong, we would rather hear it than not. The construction is standard on purpose, which means it is checkable on purpose.