diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/invite-pairing-v1.md | 526 |
1 files changed, 526 insertions, 0 deletions
diff --git a/docs/invite-pairing-v1.md b/docs/invite-pairing-v1.md new file mode 100644 index 0000000..56fccbe --- /dev/null +++ b/docs/invite-pairing-v1.md @@ -0,0 +1,526 @@ +# MeshBay — Invitation and Pairing (design) + +> Status: **proposal, not implemented.** Written 2026-08-13. +> Supersedes the invite flow described in `meshbay-draft-v5.md` §5.1 and the +> Phase 12.1 milestone in `devel-phases-next.md`, if adopted. +> +> Closes **H3** for the path where it is actually exploitable, and dissolves **M3** +> rather than patching it. Follows the v5 convention: every claim names the +> adversary it holds against. + +--- + +## 1. What is wrong today + +Two problems, one visible to users and one invisible. + +### 1.1 The workflow problem + +An invite runs entirely inside the inviter's browser (`app.js:1391-1420`): + +1. fetch the invitee's `pk_x25519` from the hub — `GET /v1/users/{name}/pubkeys` +2. take the raw GEK out of the live transport connection +3. wrap the GEK for that key and push the bundle to the node over MNP +4. add the member on the hub + +Step 2 requires the inviter to be **connected to the node with the group key in +hand**. Step 3 requires the node to accept the bundle, which since C5b means the +signature must be the node operator's (`webrtc_server.py:1023-1035`). Together: + +- only the node operator can invite — v5 §5.1 records this as deliberate +- the operator must be at a browser, connected, at the moment of the invite +- a group admin who does not run the node cannot add anyone, ever + +That is not a workflow. It is the reason the demo needs `demo.py set-admin-pk`. + +### 1.2 The security problem (H3) + +Step 1 asks the **hub** which key belongs to `bob`. The hub stores those keys as +mutable columns on the user row (`api/users.py:352-354`) and serves them with no +signature and no history. A hub that answers with a key it holds is handed the GEK +by an honest inviter following the protocol exactly. Nothing forged, nothing +injected, and nothing in the client notices. + +The account is unique and the account is right. The **key attached to the account** +is what the hub controls. + +### 1.3 M3, underneath both + +The daemon auto-pins its own keystore key as the admin key (`daemon.py:470-474`); +the browser signs with the user identity key (`app.js:1416`). They differ, so +invites and deletions fail closed with a signature error that looks like a bug +elsewhere. The demo works only because a deploy script writes the browser key into +`node.toml`. + +The tempting fix — have the daemon fetch the operator's key from the hub — turns M3 +into a second H3: the hub would then be able to install itself as node +administrator. **The node must never learn authority from the hub.** + +--- + +## 2. Principle + +Three rules. Everything below follows from them. + +1. **The node wraps the group key.** The node already holds the GEK — it encrypts + and serves the content. So it, not the inviter's browser, produces each member's + bundle. No member ever handles another member's key material. +2. **A key is bound to an identity by a one-time pairing code, then pinned.** The + code travels out of band (the inviter sends it to the invitee the way they + already talk). The hub never sees it and therefore cannot claim to be the + invitee. +3. **The node keeps its own roster.** Hub membership is an input, not an + authorization. Otherwise a hub that invents an account and mints a token for it + collects the GEK on connect. + +Human cost of the whole scheme: **one code per person, once per node**, plus one +code for the operator at install. No fingerprint comparison, no per-member +signature ceremony, no operator required to be online when someone joins. + +--- + +## 3. Flows + +### 3.1 Operator pairing (once per node, replaces M3) + +``` +operator (SSH) meshbay-node operator pair +node prints PAIR-CODE: K7M2-QX4P (also written to data_dir/pair-code) +operator (SPA) Settings → "Pair this browser with my node" → types the code +SPA → node join_request {role_hint: operator, code, pk_ed25519, pk_x25519, sig} +node code valid, unused, unexpired → pins the keys, role = operator +node writes the pin to its roster DB, prints it in `status` +``` + +The operator types 8 characters into their own browser. Nothing is pasted, nothing +is copied out of a terminal, no browser is needed on the node host, and the hub is +not involved at any point. `admin_pk_ed25519` in `node.toml` becomes a legacy +fallback, still read, no longer required. + +### 3.2 Invite (one click, operator or delegate) + +``` +grenet (SPA) Groups → Members → "Invite bob" +SPA → node admin_request {op: invite_create, group_id, invitee: bob, ttl} +node → SPA admin_challenge (structured transcript, H5 — the SPA shows one + line: "authorize bob to join <group>") +SPA → node admin_response {sig} ← signed silently with grenet's key +node creates invite: code, group, invitee, expiry; status = pending +node → SPA invite_created {code: "R3H8-TB6V", expires_at} +grenet sends the code to bob however they already talk +SPA → hub POST /v1/groups/{id}/members/bob (membership, unchanged) +``` + +Grenet's browser signs, but grenet does not *inspect* a signature — one click, one +confirmation line, one code to pass on. Same gesture as any invite link on any +platform. + +### 3.3 Join (fully automatic, operator may be asleep) + +``` +bob (SPA) opens the group; client has no GEK for it +bob → node handshake {token, group_id, nonce_c} (pre-proof window) +node → bob handshake_challenge {nonce_s} +bob → node join_request {group_id, pk_ed25519, pk_x25519, code, sig} +node 1. rate-limit + attempt count on this connection + 2. code matches a pending invite for this user_id and group + 3. identity not already pinned to a different key + 4. pin (user_id → pk_ed25519, pk_x25519), mark invite used, + member status = active + 5. wrap the ACTIVE GEK for pk_x25519 (ECIES, as today) +node → bob join_result {ok, pk_eph_b64, nonce_b64, wrapped_b64} +bob unwraps the GEK, completes the normal GEK proof, session proceeds +``` + +On every later connection bob sends `join_request` **without** a code; the node +recognises the pinned identity, re-wraps the current GEK and answers. So GEK +rotation propagates by itself, and a revoked member simply stops being served. + +### 3.4 Open-join groups: no code (decided 2026-08-13) + +A group whose hub-side `join_policy` is `"open"` (`groups.py:227-228`) admits anyone +who asks. A pairing code there protects nothing — the hub can create an account, +join through the front door and be a legitimate member — so it is pure friction. +For those groups the node pins on first contact (TOFU, `pinned_via = 'tofu'`) and +wraps the GEK immediately. + +Note the axis. `visibility` (public/private) controls **discoverability** and swarm +hash registration (H7); `join_policy` (open/request/invite) controls **admission**. +Only the second one decides whether a code is required. A `visibility = "public"` +group with `join_policy = "invite"` keeps the code — being findable is not being +open. + +**Stated plainly, per the v5 convention:** in an open-join group the hub can obtain +the GEK, because it can become a member legitimately. That is a property of open +joining, not of this design — it is equally true today. Content in such a group is +protected from the network and from non-members, and from nobody else. The docs +must say so, and the SPA should say so when someone sets `join_policy = "open"` on +a group that already holds content. + +--- + +## 4. Why a pairing code, and not something lighter + +The choice is forced by one question: when bob connects for the first time, what +stops the hub from being bob? + +| Option | What the hub can do | Verdict | +|---|---|---| +| Node wraps for the key the peer presents, no binding | Forge a JWT for bob, present its own key, receive the GEK | **Worse than today** — today a forged JWT yields a bundle wrapped to bob's real key, which is useless | +| Bind to the key the inviter fetched from the hub | Substitute at invite time — H3 unchanged, just relocated into the node | No | +| TOFU: first connection wins | Race the real bob with a forged token; small window, total consequence | No | +| Safety-number comparison at invite | Nothing — but it needs two humans reading digits at the worst moment | Correct, unusable as the default | +| **One-time pairing code** | Nothing: the code never reaches the hub | **Adopted** | + +The code is the cheapest thing that binds an identity to a key without the +directory. It is also the familiar shape — invite links work this way everywhere. + +**Boundary, stated honestly:** for a browser client the SPA is served by the hub, so +a hub that ships malicious client code can read the code out of the page. That is +**T3, accepted permanently** by decision D1 and unchanged by this design. The code +defeats a hub that *lies in its directory* — a silent, undetectable, per-request +attack — not one that *rewrites the client*, which is an artifact and is what the +native client (Phase 13) removes. Do not blur the two in the docs. + +--- + +## 5. Protocol + +### 5.1 New MNP messages (`meshbay_common/protocol.py`) + +``` +JOIN_REQUEST = "join_request" # client → node, served in the pre-proof window +JOIN_RESULT = "join_result" # node → client +INVITE_CREATE = "invite_create" # inviter → node (admin op, see 5.3) +INVITE_RESULT = "invite_result" # node → inviter, carries the code once +``` + +`join_request`: + +| Field | Meaning | +|---|---| +| `group_id` | mandatory, as everywhere since 11.5.4 | +| `pk_ed25519`, `pk_x25519` | the caller's own keys, base64 raw | +| `code` | first pairing with this node only; omitted when `join_policy == "open"` (§3.4) | +| `role_hint` | `operator` or absent; the node trusts the *code*, not the hint | +| `sig` | Ed25519 over the transcript below | + +### 5.2 Join transcript + +``` +"meshbay:join:v1" ‖ len‖node_pk ‖ len‖group_id ‖ len‖user_id + ‖ len‖pk_ed25519 ‖ len‖pk_x25519 ‖ len‖nonce_s ‖ len‖ts +``` + +Length-prefixed and domain-separated per 11.5.21. `nonce_s` is the handshake nonce +the node just issued, so a `join_request` cannot be replayed onto another +connection. `pk_x25519` is inside the signature, so the Ed25519 key vouches for the +X25519 key it is paired with — this is what makes "wrap for the presented key" safe. + +The **code is never signed and never echoed** — it is a bearer secret, compared +against a stored hash and destroyed on use. + +### 5.3 Invite creation reuses the existing admin machinery + +New op in `meshbay_common/adminop.py`, alongside `OP_FILE_DELETE` and +`OP_GEK_BUNDLE_STORE`: + +``` +OP_INVITE_CREATE = "invite_create" +"meshbay:admin:v1" ‖ len‖op ‖ len‖node_pk ‖ len‖group_id ‖ len‖subject ‖ len‖nonce ‖ len‖ts + subject = invitee user_id +``` + +Authorized by the pinned **operator** role, or by a **delegate** (§6.2). TTL 120 s, +same as every other admin op. The client rebuilds the transcript and refuses to +sign if the subject is not the person the user typed — H5's rule, unchanged. + +### 5.4 Code format + +8 characters, Crockford base32 (no `I`, `L`, `O`, `U`), rendered `XXXX-XXXX` — 40 +bits. Single use, default TTL 24 h, stored only as `sha256(code)` — a password KDF +would be pointless over 40 uniformly random bits, and `blake3` is not a node +dependency. Guessing is +bounded by: 5 attempts per connection, a node-wide limiter on failed +`join_request`s, and the fact that a code is valid for exactly one `user_id` in one +group. A brute-force attempt is an audit-log event, not a silent grind. + +--- + +## 6. Node state + +### 6.1 Schema (new tables, `roster.py`, same SQLite file style as `bundle_store.py`) + +```sql +CREATE TABLE identities ( -- one row per person, not per group + user_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + pk_ed25519 TEXT NOT NULL, + pk_x25519 TEXT NOT NULL, + pinned_at TEXT NOT NULL, + pinned_via TEXT NOT NULL -- 'code' | 'tofu' | 'legacy-config' | 'operator-reset' +); + +CREATE TABLE members ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, -- 'operator' | 'delegate' | 'member' + status TEXT NOT NULL, -- 'pending' | 'active' | 'revoked' + approved_by TEXT NOT NULL, -- user_id whose signature created the invite + approved_at TEXT NOT NULL, + PRIMARY KEY (group_id, user_id) +); + +CREATE TABLE invites ( + code_hash TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + created_by TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +); +``` + +Identity is pinned **per node, not per group**: someone already paired for one group +needs no code for the next one. The operator's pairing is the same mechanism with +`role = 'operator'` and no group. + +### 6.2 Delegation — **deferred** (decided 2026-08-13) + +A `delegate` row would let a group admin who is not the node operator create invites +without the operator being involved again. Not needed while the operator is the +inviter (the demo, and every single-operator deployment), so it is **not built in +v1**. + +The `role` column reserves the value and `invite_create` authorization is written as +a role check rather than an equality test against the operator, so adding it later +is a roster row and a CLI command — no protocol change, no migration. + +### 6.3 What the node stops doing + +- `gek_bundle_store` **no longer accepts member-supplied bundles at all.** Nothing + arriving over MNP contributes key material. C5b's rule is not merely preserved, + it becomes structural — the message can be deleted from the client path entirely. +- Per-member rows in `gek_bundles` are no longer written. The node wraps on demand. + The node's own `_node_{user_id}` bundle stays: that is how the daemon reloads its + GEK across restarts. +- **Consequence worth having:** revocation starts working for key delivery. A + stored bundle today survives revocation; on-demand wrapping does not. (Rotating + the GEK after a revocation is still required — the ex-member has the old key.) + +--- + +## 7. Security analysis + +### 7.1 Against each adversary + +| Attack | Today | With this design | +|---|---|---| +| Hub substitutes the invitee's key at invite time (**H3**) | Succeeds silently, hub gets the GEK | **Fails** — no key is ever fetched from the directory | +| Hub forges a JWT for a real member | Gets a bundle wrapped to the member's real key: useless | Unchanged: no code, no pin match → refused | +| Hub invents an account and adds it to the group | Blocked only accidentally, by the bundle requirement | **Blocked by the roster** — no invite, no code, no GEK | +| Hub substitutes the *operator's* key (M3's tempting fix) | n/a | **Impossible** — the node pins by code, never asks the hub | +| Member wraps a GEK of their choosing for the operator (**C5b**) | Blocked by operator signature | **Impossible** — the message no longer exists | +| Member replays a `join_request` from another connection | n/a | Bound to `nonce_s` | +| Member presents someone else's `pk_x25519` | n/a | Signed by the paired `pk_ed25519`, mismatch refused | +| Ex-member reconnects after revocation | Stored bundle still unwraps | Not served; GEK rotation still needed | +| Third party guesses a code | n/a | 40 bits, single use, per-user, rate-limited, audited | +| Hub joins an **open-join** group and collects the GEK | Succeeds | Still succeeds — inherent to open joining (§3.4), must be documented, not hidden | + +### 7.2 What this does **not** fix + +- **T3** — the hub serves the SPA and can read the code out of the page. Accepted + (D1); removed only by the native client plus reproducible builds. +- **C4** — the keypair-bundle pre-proof window is untouched. A first-time joiner + still needs their own identity keys before they can sign anything; that material + belongs on the user's device (Phase 13.3). +- **The node operator reads everything.** Inherent to the model. +- **The hub still knows who is in which group.** Membership is hub-side; the roster + only decides who receives the key. +- **A member can still leak the GEK out of band.** Nothing prevents that, and + nothing in the current design pretends to. + +--- + +## 8. Failure modes and edge cases + +| Case | Behaviour | +|---|---| +| Code lost or expired | Inviter clicks "Invite" again; the old invite is superseded and its hash deleted | +| Bob pairs, then loses his keys and runs `regenerateKeys` | Pin mismatch → join refused with a clear message; needs a fresh invite (operator or delegate re-issues). This is the intended blocking warning, moved to the moment it matters | +| Bob is in two groups on the same node | One pin, one code, ever | +| Node reinstalled / roster lost | Everyone re-pairs. Same class of event as losing the keystore; `status` must say so plainly | +| Operator pairs a second browser | New `operator pair` code; both browsers valid, both listed in `status` | +| Two people race one code | Single-use row, `used_at` set under a transaction; the loser gets a plain refusal | +| Invite created while the node is offline | Not possible — invites are created on the node. The SPA must say "node offline, cannot invite" instead of failing obscurely | +| Member connects while the group has no active GEK | `join_result {ok: false, reason: "no_gek"}`; the operator runs `gek-init` | +| Legacy deployment with `admin_pk_ed25519` set | Read at startup and inserted as an `identities` row with `pinned_via = 'legacy-config'`; no migration needed for the current demo | + +--- + +## 9. Operator surface + +``` +meshbay-node operator pair # print a pairing code for a browser +meshbay-node member list [--group G] # roster: who is pinned, role, status +meshbay-node member invite bob [--group G] # same as the SPA button, from SSH +meshbay-node member revoke bob [--group G] # stop serving the GEK to bob +meshbay-node member unpin bob # force re-pairing after a key rotation +``` + +`--group` is optional whenever the node hosts exactly one group. + +`meshbay-node status` gains a line per group: pinned identities, pending invites, +and — when nothing is paired — the exact command to fix it. Everything an operator +needs is reachable over SSH with no browser on the host, per the standing +constraint. This absorbs milestones 14.3 and 14.4. + +--- + +## 9bis. Implementation status + +**Slices 1 and 2 landed 2026-08-13/14.** Not yet exercised against a live +deployment — the operator tests after slice 3, so the slices are written to be +coherent with each other rather than individually demo-able. + +### Slice 1 — roster and operator pairing (M3) + +| Shipped | Where | +|---|---| +| `identities` / `members` / `invites`, codes, single-use redemption | `meshbay_node/roster.py` | +| `join_transcript` — both public keys signed together | `meshbay_common/join.py` | +| `join_request` / `join_result` handler, valid pre-proof and post-handshake | `transport/webrtc_server.py` | +| Admin authority read from the roster on every check, `admin_pk_ed25519` kept as legacy | `webrtc_server._verify_admin_sig` | +| **Auto-pin of the keystore key deleted** (M3) | `daemon._legacy_admin_pk` | +| `meshbay-node operator pair`, roster in `status` | `daemon.main`, `ui/app.py` | +| Pairing form in the group's Members tab | `app.js`, `transport.js` | + +### Slice 2 — the node wraps the key (H3) + +| Shipped | Where | +|---|---| +| Node wraps the GEK for the key the joiner proved, on every connection | `webrtc_server._join_ok` | +| Roster decides who may receive it — hub membership alone does not | `Roster.is_authorized` | +| `invite_create` admin op; the SPA shows a code instead of handling keys | `adminop.py`, `app.js` | +| **`gek_bundle_store` deleted** — no member ever hands the node key material | `protocol.py`, `webrtc_server.py` | +| **`gek-init` no longer fetches member keys from the hub** — it was H3 with the node as victim | `ui/app.py` | +| Open-join groups admit without a code; policy read from `node.toml` | `config.py`, `_group_join_policy` | +| Client asks for the key when it has none; prompts for a code when required | `transport.js`, `app.js` | + +**Tests: 148 node, 174 hub/common** (from 121/168 before this work). The end-to-end +one worth knowing about is `test_invite_then_join_delivers_the_gek`: over a real +DataChannel, the operator gets a code, and a member who has never held the group +key redeems it in the pre-proof window and receives the key wrapped for a key only +they can open. + +### Deliberate departures from this document + +| Written | Built | Why | +|---|---|---| +| `blake3(code)` | `sha256(code)` | 40 uniformly random bits; blake3 is not a node dependency | +| Pairing in Settings | Pairing in the group's Members tab | that is where a live node connection exists | +| — | `gek-init` rewritten | not in the plan: it wrapped the GEK for keys fetched from the hub, which is the same substitution the design closes | + +Two C5b tests were rewritten rather than kept: they asserted that +`gek_bundle_store` demanded an operator signature, and that message no longer +exists. They now assert the stronger property — that no member can hand the node +key material at all, and that the retired message reaches no handler. + +### Slice 3 — the operator surface + +| Shipped | Where | +|---|---| +| `member list` / `invite` / `revoke` / `unpin`, all over SSH, no browser | `daemon.main` | +| Roster endpoints behind the per-run session token (11.5.3) | `ui/app.py` | +| Roster section in the local admin UI, every value escaped (H2) | `ui/app.py._render_roster` | +| `_daemon_api` / `_resolve_group` — one loopback call path for every command | `daemon.py` | +| Codes written to `data_dir/invite-code` and `data_dir/pair-code` | `roster.write_code_file` | + +Revocation tells the operator what it does **not** do: the ex-member stops +receiving the key on their next connection, but they still hold the current one, +so the message ends with the `gek-init` command that rotates it. + +`member revoke`/`unpin` resolve a username against the roster and refuse an +unknown one rather than acting on nobody — a typo must not look like success. + +**Tests: 158 node, 174 hub/common** (from 121/168 before this work). + +### Code lifetimes (settled 2026-08-14) + +| Code | Default | Configurable via | +|---|---|---| +| Member invitation | **7 days** | `[node] invite_ttl_hours` | +| Operator pairing | 24 h | `[node] pair_ttl_hours` | + +They differ because the acts differ: an invitation waits for someone to read their +messages, a pairing code is typed during the SSH session that printed it. The +longer window costs little — single use, one account, never seen by the hub, and +40 bits do not fall to guessing in a week against the node-wide lockout. + +--- + +## 10. Work to do + +### Node +- `roster.py` — new module, the three tables and their queries +- `transport/webrtc_server.py` — `join_request` / `join_result` in the pre-proof + window (beside the existing bundle fetches, `:243-258`); delete member-supplied + `gek_bundle_store`; `OP_INVITE_CREATE` in the admin-op dispatch +- `transport/quic_server.py` — same handler via the shared path (11.5.4 parity test + must cover `join_request`) +- `daemon.py` — `_resolve_admin_pk` → roster lookup with the legacy config fallback; + new CLI commands; `status` output +- `ui/app.py` — roster and invites in the local admin UI, escaped as per 11.5.16 + +### Common +- `protocol.py` — four message constants +- `adminop.py` — `OP_INVITE_CREATE` +- `handshake.py` — expose `nonce_s` to the join transcript builder + +### Hub +- **No change.** Membership endpoints stay as they are. Worth stating in the commit + message: the fix for H3 removes a hub responsibility rather than adding one. + +### SPA +- Invite dialog shows the code and a copy button, instead of doing crypto +- Settings gains "Pair this browser with my node" (code entry) +- `join_request` on connect when no GEK is held; drop the wrap-and-store path +- Delete the `pubkeys` fetch from the invite flow — the line that is H3 + +### Tests (negative assertions, per §10 of v5) +``` +test_join_requires_code_first_time — unpinned identity without a code is refused +test_join_rejects_key_swap — pinned user presenting a new key is refused +test_join_replay_across_connections — join_request bound to nonce_s +test_invite_requires_operator_role +test_gek_never_wrapped_for_hub_supplied_key — the H3 regression test +test_revoked_member_gets_no_gek +test_code_bruteforce_bounded +test_open_join_group_pins_on_first_contact — no code required, TOFU pin recorded +test_invite_group_still_requires_code — public visibility does not skip it +``` + +### Docs to rewrite +- `meshbay-draft-v5.md` §2 (H3 row in the claims table), §5.1 (the "a group admin + who does not run the node can no longer invite" consequence is reversed), §9 + (H3 moves to closed; note what remains open — T3, C4) +- `devel-phases-next.md` — Phase 12.1 becomes this; 14.3/14.4 absorbed +- `second-review.md` — H3 and M3 marked closed by this design +- `QE/deploy/README.md` — `demo.py set-admin-pk` retired in favour of `operator pair` + +--- + +## 11. Decisions + +Settled 2026-08-13 with the operator: + +| # | Question | Decision | +|---|---|---| +| 1 | Groups that skip the pairing code | **`join_policy == "open"` only** — asked as "public groups"; corrected to the admission axis, since `visibility` governs discoverability, not entry (§3.4) | +| 2 | Delegation (group admin ≠ node operator) | **Deferred.** Role value reserved, authorization written as a role check so it drops in later (§6.2) | +| 3 | Code TTL and length | 24 h, 40 bits, `XXXX-XXXX`. Default unless the first real use says otherwise | +| 4 | `gek_bundle_store` from members | Deleted, not disabled — it is the C5b surface and keeping it dead-but-present invites its return | + +Items 3 and 4 are defaults chosen for v1, not constraints; both are one-line changes +if the deployment argues against them. |