summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 12:04:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 12:04:39 +0200
commitf4bbc090d7e840c5dc7a838289441955cbdb7fd5 (patch)
tree767be375a1b1041ddb8a1d4c429284bbadb912bc /docs
parenta8c1df9fdbcf223c83ef5e92316096d1a206f5dd (diff)
downloadmeshbay-f4bbc090d7e840c5dc7a838289441955cbdb7fd5.tar.gz
docs: draft-v5 architecture spec
Supersedes draft-v4, which described a system the code did not implement and made several claims that were simply wrong — "ALL operations require the GEK proof" (true on one of four transports), "Argon2id 256 MB" (hub only), "hub stores no content metadata" (private file hashes were registered with it). Written as a delta over v4: sections not restated are unchanged. Carries an explicit rule — a claim must name the adversary it holds against — and a per-adversary table replacing v4's informal assurances. Records the decisions: transport (aiortc primary, QUIC retained, TCP and the node HTTP API removed), unified handshake with mutual authentication, admin operation transcripts, node authority over GEK storage and activation, upload confinement, hub node-registration and signaling authorization, and the client architecture — hub keeps serving the web SPA, native client offered alongside, hub minimization deferred. States plainly what is NOT true. The defensible claim is "the hub cannot read your content unless it actively attacks you", not "unreadable by other parties, even the hub": H3 (hub is the key directory and can substitute a key at invite time) is open until Phase 12.1, and T3 (hub serves the SPA) is accepted permanently by decision. Content is also readable by every group member and by the node operator, so "end-to-end" here means client-to-node, never client-to-client. Corrects the v4 NAT traversal account: punch_nat() is a single UDP probe with no STUN, no candidate gathering and no fallback, validated on one ISP. ICE is the traversal path, including for native clients. Open items listed with status, including C6 on the QUIC path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
-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.