summaryrefslogtreecommitdiffstats
path: root/docs/meshbay-draft-v4.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
commitf0248975908ad670fa8a820f865bf22ea8d0172d (patch)
treef4af64d36cacaccb4f6d13436e001aeb57e861e3 /docs/meshbay-draft-v4.md
parent35130e5528a52161630fd1c93572e1b2b7cd911b (diff)
downloadmeshbay-f0248975908ad670fa8a820f865bf22ea8d0172d.tar.gz
feat: Phase 12 — P2P crypto material, password split, node Ed25519 auth
Baseline commit capturing in-progress Phase 12 work that was already present in the working tree (uncommitted) before the Phase 11.5 security remediation begins. Committed as-is, without review or modification, so that remediation changes arrive as a separable diff. Contents: BundleStore (P2P GEK + keypair bundles), password split (auth_key / bundle_key), node Ed25519 auth (POST /v1/nodes/auth, node-scoped JWT), GEK-HMAC handshake proof with DTLS channel binding, Ed25519 admin challenge-response, node local admin UI rewrite, browser key persistence. Not authored in this session — captured to establish a baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs/meshbay-draft-v4.md')
-rw-r--r--docs/meshbay-draft-v4.md221
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)