# MeshBay — Invitation and Pairing (design) > Status: **implemented and deployed** (2026-08-14). Written 2026-08-13 as a proposal; > §9bis records what shipped, what departed from the plan, and what a browser found > that the automated run could not. > 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) group → Members tab → "Pair this browser" → 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 ") 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, node_pk} ← bob signs over both 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. ### What a browser found that the harness could not The design was exercised end to end by `QE/deploy/e2e.py` and passed. It was then tried in a browser and failed four times, each for a different reason, none of them the pairing code: | Symptom | Cause | |---|---| | No code ever requested, "no GEK available" | the browser held no identity keys — session restore never re-read the encrypted bundle sitting in its own localStorage | | "No nodes available" | the node had gone deaf: its hub socket sat in CLOSE-WAIT because the WebRTC offer handler was awaited inside the read loop (a Phase 11 bug) | | "Not a member of this group" | the token predated being added to the group; `groups` is baked in at sign-in, and nothing refreshed it | | "Handshake incomplete — reconnect and retry" | `joinGroup()` read `node_pk` and `nonce_node` before they were assigned | The common thread is worth keeping: **e2e.py is a second implementation of the client**, written in the right order by construction, so it proves the protocol and nothing about `app.js`. Three of those four were ordering or plumbing mistakes it could never see. Hence `packages/meshbay-hub/tests/test_spa_ordering.py`, which asserts on source order — inelegant, and the only thing that catches them. ### 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.