diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/meshbay-draft-v4.md | 221 |
1 files changed, 206 insertions, 15 deletions
diff --git a/docs/meshbay-draft-v4.md b/docs/meshbay-draft-v4.md index 7fea132..26c7bf6 100644 --- a/docs/meshbay-draft-v4.md +++ b/docs/meshbay-draft-v4.md @@ -1,7 +1,7 @@ # MeshBay — Architecture Draft v4 -> Status: active development — Phases 1–10b complete (except 10.9 → Phase 13), 166 tests. -> Changes from v3: web client transport (WebRTC DataChannel), web UI architecture, hub roles (admin/moderator), hub mirror design, browser-specific NAT traversal, chat storage clarified, Phase 8 security items resolved, Phase 10 site overlay + admin/moderation UI + notifications + group search + version endpoint, Phase 10b self-service UI (group create/join/invite, file upload, IndexedDB caching, cross-group search). +> Status: active development — Phases 1–12 complete (except 10.9 → Phase 13), 191 tests. +> Changes from v3: web client transport (WebRTC DataChannel), web UI architecture, hub roles (admin/moderator), hub mirror design, browser-specific NAT traversal, chat storage clarified, Phase 8 security items resolved, Phase 10 site overlay + admin/moderation UI + notifications + group search + version endpoint, Phase 10b self-service UI (group create/join/invite, file upload, IndexedDB caching, cross-group search), **node sovereignty model** (§4.2.x — node operator is sole content authority, deny-by-default, uploader_id tracking), **cryptographic sovereignty enforcement** (GEK-HMAC handshake challenge, Ed25519 admin challenge-response, gek_req removed), **Phase 12 — P2P crypto material** (GEK+keypair bundles moved off hub to node BundleStore, password split, key persistence in IndexedDB/sessionStorage, DTLS channel binding fix). --- @@ -21,6 +21,11 @@ The following items are **architectural decisions** driven by Phase 8 implementa | 8 | Hub mirror | Design defined (future implementation): active-active with shared signing key, PostgreSQL logical replication, DNS round-robin. Not implemented yet. | Web client design session | | 9 | Security items | S1 (admin authz), S2 (email encryption), S5 (refresh token rotation) resolved in Phase 8. Argon2id bumped to 256 MB with transparent rehash. | Phase 8 implementation | | 10 | File search | Client-side search on cached indexes (IndexedDB). No hub involvement. Private group indexes are GEK-encrypted — hub stores opaque, client decrypts locally. | Web client design session | +| 11 | P2P crypto material | **ALL crypto material moved off hub to P2P channel.** GEK bundles and keypair bundles stored on node (`BundleStore` SQLite), exchanged via MNP DataChannel. Hub `GEKBundle` model and `/gek` endpoint removed. Hub never touches, stores, or proxies any crypto material. | Phase 12 — T3 attack surface reduction | +| 12 | Password split | Hub receives `auth_key` (PBKDF2-SHA512, auth salt), never raw password. Separate `bundle_key` (PBKDF2-SHA512, bundle salt) encrypts keypair bundles on the node. Hub cannot derive `bundle_key` from `auth_key`. | Phase 12 — T1 | +| 13 | Node auth | Node daemon authenticates to hub via Ed25519 signed timestamp (`POST /v1/nodes/auth`), not password. JWT `scope: "node"` blocks group mutation endpoints. | Phase 12 — NS7 | +| 14 | Key persistence | Browser stores `_bundleKey` (CryptoKey) in IndexedDB and `_sessionKeys` in sessionStorage. Survives page refresh without re-login. Public key derived from recovered private key via JWK export (`_pkFromSk`), no hub dependency. | Phase 12 — browser hardening | +| 15 | DTLS channel binding | Browser saves raw answer SDP before `setRemoteDescription` (Chrome may drop sha-256 fingerprint). GEK-HMAC uses `_rawAnswerSdp` for fingerprint extraction. | Phase 12 — handshake fix | --- @@ -284,6 +289,157 @@ contains the requested group_id before serving any content. Without this check, any authenticated user could access any group on the node. This is enforced at the MNP handshake layer, not the transport layer. +#### 4.2.x Node Sovereignty — Content Authorization Model + +The node operator is the **sole authority** over content stored on their machine. +No external actor — including the hub admin — can modify, delete, or control +files on a node they do not operate. This is a non-negotiable design invariant, +enforced by **cryptography**, not just policy. + +**Two trust domains, strictly separated:** + +| Domain | Authority | Scope | +|---|---|---| +| **Hub** | Hub admin / moderator | User accounts, group registry, group membership, GEK distribution, moderation (suspend user/group at hub level) | +| **Node** | Node operator | Files on disk, file deletion, upload acceptance, chat storage, who can do what with node content | + +The hub certifies **identity** (JWT) and **group membership** (`groups` claim). +The node decides **authorization for content operations** based on that identity. +These two concerns must never be conflated. + +##### Cryptographic enforcement — two defense layers + +A malicious hub admin controls the JWT signing key and could forge JWTs to +impersonate any user, including the node operator. Policy-only checks (comparing +`user_id` to `node_user_id`) are insufficient because the hub controls the +identity layer. Two cryptographic mechanisms make this impossible: + +**Layer 1 — GEK proof in handshake (membership verification):** + +After JWT verification, the node challenges the connecting user to prove they +possess the Group Encryption Key (GEK). The hub never has the GEK — it only +stores opaque ECIES-wrapped bundles. Without the GEK, a hub admin who forges +a JWT still cannot access any group content. + +``` +Client → Node: handshake { token, group_id } +Node: verify JWT, verify group_id in claims + nonce = random(32) +Node → Client: handshake_challenge { nonce: base64(nonce) } +Client: proof = HMAC-SHA256(GEK, nonce) +Client → Node: handshake_response { proof: base64(proof) } +Node: verify HMAC — if wrong, reject connection +Node → Client: handshake_ack { is_node_admin, node_pk, v } +``` + +This blocks: content reading, index reading, chat reading, file upload, chat +injection — ALL operations require passing the GEK proof first. + +**Layer 2 — Ed25519 challenge-response for admin operations:** + +The node operator's Ed25519 public key is pinned locally in `node.toml` +(auto-pinned from keystore on first startup). Destructive operations (file +deletion) require the user to sign a random challenge with their Ed25519 +private key. The hub cannot forge this signature. + +``` +Client → Node: file_delete { file_id } +Node: (if uploader → allow immediately) + (else) challenge = random(32) +Node → Client: admin_challenge { challenge: base64(challenge), file_id } +Client: signature = Ed25519.sign(sk_ed, challenge) +Client → Node: admin_response { signature: base64(signature), file_id } +Node: verify(admin_pk_ed25519, signature, challenge) + if valid → delete file +``` + +**Node configuration — admin key pinning:** + +```toml +# node.toml +admin_pk_ed25519 = "base64-encoded-32-bytes-raw-Ed25519-public-key" +``` + +Auto-pinned from the node operator's keystore on first startup. The daemon +logs: "Admin Ed25519 key pinned for node sovereignty". + +**GEK distribution — browser flow (node no longer serves GEK):** + +The node NEVER serves the GEK in plaintext. Browser clients obtain the GEK +from their hub-stored encrypted bundle: + +1. `GET /v1/groups/{id}/gek` → encrypted ECIES bundle (AES-256-GCM variant) +2. Browser unwraps with its X25519 private key (from keypair bundle) +3. Browser uses raw GEK bytes for the handshake HMAC proof +4. Browser imports GEK as HKDF key for chunk decryption + +This eliminates the `gek_req`/`gek_resp` MNP messages from the protocol. + +**Authorization rules for destructive file operations (enforced by the node):** + +| Action | Who can do it | Enforcement point | +|---|---|---| +| Delete a file | Node operator (Ed25519 challenge-response) OR the user who uploaded it | Node (`_do_file_delete`) | +| Delete any file | Node operator only (Ed25519 challenge-response) | Node (`_do_file_delete`) | + +Default posture: **deny.** If the admin key is not pinned, all admin operations +are refused. If the GEK proof fails, the connection is refused entirely. + +**Protocol enforcement — MNP handshake_ack:** + +The handshake_ack message carries `is_node_admin: bool` — the node tells the +client whether the authenticated user is the node operator. Clients MUST use +this node-reported flag (not the hub's `group.admin_id`) to decide whether +to show destructive operations like file deletion. + +``` +handshake_ack: + v: "0.1" + node_pk: "<base64>" + is_node_admin: true | false # node-side authorization, NOT hub-side +``` + +**Index entry — uploader tracking:** + +Each `IndexEntry` carries an `uploader_id` field (user_id of who uploaded the +file, or null for files that pre-existed on disk). This enables the "uploader +can delete their own files" rule without granting node-admin privileges. + +**What the hub admin CANNOT do on a node they don't operate:** +- Delete files (requires Ed25519 key pinned on node — hub can't forge) +- Read files (requires GEK — hub never has it) +- Read index / chat (requires GEK proof in handshake) +- Upload files (requires GEK proof in handshake) +- Impersonate the node operator (JWT forgery blocked by Ed25519 challenge) + +**What the hub admin CAN do (hub-level only):** +- Suspend a user account (blocks JWT issuance → user loses access everywhere) +- Suspend a group (blocks signaling → no new P2P connections to nodes for that group) +- These are hub-level actions that don't touch node content + +**Remaining trust assumptions:** +- The hub serves the SPA code to browsers (a malicious hub could inject JS — fundamentally unsolvable in browser; native client or browser extension required for full integrity) +- The hub relays WebRTC signaling — ✅ MITIGATED: DTLS channel binding in GEK-HMAC proof (`HMAC(GEK, nonce || offer_fp || answer_fp)`) detects fingerprint substitution (MitM) +- The hub receives raw password at login — ✅ MITIGATED: password split (auth_key ≠ bundle_key, independent PBKDF2 derivations). Hub receives auth_key only, cannot derive bundle_key to decrypt keypair bundle. Legacy accounts migrated on first login. +- The hub controls public key distribution — can substitute keys during invite to intercept GEK. Fix: out-of-band key verification (safety numbers) — Phase 12 + +> **Design lesson (2026-08-12):** The initial implementation conflated hub +> `group.admin_id` (who created the group on the hub) with node operator +> authority (who runs the machine). The SPA used the hub's `is_admin` flag +> to show file deletion controls, and the node's delete handler used a +> fail-open check (`if node_user_id and ...` — allowed everyone when +> `node_user_id` was not set). Both violated node sovereignty. Fixed by: +> (1) deny-by-default on the node, (2) `is_node_admin` in handshake_ack, +> (3) `uploader_id` tracking in the index, (4) SPA uses node-reported +> permissions only. Then hardened with cryptographic enforcement: +> (5) GEK-HMAC proof in handshake (blocks forged-JWT access), +> (6) Ed25519 challenge-response for admin ops (blocks identity impersonation), +> (7) removal of `gek_req` endpoint (node never serves GEK in plaintext), +> (8) DTLS channel binding in GEK-HMAC proof to detect WebRTC signaling MitM, +> (9) chat `sender_id` fixed to authenticated identity (prevents impersonation), +> (10) Ed25519 challenge for ALL file deletions — uploaders verified by stored pk, not JWT sub, +> (11) password split — hub receives PBKDF2 auth_key, never raw password (cannot derive bundle_key). + **Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability. #### 4.2.1 Keystore and Unlock @@ -477,7 +633,8 @@ Groups are the core organizational unit. |---|---| | Visibility | Public / Private | | Join policy | Open / On request / By invitation only | -| Admin | The hosting node operator (legal host) | +| Node admin | The hosting node operator — sovereign over content, sole delete authority (see §4.2.x) | +| Hub group creator | The user who registered the group on the hub — manages membership and GEK distribution | A public group functions like a themed forum: files, chat thread, member list. Join policy is independent of visibility (a public group can require approval to join). @@ -803,11 +960,26 @@ Application-level protocol over TCP+TLS 1.3 (v1) or QUIC (v2). All messages carr | Type | Description | |---|---| | `handshake` | Key exchange, JWT presentation, version negotiation | +| `handshake_challenge` | Node sends GEK proof nonce (base64, 32 bytes random) — see §4.2.x | +| `handshake_response` | Client proves GEK possession: HMAC-SHA256(GEK, nonce) | +| `handshake_ack` | Node response: version, node public key, `is_node_admin` (node-level authorization) | | `index_sync` | Encrypted Mesh Group Index delta | | `file_request` | Request chunk(s) of a file by hash + chunk index | | `file_chunk` | Chunk data + Ed25519 signature | +| `file_delete` | Client requests file deletion by file_id | +| `file_delete_ack` | Node confirms deletion | +| `file_upload` | Client pushes file chunk to node | +| `file_upload_ack` | Node acknowledges chunk receipt | +| `admin_challenge` | Node sends Ed25519 sign challenge for admin ops (base64, 32 bytes) | +| `admin_response` | Client returns Ed25519 signature over the challenge | +| `stream_request` | Client requests MSE video stream | +| `stream_init` | Node sends codec info + signals stream start | +| `stream_data` | Node sends encrypted fMP4 segment | +| `stream_end` | Node signals end of stream | | `stream_segment` | HLS/DASH segment (VOD), encrypted with GEK-derived key | -| `chat_message` | Double Ratchet encrypted message frame | +| `chat_message` | Sender Keys encrypted message frame (group chat) | +| `chat_history` | Client requests chat history | +| `chat_history_response` | Node responds with stored messages | | `chat_attachment` | Attachment metadata + key; data transferred as file chunks | | `ephemeral_stream` | [reserved, future] Ephemeral video with TTL metadata | @@ -843,15 +1015,16 @@ Format: `msgpack` → `zstd` → GEK-encrypted (private groups) or plaintext + E Entry structure: ```python { - "version": 1, - "id": "<blake3_hash>", - "name": "filename.mkv", - "path": "Movies/2024/", - "size": 4294967296, - "type": "video", # video | audio | image | document | archive | other - "duration": 7245, # seconds, for media - "thumb_hash": "<blake3>", # thumbnail also GEK-encrypted - "added_at": 1720000000 + "version": 1, + "id": "<blake3_hash>", + "name": "filename.mkv", + "path": "Movies/2024/", + "size": 4294967296, + "type": "video", # video | audio | image | document | archive | other + "duration": 7245, # seconds, for media + "thumb_hash": "<blake3>", # thumbnail also GEK-encrypted + "added_at": 1720000000, + "uploader_id": "<user_id>" # who uploaded this file (null = pre-existing on disk) } ``` @@ -926,7 +1099,8 @@ esbuild. ESM modules loaded natively by modern browsers. - Actions: download, stream (for media files) - Files fetched directly from node via DataChannel - Upload: photos/videos posted to the group's shared upload folder - (only the uploader or group admin can modify/delete) +- Delete: node operator can delete any file; uploader can delete their own uploads. + Hub admin has NO delete authority on nodes they don't operate (see §4.2.x). **Group view — Chat/Forum:** - Sender Keys encrypted messages, fetched from node @@ -1167,11 +1341,28 @@ The node loads extension modules (Python) in a sandboxed subprocess. **Chat is a |---|---|---|---| | R20 | WebRTC DataChannel validation? | Confirmed: browser→NAT→node file transfer works. Tested 3 scenarios on SFR residential (Port-Restricted Cone NAT) + 4G CGNAT: WiFi LAN (IPv6 direct, ~100ms), 4G IPv6 inter-network (~600ms), 4G IPv4 STUN hole-punch (~650ms). No TURN relay needed. | Phase 9.5 spike | +**Resolved by node sovereignty fix (2026-08-12):** + +| # | Question | Resolution | Source | +|---|---|---|---| +| R21 | Who authorizes file deletion on a node? | Node operator (sovereign) OR original uploader. Hub admin has no authority over node content. Enforced: deny-by-default in MNP `file_delete`, `is_node_admin` in handshake_ack, `uploader_id` in IndexEntry. | Security fix — §4.2.x | +| R22 | Can a malicious hub admin access node content? | No. Two cryptographic layers: (1) GEK-HMAC proof in handshake — hub never has the GEK, can't pass the challenge. (2) Ed25519 challenge-response for admin ops — hub can't forge the node operator's signature. `gek_req` endpoint removed. | Crypto enforcement — §4.2.x | + +**Resolved by Phase 12 — P2P crypto material (2026-08-13):** + +| # | Question | Resolution | Source | +|---|---|---|---| +| R23 | Where are GEK bundles stored? | On node only (`BundleStore` SQLite, `data_dir/bundles.db`). Hub `GEKBundle` model removed. Exchanged via MNP `gek_bundle_store`/`gek_bundle_fetch`/`gek_bundle_resp` over WebRTC DataChannel. | Phase 12 — T3 | +| R24 | Where are keypair bundles stored? | On node only (`BundleStore`). Encrypted with password-derived AES key (`bundle_key`). Browser pushes after registration, recovers during handshake. Hub `keypair_bundle` column removed. | Phase 12 — T3 | +| R25 | How does the browser recover keys after localStorage cleared? | Transport fetches `keypair_bundle` from node during handshake, decrypts with `_bundleKey` (PBKDF2 from password). Public key derived from private key via JWK export — no hub fetch needed. `_bundleKey` persisted in IndexedDB, `_sessionKeys` in sessionStorage. | Phase 12 | +| R26 | How does the browser handle Chrome SDP re-serialization? | `this._rawAnswerSdp = answer.sdp` saved before `setRemoteDescription`. DTLS fingerprint extracted from raw SDP, not `pc.remoteDescription.sdp` (Chrome may drop sha-256 line when re-serializing multi-hash SDP from aiortc). | Phase 12 | +| R27 | Should the browser auto-regenerate keys on login? | No. Auto-regeneration silently rotates hub keys, breaking GEK unwrap (GEK wrapped for old keys). Keys recovered from node via `_bundleKey`. Regeneration only on explicit user request. | Phase 12 | + **Still open:** 1. **Refresh token validity:** 30 or 90 days? 2. **Group address scheme:** final URL format confirmation -3. **GEK bundle location for groups with mixed access** (public-restricted): hub or node? +3. **GEK bundle location for groups with mixed access** (public-restricted): hub or node? → Resolved: always on node. 4. **MHP federation sync frequency and conflict resolution** 5. **Chat attachment storage:** stored on node like regular files, or separate store? 6. **Relay registration protocol design** (when implemented) |