Files
Shade/THREAT-MODEL.md
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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>
2026-05-03 18:35:35 +02:00

26 KiB
Raw Blame History

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 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/<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. .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 <RecoveryApprove /> 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.
  • <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/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 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() 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 LowMedium 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 LowMedium Use address-hashes + per-session sender keys (V3.6 §6); mix-net relay tier is a future candidate