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.md129
1 files changed, 99 insertions, 30 deletions
diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md
index 8035824..b4a9e45 100644
--- a/docs/meshbay-draft-v5.md
+++ b/docs/meshbay-draft-v5.md
@@ -1,6 +1,8 @@
# MeshBay — Architecture Draft v5
-> Status: **Phase 11.5 (security remediation) complete.** See `devel-phases-next.md`.
+> Status: **Phase 11.5 (security remediation) complete. Invite redesign complete
+> (2026-08-14) — H3 and M3 closed.** See `devel-phases-next.md` and, for the invite
+> and pairing design, `docs/invite-pairing-v1.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
@@ -19,7 +21,7 @@ v4 described intent. Several of its statements were contradicted by the implemen
| "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 |
+| "GEK bundles: opaque blobs; hub cannot decrypt them" | True, and irrelevant — the hub was the key directory, so it could substitute a key at invite time and be handed the GEK legitimately *(fixed 2026-08-14; the invite path no longer reads the directory — §5.5)* |
| "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
@@ -38,7 +40,8 @@ written that way.
| 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 |
+| 7 | Node authority | **Nothing arriving over MNP can activate a GEK.** Member-supplied bundles are not gated but *gone*: `gek_bundle_store` was deleted when the node took over wrapping | Finding C5b |
+| 7b | Admission | **The node wraps the group key itself**, for a key the recipient proved possession of, and admits people from its own roster using one-time codes the hub never sees (§5.5) | Findings **H3**, **M3** |
| 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 |
@@ -54,31 +57,39 @@ 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 | ❌ | ❌ | ✅ |
+| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the GEK | ✅ |
+| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ plaintext at rest until Phase 15 | ❌ | ✅ |
+| File index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ |
| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ |
| Node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ |
| Client code integrity | ❌ **T3, accepted** | ❌ T3 | ✅ | ✅ | ✅ |
| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ |
+| Hub cannot obtain the group key | ✅ | ✅ **since H3 closed** — except in `join_policy = "open"` groups, where it can join legitimately | — | — | ✅ |
-**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 this project can make:** *the hub cannot read your content unless it ships you
+malicious client code.* Since H3 closed (2026-08-14) that is the **only** remaining path,
+it is not silent, and it does not exist for a native client.
-**The claim it must not make:** *"everything is encrypted and unreadable by other parties,
-even the hub."* Two reasons, both deliberate:
+**What changed.** The hub used to be the key directory on the invite path: the inviter
+fetched the invitee's `pk_x25519` from the hub and wrapped the GEK for whatever came back,
+so a hub answering with its own key was handed the group key by an honest member following
+the protocol exactly. That path is gone. The node holds the GEK and wraps it itself, for a
+key the recipient proved possession of over an authenticated channel, bound to an identity
+the operator admitted with a one-time code the hub never sees (§5.5).
-- **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.
+**The claim it must not make:** *"everything is encrypted and unreadable by other parties,
+even the hub."* Three reasons, all deliberate:
-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*.
+- **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 — and
+ can read a pairing code out of it. This is an artifact-level attack, not a silent
+ directory lie: the native client (Phase 13) plus reproducible builds (18.7) remove it.
+- **Open-join groups.** Where `join_policy = "open"`, anyone the hub calls a member is
+ served the key, so the hub can join through the front door. That is a property of open
+ joining, not a defect, and the setting is read from `node.toml` — the hub cannot declare
+ a group open.
+- **Members and the operator read everything.** Inherent: "end-to-end" here describes
+ *client ↔ node*, never *client ↔ client*.
---
@@ -187,14 +198,22 @@ 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** |
+| `invite_create` | **Node operator only** (delegation designed, deferred) |
| GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK |
+| ~~`gek_bundle_store`~~ | **Message deleted.** No member ever hands the node key material |
`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.
+group. It was first gated behind an operator signature, then removed outright when the
+node took over wrapping (§5.5) — the message has no remaining purpose, and a path that
+does not exist cannot be mis-authorized.
+
+**Who can invite.** Still the node operator only: `invite_create` is verified against the
+operator keys in the node's roster. A group admin who does not run the node cannot admit
+anyone until delegation ships (§6.2 of `invite-pairing-v1.md`, deferred by decision
+2026-08-13). What *did* change is the timing: the operator issues a code and is then out
+of the loop, so nobody has to be online when the invitee actually joins.
### 5.2 Uploads
@@ -210,22 +229,65 @@ Two personas, and they need different tools:
| Operator | Reaches the node via |
|---|---|
| Desktop | the local admin UI in their own browser |
-| **Headless / SSH** | the CLI — `meshbay-node status`, `ui`, `gek-init` |
+| **Headless / SSH** | the CLI — `status`, `ui`, `gek-init`, `operator pair`, `member list\|invite\|revoke\|unpin` |
The CLI is the primary interface for servers, which is the normal deployment.
`status` deliberately reads the keystore and config directly so it works while
the daemon is stopped — the state an operator is most often in, since the daemon
will not stay up before its key is linked or before a group exists. Anything the
-UI can do should eventually have a CLI equivalent (Phase 14); until then a
-headless operator still needs a browser to invite members or delete files.
+UI can do should eventually have a CLI equivalent (Phase 14). Inviting, revoking and
+pairing now have one; deleting a file is the remaining browser-only operation.
### 5.4 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.
+keys, issues invitations and reads the audit log. All interpolated values are
+HTML-escaped — filenames chosen by members, and usernames that originate at the hub;
+CSP contains exfiltration but cannot prevent injected inline script, so escaping is the
+actual fix.
+
+### 5.5 Admission and key delivery (closes H3)
+
+The node decides who receives the group key, and produces every copy of it itself.
+
+```
+grenet (SSH) meshbay-node member invite bob → CODE R3H8-TB6V
+ (or the same from the SPA's Members tab, signed by the paired browser)
+grenet sends the code to bob out of band
+bob opens the group; the client holds no GEK
+bob → node join_request {pk_ed25519, pk_x25519, code, sig} ← pre-proof window
+node code valid for this account → pin the identity, admit to the group
+node → bob the GEK, wrapped for the X25519 key bob just proved he holds
+```
+
+Four properties, each load-bearing:
+
+1. **No public key is ever fetched from the directory.** The invitee's key arrives from
+ the invitee, signed by their identity key in the same transcript (`meshbay:join:v1`),
+ so the identity key vouches for the encryption key. This is the H3 fix.
+2. **The code binds the key to the account**, and the hub never sees it. 40 bits,
+ single-use, one account, node-wide lockout on repeated failures. Codes live 7 days
+ for invitations and 24 h for operator pairing, both configurable.
+3. **The node's roster is the authority**, not hub membership. A hub that invents an
+ account, adds it to a group and mints it a token gets `not_authorized_for_group`.
+ Exception by configuration: `join_policy = "open"` pins the first comer (TOFU) and
+ serves them — read from `node.toml`, never from the hub.
+4. **Wrapping happens on every connection**, so key rotation propagates by itself and
+ revocation actually takes effect. No member bundle is stored; nothing outlives the
+ decision to stop serving someone. (Rotating the GEK after a revocation is still
+ required — the ex-member holds the old one.)
+
+`gek-init` no longer pre-wraps the key for anyone but the node's own keystore. It used to
+fetch every member's public key from the hub and wrap for what came back, which was H3
+with the node as the victim rather than the inviter.
+
+Node authority is established the same way, once per node: `meshbay-node operator pair`
+prints a code, the operator types it into their browser, and the node pins that identity
+key. It is never learned from the hub — a hub able to name the operator's key could
+install itself as node administrator (finding M3, which the old keystore auto-pin left
+inert and failing closed).
---
@@ -248,6 +310,11 @@ private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling.
was posted and by whom (`chat_notify` — minimization was in the dropped phase), and
content hashes for **public** groups only.
+**No longer decides:** who holds the group key. The hub still publishes public keys, and
+they are still used to look up an account by name — but nothing wraps a key for a key
+that came from there. Hub membership lets someone *reach* a node; the node's roster
+decides whether it wraps anything for them (§5.5). A pairing code never touches the hub.
+
### 6.2 Node registration
Registration on `/v1/nodes/ws` requires a node-scoped token, verifies `Node.user_id`
@@ -335,16 +402,18 @@ format.
| 11.5.6 | QUIC channel-binding anchor | Certificate hash. Server reads its own certificate from disk; the client's access to the peer certificate is guarded and fails loudly if aioquic moves it. **A resumed TLS session carries no certificate**, so the anchor travels with the session ticket — sound, because the ticket is derived from the handshake where the certificate was presented |
| 11.5.8 | `pk_node` TOFU pinning | Pinned per `node_id` on first handshake; a change is **refused**, with a deliberate reset in Settings |
| **M8** | Node announce proof-of-possession | Announcer signs `meshbay:node_announce:{user_id}:{pk_node}:{timestamp}`; re-announcing updates in place |
+| **H3** | Hub key substitution | **Closed 2026-08-14** by the invite redesign (§5.5), not by safety numbers: no public key is fetched from the directory at all, so there is nothing to substitute. See `docs/invite-pairing-v1.md` |
+| **M3** | Node authority inert as shipped | Closed with it — authority comes from the roster, established locally by pairing |
### Still open
| # | Item | Status |
|---|---|---|
-| **H3** | Hub key substitution | **Open** — the last unfixed finding. The hub is the public-key directory, so it can be handed the GEK at invite time. Fixed by Phase 12.1 safety numbers |
| C4 | Remote keypair bundles | Bounded (4 pre-proof fetches, audited); closes in Phase 13.3 when the native client stops storing them remotely |
| — | 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) |
+| — | Delegation | Designed, deferred: a group admin who does not run the node still cannot invite |
+| **T3** | Hub serves the SPA | **Accepted permanently** (D1). Now the *only* way an active hub reads content — and it can also read a pairing code out of the page it served. Removed for native clients by Phase 13 + reproducible builds (18.7) |
### Scope note on pinning