summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
commitc83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch)
treedea71c8e115742beaac5952c8c65481bbc130b07 /docs
parentee6573c57f721db8550e34e1c1c79c5922c62a4b (diff)
parentd324792d68503109ab99616af6c85ee37045e169 (diff)
downloadmeshbay-c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60.tar.gz
merge: Phase 11.5 security remediation, invite redesign, per-node identity
Brings in the security remediation branch. Three bodies of work, and what they changed about what this project may claim. Phase 11.5 closed the gap between the documents and the code: the unauthenticated node HTTP API and the TCP transport deleted, one handshake shared by the remaining two transports, mutual authentication, structured admin transcripts, upload confinement, group isolation, revocation that reaches nodes. Six critical and seven high findings closed, bounded, or deferred by decision. The invite redesign closed H3 and M3 — the last open High. The hub was the key directory: an inviter fetched the invitee's key from it and wrapped the group key 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 lookup is gone. The node holds the group key and wraps it itself, for a key its recipient proves possession of, bound to an account by a one-time code the hub never sees. M3 fell out of the same work: node authority comes from a local roster, never from the hub. Per-node identity cut what remains of C4 down to one operator. A single keypair used to be copied to every node its owner joined; each node now gets its own, so cracking the bundle on one machine yields a key that is a stranger everywhere else — and on that machine, one that unlocks nothing its holder did not already serve. The bundle KDF moved to Argon2id 128 MB, and the hub stopped storing or publishing user keys at all. What this project may now say: the hub cannot read your content unless it ships you malicious client code. T3 remains, accepted (D1), and is what the native client removes. C4 is reduced, not closed, until 13.3. Chat is still plaintext at rest until Phase 15. Draft-v5 §2 states each claim against the adversary it holds against, which is the convention this branch exists to keep. Four defects were found by deploying it and using a browser, none by the test suite: a node going deaf on its hub socket, a token that predated group membership, a client reading values before they were assigned, and identity keys a browser held but never re-read. The lessons are recorded in CLAUDE.md. Tests: 343 across the three packages, plus QE/deploy/e2e.py — register, pair, invite, join, download, stream, second browser, revoke — run against the live deployment on a wiped hub and node.
Diffstat (limited to 'docs')
-rw-r--r--docs/USERGUIDE.md97
-rw-r--r--docs/invite-pairing-v1.md546
-rw-r--r--docs/meshbay-draft-v5.md534
-rw-r--r--docs/per-node-identity-v1.md215
4 files changed, 1335 insertions, 57 deletions
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index ccbf112..71b0060 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -62,7 +62,9 @@ MeshBay has three components. Understanding which role each plays avoids a lot o
### Register
-Registration submits your Ed25519 (signing) and X25519 (key agreement) public keys. These let other members wrap GEK bundles for you and let nodes verify your JWT offline.
+Registration creates an account and nothing else: a username, an email, and a value derived from your passphrase that lets the hub check it without ever seeing it.
+
+**No keys are generated here.** An identity keypair belongs to a *node*, not to the hub: one is created the first time you join a given node, encrypted under your passphrase, and left with that node. So an operator who takes their own disk holds a key that is worthless on anyone else's, and the hub has no key directory to publish — which is what finding H3 read.
**Deux modes de génération de clés :**
@@ -86,22 +88,13 @@ Implémenté dans `static/keyderive.js`.
```
POST /v1/users/register
{
- "username": "string",
- "password": "string (min 8 chars)",
- "pk_user_ed25519": "base64 raw 32-byte Ed25519 public key",
- "pk_user_x25519": "base64 raw 32-byte X25519 public key",
- "keypair_bundle": "base64 AES-256-GCM encrypted bundle (web clients only, optional)"
+ "username": "string",
+ "email": "string",
+ "auth_key": "base64 (PBKDF2-SHA512 of your passphrase — the hub never sees the passphrase itself)"
}
→ 201 {"user_id": "uuid"}
→ 409 if username is taken
```
-
-Passwords are hashed with Argon2id (iterations=3, memory=64 MB in dev;
-target 256 MB / ~500ms in production). Intentionally slow to resist offline attacks.
-
-### Login
-
-```
POST /v1/users/login
{"username": "yourname", "password": "yourpassword"}
→ {
@@ -109,12 +102,12 @@ POST /v1/users/login
"refresh_token": "opaque 256-bit token (30 days)",
"token_type": "bearer",
"expires_in": 3600,
- "keypair_bundle": "base64 AES-GCM blob (présent uniquement si enregistré via web)"
}
```
-Les clients web utilisent `keypair_bundle` pour récupérer leurs clés privées
-sur un nouvel appareil : déchiffrement local avec le mot de passe via `keyderive.js`.
+Le trousseau ne vient pas d'ici : chaque nœud conserve celui qui lui est propre,
+chiffré par votre phrase de passe, et un nouveau navigateur le récupère auprès du
+nœud auquel il se connecte.
```bash
curl -s -X POST https://meshbay.org/v1/users/login \
@@ -189,59 +182,49 @@ Save `group_id` — you will need it in your `node.toml` and when adding members
| Join | Open / approval-gated | By invitation only |
| GEK | Not applicable | Required |
-For private groups, the group admin generates a Group Encryption Key (GEK) — a random 32-byte ChaCha20-Poly1305 key. The GEK is never sent over the wire in cleartext. Instead, the admin wraps a copy of it for each member using that member's X25519 public key and stores the opaque bundle on the hub.
+For private groups the node holds a Group Encryption Key (GEK) — a random 32-byte key that is never sent over the wire in cleartext. Each member receives a copy wrapped for their own X25519 public key (ECIES: X25519 + HKDF + AEAD).
+
+**The node does the wrapping, and it never asks the hub for anybody's key.** That matters: the hub is the account directory, so a hub that answered a key lookup with its own key would be handed the group key by an honest member following the protocol exactly (finding H3). Instead the recipient presents their own public keys over the authenticated P2P channel, signed by their identity key, and the node wraps for what it just verified.
### Add a member to a private group
-The group admin fetches the new member's X25519 public key from the hub, wraps the GEK for them, and uploads the bundle:
+The node operator issues a one-time code, from the server or from their browser:
-```python
-import base64, httpx
-from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
-from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives import hashes, serialization
-import os
+```bash
+# On the node, over SSH — no browser needed
+meshbay-node member invite bob
-HUB = "https://meshbay.org"
-GROUP_ID = "10484cb7-e45a-4cdc-8b06-f8ccdacc04d2"
-TOKEN = "alice_test-access-token"
-GEK_RAW = bytes.fromhex("your-32-byte-gek-in-hex") # load from keystore
+INVITATION CODE R3H8-TB6V
+valid until 2026-08-21T12:00:00+00:00
+```
+
+Send the code to Bob however you already talk to him — it never passes through the hub, which is what stops the hub from claiming to be Bob. He enters it the first time he opens the group, and the node then wraps the group key for the key he proved he holds.
-headers = {"Authorization": f"Bearer {TOKEN}"}
+After that first time the pin is his credential: he is recognised on every later connection, and asked for nothing. You do not need to be online when he joins.
-# 1. Fetch new member's X25519 public key
-r = httpx.get(f"{HUB}/v1/users/bob_test/pubkeys", headers=headers)
-r.raise_for_status()
-pk_member_raw = base64.b64decode(r.json()["pk_x25519"])
+| | |
+|---|---|
+| Code lifetime | 7 days (`[node] invite_ttl_hours`) |
+| Reuse | Single use; re-inviting supersedes the previous code |
+| If it expires | Issue another one — nothing else is affected |
+| Wrong code, repeatedly | Bounded per connection and node-wide, and logged in the node's audit log |
-# 2. Wrap GEK for them (ECIES-like)
-sk_eph = X25519PrivateKey.generate()
-pk_eph_raw = sk_eph.public_key().public_bytes(
- serialization.Encoding.Raw, serialization.PublicFormat.Raw)
-shared = sk_eph.exchange(X25519PublicKey.from_public_bytes(pk_member_raw))
-wrap_key = HKDF(algorithm=hashes.SHA256(), length=32,
- salt=pk_eph_raw, info=b"meshbay:gek_wrap:v1").derive(shared)
-nonce = os.urandom(12)
-wrapped = ChaCha20Poly1305(wrap_key).encrypt(nonce, GEK_RAW, pk_member_raw)
+The same operation is available in the web app: the group's **Members** tab, if your browser is paired with the node (`meshbay-node operator pair`).
-# 3. Upload the opaque bundle to the hub
-bundle = {
- "pk_eph_b64": base64.b64encode(pk_eph_raw).decode(),
- "nonce_b64": base64.b64encode(nonce).decode(),
- "wrapped_b64": base64.b64encode(wrapped).decode(),
-}
-r = httpx.post(f"{HUB}/v1/groups/{GROUP_ID}/members/bob_test/gek",
- json=bundle, headers=headers)
-r.raise_for_status()
-print("Bundle stored. bob_test can now access the group.")
+### Removing a member
+
+```bash
+meshbay-node member revoke bob
+meshbay-node gek-init # rotate: Bob still holds the old key
```
-The hub stores the bundle as an opaque blob. It cannot decrypt it — it stores `pk_eph`, `nonce`, and `wrapped` as separate columns but has no key to derive `wrap_key`.
+Revoking stops the node serving Bob the key from his next connection onward — there is no stored bundle left behind that could outlive the decision. It does **not** take back the key he already has, which is why the second command exists.
+
+### What revocation does and does not do
-### Member revocation
+Rotating the GEK (`meshbay-node gek-init`) makes the node encrypt new content with a new key, which every remaining member picks up automatically on their next connection — nothing has to be re-uploaded or re-wrapped by hand.
-To revoke a member: generate a new GEK, re-encrypt it for all remaining members, and upload the new bundles. The node begins encrypting new content with the new GEK from that point. Former members can still decrypt previously received content (no retroactive re-encryption).
+A former member can still decrypt content they already received: there is no retroactive re-encryption, and there is no way to reach into someone's disk. Revocation controls what happens next, not what already happened.
---
@@ -764,7 +747,7 @@ All hub endpoints are under `https://meshbay.org`. Node endpoints are under `htt
| Method | Path | Auth | Description |
|---|---|---|---|
-| POST | `/v1/users/register` | None | Register account. Body: `username, password, pk_user_ed25519, pk_user_x25519`. Returns `user_id`. |
+| POST | `/v1/users/register` | None | Register account. Body: `username, email, auth_key`. No keys — identity keypairs are per node. Returns `user_id`. |
| POST | `/v1/users/login` | None | Authenticate. Body: `username, password`. Returns `access_token, refresh_token`. |
| POST | `/v1/users/token/refresh` | Refresh token | Issue new access token. Body: `refresh_token`. |
| GET | `/v1/users/{username}/pubkeys` | Access token | Fetch `pk_ed25519` and `pk_x25519` for any user (used by group admins for GEK wrapping). |
diff --git a/docs/invite-pairing-v1.md b/docs/invite-pairing-v1.md
new file mode 100644
index 0000000..290c756
--- /dev/null
+++ b/docs/invite-pairing-v1.md
@@ -0,0 +1,546 @@
+# 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 <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, 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.
diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md
new file mode 100644
index 0000000..2a0f3b1
--- /dev/null
+++ b/docs/meshbay-draft-v5.md
@@ -0,0 +1,534 @@
+# MeshBay — Architecture Draft v5
+
+> 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
+> that v4 described a system the code did not implement, and because several v4 claims
+> were simply wrong. This document states what is true, what is chosen, and what is
+> knowingly accepted. **Where a property does not hold, it says so.**
+
+---
+
+## 0. How to read this document
+
+v4 described intent. Several of its statements were contradicted by the implementation:
+
+| v4 said | Reality found in the code |
+|---|---|
+| "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 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
+holds against.** A property that holds against a passive hub and not an active one is
+written that way.
+
+---
+
+## 1. Changes from v4
+
+| # | Category | Change | Source |
+|---|---|---|---|
+| 1 | Transport | TCP+TLS transport and the node HTTP file API **removed**. Two listeners remain: WebRTC DataChannel (primary) and QUIC (LAN / port-forwarded / hub-less) | 11.5.1–2, findings C1/C6 |
+| 2 | NAT traversal | **ICE/STUN is the traversal mechanism**, for native clients too. `punch_nat()` is a direct-connection helper, not a traversal stack | Decision D3 |
+| 3 | Handshake | One implementation in `meshbay_common.handshake`, shared by every transport | 11.5.4, finding C6 |
+| 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 | **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 |
+| 11 | Privacy | Only **public** groups register content hashes with the hub | Finding H7 |
+| 12 | Client architecture | The hub **keeps serving the web UI**. A native desktop client is offered **alongside** it | Decision D1/D2 |
+| 13 | Hub role | "Hub minimization" is **deferred and may be dropped**. The hub stays in the trusted path by choice | Decision D4 |
+
+---
+
+## 2. Security claims — what holds, against whom
+
+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 | ✅ | ❌ **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 | — | — | ✅ |
+| Your identity keys stay yours | ✅ | ✅ | ⚠️ **offline attack on the bundle they hold** — see §7.1. Succeeds against a weak passphrase, and yields the identity used **on that node only**: nothing on anyone else's | ✅ | ✅ |
+
+**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.
+
+**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).
+
+**The claim it must not make:** *"everything is encrypted and unreadable by other parties,
+even the hub."* Three reasons, all deliberate:
+
+- **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*.
+
+One boundary deserves naming: an operator hosts your content by design, but they should not
+be able to become *you*. They can still try — a bundle sits on their disk and a weak
+passphrase gives it up — but since 2026-08-14 what it gives up is **the identity you use
+with them**, which unlocks nothing they did not already have. Reading what they host is by
+design; reading what *other* operators host is not, and no longer follows.
+
+---
+
+## 3. Transport (replaces v4 §6.4, §6.5, §7.1)
+
+### 3.1 Listeners
+
+| Listener | Role | Status |
+|---|---|---|
+| **WebRTC DataChannel** (aiortc) | Primary, browser **and** native clients | Unified handshake enforced |
+| **QUIC** (aioquic) | LAN, port-forwarded, hub-less `group://` | Unified handshake enforced |
+| ~~TCP + TLS 1.3~~ | — | **Removed** (C6) |
+| ~~HTTP file API~~ | — | **Removed** (C1) |
+
+The HTTP API bound `0.0.0.0` for every group, private ones included, and served the
+Mesh Group Index and raw plaintext files with no authentication. It was deleted rather
+than repaired: it duplicated MNP without any of its controls.
+
+### 3.2 NAT traversal — correction
+
+v4 presented `punch_nat()` as the native-client traversal mechanism. It is not one. It
+is a single UDP probe to one address (`quic_server.py`), with:
+
+- no STUN client (the `aioice` dependency is pulled in by `aiortc` only)
+- no candidate gathering, no candidate pairs, no dual-stack fallback
+- a requirement that the client already know its own external IP:port and connect from a
+ fixed source port
+
+It was validated on one ISP and one NAT type. **ICE/STUN is the traversal path** —
+validated across two ISPs, two browsers, IPv4 STUN + IPv6 direct, and 4G CGNAT.
+
+Native clients therefore run `aiortc` in Python rather than reimplementing traversal.
+Every native benefit (local keystore, ChaCha20, Argon2id, Ed25519 auth) comes from being
+native, not from the transport. QUIC is retained for LAN, port-forwarded and hub-less
+access, where a signaling channel is unavailable or unwanted.
+
+---
+
+## 4. MNP handshake (replaces v4 §4.2.x handshake description)
+
+One implementation: `meshbay_common/handshake.py`.
+
+```
+client → node handshake {token, group_id, nonce_c}
+node authorize_token() JWT · scope · denylist · group_id · membership · hosting
+node → client handshake_challenge {nonce_s, node_pk}
+ ── pre-proof window: keypair/GEK bundle fetch, join ──
+client → node handshake_response {proof}
+node verify HMAC(GEK, client transcript)
+node → client handshake_ack {proof, sig, node_pk, is_node_admin}
+client verify HMAC(GEK, node transcript) + Ed25519(node_pk)
+ and that ack.node_pk is the key announced above
+```
+
+`node_pk` is announced in the challenge because joining needs it before the ack: a
+first-time member signs a transcript naming this node (§5.5) and has no GEK to complete a
+handshake with. It is unverified at that point and is never a substitute for the ack — the
+ack proves possession and signs the transcript, and the client refuses a peer whose key
+changed between the two. A wrong value there only makes the node's own verification fail.
+
+**Refusals carry a code**, not only a sentence. `not_a_member` in particular is usually a
+token issued before the person was added to the group — `groups` is baked in at sign-in and
+the hub pushes no updates — so the client refreshes once and retries rather than telling
+someone who was invited a minute ago that they are not a member.
+
+### 4.1 Transcript
+
+```
+"meshbay:mnp:handshake:v1" ‖ len‖role ‖ len‖group_id ‖ len‖nonce_c ‖ len‖nonce_s ‖ len‖binding
+```
+
+Every field length-prefixed; the role bound in so a client proof cannot be replayed as a
+node proof. v4's transcript was `nonce ‖ offer_fp ‖ answer_fp` — bare concatenation, and
+a missing fingerprint silently degraded it to nonce-only, dropping MitM detection.
+**An absent channel binding is now refused.**
+
+### 4.2 Channel binding
+
+| Transport | Anchor |
+|---|---|
+| WebRTC | both DTLS certificate fingerprints |
+| QUIC | SHA-256 of the server certificate. aioquic exposes no RFC 5705 exporter; the client's access to the peer certificate is a guarded private attribute, and on a **resumed** session — which carries no certificate — the anchor travels with the session ticket |
+
+### 4.3 Authorization rules
+
+- `group_id` is **mandatory**. Omitting it previously skipped the membership check and
+ fell back to the node's first group.
+- Node-scoped daemon tokens are **refused** on the client path.
+- The denylist is consulted for user, `jti` **and** group.
+
+### 4.4 Mutual authentication
+
+The node proves GEK possession over a client-chosen nonce **and** signs the transcript
+with its long-term key. Previously the client received an unverifiable `node_pk` and
+trusted `is_node_admin` from whoever answered — so a peer that had hijacked signaling
+could serve a forged index, forged chat history and forged permissions.
+
+The client also **pins** `pk_node` per node on first use (11.5.8) and refuses a changed
+key outright, with a deliberate reset path in Settings for a legitimate rotation.
+
+---
+
+## 5. Node authority (replaces v4 §4.2.x)
+
+### 5.1 Privileged operations
+
+Destructive and privileged operations require an Ed25519 signature over a structured
+transcript, never a JWT — the hub controls JWT issuance and can therefore never
+establish node-level authority.
+
+```
+"meshbay:admin:v1" ‖ len‖op ‖ len‖node_pk ‖ len‖group_id ‖ len‖subject ‖ len‖nonce ‖ len‖ts
+```
+
+TTL 120 s. The client reconstructs the transcript from announced fields and **refuses to
+sign** if the operation or subject does not match what the user requested. v4's challenge
+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) |
+| `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. 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
+
+Confined to `shared_root/.uploads/{user_id}/`, filename allowlist, no overwrite, chunk
+ordering enforced, size capped. Previously uploads landed in the shared root under a
+client-chosen name and overwrote anything there — which also defeated deletion
+authorization, since overwriting a file made the attacker its recorded uploader.
+
+### 5.3 Operator interface
+
+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 — `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). 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, 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).
+
+---
+
+## 6. Hub (replaces v4 §4.1)
+
+### 6.1 Role — chosen, not minimal
+
+Hub minimization was considered and **deferred** (decision D4). The hub keeps serving
+the web UI and remains in the trusted path by choice. This section describes what it
+*does*, not an aspiration.
+
+**Stores:** accounts (username, encrypted email, status, role), group registry and
+membership, IP logs (1 year, legal), node registrations, refresh tokens, notifications,
+moderation blocklist.
+
+**No longer stores user identity keys** (2026-08-14). `users.pk_ed25519` and
+`users.pk_x25519` are dropped, `PUT /me/keys` is gone, and `/pubkeys` returns an account id
+and a node's linking key — nothing to wrap for. Tokens carry no `pk_user` claim either: the
+node used to record it as the uploader's identity, which let whoever issued tokens decide
+who could delete a file.
+
+**Does not store:** file content, file names, private-group indexes, message content,
+private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling.
+
+**Knows, unavoidably:** who is a member of what, when nodes connect, when a chat message
+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`
+against the token subject, and derives group claims from the database — a node may
+narrow the set to what it hosts but cannot widen it. It previously took `node_id` and
+`group_ids` verbatim from the client's first message, letting any user hijack a victim
+node's signaling and impersonate it to browsers.
+
+### 6.3 Signaling
+
+Rate-limited, SDP size bounded, capped per user, and the caller must share an **active**
+group with the target node. Previously reachable by any authenticated user for any node,
+making a third party's machine allocate peer connections on demand. `peer_ip` in the NAT
+punch request must match the caller's source address.
+
+### 6.4 Client addresses
+
+`X-Forwarded-For` is honoured only from a trusted proxy, rightmost hop. It was trusted
+unconditionally, so anyone could forge the IP written to the compliance log.
+
+---
+
+## 7. Cryptography (amends v4 §6)
+
+Unchanged: GEK wrapping (ECIES, X25519 + HKDF + AEAD), per-chunk key derivation, domain
+separation, on-the-fly encryption.
+
+| Parameter | Value |
+|---|---|
+| Node keystore KDF | Argon2id **256 MB**, iterations 3, lanes 4 — parameters recorded per envelope so they can be raised without orphaning existing keystores |
+| Hub password verifier | Argon2id 256 MB over a PBKDF2-derived `auth_key` |
+| Browser bundle key | **Argon2id 128 MB, t=3, p=1** (was PBKDF2-SHA512 600k until 2026-08-14) |
+| Browser `auth_key` | PBKDF2-SHA512, 600 000 iterations — the hub's Argon2id verifier is what protects it at rest |
+
+### 7.1 The keypair bundle, and what it is worth to an attacker (C4)
+
+A bundle carries **one node's** identity keys, encrypted under the owner's passphrase, and
+is stored on that node. It is what lets a second browser open the same account there — the
+ordinary expectation, and the only mechanism available to a browser that keeps nothing
+durable of its own.
+
+The adversary is concrete: an operator holding their own node's disk, attacking offline at
+their leisure.
+
+**What cracking one yields.** The identity that person uses **on that node** — where the
+operator already holds the content, the index and every byte they serve. It is not a key
+anywhere else: each node gets its own, generated the first time its owner joins it, and a
+key one node pinned is a stranger to the next (which asks for a code, like any first
+contact). Until 2026-08-14 a single identity was copied to every node joined, so one crack
+yielded content on *other* operators' nodes and the ability to sign as that user anywhere —
+that was the part worth attacking, and it is gone.
+
+Two smaller consequences fall out. Two operators can no longer tell they host the same
+person by comparing keys. And the hub publishes no user keys at all now (§6.1), so there is
+no directory left to substitute from.
+
+**Why Argon2id.** PBKDF2 is compute-only, which is exactly what a GPU is good at. Measured
+on the development machine: PBKDF2-SHA512 600k costs 241 ms per guess on one core,
+Argon2id 128 MB/t=3 costs 88 ms — the defender pays *less* — but only one of them forces
+an attacker to find 128 MB per guess.
+
+The honest size of the gain: on a single high-end card the ceiling moves from roughly 8k
+guesses/s to roughly 2k, a factor of four, not a thousand. What it really buys is the cost
+of scale — 128 MB per lane caps a 24 GB card near 187 concurrent guesses, and makes custom
+hardware unattractive, where SHA-512 silicon is cheap.
+
+**The passphrase decides this, not the KDF.** At ~2k guesses/s, a dictionary-and-rules run
+of 10⁹ candidates — which covers a large share of human-chosen passwords — takes about six
+days on one card. Four random words (~52 bits) outlasts the sun. No parameter choice saves
+a weak passphrase; it only moves it from hours to days. The client therefore refuses
+passphrases below 12 characters and ~60 estimated bits, and that floor can only live in the
+client: with the password split (T1) the hub never sees a passphrase and cannot enforce
+anything about one.
+
+**Operational notes.**
+
+- Argon2id runs in WebAssembly, vendored under `static/vendor/` with its provenance —
+ the CSP forbids external hosts, and 12.2 must keep `wasm-unsafe-eval` in `script-src`.
+- Two implementations exist (the browser's WASM, `argon2-cffi` in QE). A parity test holds
+ them byte-identical, because a disagreement would not look like an error — it would look
+ like an account nobody can open.
+- Bundles written under the old KDF carry no marker, are still readable, and are
+ re-encrypted the first time their owner's browser backs them up.
+- Cost is paid **once per sign-in**: 650 ms for the bundle key, plus 239 ms for `auth_key`.
+ Opening a group, downloading, streaming and reloading the page all reuse it — the key
+ lives in IndexedDB for the session.
+- The pre-proof window that serves bundles is still bounded (4 fetches) and audited.
+
+C4 is **reduced, not closed**: bundles still sit on disks their owner does not control, and
+a weak passphrase still gives up the key for that node. It closes when the native client
+stops storing them remotely (Phase 13.3) — the material belongs on the user's own device,
+not on the hub *or* on other people's nodes.
+
+---
+
+## 8. Clients (replaces v4 §4.3)
+
+### 8.1 Two clients, deliberately
+
+| | Hub-served web SPA | Native desktop client |
+|---|---|---|
+| Distribution | Served by the hub | Installed, signed release |
+| Code integrity | **T3 accepted** — the hub can inject | Detectable *if* reproducible builds ship |
+| Key storage | IndexedDB / sessionStorage | OS-protected local keystore |
+| Crypto | WebCrypto (AES-GCM) | Full (ChaCha20, Argon2id) |
+| Transport | WebRTC | WebRTC **+ QUIC** |
+| Positioning | **Convenience tier** — zero install | Recommended for sensitive use |
+
+**Several browsers, one identity per node.** A browser keeps nothing durable the user
+controls, so the identity it creates for a node is left with that node, encrypted under the
+passphrase. Any other browser recovers it there with the passphrase alone: same identity on
+that node, same pin, no second code. Joining a *different* node creates a different key and
+needs that operator's code, which is the first contact it has always needed.
+
+This is what makes the product behave the way people expect, and it is also finding C4
+(§7.1) — with a blast radius of one node. The native client removes the need for it
+entirely, which is a large part of why it exists.
+
+The SPA is not deprecated. It is the zero-install path and it stays. It must be labelled
+honestly: `/app/` states that the hub serves this code, and the docs never claim
+end-to-end *integrity* for that path.
+
+### 8.2 Native client
+
+pywebview + aiortc, sharing ~69% of the existing UI unchanged (the SPA contains no direct
+`crypto.subtle` or `RTCPeerConnection` usage; everything goes through three injected
+globals and one fetch helper). Assets ship **inside the package** and load from disk — a
+shell pointing its WebView at the hub's `/app/` would be a browser with a different icon
+and would fix nothing.
+
+Native does **not** remove trust in the hub operator; it converts an undetectable,
+per-request, per-user attack into a persistent artifact that can be hashed and compared.
+That value is realised by reproducible builds and published hashes, not by the packaging
+format.
+
+---
+
+## 9. Open items
+
+**Phase 11.5 is complete.** All six critical and all seven high findings from
+`second-review.md` are closed, bounded, or deferred by explicit decision.
+
+### Closed since this document was drafted
+
+| # | Item | Closed by |
+|---|---|---|
+| **C6** | GEK proof on the QUIC path | QUIC now runs the same challenge/response as WebRTC — client nonce, role-bound transcript, GEK proof, and the node proving itself with a GEK proof plus an Ed25519 signature |
+| 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 |
+|---|---|---|
+| 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 |
+| — | 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
+
+With C6 closed, `pk_node` pinning is defence in depth rather than the primary control: a
+substituted node already fails the GEK proof. Pinning covers the case where an attacker
+*holds* the group key — an ex-member, or a leaked GEK — and swaps the node underneath,
+which the proof alone cannot distinguish from the genuine node.
+
+### Sender Keys — scope correction
+
+Sender Keys protects chat from someone holding the node's disk who is **not** a group
+member — a seized machine, a hosting provider, a compromised node. It does **not**
+protect chat from the node operator, who is a group member and therefore a legitimate
+recipient. Distributing sender keys under the GEK would make the encryption decorative,
+since anyone with the GEK would recover every sender key.
+
+---
+
+## 10. Testing posture
+
+The pre-remediation suite passed 209 tests while every finding in the review was live. It
+tested that features work, and in doing so **pinned four vulnerabilities in place** as
+expected behaviour: GEK auto-activation, the transport-wide chat store, the blind admin
+challenge, and private-group hashes registered with the hub. A refactor that accidentally
+fixed one would have been reported as a regression.
+
+Security tests are therefore written as **negative assertions** — "this attack does not
+work" — and verified to fail against the pre-fix source before being trusted.
+`test_security_regressions.py`, `test_node_ws_auth.py` and `test_handshake.py` hold them.
diff --git a/docs/per-node-identity-v1.md b/docs/per-node-identity-v1.md
new file mode 100644
index 0000000..673fec1
--- /dev/null
+++ b/docs/per-node-identity-v1.md
@@ -0,0 +1,215 @@
+# MeshBay — Per-node identity
+
+> Status: **implemented 2026-08-14**, deployed and exercised end to end against a wiped
+> hub and a wiped node. Written first as a proposal; §9 records what shipped.
+> Reduces **C4** from "one crack yields the network" to "one crack yields one node".
+> Removes the hub-published user keys, which stopped being load-bearing when H3 closed.
+> Follows the v5 convention: every claim names the adversary it holds against.
+
+---
+
+## 1. What this changes, in one paragraph
+
+Today one identity keypair is copied to every node its owner joins. Cracking the copy
+stored on *any* node yields the identity used on *all* of them. This proposal gives each
+node its own keypair, generated the first time its owner joins it. An operator who cracks
+what sits on their own disk then holds a key that is worthless anywhere else — and on
+their own node they already hold everything it could unlock.
+
+No new screen, no extra code to type, no change to how anyone signs in.
+
+---
+
+## 2. Where we are
+
+Two things are **already per node**, which is what makes this cheap:
+
+| | Today |
+|---|---|
+| Roster pin | per node — each node pins the key it was shown (`roster.identities`) |
+| Bundle storage | per node — each node has its own `bundles.db` |
+| **Key material** | **global — the same keypair is copied into every node's database** |
+
+So the plumbing is in place and only the contents are shared. The change is: generate a
+fresh keypair per node instead of reusing one.
+
+The global key exists because registration creates it (`keyderive.js registerUser`) and
+publishes it to the hub, from where everything else used to fetch it. Since H3 closed,
+almost nothing does.
+
+---
+
+## 3. The design
+
+### 3.1 One keypair per node
+
+The first time a browser joins node N, it generates a keypair for N, encrypts it under the
+passphrase-derived key, and stores it on N — which is the message it already sends
+(`keypair_bundle_store`). N pins the public half through the ordinary join, using the
+pairing code its operator already issues for a first contact.
+
+Nothing else in the join changes: same transcript, same code, same pin, same refusals.
+
+### 3.2 Flows
+
+**First join to a node** — unchanged from the user's side:
+
+```
+browser no key for node N yet → generate one
+browser → N join_request {pk_ed25519_N, pk_x25519_N, code, sig}
+N code valid → pins the pair, wraps the GEK for pk_x25519_N
+browser → N keypair_bundle_store (that node's key, encrypted under the passphrase)
+```
+
+**Second browser, same node** — unchanged:
+
+```
+browser → N keypair_bundle_fetch → decrypt with the passphrase → same key as browser 1
+browser → N join_request (no code) → recognised
+```
+
+**Joining a second node** — already requires a code from that node's operator, so the new
+key is generated in the same step. The user does nothing extra.
+
+**Operator pairing** — identical; the operator's key is per node like everyone else's.
+
+### 3.3 Where the browser keeps them
+
+A map `node_id → bundle` in IndexedDB, instead of one identity. A browser that has never
+seen node N simply fetches N's bundle from N; it only ever needs the key of the node it is
+talking to.
+
+---
+
+## 4. What this fixes, and what it does not
+
+**Fixes: the blast radius.** An operator who cracks the bundle on their own disk gets the
+key used with their own node. There they already control the content, the index and every
+byte they serve — the key adds almost nothing. What disappears is the part that mattered:
+reading that person's content on **other** operators' nodes, and signing as them anywhere
+else. That is the whole of what made C4 more than a redundancy.
+
+**Fixes, incidentally: linkability.** Two operators can no longer tell they host the same
+person by comparing keys. Today they can.
+
+**Does not fix:** a weak passphrase still gives up that node's key, and the bundles still
+sit on disks their owner does not control. Only Phase 13.3 (native client, keys on the
+device) removes that. C4 stays open, with a smaller consequence.
+
+**Does not change** the operator's ability to read what they host. That is by design and
+stated in draft-v5 §2.
+
+---
+
+## 5. Removing the hub-published user keys
+
+They were the directory H3 exploited. Since the node wraps the GEK itself, nothing wraps
+anything for a key fetched from the hub. What remains is inventory.
+
+### 5.1 What still uses them
+
+| Use | Verdict |
+|---|---|
+| `_sessionKeys.pkXB64` set from `/pubkeys` (`app.js:974, 2768`) | **replaceable** — the browser can derive its own public half from its own secret (`_pkXFromSk`, already written) |
+| Invite: username → `user_id` (`app.js:1602`, `ui/app.py`) | **keep the endpoint** — an account id is not a key, and it is how a name is resolved |
+| Settings: `pk_node_ed25519` (`app.js:2210`) | **keep** — that is the node linking key, a different field |
+| JWT claim `pk_user` (`auth.py:151`, filled from `user.pk_ed25519`) | **remove** — see 5.2, it is a live defect |
+
+### 5.2 A defect this uncovered
+
+`pk_user` travels in the JWT and the node records it as `uploader_pk` at upload
+(`webrtc_server.py:1178`), then uses it to authorize deletion by the uploader
+(`:1329-1340`). That key is chosen by the **hub**. A hub that issued a token naming its own
+key could then delete that user's files on any node — deny-by-default was supposed to make
+deletion node-authorized, and this is a hole in it.
+
+With per-node identity there is a better answer available for free: authorize deletion
+against the key the **roster pinned**, which the node established locally and the hub never
+touched. This should be fixed whether or not the rest of the proposal proceeds.
+
+### 5.3 What goes
+
+- `User.pk_ed25519`, `User.pk_x25519` columns (Alembic migration)
+- `pk_user_ed25519` / `pk_user_x25519` in the registration body
+- `PUT /v1/users/me/keys`, and `regenerateKeys()` in `keyderive.js` — rotation becomes
+ per node: `member unpin` plus a fresh code, which already exists
+- the `pk_user` JWT claim, and `AuthorizedPeer.pk_user`
+- the two key fields in the `/pubkeys` response; the endpoint stays for `user_id` and
+ `pk_node_ed25519`
+
+Old tokens keep working while they live (1 h): the node already reads the claim with
+`.get()`, so its absence is not an error.
+
+---
+
+## 6. Work plan
+
+| # | Slice | Where | Effort |
+|---|---|---|---|
+| 1 | `uploader_pk` from the roster pin, not the JWT | `webrtc_server.py` | small — and it is a fix on its own |
+| 2 | Per-node keypair: generate at first join, store per node, keep a `node_id → bundle` map | `app.js`, `transport.js`, `keyderive.js` | **the bulk of it**, all client-side |
+| 3 | Derive our own `pkX` locally instead of reading it back from the hub | `app.js` | small |
+| 4 | Remove the published keys: columns, endpoint fields, registration body, JWT claim, `regenerateKeys` | `db/models.py`, `api/users.py`, `auth.py`, migration, `keyderive.js`, `handshake.py` | medium, touches the schema |
+| 5 | Harness + docs: `e2e.py` per-node keys, draft-v5 §2/§7.1/§8.1, `second-review.md` | QE, docs | small |
+
+Slices 1 and 3 stand alone and could land first. Slice 4 is the only one with a migration.
+
+---
+
+## 7. Risks and open questions
+
+**Existing users.** No big-bang migration: a bundle already on a node simply becomes that
+node's key, and only *new* joins generate fresh ones. Someone already on three nodes keeps
+one shared key across those three until they re-pair — the improvement applies going
+forward. Forcing it would mean unpinning everyone, which is not worth it.
+
+**A browser that loses its map.** It refetches from the node it is connecting to; nothing
+is lost, since a node's key is only needed with that node.
+
+**Is per node the right granularity, rather than per group?** Per node matches the roster,
+which pins per account and not per group, and matches the trust boundary — the operator is
+the adversary, and one operator may host several of your groups. Per group would multiply
+keys with no adversary to justify them.
+
+**Does anything need one identity across nodes?** Nothing found. Chat identity and upload
+attribution are per node; account identity on the hub is the username plus `auth_key`, which
+is untouched. If a future feature needs a global identity — cross-node contacts, say — it
+would need its own key, published deliberately, not this one reused by accident.
+
+---
+
+## 8. How it gets tested
+
+- `e2e.py`: a member joins two groups on the node with one key (unchanged), and the
+ second-browser recovery still works. Add a check that the key stored on the node is the
+ one pinned there, not a global one
+- a negative test: a key pinned by node A, presented to node B, is refused without a code —
+ which is the property the whole proposal buys
+- `test_spa_ordering.py`: generation of the per-node key must happen before `joinGroup()`,
+ same class of ordering guard as the others
+- the hub tests must fail if `/pubkeys` starts returning user keys again
+
+
+---
+
+## 9. What shipped
+
+All five slices, against a deployment wiped for the next demo — so no compatibility path
+was kept and none is owed.
+
+| Slice | Outcome |
+|---|---|
+| 1 | `uploader_pk` comes from the roster pin (`_pinned_pk`), never from the token |
+| 2 | Identity is created at first contact with a node and left there; `transport.js` fetches it or generates it, `app.js` no longer holds a global one |
+| 3 | The browser derives its own public half; nothing is read back from the hub |
+| 4 | `users.pk_ed25519` / `pk_x25519` dropped (migration `a7c31f9e40b2`), `PUT /me/keys` and `regenerateKeys()` gone, `pk_user` claim gone, `/pubkeys` reduced to an account id and the node linking key |
+| 5 | Harness mirrors the client (recover, else generate, then leave the key with the node); tests for the property; docs |
+
+**A simplification worth noting.** Registration no longer generates anything, so a scripted
+signup is now a real account — `demo.py bootstrap` brings a wiped deployment to a working
+demo without a browser, which was impossible before. The old rule "only the admin can be
+registered by script" is gone with the keys it existed for.
+
+**Tests added:** a key pinned by one node, presented to another, is refused as a first
+contact; and someone else's code does not admit it either. That pair is the property this
+whole change buys.