summaryrefslogtreecommitdiffstats
path: root/docs/invite-pairing-v1.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:21 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:21 +0200
commitf15efd23f66c521ca9206789482bb38e7326eeb4 (patch)
treef069b741d3fe0114c3b5889c02dc0392c5201f68 /docs/invite-pairing-v1.md
parentaab4bc98a3361d9f23e048a52705baa2f4a4a078 (diff)
downloadmeshbay-f15efd23f66c521ca9206789482bb38e7326eeb4.tar.gz
feat(node)!: the node wraps the group key — closes H3 and M3
The invite flow fetched the invitee's pk_x25519 from the hub and wrapped the GEK for whatever came back (app.js:1466, and gek-init did the same server-side). The hub is the key directory, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. No forgery, no injection, nothing for the client to notice. That was H3. The fix is not safety numbers. Nobody reads the directory any more: - the node holds the GEK and wraps it itself, on every connection, for the X25519 key the joiner signed with their Ed25519 identity in one transcript (meshbay:join:v1), so the identity key vouches for the encryption key; - identities are bound to accounts by a one-time code the hub never sees — 40 bits, single use, one account, bounded per connection AND node-wide; - the node's own roster decides who may receive the key. Hub membership lets someone reach a node; it no longer gets them anything. A hub that invents an account and mints it a token is answered not_authorized_for_group. Safety numbers would have made substitution detectable by a human who checks, at the moment there is nothing to check against — first contact. Removing the lookup makes it impossible, and costs the user one code to pass along. M3 falls out of the same work. The daemon auto-pinned its own keystore key as admin_pk_ed25519 while the browser signs with the user identity key, so every privileged operation failed closed with a signature error that looked like a bug somewhere else; the demo only worked because a deploy script overwrote the value. Authority now comes from the roster, established locally by `operator pair`. Asking the hub for the operator's key — the obvious-looking fix — would have let the hub install itself as node administrator. BREAKING: gek_bundle_store is deleted, not gated. No member hands the node key material at all, so C5b becomes structural rather than an authorization to check. Existing stored bundles are still served, so current deployments keep working. Also: - join_policy (invite|open) is read from node.toml, never from the hub — a hub able to declare a group open would be handed its key. Unknown group ⇒ invite. - admin signatures are verified against the roster on every check, so unpinning takes effect without a restart. admin_pk_ed25519 stays readable as legacy. - two C5b tests were rewritten, deliberately: they asserted that gek_bundle_store demanded an operator signature, and the message is gone. They now assert the stronger property. The file says not to fix these tests, so this is the record of why they changed. - a slice-1 bug found while writing slice 2: connect() never passed skEdB64, so pairing would have failed at runtime with no test able to catch it. Tests: 152 node+common here, including an end-to-end DataChannel run where a member who has never held the group key redeems a code in the pre-proof window and receives the key wrapped for a key only they can open. Design: docs/invite-pairing-v1.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs/invite-pairing-v1.md')
-rw-r--r--docs/invite-pairing-v1.md526
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.