# Threat model — StealthLedger (community deploy)

**Scope.** This document is about the multi-tenant community deploy
(`MULTITENANT=1`), not the fortress single-tenant deploy at
`portrun.netlify.app`. The fortress has a smaller trust surface (one user,
one bearer token, no signup, no admin panel) and is not covered here except
where noted.

**Status.** Living document. Update whenever a code change moves an assumption
listed below.


## 1. What the design protects

The one-line goal: **the operator (and any attacker who compromises the
operator) never sees plaintext portfolio contents.**

Everything else is subordinate to that. In particular:

- Amounts, coin metadata, notes, custom names are encrypted client-side with
  a passphrase-derived key. The server stores ciphertext blobs, and there is
  no server-side path to a plaintext amount.
- The passphrase never leaves the browser. The login passphrase produces a
  scrypt verifier over a per-user salt; only the verifier crosses the wire.
- Coin IDs sent to the price proxy are the only cryptocurrency metadata the
  server ever sees, and they identify a coin type (`monero`) not a holding.
  This is called out in `README.md`.


## 2. Actors

- **User.** Normal account holder. Trusted with their own vault only.
- **Operator.** Owns the Netlify site and `OPERATOR_TOKEN`. Can mint invites,
  toggle signup mode, list users (count only), read raw ciphertext.
  **Cannot** decrypt any vault: they don't have any user's passphrase.
- **Passive network attacker.** Sees HTTPS metadata (Netlify edge <-> user).
  Sees nothing about vault contents.
- **Active bot.** Wants to create many accounts, exhaust rate limits, or
  degrade the deploy.
- **Compromised user session.** An attacker with valid cookies for one user.
  Should be able to affect only that user.
- **Compromised operator.** An attacker who obtains `OPERATOR_TOKEN`. Should
  be able to modify the deploy's signup gates and mint invites, but still
  not decrypt any vault.


## 3. Assets

| Asset                    | Sensitivity                                  |
|--------------------------|----------------------------------------------|
| Vault ciphertext         | Public-safe (encrypted). Availability matters. |
| Passphrase / DEK         | Never leaves the browser.                    |
| Username                 | Semi-public (visible to operator).           |
| Email (optional)         | Sensitive (recovery hint only).              |
| Session cookies          | Session-lived, HttpOnly, Secure, SameSite=Lax. |
| CSRF token               | Session-lived, non-HttpOnly (needs to be readable by JS). |
| Operator token           | Highest sensitivity. Rotates via Netlify env. |
| Signup invite codes      | Single-use, 192-bit random.                  |
| PoW challenge blobs      | Ephemeral (5 min TTL), single-use.           |
| Price cache blobs        | Public data.                                 |


## 4. Attacker capabilities and mitigations

### 4.1 Bulk signup / abuse of open mode

- **Threat.** Bot scripts open thousands of accounts to exhaust Blobs quota
  or squat usernames.
- **Mitigations.**
  1. **Signup mode.** Default `invite`: only holders of a minted single-use
     code can sign up. `open` mode is opt-in and always paired with (2).
  2. **Proof of work.** `SIGNUP_POW_BITS` (default 20, ~1M SHA-256 tries,
     1-3s in a real browser). Applied in **every** signup mode, including
     `invite`, so a leaked invite pile can't be burnt through in an afternoon.
  3. **Per-IP throttle.** 5 signups/hour per IP unconditionally; open mode
     adds a per-IP daily cap of 10.
  4. **Global daily cap.** `SIGNUP_GLOBAL_DAILY_MAX` (default 50) site-wide.
     Backstop for when a single actor scales horizontally across IPs.
- **Residual risk.** A patient attacker with a botnet and CPU-hard PoW
  solvers can still open accounts up to the global cap. The cap is a knob;
  set it to the number of new accounts the operator is willing to onboard
  per day.
- **Not implemented.** No captcha (third-party dependency, privacy leak).
  No email verification (mail is optional). No IP reputation.

### 4.2 Password / passphrase attacks

- **Threat.** Attacker guesses a login passphrase or DEK passphrase.
- **Mitigations.**
  1. **Client-side scrypt.** Both passphrases go through scrypt with a
     per-user salt before anything leaves the browser. `N=2^15, r=8, p=1`
     currently; tunable.
  2. **12-character minimum.** Enforced client- and server-side. Blocks the
     "password" / "123456789012" class immediately.
  3. **Login rate limiting.** Per-IP (20 attempts/15min) and per-username
     (10/15min), independent, both must pass.
- **Residual risk.** Users are free to pick weak-but-long passphrases. The
  scrypt work factor makes online brute force uneconomic; a stolen verifier
  blob would still fall to offline attack against a bad passphrase. This is
  documented in the FAQ.

### 4.3 Session theft

- **Threat.** Attacker exfiltrates a session cookie via XSS or a stolen
  device.
- **Mitigations.**
  1. **HttpOnly session cookie.** Not readable by JS; XSS can't grab it.
  2. **SameSite=Lax + Secure.** CSRF surface bounded; also required for
     modern Chrome.
  3. **Absolute session cap.** 30 days maximum, then the cookie is refused
     regardless of activity.
  4. **Idle relock.** Client locks after configurable inactivity; the DEK
     is dropped from memory.
- **Residual risk.** A device that's already unlocked and active leaks
  everything the user can see. Mitigation is out of scope (Sec ops).

### 4.4 CSRF

- **Threat.** A malicious page tricks a logged-in user's browser into
  writing to the vault.
- **Mitigations.**
  1. **Double-submit token.** Every state-changing endpoint requires both
     the session cookie and a matching `x-csrf-token` header. The token is
     stored in a non-HttpOnly cookie the same-origin JS can read; a
     cross-origin page can't.
  2. **SameSite=Lax** on the session cookie is a second wall.

### 4.5 Operator compromise (SEP 2025 amendment)

- **Threat.** `OPERATOR_TOKEN` leaks or is stolen. Compromised operator can
  admin the site.
- **Mitigations.**
  1. **No plaintext access.** Even a full operator compromise cannot decrypt
     any user's vault. The operator has the ciphertext and metadata, not the
     passphrase.
  2. **Constant-time compare.** Token check is length-then-XOR to remove the
     timing side channel.
  3. **Idle relock on admin panel.** 15 minutes of admin inactivity clears
     the operator token from sessionStorage.
  4. **404-on-no-token.** If `OPERATOR_TOKEN` isn't set, `/api/admin` and
     `/api/invites` return 404 — no fingerprint of an admin endpoint at all.
- **Residual risk.** Compromised operator can:
  - mint arbitrary invites and toggle signup mode -> possible spam wave.
  - block signups (`closed`) -> denial of service.
  - inspect blob metadata: usernames, email hints, ciphertext sizes.
  - **not** read plaintext contents.
  Response: rotate `OPERATOR_TOKEN` in Netlify env, redeploy, revoke unused
  invites via the admin panel.

### 4.6 Price provider abuse

- **Threat.** Someone hammers `/api/prices` to burn CoinGecko quota or spam
  the fallback.
- **Mitigations.**
  1. **Three-layer cache** (in-flight dedupe, in-memory memo, Blobs).
     Multiple instances share the same fetched result for 60s.
  2. **Stale-if-error up to 15 min.** Upstream failures return stale data
     with a warning rather than an outage.
  3. **Input validation.** `MAX_IDS=60`, strict regex on coin IDs, strict
     regex on `vs_currencies`. No injection surface.
- **Residual risk.** A determined attacker with many IPs and many distinct
  coin-id sets can push cache misses. The COINGECKO_API_KEY pro-tier ceiling
  is the ultimate cap.

### 4.7 Rename spam

- **Threat.** Compromised session sprays rename requests to squat usernames
  and confuse other users.
- **Mitigations.** Per-user throttle at 5 renames/hour. Rename endpoint
  requires session + CSRF. Considered adding a "flag" on >3/day but the
  throttle already binds tighter; not worth the storage.
- **Residual risk.** A slow drip below the throttle threshold is possible;
  operator can revoke the account via the admin panel if noticed.

### 4.8 Blob tampering by operator

- **Threat.** Operator edits a user's vault ciphertext in Blobs.
- **Effect.** Vault fails to decrypt on next unlock -- AEAD tag mismatch.
  User sees "vault corrupt", not "attacker won".
- **Mitigations.** AES-256-GCM is authenticated; any tampering surfaces.
- **Residual risk.** Operator can delete a vault (denial of service).
  Availability is not a promised property of the free-tier community deploy.


## 5. Explicit non-goals

The following are **not** protected against and users should assume they
work as normal websites:

- A compromised browser (malware, malicious extension) can read anything
  the user could see.
- Metadata patterns visible to the network (login times, request sizes,
  coin IDs requested) are not obfuscated.
- Deleted vaults are best-effort deleted; Netlify Blobs replication and
  backups may retain them beyond the delete API's contract.
- The operator can see username, optional email, ciphertext blob sizes,
  and access patterns. Users who need this hidden should self-host.


## 6. Change history

- 2026-08: initial draft alongside `multitenant-preview` branch.
