# Cryptography — what your browser actually does

This is a short, honest description of the crypto that runs on
`stealthledger.net`. It exists so that a reader can decide whether the
guarantees make sense, without asking anyone to trust marketing copy.

For the wider "what the operator can see and can't see" picture, read
the [threat model](/docs/threat-model.md) alongside this doc.


## Design goal

**The operator of `stealthledger.net` cannot read your holdings, even
with full control of every server they run.** Everything below serves
that one property.

Consequences the design accepts up front:

- The plaintext portfolio never leaves your browser.
- Losing your passphrase (and any recovery credential you set up) means
  the data is gone. There is no reset path, because there is no reset
  key. This is a feature.
- The site owner turning malicious is a real threat, not a rhetorical
  one — see *What this design does not protect against* below.


## Primitives

Standard building blocks, boring parameters, no in-house cryptography.

| Purpose | Primitive |
|---|---|
| Symmetric encryption | **AES-256-GCM** (96-bit random IV per message, 128-bit tag) |
| Password-based key derivation | **scrypt** (RFC 7914) — `N = 65536, r = 8, p = 1, dkLen = 32` (~64 MiB per guess, ~150–250 ms on a desktop core) |
| Passkey-based key derivation | **HKDF-SHA-256** over the WebAuthn PRF extension output |
| Randomness | Web Crypto `getRandomValues` only — never `Math.random`, never seeded |

Every operation runs through the browser's native
[`crypto.subtle`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto)
API. There is no bespoke primitive, no custom mode, and no third-party
crypto dependency beyond a vendored WASM build of scrypt.

Passphrases are **NFKC-normalised** before entering the KDF, so the
same human-typed phrase derives the same key across iOS keyboards,
macOS, and Linux terminals.


## What the server sees

- Your **username**, so it knows which encrypted blob to hand back.
- A **scrypt verifier** of your login credential (passphrase or
  passkey-derived secret). The credential itself never crosses the wire.
- If you signed up with a passkey: the **credential ID**, so the browser
  can prompt the right one next time. Never the private key.
- Your **encrypted vault blob**. It is opaque bytes to every server-side
  code path.
- An **audit trail** of *when* your vault changed, from roughly which
  region, and how big it got. Never *what* changed.

That's the full list. There is no server-side path to a plaintext
amount, holding, or note.


## What this design does NOT protect against

Listed rather than swept aside:

- **A malicious build of the client.** Your browser runs whatever the
  site serves. A determined operator (or an attacker who owns the site)
  could push a build that captures your passphrase before it reaches
  the KDF. Mitigations are architectural, not cryptographic: a strict
  Content-Security-Policy (Trusted Types, no inline scripts, no
  third-party origins), a small hand-written client, and this
  published spec so you have something to diff against.
- **Endpoint compromise.** Malware on your device that reads your typed
  passphrase, or scrapes the decrypted DOM, defeats the whole scheme.
- **Metadata correlation.** Login times, IP regions, envelope size,
  and audit-log timing are visible to the operator. See §4 of the
  [threat model](/docs/threat-model.md).
- **Coercion.** No cryptography can help someone force you to type your
  passphrase.


## Verifying it for yourself

The client-side crypto is one module —
[`/shared/vault-core.js`](/shared/vault-core.js). Your browser is
running it right now.

- **Read it.** ~780 lines, no external crypto dependencies.
- **Hash it.** `sha256` the served file and compare against a build of
  the source you trust.
- **Diff it.** If any claim in this document disagrees with what the
  module does, the module wins and this doc is a bug. Please open an
  issue so it can be fixed.


## Change log

- **2026-08.** First public revision.
