# Threat Model This document describes what Shade protects against and what it doesn't. Read this before deploying Shade in any context where the answers matter. > Each numbered "Mitigations" entry below ends with a `[tests:]` > footnote that links to the concrete test file(s) demonstrating the > mitigation. If a mitigation has no `[tests:]` line, treat it as > documentary — there is no automated test holding the line yet. > See [SECURITY.md § Threat-/test-matrix](./SECURITY.md#threat--test-matrix) > for the consolidated index. ## Assets The thing we're protecting: - **Message plaintext** — the actual content of encrypted messages between peers - **Identity private keys** — long-term Ed25519 signing key + X25519 DH key - **Session state** — Double Ratchet root keys, chain keys, DH keypairs ## Adversaries we consider ### 1. Network attacker (active) Can intercept, modify, drop, replay, and inject network traffic between clients and the prekey server, and between two clients. **Mitigations:** - All identity-key writes to the prekey server are signed (Ed25519). Tampering is detected. `[tests: packages/shade-server/tests/server.test.ts — "rejects unsigned registration", "rejects registration with wrong signing key"]` - Signed requests have a 5-minute replay window. `[tests: packages/shade-server/tests/server.test.ts — "rejects registration with stale signedAt"]` - The Double Ratchet binds message headers to ciphertext via AES-GCM AAD, so header tampering breaks decryption. `[tests: packages/shade-core/tests/ratchet.test.ts — "tampered ciphertext fails", "tampered header (counter) fails due to AAD"; packages/shade-streams/tests/tamper.test.ts; packages/shade-streams/tests/aead.test.ts]` - Forward secrecy: even if an attacker captures all traffic, compromising a key later doesn't help them read past messages. `[tests: packages/shade-crypto-web/tests/hardening.test.ts; packages/shade-core/tests/ratchet.test.ts — DH ratchet steps + out-of-order delivery]` **NOT mitigated:** - Initial session establishment can be MITM'd if users don't verify identity fingerprints. The prekey server could distribute a fake bundle on first contact. Always compare safety numbers out-of-band for high-stakes communications. ### 2. Malicious or compromised prekey server The server holds identity public keys and prekey bundles. It can serve them to anyone. **Mitigations:** - The server only stores PUBLIC keys, never private ones. `[tests: packages/shade-server/tests/server.test.ts — registration, bundle fetch, replenish; packages/shade-storage-sqlite/tests/sqlite-prekey-store.test.ts]` - Write operations are signed with the identity private key, so the server can't forge new identities or replenishments without the user's key. `[tests: packages/shade-server/tests/server.test.ts — "rejects replenishment signed by wrong identity", "rejects delete signed by wrong identity"]` - Bundle fetches are unauthenticated, so a malicious server can serve fake bundles. Detection requires out-of-band fingerprint comparison. `[tests: packages/shade-core/tests/fingerprint-session.test.ts]` **NOT mitigated:** - A malicious server can substitute one user's prekey bundle with the server operator's own keys, enabling MITM at session establishment. Users must verify safety numbers to detect this. **Partially mitigated by V3.12 Key Transparency** (opt-in): - When the operator runs the server with `keyTransparency: { ... }` and clients pin the operator's STH-signing public key, every bundle fetch returns a Merkle inclusion proof against an append-only Signed Tree Head. A server that swaps `alice`'s bundle for one client and not another, or rewrites history to hide an earlier swap, is detected by an independent witness. KT does **not** prevent first-contact impersonation — a never-seen-before address can still be served maliciously on its very first registration. `[tests: packages/shade-key-transparency/tests/manager.test.ts — "rotation: new register replaces old"; packages/shade-transport/tests/kt-split-view-e2e.test.ts — "two divergent views at the same tree_size are caught by witness"; packages/shade-server/tests/kt.test.ts — "bundle response carries verified inclusion proof"]` ### 3. Compromised endpoint (post-compromise) Attacker briefly gains code execution or filesystem access on a user's device, exfiltrates session state, then loses access. **Mitigations:** - Forward secrecy: messages sent BEFORE the compromise cannot be decrypted with the leaked state. Old chain keys are zeroed after use. `[tests: packages/shade-core/tests/ratchet.test.ts — basic send/receive, ping-pong; packages/shade-crypto-web/tests/hardening.test.ts — zeroize]` - Post-compromise security: as soon as a peer initiates a new DH ratchet step, the leaked state becomes useless for new messages. `[tests: packages/shade-core/tests/ratchet.test.ts — "alternating messages trigger DH ratchets"]` - Memory zeroization: message keys and chain keys are wiped from JS memory after use (best-effort — V8 may retain copies). `[tests: packages/shade-crypto-web/tests/hardening.test.ts — "zeroize" describe block]` - Identity rotation invalidates leaked at-rest stream-resume secrets (device-key derived from signing key). `[tests: packages/shade-core/tests/identity-rotation.test.ts; packages/shade-transfer/tests/resume.test.ts]` **NOT mitigated:** - An ongoing endpoint compromise can read messages in real time and exfiltrate identity private keys. - Attackers with persistent access can intercept new identity rotations. ### 4. Compromised device storage Attacker gains access to the persistent storage (e.g., steals the SQLite file or dumps the PostgreSQL table). **Mitigations (default, no at-rest encryption):** - Stream-resume secrets *are* encrypted at rest under a device-key derived from the identity signing key, so a stolen DB without the live identity key cannot resume in-flight transfers. `[tests: packages/shade-transfer/tests/resume.test.ts]` - Filesystem-level encryption (LUKS, FileVault, BitLocker) is recommended but is the user's responsibility. **Mitigations (with at-rest encryption enabled — V3.2 / `@shade/storage-encrypted`):** - All sensitive payloads are sealed with AES-256-GCM under per-(table, column) field keys derived from a passphrase (scrypt) / OS keychain / app-injected master key. A stolen DB file alone yields no usable private key material. `[tests: packages/shade-storage-encrypted/tests/encrypted-sqlite.test.ts]` - AAD binds (table, column, pk) so an attacker cannot swap rows or move ciphertext between columns without triggering decrypt failure. `[tests: packages/shade-storage-encrypted/tests/encrypted-sqlite.test.ts — "row swap (sessions) → decrypt fails due to AAD mismatch"]` - Bit-flips in the ciphertext blob are detected by the AEAD tag; the storage layer raises rather than returning corrupt key material. `[tests: packages/shade-storage-encrypted/tests/aead.test.ts; encrypted-sqlite.test.ts — "flipped ciphertext byte → decrypt fails"]` - Wrong passphrase / wrong keychain entry is rejected up-front via a fingerprint check, never silently writing under the wrong key. `[tests: packages/shade-storage-encrypted/tests/encrypted-sqlite.test.ts — "rejects open with wrong key (fingerprint mismatch)"]` - Online key rotation re-keys every row without downtime; the old key no longer opens the DB after rotation. `[tests: packages/shade-storage-encrypted/tests/migrate.test.ts — "re-keys all rows; old key no longer opens DB"]` **NOT mitigated (even with at-rest enabled):** - A live process holds the storageKey and field keys in memory; an attacker who can read process memory (e.g., via `/proc//mem`, swap dump, hibernation file) recovers the keys and thus the data. At-rest encryption protects the DB *file*, not the running process. - The kernel's swap partition is not encrypted by Shade. If the OS pages key material to disk, it can be recovered. Use an encrypted swap device. - A coredump of the live process exposes plaintext private keys. - Filesystem-level encryption of the DB *backup* (e.g. `.bak` file produced by `shade migrate-storage`) is the operator's responsibility — the backup is plaintext during the brief migration window. - If the master key is lost (forgotten passphrase, deleted keychain entry, lost injected key) the DB is permanently unrecoverable. V3.10 (Social Recovery) is the long-term mitigation. ### 5. Side-channel attacks (timing) Attacker measures timing of identity verification operations to recover key bits. **Mitigations:** - All comparisons of secret material use constant-time XOR-accumulator comparison (`constantTimeEqual`). `[tests: packages/shade-crypto-web/tests/hardening.test.ts — "constantTimeEqual", "timing variance stays bounded across mismatch positions"]` - AES-GCM and the underlying primitives are constant-time as implemented by SubtleCrypto and @noble/curves. `[tests: packages/shade-crypto-web/tests/provider.test.ts; packages/shade-streams/tests/aead.test.ts]` **NOT mitigated:** - JavaScript JIT compilation can introduce timing variability that's hard to control. - We don't claim resistance to power-analysis or fault-injection attacks (out of scope for a JS library). ### 6. Malicious or compromised inbox relay (V3.6 store-and-forward) The inbox relay holds **ciphertext blobs with TTL** so senders can deliver to offline recipients. It is a separate trust domain from the prekey server, and exposes a different surface. **Mitigations:** - The relay only stores `address || msgId || ciphertext-bytes || expires_at`. Plaintext, ratchet state, and any private keys live exclusively on the client. A DB dump leaks no message content. `[tests: packages/shade-inbox-server/tests/routes.test.ts; packages/shade-inbox-server/tests/lifecycle.test.ts — "Tamper resistance"]` - Recipient identity is bound to the address via TOFU: first `POST /v1/inbox/register` claims the slot, and subsequent fetch/ack must be Ed25519-signed by the same key. A different key claiming an existing address is rejected with 401. `[tests: packages/shade-inbox-server/tests/routes.test.ts — "rejects different key claiming same address", "rejects fetch from a different signing key", "rejects ack from a different signing key"]` - Each PUT is signed by the sender's per-PUT signing key; the relay verifies the signature before persisting. Bad sigs return 401. `[tests: packages/shade-inbox-server/tests/routes.test.ts — "rejects bad sender signature"]` - `msgId = sha256(ciphertext)` is verified server-side on PUT and recomputed client-side on FETCH. A relay that flips a bit in storage produces a digest mismatch the recipient flags as `inbox.message_decrypt_failed` *without* acking, so the divergence surfaces in operator telemetry instead of being silently consumed. `[tests: packages/shade-inbox-server/tests/routes.test.ts — "rejects mismatched msgId"; packages/shade-inbox-server/tests/lifecycle.test.ts — "Tamper resistance"; packages/shade-inbox/tests/client.test.ts — "tamper detection"]` - Replay-window of ±5 minutes on `signedAt` (matches the prekey server's policy). Replays past that window return 409. `[tests: packages/shade-inbox-server/tests/routes.test.ts — "rejects stale signature (replay window)"]` - Idempotent PUT: two clients (or a buggy retry loop) submitting the same ciphertext do *not* create duplicate rows; the second PUT returns 200 with `idempotent: true`. `[tests: packages/shade-inbox-server/tests/routes.test.ts — "idempotent on duplicate ciphertext"]` - Periodic `InboxPruneTask` drops blobs past their TTL so a slow consumer never sees a payload past expiry. `[tests: packages/shade-inbox-server/tests/lifecycle.test.ts — "prune removes expired blobs but keeps live ones"]` **NOT mitigated:** - **Sender-recipient graph leakage.** The relay sees recipient address + per-PUT sender pubkey + ciphertext byte-counts. Privacy-sensitive deployments should use address-hashes (`sha256(real-address || salt)`) and rotate sender signing keys per session. Mixing/onion-routing is out of scope for V3.6 and a candidate for a future relay tier. - **Operator-side queue deletion.** A malicious operator can drop every blob queued for a target, forcing senders to resend. Recipient-side ack happens *after* successful decrypt, so a delete only burns one delivery attempt rather than silently consuming a message. - **TTL-based reachability signal.** A PUT silently expiring after 7 days reveals that the recipient never came online. Operators concerned with this metadata should clamp TTLs to a fixed value via the `quota.maxTtlSeconds` / `quota.minTtlSeconds` knobs. ### 7. Denial of service Attacker floods the prekey server to exhaust resources or one-time prekeys. **Mitigations:** - Per-IP rate limiting on registration and bundle fetches. `[tests: packages/shade-server/tests/rate-limit.test.ts — "register endpoint rate-limits per IP", "rate limit returns Retry-After header"]` - Per-identity rate limiting on replenish and delete. `[tests: packages/shade-server/tests/rate-limit.test.ts — "different keys have independent limits"]` - 64 KiB body size limit on POST endpoints. `[tests: packages/shade-server/tests/server.test.ts — body-size enforcement]` - Address validation rejects path traversal and malformed inputs. `[tests: packages/shade-server/tests/server.test.ts — "rejects invalid address format", "rejects invalid address in URL"]` - Per-sender ops/byte quotas on `@shade/files` filesystem RPC. `[tests: packages/shade-files/tests/security/quota.test.ts]` - Per-recipient blob quota on `@shade/inbox-server` (default 1000 blobs per address) + per-blob byte cap (default 1 MiB) so a single sender cannot fill a recipient's queue. `[tests: packages/shade-inbox-server/tests/routes.test.ts — "rejects ciphertext > maxBlobBytes", "enforces per-address quota"]` - Per-IP token-bucket on inbox PUT/FETCH/DELETE/REGISTER routes. **NOT mitigated:** - Application-level DDoS at the network layer is your hosting platform's responsibility. ### 8. Social-recovery adversaries (V3.10) Once a user has set up `@shade/recovery`, the guardian set becomes a new attack surface. We split the threat into four cases: **8a. Coalition of ≤ k-1 guardians.** **Mitigations:** - Shamir Secret Sharing over GF(2^8) is information-theoretically secure: the shares are points on a polynomial whose constant term is the secret, and any subset of `< k` points is consistent with every possible secret. No coalition smaller than the threshold recovers anything beyond the secret's length. `[tests: packages/shade-recovery/tests/shamir.test.ts — "k-1 shares yield a wrong (random-looking) result", "property: any k-1 share subset yields a different output than the secret"; packages/shade-recovery/tests/adversarial.test.ts — "property: any (k-1) subset of shares fails to recover the key"]` **8b. Single malicious guardian who forges a share.** **Mitigations:** - The reconstructed `recoveryKey` is authenticated by the AES-GCM tag inside the backup blob (`Shade.exportBackup`'s ciphertext). A forged share produces a different reconstructed key; AES-GCM decryption fails. - `requestRecovery` exhaustively tries every threshold-sized subset of received grants until one authenticates; if none do, it raises `RecoveryReconstructionError` and refuses to apply the result. The user is told that at least one guardian is malicious. `[tests: packages/shade-recovery/tests/adversarial.test.ts — "a corrupted share never authenticates against the backup AEAD tag"]` **8c. Social-engineering (impersonator calls a guardian).** **Mitigations:** - The guardian's `approve` callback receives the new device's TEMPORARY safety number; the spec REQUIRES out-of-band comparison before approving. - The shipped `` widget enforces a two-checkbox gate ("fingerprint matches" + "I verified OOB") before the release button is enabled. - The protocol-level `share-decline` envelope is sent regardless of whether the guardian's `approve` callback returns false or throws, so a hard "no" terminates the requesting flow promptly. `[tests: packages/shade-recovery/tests/adversarial.test.ts — "approve handler that REJECTS a wrong fingerprint never sends a grant", "throwing approve handler counts as decline with descriptive reason"]` **NOT mitigated:** - A guardian who is duped by an impersonator AND whose user clicks through both checkboxes WILL release their share. Defense in depth requires user education + per-guardian cool-down windows (a follow-up release). **8d. Guardian device compromise.** If an attacker fully owns a guardian's device, they can: - Read the share + backup blob → contributes one polynomial point. - Ship `share-grant` envelopes if they convince the guardian's `approve` callback to return true. **Mitigations:** - No single guardian's compromise is sufficient — the threshold invariant still holds: the attacker needs `k-1` other shares to rebuild the identity. - Backup blobs are encrypted at-rest under the guardian's existing StorageProvider scheme (V3.2 covers this for SQLite/Postgres backends). **NOT mitigated:** - Compromise of `≥ k` guardians simultaneously is a complete break. This is by design: the recovery flow is meant to survive *device* loss, not coordinated mass compromise of the social graph. ### 9. Cross-sender X3DH state corruption Before V3.10, `initReceiverSession` shared a reference to the receiver's signed prekey keypair with the new session. The first DH ratchet step zeroed the session's "previous" private key, which silently zeroed the persisted signed prekey. A second X3DH from a *different* sender to the same receiver then derived a divergent root key and decryption failed with "wrong key or tampered data". This was a pre-existing bug surfaced by the V3.10 multi-sender recovery flow. **Mitigations:** - `initReceiverSession` now copies the localDHKeyPair into the session so the eventual zeroize touches a scratch buffer, not the persisted prekey. `[tests: packages/shade-recovery/tests/integration.test.ts — "recovery from new device with all 5 guardians available"; packages/shade-core/tests/x3dh.test.ts]` ### 10. MITM bypass via skipped fingerprint verification (V3.3) The strongest mitigation for §1 / §2 / §6 — out-of-band safety-number verification — is a *user* responsibility. Shade 4.0 ships `@shade/sdk` fingerprint gates that move it from "convention" to "enforced policy on the operations that matter". **Mitigations:** - `Shade.beforeFirstLargeFile(threshold, handler)` — runs in `upload()` when payload ≥ threshold (default 10 MiB) and the peer is unverified. A handler that returns `false` (or throws / is missing in policy- forbid-TOFU mode) raises `FingerprintNotVerifiedError` (HTTP 403). `[tests: packages/shade-sdk/tests/fingerprint-gates.test.ts; packages/shade-files/tests/security/fingerprint-gate.test.ts]` - `Shade.beforeBackupImport(handler)` — receives the *backup-embedded* fingerprint before any state is written. Decrypted backups whose embedded identity does not match the user's expectation are rejected before they touch storage. `[tests: packages/shade-sdk/tests/fingerprint-gates.test.ts]` - `Shade.beforeNewDeviceTrust(handler)` — runs from `Shade.acceptIdentityChange()` after the peer's identity-version is bumped, so any prior verification automatically goes stale and the user must re-verify. `[tests: packages/shade-sdk/tests/fingerprint-gates.test.ts]` - `markPeerVerified` / `isPeerVerified` / `unmarkPeerVerified` are storage-backed; the `peer_verifications` + `peer_identity_versions` tables are subject to V3.2 at-rest encryption when the encrypted storage backend is used. - `` and `` widgets present the safety number side-by-side and require an explicit "matches" click before children render. **NOT mitigated:** - Apps that never register handlers default to "TOFU + warning". The warning is logged, not rendered, so a UX that ignores the log silently keeps TOFU semantics. - Once verified, a peer's persisted verification stays valid until identity rotation. A device-compromise that does **not** trigger rotation keeps the verification alive. ### 11. WebRTC peer-to-peer transport (V3.11) `@shade/transport-webrtc` lets two peers ship `@shade/transfer` chunks over an `RTCDataChannel` instead of HTTP. The DTLS layer is opaque to Shade; we treat WebRTC strictly as a **byte-pipe** — not a trust boundary. **Mitigations:** - The same Double Ratchet that authenticates Shade messages authenticates the SDP offer / answer / ICE / bye signaling envelopes. A network attacker who replaces an SDP offer must forge a ratcheted message — the receiver decrypts via the existing peer session and rejects on AEAD failure. `[tests: packages/shade-transport-webrtc/tests/signaling.test.ts; packages/shade-sdk/tests/webrtc-integration.test.ts]` - Frame payloads on the DataChannel are AES-GCM-sealed by `@shade/streams` with deterministic nonce + AAD bound to `streamId || laneId || seq || isLast`. A WebRTC implementation that returns altered bytes fails AEAD verification and the receiver raises `StreamDecryptionError`. `[tests: packages/shade-streams/tests/tamper.test.ts; packages/shade-transport-webrtc/tests/wire-format.test.ts]` - Glare resolution is deterministic (lexicographic address compare) so both sides converge on a single connection without re-running signaling. `[tests: packages/shade-transport-webrtc/tests/glare.test.ts]` - When NAT traversal fails, `MultiTransportFallback([webrtc, http])` demotes to HTTP within the configured `connectTimeoutMs` (default 5 s) without losing chunks already in flight. No silent stall. `[tests: packages/shade-sdk/tests/webrtc-failover.test.ts]` - `IRtcFactory` is pluggable; production uses `globalThis.RTCPeerConnection` (browser / Workers / Deno), `MemoryRtcFactory` is in-process for tests. **NOT mitigated:** - TURN relay metadata. If the deployment ships a TURN server, the operator sees relayed-byte counts and timing for every flow that traverses the relay. Use a TURN you control or a hosted relay you trust. - Browser/RTC stack vulnerabilities. A compromised `RTCPeerConnection` implementation is outside the scope of a JS library; we ride the platform's WebRTC. - Public STUN exposes the client's public IP to the STUN server. This is unavoidable without a privacy-preserving NAT discovery mechanism (out of scope). ### 12. Web-Worker thread boundary (V3.8) `@shade/crypto-web/worker` runs AEAD, HKDF, HMAC, X25519, Ed25519, and per-lane stream state inside a dedicated Web Worker so the main thread never holds key material for very long. **Mitigations:** - Lane keys, identity private keys and ratchet chain keys are passed into the worker once at setup; subsequent operations move plaintext via transferable `ArrayBuffer`s and never re-export keys. `[tests: packages/shade-crypto-web/tests/worker-streams.test.ts; packages/shade-crypto-web/tests/worker-provider.test.ts]` - Idle timeout (default 30 s) calls `terminate()` on the worker, which drops the global JS heap and releases the OS-level memory backing any keys that were not yet zeroized. - `rotate()` and `destroy()` lifecycle controls let apps bound the worst-case duration any lane key sits in worker memory. - Worker-protocol version handshake on first message rejects mismatched workers (e.g. cached old build). **NOT mitigated:** - The worker is still inside the same browsing context; an attacker who can inject script into the page can post a malicious message and read the worker's reply. CSP and SRI on the worker entrypoint are the user's responsibility. - Heap memory is not synchronously wiped when `postMessage` returns ownership; the runtime may keep deallocated buffers around for GC. Memory zeroization is best-effort for both threads. ## Assumptions 1. **The user has a secure way to bootstrap trust.** Either: - Trust on first use (TOFU) — accept the first identity key seen for a peer - Out-of-band verification — compare safety numbers in person/video before trusting 2. **Cryptographic primitives are sound.** We trust X25519, Ed25519, AES-256-GCM, HKDF-SHA256, HMAC-SHA256. 3. **The runtime is honest.** A malicious Bun/Node/browser runtime can defeat any JS library. 4. **The prekey server is reachable.** If it's offline, new sessions can't be established (but existing sessions continue working). ## Residual risks | Risk | Severity | Mitigation | |------|----------|------------| | MITM at first session establishment | High | Compare safety numbers out-of-band; in 4.0, register `Shade.beforeFirstLargeFile` / `beforeBackupImport` / `beforeNewDeviceTrust` to enforce verification on the operations that matter (V3.3) | | Identity private key theft from device | Critical | Filesystem encryption, secure enclave (future); V3.10 Social Recovery for *recovery* after loss | | Prekey server operator runs a "key oracle" attack | Medium | V3.12 Key Transparency (opt-in) detects split-view + history rewrites; gossip via a `LightWitness` raises the cost of a sustained attack | | TURN relay sees byte-counts of P2P transfers | Low–Medium | Only when WebRTC fails over to TURN. Operate your own TURN if the metadata matters | | Side-channel via JIT timing variability | Low | Constant-time primitives reduce but don't eliminate; V3.8 Web-Worker isolation bounds the lifetime of in-memory key material | | Metadata visibility to prekey server | Low | Acceptable for most use cases; mix networks for stronger metadata protection | | Inbox relay sees recipient address + byte-counts | Low–Medium | Use address-hashes + per-session sender keys (V3.6 §6); mix-net relay tier is a future candidate |