When the server must compute on confidential data, split the data by who may read it

·8 mins

I designed a web app that computes a person’s tax on their investment trades. Professional advisory firms run it for their own clients. Clients connect broker APIs or upload statements, and the server runs lot matching, currency conversion, and the holding-period tests. A client with a messy case can hand it to an advisor.

The confidentiality requirement may sound contradictory. The advisor owes the client professional secrecy from the first contact. The platform operator should not be able to read client data at rest, and the same goes for a stolen backup, a tenant boundary bug, or a subpoena served on the platform. The server still has to compute on all of it, including in background jobs while the user is offline.

Neither usual answer works:

  • Disk or database encryption protects against a stolen disk. The operator, its DBAs, and a subpoena still get plaintext.
  • Client-side end-to-end encryption fails because the server is the calculator. Background sync, recomputation after new data, and the advisor’s view all need plaintext on the server. In a web app it is weaker than it sounds anyway: the server ships the JavaScript on every load, so an operator that serves malicious code gets the password.

What worked was a question I had skipped: which fields does the computation actually read?

List what the engine needs

For every realized event, the tax engine needs a date, a quantity, amounts in the reporting currency, fees, an event type, and an asset category (security, crypto, derivative). To match lots, it has to know that two trades are the same instrument. It doesn’t need the instrument’s name, ticker, or ISIN, and it doesn’t need the broker, account number, or person.

One step needs real identity: classifying an instrument into a category. A broker may report a CFD under “stocks”, and a share can be fractional or whole. That happens once, at ingestion, inside a per-broker adapter. The adapter sees identity in memory, emits a de-identified, categorized record, and the identity goes elsewhere.

So the data splits into two zones, each with its own readers:

Zone 1: tax skeletonZone 2: identity vault
Contentssurrogate instrument ID, category, dates, quantity, amounts, fees, event typesurrogate → name/ticker/ISIN, broker, account, personal data
Encrypted underper-tenant key encryption key in a KMS (envelope encryption)key chain rooted in the user’s password
Readersengine (in memory, during compute), the firm after handoffthe user in session, the firm after handoff
Platform operatorciphertext at rest; if it decrypts, it gets anonymous arithmeticciphertext only
Background jobsread and writewrite only, through a public-key layer
Lost passwordsurvives; the case can still be computed and filedlost unless the user has the recovery code

Zone 1 is intentionally not under the user’s key. Under the user’s key, the background sync, the in-year recompute, and the advisor’s computation would all stop working as soon as the user logged out. Background jobs write to Zone 2 by encrypting to the user’s public key. Only a logged-in user can decrypt.

Surrogate IDs: a keyed hash, scoped per user

Lot matching groups trades by instrument, so the skeleton needs a stable ID that is not the instrument. The design is an HMAC with a per-user key held in the KMS:

// Stable across one user's accounts and years, unlinkable across users.
export async function deriveSurrogateInstrumentId(userId: string, instrument: string, broker: string) {
  if (!instrument || !broker) throw new Error("surrogate requires instrument identity and broker");
  return kmsHmac(`surrogate:user:${userId}`, JSON.stringify([instrument, broker]));
}

Each alternative breaks one requirement:

  • A plain hash of the ISIN falls to a dictionary attack. The set of listed instruments is small, so hashing them all takes seconds.
  • One global HMAC key makes the same instrument correlate across every user in a dump.
  • A random ID per trade cannot be recomputed. Next year’s sync, or a second account at another broker, has to land on the same ID without reading the vault, or carried-in lots stop matching.

The keyed hash protects a database dump. An operator who can call the KMS live can still test “does this user hold this instrument”. That is past the threat model’s at-rest boundary, so write it down instead of claiming more.

Access and liability begin at the same instant

Before handoff, the firm’s advisors have no access to a prospect’s case. For identity, the user’s key enforces that cryptographically. The anonymous skeleton sits under the firm’s own key, so for it, access control is the barrier. It matches the legal position: no engagement contract, no professional liability, only precontractual confidentiality.

Handoff is one atomic event. The engagement contract forms, the firm’s anti-money-laundering (AML) and know-your-customer (KYC) duties start, and the vault’s relevant portion is rewrapped to the firm’s key. Data access and liability start together, so neither exists without the other.

The coding agent’s first draft at this step was wrong. It proposed default-minimal disclosure: share only the vault fields the flagged items needed. I rejected it because an advisor who takes on liability and an AML duty cannot sign off on a case half-blind. The boundary that held was purpose, not field sensitivity: everything the past outcome was built from transfers in full, and anything forward-looking (plans, rebalancing ideas, notes) never transfers. Data minimization still applies, but the line runs between past and future, not through the case.

One password, auto-unlock, never transmitted: pick an aPAKE

The first plan was standard session login plus a client-side KDF for the vault key, with OPAQUE as a later upgrade. That leaves two bad choices:

  • Two passwords: one for login, sent to the server, and one for the vault, used only in the browser. Users hate it.
  • One password, still POSTed for login, also used to derive the vault key. It gives auto-unlock, and it quietly hands the operator the vault password on every login. Zone 2 stops protecting anything.

With a standard login, one password cannot also stay off the server. An asymmetric PAKE fixes the login side. OPAQUE (RFC 9807) authenticates without the server learning the password, and gives the client an export_key that the server never sees. Root the vault in it:

// K_vault = HKDF(export_key); unwraps user key → RSA private key → vault entries
function deriveVaultKeyBytes(exportKey: Uint8Array): Uint8Array {
  if (exportKey.length === 0) throw new Error("export_key is required");
  return hkdf(sha256, exportKey, new Uint8Array(0), K_VAULT_INFO, 32);
}

A password reset is a new OPAQUE registration with a new export_key. The mandatory recovery code rewraps the vault chain to the new root, and Zone 1 is untouched. Use a vetted library: the registration and login flows and the export_key handling are now part of the vault’s security.

The tenant boundary belongs in the database

Each firm is a tenant, and one firm’s prospects must never show up in another firm’s queries. A forgotten WHERE firm_id = ? should return nothing, not another tenant’s rows. With Postgres row-level security, three details decide whether it fails closed:

alter table client owner to app_migrator;          -- the app role is not the owner
alter table client force row level security;       -- the owner obeys policies too
create policy firm_isolation on client
  using      (firm_id = current_setting('app.firm_id')::uuid)
  with check (firm_id = current_setting('app.firm_id')::uuid);
await db.transaction().execute(async (tx) => {
  await sql`select set_config('app.firm_id', ${firmId}, true)`.execute(tx); // true = transaction-local
  return work(tx);
});
  • Table owners bypass RLS unless it is forced. Superusers and BYPASSRLS roles bypass it always, so the app must connect as neither.
  • current_setting without missing_ok, plus the ::uuid cast, raises when the tenant was never set or is empty. A missing tenant is an error, not an empty match.
  • set_config(..., true) resets at commit or rollback. With false, a pooled connection carries one firm’s ID into the next request, and a transaction-mode pooler can hand the setting to a different client.

Write down the floors

The design makes specific claims and lists what it does not cover. That list is part of the design:

  • Plaintext exists in memory during ingestion and compute. The claim is “zero to the operator at rest”, never “the platform never sees it”.
  • amount ÷ quantity approximates a unit price that can be matched against market data. It is exploitable only by someone who already decrypted Zone 1.
  • The web JavaScript trust gap stays open. OPAQUE stops offline cracking of a stolen verifier table, not a malicious bundle. A native client is the eventual fix.
  • An API-connected account reveals which broker it is, because the token names it. A manual upload keeps the broker in Zone 2. The user gets that choice in the UI.
  • A forgotten password without the recovery code loses the labels. The skeleton survives, and the user is told to keep their own copies of the source statements.

Takeaway

When the server has to be the calculator, don’t choose between “encrypt the disk” and “encrypt everything client-side”. List the readers (user, professional, engine, operator) and what each one needs. Then check whether the computation can run without identity. When it can, identity moves into a zone the computation never opens, and the remaining decisions follow from that: keyed surrogates, a handoff that rewraps keys, an aPAKE for the single password.

In practice, I ran the design as an interview with a coding agent. It asked one question at a time, proposed options with a recommendation, and recorded each decision as an ADR with a “Why this is non-obvious” section: the design a reader would expect, and why it was rejected here. Several recommendations were reasonable but wrong. Two of my corrections removed whole planned pieces: the default-minimal handoff, and an instrument catalog that event-driven classification made unnecessary.