V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
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 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 swapsalice'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/<pid>/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.
.bakfile produced byshade 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/registerclaims 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 asinbox.message_decrypt_failedwithout 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
InboxPruneTaskdrops 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.minTtlSecondsknobs.
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/filesfilesystem 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
< kpoints 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
recoveryKeyis 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. requestRecoveryexhaustively tries every threshold-sized subset of received grants until one authenticates; if none do, it raisesRecoveryReconstructionErrorand 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
approvecallback receives the new device's TEMPORARY safety number; the spec REQUIRES out-of-band comparison before approving. - The shipped
<RecoveryApprove />widget enforces a two-checkbox gate ("fingerprint matches" + "I verified OOB") before the release button is enabled. - The protocol-level
share-declineenvelope is sent regardless of whether the guardian'sapprovecallback 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-grantenvelopes if they convince the guardian'sapprovecallback to return true.
Mitigations:
- No single guardian's compromise is sufficient — the threshold
invariant still holds: the attacker needs
k-1other 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
≥ kguardians 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:
initReceiverSessionnow 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 inupload()when payload ≥ threshold (default 10 MiB) and the peer is unverified. A handler that returnsfalse(or throws / is missing in policy- forbid-TOFU mode) raisesFingerprintNotVerifiedError(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 fromShade.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/unmarkPeerVerifiedare storage-backed; thepeer_verifications+peer_identity_versionstables are subject to V3.2 at-rest encryption when the encrypted storage backend is used.<FingerprintCompare />and<FingerprintGate />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/streamswith deterministic nonce + AAD bound tostreamId || laneId || seq || isLast. A WebRTC implementation that returns altered bytes fails AEAD verification and the receiver raisesStreamDecryptionError.[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 configuredconnectTimeoutMs(default 5 s) without losing chunks already in flight. No silent stall.[tests: packages/shade-sdk/tests/webrtc-failover.test.ts] IRtcFactoryis pluggable; production usesglobalThis.RTCPeerConnection(browser / Workers / Deno),MemoryRtcFactoryis 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
RTCPeerConnectionimplementation 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
ArrayBuffers 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()anddestroy()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
postMessagereturns ownership; the runtime may keep deallocated buffers around for GC. Memory zeroization is best-effort for both threads.
Assumptions
- 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
- Cryptographic primitives are sound. We trust X25519, Ed25519, AES-256-GCM, HKDF-SHA256, HMAC-SHA256.
- The runtime is honest. A malicious Bun/Node/browser runtime can defeat any JS library.
- 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 |