summaryrefslogtreecommitdiffstats
path: root/docs/meshbay-draft-v5.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/meshbay-draft-v5.md')
-rw-r--r--docs/meshbay-draft-v5.md343
1 files changed, 343 insertions, 0 deletions
diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md
new file mode 100644
index 0000000..0d64b0c
--- /dev/null
+++ b/docs/meshbay-draft-v5.md
@@ -0,0 +1,343 @@
+# MeshBay — Architecture Draft v5
+
+> Status: Phase 11.5 (security remediation) largely complete — see `devel-phases-next.md`.
+> Supersedes `meshbay-draft-v4.md`. Sections not restated here are unchanged from v4.
+>
+> v5 exists because the second security review (`second-review.md`, 2026-08-13) found
+> that v4 described a system the code did not implement, and because several v4 claims
+> were simply wrong. This document states what is true, what is chosen, and what is
+> knowingly accepted. **Where a property does not hold, it says so.**
+
+---
+
+## 0. How to read this document
+
+v4 described intent. Several of its statements were contradicted by the implementation:
+
+| v4 said | Reality found in the code |
+|---|---|
+| "ALL operations require passing the GEK proof first" (§4.2.x) | True on WebRTC only. QUIC, TCP and the node HTTP API each had their own weaker handshake |
+| "The node NEVER serves the GEK in plaintext" | True, but the node's HTTP API served the *decrypted files themselves* with no authentication at all |
+| "Argon2id parameters updated: memory_cost 262144" | Applied to the hub's password verifier only; the node keystore was still at 64 MB |
+| "GEK bundles: opaque blobs; hub cannot decrypt them" | True, and irrelevant — the hub is the key directory, so it can substitute a key at invite time and be handed the GEK legitimately |
+| "hub stores no content metadata" | The node registered blake3 hashes of **private** files with the hub |
+
+So v5 carries an explicit rule: **a claim in this document must name the adversary it
+holds against.** A property that holds against a passive hub and not an active one is
+written that way.
+
+---
+
+## 1. Changes from v4
+
+| # | Category | Change | Source |
+|---|---|---|---|
+| 1 | Transport | TCP+TLS transport and the node HTTP file API **removed**. Two listeners remain: WebRTC DataChannel (primary) and QUIC (LAN / port-forwarded / hub-less) | 11.5.1–2, findings C1/C6 |
+| 2 | NAT traversal | **ICE/STUN is the traversal mechanism**, for native clients too. `punch_nat()` is a direct-connection helper, not a traversal stack | Decision D3 |
+| 3 | Handshake | One implementation in `meshbay_common.handshake`, shared by every transport | 11.5.4, finding C6 |
+| 4 | Handshake | **Mutual authentication**: the node proves GEK possession and signs the transcript. Authentication used to run one way only | 11.5.7, finding C3 |
+| 5 | Handshake | Transcripts are length-prefixed and domain-separated; `group_id` is mandatory; missing channel binding is refused | L4, M1 |
+| 6 | Admin ops | Destructive operations sign a structured transcript naming operation, node, group, subject, nonce and time — not 32 opaque random bytes | Finding H5 |
+| 7 | Node authority | GEK bundle storage requires the node operator's signature; **nothing arriving over MNP can activate a GEK** | Finding C5b |
+| 8 | Uploads | Confined to a per-user quarantine directory; no overwrite | Finding C5a |
+| 9 | Node identity | Hub verifies node ownership on WebSocket registration; group claims are derived from the database | Finding C2 |
+| 10 | Revocation | Group revocations are honoured by nodes; denylist persists across restarts | Finding H4 |
+| 11 | Privacy | Only **public** groups register content hashes with the hub | Finding H7 |
+| 12 | Client architecture | The hub **keeps serving the web UI**. A native desktop client is offered **alongside** it | Decision D1/D2 |
+| 13 | Hub role | "Hub minimization" is **deferred and may be dropped**. The hub stays in the trusted path by choice | Decision D4 |
+
+---
+
+## 2. Security claims — what holds, against whom
+
+This replaces the informal assurances scattered through v4 §4.2.x and §13.
+
+| Claim | Passive hub | **Active hub** | Malicious node operator | Malicious group member | Network attacker |
+|---|---|---|---|---|---|
+| File content is unreadable | ✅ | ❌ **H3** | ❌ by design — the operator hosts the files | ❌ members share the GEK | ✅ |
+| Chat content is unreadable | ✅ | ❌ H3 | ❌ plaintext at rest until Phase 15 | ❌ | ✅ |
+| File index is unreadable | ✅ | ❌ H3 | ❌ | ❌ | ✅ |
+| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ |
+| Node cannot be impersonated | ✅ | ⚠️ pending 11.5.8 pinning | — | ✅ | ✅ |
+| Client code integrity | ❌ **T3, accepted** | ❌ T3 | ✅ | ✅ | ✅ |
+| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ |
+
+**The claim this project can make:** *the hub cannot read your content unless it actively
+attacks you.* That is true, defensible, and stronger than most platforms offer.
+
+**The claim it must not make:** *"everything is encrypted and unreadable by other parties,
+even the hub."* Two reasons, both deliberate:
+
+- **H3 — key substitution.** The hub is the public-key directory. When a member invites
+ someone, the inviter fetches the invitee's `pk_x25519` **from the hub** and wraps the
+ GEK for it. A hub returning its own key is handed the group key. No forgery, no code
+ injection, undetectable by the client today. Fixed only by key transparency and safety
+ numbers (Phase 12.1); open until then.
+- **T3 — the hub serves the SPA.** Accepted permanently for browser users (decision D1).
+ A hub that ships the code can lift keys from the page regardless of protocol design.
+
+Content is also readable by **every group member** and by **the node operator**, who
+stores it in plaintext on disk. That is inherent to the model, not a defect — but it
+means "end-to-end" here describes *client ↔ node*, never *client ↔ client*.
+
+---
+
+## 3. Transport (replaces v4 §6.4, §6.5, §7.1)
+
+### 3.1 Listeners
+
+| Listener | Role | Status |
+|---|---|---|
+| **WebRTC DataChannel** (aiortc) | Primary, browser **and** native clients | Unified handshake enforced |
+| **QUIC** (aioquic) | LAN, port-forwarded, hub-less `group://` | Authorization shared; **GEK proof pending** |
+| ~~TCP + TLS 1.3~~ | — | **Removed** (C6) |
+| ~~HTTP file API~~ | — | **Removed** (C1) |
+
+The HTTP API bound `0.0.0.0` for every group, private ones included, and served the
+Mesh Group Index and raw plaintext files with no authentication. It was deleted rather
+than repaired: it duplicated MNP without any of its controls.
+
+### 3.2 NAT traversal — correction
+
+v4 presented `punch_nat()` as the native-client traversal mechanism. It is not one. It
+is a single UDP probe to one address (`quic_server.py`), with:
+
+- no STUN client (the `aioice` dependency is pulled in by `aiortc` only)
+- no candidate gathering, no candidate pairs, no dual-stack fallback
+- a requirement that the client already know its own external IP:port and connect from a
+ fixed source port
+
+It was validated on one ISP and one NAT type. **ICE/STUN is the traversal path** —
+validated across two ISPs, two browsers, IPv4 STUN + IPv6 direct, and 4G CGNAT.
+
+Native clients therefore run `aiortc` in Python rather than reimplementing traversal.
+Every native benefit (local keystore, ChaCha20, Argon2id, Ed25519 auth) comes from being
+native, not from the transport. QUIC is retained for LAN, port-forwarded and hub-less
+access, where a signaling channel is unavailable or unwanted.
+
+---
+
+## 4. MNP handshake (replaces v4 §4.2.x handshake description)
+
+One implementation: `meshbay_common/handshake.py`.
+
+```
+client → node handshake {token, group_id, nonce_c}
+node authorize_token() JWT · scope · denylist · group_id · membership · hosting
+node → client handshake_challenge {nonce_s}
+client → node handshake_response {proof}
+node verify HMAC(GEK, client transcript)
+node → client handshake_ack {proof, sig, node_pk, is_node_admin}
+client verify HMAC(GEK, node transcript) + Ed25519(node_pk)
+```
+
+### 4.1 Transcript
+
+```
+"meshbay:mnp:handshake:v1" ‖ len‖role ‖ len‖group_id ‖ len‖nonce_c ‖ len‖nonce_s ‖ len‖binding
+```
+
+Every field length-prefixed; the role bound in so a client proof cannot be replayed as a
+node proof. v4's transcript was `nonce ‖ offer_fp ‖ answer_fp` — bare concatenation, and
+a missing fingerprint silently degraded it to nonce-only, dropping MitM detection.
+**An absent channel binding is now refused.**
+
+### 4.2 Channel binding
+
+| Transport | Anchor |
+|---|---|
+| WebRTC | both DTLS certificate fingerprints |
+| QUIC | SHA-256 of the server certificate — **unproven**, see 11.5.6. An RFC 5705 exporter would be stronger; aioquic does not currently expose one |
+
+### 4.3 Authorization rules
+
+- `group_id` is **mandatory**. Omitting it previously skipped the membership check and
+ fell back to the node's first group.
+- Node-scoped daemon tokens are **refused** on the client path.
+- The denylist is consulted for user, `jti` **and** group.
+
+### 4.4 Mutual authentication
+
+The node proves GEK possession over a client-chosen nonce **and** signs the transcript
+with its long-term key. Previously the client received an unverifiable `node_pk` and
+trusted `is_node_admin` from whoever answered — so a peer that had hijacked signaling
+could serve a forged index, forged chat history and forged permissions.
+
+**Still open (11.5.8):** the client verifies the signature but does not yet *pin* the
+key, so a substituted node is caught only by its lack of the GEK.
+
+---
+
+## 5. Node authority (replaces v4 §4.2.x)
+
+### 5.1 Privileged operations
+
+Destructive and privileged operations require an Ed25519 signature over a structured
+transcript, never a JWT — the hub controls JWT issuance and can therefore never
+establish node-level authority.
+
+```
+"meshbay:admin:v1" ‖ len‖op ‖ len‖node_pk ‖ len‖group_id ‖ len‖subject ‖ len‖nonce ‖ len‖ts
+```
+
+TTL 120 s. The client reconstructs the transcript from announced fields and **refuses to
+sign** if the operation or subject does not match what the user requested. v4's challenge
+was 32 opaque random bytes signed blind — an unbound signing oracle.
+
+| Operation | Authority |
+|---|---|
+| `file_delete` | Node operator, or the user who uploaded the file (verified by the key recorded at upload) |
+| `gek_bundle_store` | **Node operator only** |
+| GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK |
+
+`gek_bundle_store` previously accepted any member's bundle and auto-activated one
+addressed to the operator. Since the operator's X25519 public key is published in
+`handshake_ack`, any member could wrap a key of their choosing for it and take over the
+group. **Consequence of the fix:** a group admin who does not run the node can no longer
+invite members — the operator decides what is stored on their machine.
+
+### 5.2 Uploads
+
+Confined to `shared_root/.uploads/{user_id}/`, filename allowlist, no overwrite, chunk
+ordering enforced, size capped. Previously uploads landed in the shared root under a
+client-chosen name and overwrote anything there — which also defeated deletion
+authorization, since overwriting a file made the attacker its recorded uploader.
+
+### 5.3 Local admin UI
+
+Loopback plus a **per-run session token** (`?t=` or `X-MeshBay-Token`), printed at
+startup. "Localhost only" is not authentication: any local process can reach it, as can
+a page in the operator's browser via DNS rebinding — and this API re-initialises group
+keys and reads the audit log. All interpolated values are HTML-escaped; CSP contains
+exfiltration but cannot prevent injected inline script, so escaping is the actual fix.
+
+---
+
+## 6. Hub (replaces v4 §4.1)
+
+### 6.1 Role — chosen, not minimal
+
+Hub minimization was considered and **deferred** (decision D4). The hub keeps serving
+the web UI and remains in the trusted path by choice. This section describes what it
+*does*, not an aspiration.
+
+**Stores:** accounts (username, encrypted email, public keys, status, role), group
+registry and membership, IP logs (1 year, legal), node registrations, refresh tokens,
+notifications, moderation blocklist.
+
+**Does not store:** file content, file names, private-group indexes, message content,
+private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling.
+
+**Knows, unavoidably:** who is a member of what, when nodes connect, when a chat message
+was posted and by whom (`chat_notify` — minimization was in the dropped phase), and
+content hashes for **public** groups only.
+
+### 6.2 Node registration
+
+Registration on `/v1/nodes/ws` requires a node-scoped token, verifies `Node.user_id`
+against the token subject, and derives group claims from the database — a node may
+narrow the set to what it hosts but cannot widen it. It previously took `node_id` and
+`group_ids` verbatim from the client's first message, letting any user hijack a victim
+node's signaling and impersonate it to browsers.
+
+### 6.3 Signaling
+
+Rate-limited, SDP size bounded, capped per user, and the caller must share an **active**
+group with the target node. Previously reachable by any authenticated user for any node,
+making a third party's machine allocate peer connections on demand. `peer_ip` in the NAT
+punch request must match the caller's source address.
+
+### 6.4 Client addresses
+
+`X-Forwarded-For` is honoured only from a trusted proxy, rightmost hop. It was trusted
+unconditionally, so anyone could forge the IP written to the compliance log.
+
+---
+
+## 7. Cryptography (amends v4 §6)
+
+Unchanged: GEK wrapping (ECIES, X25519 + HKDF + AEAD), per-chunk key derivation, domain
+separation, on-the-fly encryption.
+
+| Parameter | Value |
+|---|---|
+| Node keystore KDF | Argon2id **256 MB**, iterations 3, lanes 4 — parameters recorded per envelope so they can be raised without orphaning existing keystores |
+| Hub password verifier | Argon2id 256 MB over a PBKDF2-derived `auth_key` |
+| Browser bundle key | PBKDF2-SHA512, 600 000 iterations |
+
+**Known weakness (C4).** Keypair bundles are protected only by PBKDF2 and are pushed to
+every node whose group the user joins, while GEK and keypair bundle fetches are served in
+the pre-proof window because the client needs its bundle to compute the proof. The window
+is bounded (4 fetches) and audited. It closes properly when the native client stops
+storing keypair bundles remotely (Phase 13.3) — the material belongs on the user's own
+device, not on the hub *or* on other people's nodes.
+
+---
+
+## 8. Clients (replaces v4 §4.3)
+
+### 8.1 Two clients, deliberately
+
+| | Hub-served web SPA | Native desktop client |
+|---|---|---|
+| Distribution | Served by the hub | Installed, signed release |
+| Code integrity | **T3 accepted** — the hub can inject | Detectable *if* reproducible builds ship |
+| Key storage | IndexedDB / sessionStorage | OS-protected local keystore |
+| Crypto | WebCrypto (AES-GCM) | Full (ChaCha20, Argon2id) |
+| Transport | WebRTC | WebRTC **+ QUIC** |
+| Positioning | **Convenience tier** — zero install | Recommended for sensitive use |
+
+The SPA is not deprecated. It is the zero-install path and it stays. It must be labelled
+honestly: `/app/` states that the hub serves this code, and the docs never claim
+end-to-end *integrity* for that path.
+
+### 8.2 Native client
+
+pywebview + aiortc, sharing ~69% of the existing UI unchanged (the SPA contains no direct
+`crypto.subtle` or `RTCPeerConnection` usage; everything goes through three injected
+globals and one fetch helper). Assets ship **inside the package** and load from disk — a
+shell pointing its WebView at the hub's `/app/` would be a browser with a different icon
+and would fix nothing.
+
+Native does **not** remove trust in the hub operator; it converts an undetectable,
+per-request, per-user attack into a persistent artifact that can be hashed and compared.
+That value is realised by reproducible builds and published hashes, not by the packaging
+format.
+
+---
+
+## 9. Open items
+
+| # | Item | Status |
+|---|---|---|
+| **C6** | GEK proof on the QUIC path | **Open** — authorization shared, proof pending. A forged token still injects chat over QUIC |
+| 11.5.6 | QUIC channel-binding anchor | Unproven — certificate hash vs RFC 5705 exporter |
+| 11.5.8 | `pk_node` TOFU pinning | Open |
+| 11.5.10 | Node announce proof-of-possession | Open |
+| **H3** | Hub key substitution | **Open** — fixed by Phase 12.1 safety numbers |
+| C4 | Remote keypair bundles | Bounded; closes in Phase 13.3 |
+| — | Chat encryption (Sender Keys) | Phase 15. Distribution must be **pairwise to identity keys**, never GEK-derived |
+| — | Chat attachments | Plaintext on the node; decide in Phase 15.7 |
+| **T3** | Hub serves the SPA | **Accepted permanently** (D1) |
+
+### Sender Keys — scope correction
+
+Sender Keys protects chat from someone holding the node's disk who is **not** a group
+member — a seized machine, a hosting provider, a compromised node. It does **not**
+protect chat from the node operator, who is a group member and therefore a legitimate
+recipient. Distributing sender keys under the GEK would make the encryption decorative,
+since anyone with the GEK would recover every sender key.
+
+---
+
+## 10. Testing posture
+
+The pre-remediation suite passed 209 tests while every finding in the review was live. It
+tested that features work, and in doing so **pinned four vulnerabilities in place** as
+expected behaviour: GEK auto-activation, the transport-wide chat store, the blind admin
+challenge, and private-group hashes registered with the hub. A refactor that accidentally
+fixed one would have been reported as a regression.
+
+Security tests are therefore written as **negative assertions** — "this attack does not
+work" — and verified to fail against the pre-fix source before being trusted.
+`test_security_regressions.py`, `test_node_ws_auth.py` and `test_handshake.py` hold them.