diff options
18 files changed, 2637 insertions, 285 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. diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 71446ca..2d90102 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -34,7 +34,12 @@ ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1" # Operations that require node-operator authority. OP_FILE_DELETE = "file_delete" -OP_GEK_BUNDLE_STORE = "gek_bundle_store" +OP_INVITE_CREATE = "invite_create" +# OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at +# all: the node holds the GEK and wraps it itself, for a key the recipient proved +# they hold (see `join.py` and docs/invite-pairing-v1.md). The operation existed +# only to make member-supplied bundles safe, and deleting the message is a +# stronger guarantee than authorizing it. # A challenge older than this is refused, so a signature captured from a stale # exchange cannot be replayed later. @@ -53,7 +58,7 @@ def admin_transcript( Build the exact byte string signed for an admin operation. `subject` identifies what is being acted on: a file_id for OP_FILE_DELETE, the - target user_id for OP_GEK_BUNDLE_STORE. + invitee's user_id for OP_INVITE_CREATE. """ fields = [ op.encode(), diff --git a/packages/meshbay-common/src/meshbay_common/join.py b/packages/meshbay-common/src/meshbay_common/join.py new file mode 100644 index 0000000..6ee543f --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/join.py @@ -0,0 +1,63 @@ +""" +Join and pairing transcript (MNP). + +A client proves, in one signature, that the X25519 key it wants the group key +wrapped for belongs to the Ed25519 identity the node pins. Both keys travel inside +the transcript, so the identity key vouches for the encryption key it is paired +with — that is what makes "wrap the GEK for the key the peer presented" safe. + +Why this exists at all (H3): the invite flow used to fetch the invitee's public key +from the hub and wrap the group key for whatever came back. The hub is the key +directory, so a hub answering with its own key was handed the GEK by an honest +inviter following the protocol exactly. The key now comes from the peer over an +authenticated channel and is bound to an identity by a one-time pairing code the +hub never sees. See `docs/invite-pairing-v1.md`. + +Fields are length-prefixed and domain-separated, per L4 — the same rule as +`handshake.py` and `adminop.py`. `nonce_node` is the handshake nonce the node just +issued, so a signed join cannot be lifted onto another connection. +""" + +from __future__ import annotations + +JOIN_PREFIX = b"meshbay:join:v1" + +# A join older than this is refused. Same value as the admin challenge: both are +# interactive exchanges that complete in milliseconds. +JOIN_TTL = 120 # seconds + +ROLE_OPERATOR = "operator" +ROLE_DELEGATE = "delegate" # reserved; delegation is deferred (§6.2 of the design) +ROLE_MEMBER = "member" + + +def join_transcript( + node_pk_b64: str, + group_id: str, + user_id: str, + pk_ed25519_b64: str, + pk_x25519_b64: str, + nonce_node: bytes, + ts: int, +) -> bytes: + """ + Bytes signed by a client asking to be pinned by, or recognised on, a node. + + `group_id` is empty for operator pairing, which is node-wide rather than + per-group. The node builds this from its own state and the values in the + message; nothing signed is ever taken from the wire unverified. + """ + fields = [ + node_pk_b64.encode(), + group_id.encode(), + user_id.encode(), + pk_ed25519_b64.encode(), + pk_x25519_b64.encode(), + nonce_node, + str(ts).encode(), + ] + out = bytearray(JOIN_PREFIX) + for field in fields: + out += len(field).to_bytes(4, "big") + out += field + return bytes(out) diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index d86b4ef..510813a 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -46,12 +46,18 @@ class MNP: HANDSHAKE_RESPONSE = "handshake_response" # client → node: HMAC(GEK, nonce) ADMIN_CHALLENGE = "admin_challenge" # node → client: Ed25519 sign challenge ADMIN_RESPONSE = "admin_response" # client → node: Ed25519 signature - GEK_BUNDLE_STORE = "gek_bundle_store" # client → node: store wrapped GEK for a user + # GEK_BUNDLE_STORE was removed with the invite redesign: the node wraps the GEK + # itself, for a key the recipient proved possession of, so no member ever hands + # the node key material (C5b, and the H3 substitution it enabled). GEK_BUNDLE_FETCH = "gek_bundle_fetch" # client → node: request own wrapped GEK GEK_BUNDLE_RESP = "gek_bundle_resp" # node → client: wrapped GEK bundle KEYPAIR_BUNDLE_STORE = "keypair_bundle_store" # client → node: store encrypted keypair bundle KEYPAIR_BUNDLE_FETCH = "keypair_bundle_fetch" # client → node: request own keypair bundle KEYPAIR_BUNDLE_RESP = "keypair_bundle_resp" # node → client: encrypted keypair bundle + JOIN_REQUEST = "join_request" # client → node: pair/recognise this identity + JOIN_RESULT = "join_result" # node → client: outcome + wrapped GEK + INVITE_CREATE = "invite_create" # operator → node: issue a pairing code + INVITE_RESULT = "invite_result" # node → operator: the code, once # ── Index entry ─────────────────────────────────────────────────────────────── diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py index 9adac51..340ea3e 100644 --- a/packages/meshbay-common/tests/test_js_python_parity.py +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -22,6 +22,7 @@ import pytest from meshbay_common.adminop import admin_transcript from meshbay_common.handshake import handshake_transcript, webrtc_binding +from meshbay_common.join import join_transcript CRYPTO_JS = (Path(__file__).resolve().parents[2] / "meshbay-hub" / "src" / "meshbay_hub" / "static" / "crypto.js") @@ -52,6 +53,18 @@ ADMIN_VECTORS = [ ("file_delete", "Tk9ERVBL", "café", "fichier é.mp4", "04" * 32, 1_700_000_002), ] +# (node_pk_b64, group_id, user_id, pk_ed b64, pk_x b64, nonce hex, ts) +JOIN_VECTORS = [ + # Operator pairing: group_id is empty and must stay distinguishable from a + # request that names a group. + ("Tk9ERVBL", "", "grenet", "QUFB", "QkJC", "01" * 32, 1_700_000_000), + ("Tk9ERVBL", "g" * 32, "grenet", "QUFB", "QkJC", "01" * 32, 1_700_000_000), + # Identical to the previous vector except that the two keys are swapped — + # they are adjacent fields, so this isolates the ordering. + ("Tk9ERVBL", "g" * 32, "grenet", "QkJC", "QUFB", "01" * 32, 1_700_000_000), + ("Tk9ERVBL", "café", "utilisateur-é", "QUFB", "QkJC", "03" * 32, 0), +] + _HARNESS = r""" const fs = require('fs'); @@ -62,7 +75,8 @@ globalThis.crypto = globalThis.crypto || {}; const src = fs.readFileSync(process.argv[2], 'utf8'); const load = new Function( - src + '\nreturn { handshakeTranscript, adminTranscript, webrtcBinding, b64encode };'); + src + '\nreturn { handshakeTranscript, adminTranscript, joinTranscript, ' + + 'webrtcBinding, b64encode };'); const M = load(); const hex = (s) => { @@ -74,7 +88,7 @@ const toHex = (u8) => Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join(''); const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); -const out = { handshake: [], admin: [] }; +const out = { handshake: [], admin: [], join: [] }; for (const v of input.handshake) { const binding = M.webrtcBinding(hex(v.offer_fp), hex(v.answer_fp)); @@ -87,6 +101,11 @@ for (const v of input.admin) { v.op, v.node_pk, v.group_id, v.subject, M.b64encode(hex(v.nonce)), v.ts))); } +for (const v of input.join) { + out.join.push(toHex(M.joinTranscript( + v.node_pk, v.group_id, v.user_id, v.pk_ed, v.pk_x, hex(v.nonce), v.ts))); +} + process.stdout.write(JSON.stringify(out)); """ @@ -110,6 +129,11 @@ def js_output(tmp_path_factory): "subject": s, "nonce": n, "ts": ts} for op, pk, g, s, n, ts in ADMIN_VECTORS ], + "join": [ + {"node_pk": pk, "group_id": g, "user_id": u, + "pk_ed": pe, "pk_x": px, "nonce": n, "ts": ts} + for pk, g, u, pe, px, n, ts in JOIN_VECTORS + ], })) proc = subprocess.run( @@ -164,6 +188,43 @@ def test_admin_transcript_parity(idx, vector, js_output): ) +@pytest.mark.parametrize("idx,vector", list(enumerate(JOIN_VECTORS))) +def test_join_transcript_parity(idx, vector, js_output): + """ + A mismatch here means no browser can pair with a node and no member can be + recognised — the node would reject every signature as invalid, and, as with + the other two, nothing else in the suite crosses this boundary. + """ + node_pk, group_id, user_id, pk_ed, pk_x, nonce, ts = vector + + expected = join_transcript( + node_pk_b64=node_pk, + group_id=group_id, + user_id=user_id, + pk_ed25519_b64=pk_ed, + pk_x25519_b64=pk_x, + nonce_node=bytes.fromhex(nonce), + ts=ts, + ) + assert js_output["join"][idx] == expected.hex(), ( + f"crypto.js and meshbay_common.join disagree for user={user_id!r} " + f"group={group_id!r}" + ) + + +def test_join_transcript_binds_the_two_keys_in_order(js_output): + """ + The X25519 key is trusted only because the Ed25519 identity signed it, so the + two must not be interchangeable: swapping them has to produce different bytes. + """ + assert js_output["join"][1] != js_output["join"][2] + + +def test_operator_pairing_is_distinguishable_from_a_group_join(js_output): + """An empty group_id (node-wide operator authority) must not collide.""" + assert js_output["join"][0] != js_output["join"][1] + + def test_length_prefixing_actually_disambiguates(js_output): """ The reason both sides length-prefix: two different field splits must not collide. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index a006dfe..1ef878a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -68,6 +68,9 @@ async function getAllCachedIndexes() { let _sessionKeys = null; let _bundleKey = null; let _pendingBundlePush = null; +// A one-time pairing code the user just typed, consumed by the next connection +// attempt. Deliberately not persisted: it is single-use and short-lived. +let _pendingJoinCode = null; function _openKeyDB() { return new Promise((resolve, reject) => { @@ -778,9 +781,23 @@ function GroupPage({ groupId, group, token, username, userId }) { const [uploading, setUploading] = useState(false); const [menuOpen, setMenuOpen] = useState(null); const [isNodeAdmin, setIsNodeAdmin] = useState(false); + const [needsCode, setNeedsCode] = useState(false); + const [codeInput, setCodeInput] = useState(''); + const [retryKey, setRetryKey] = useState(0); const transportRef = useRef(null); const gekRef = useRef(null); + const submitJoinCode = useCallback((e) => { + e.preventDefault(); + const code = codeInput.trim(); + if (!code) return; + _pendingJoinCode = code; + setCodeInput(''); + setNeedsCode(false); + setError(''); + setRetryKey(k => k + 1); + }, [codeInput]); + useEffect(() => { if (menuOpen === null) return; const close = () => setMenuOpen(null); @@ -812,9 +829,12 @@ function GroupPage({ groupId, group, token, username, userId }) { return; } - // Session keys for P2P GEK bundle fetch (node delivers wrapped GEK) + // Session keys for the P2P key exchange. skEdB64 belongs here too: the + // node identifies us by the Ed25519 identity, and join_request signs both + // public keys with it — without it we can neither join nor pair. const sessionKeys = _sessionKeys ? { skXB64: _sessionKeys.skXB64, + skEdB64: _sessionKeys.skEdB64, pkXB64: _sessionKeys.pkXB64, } : null; @@ -824,7 +844,9 @@ function GroupPage({ groupId, group, token, username, userId }) { transportRef.current = transport; const ack = await transport.connect( - nodeId, token, groupId, null, sessionKeys, _bundleKey, username); + nodeId, token, groupId, null, sessionKeys, _bundleKey, username, + userId, _pendingJoinCode); + _pendingJoinCode = null; if (cancelled) return; setIsNodeAdmin(!!ack.is_node_admin); @@ -881,6 +903,10 @@ function GroupPage({ groupId, group, token, username, userId }) { cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries); } catch (err) { if (!cancelled) { + // The node has never seen this browser for this account: it needs a + // one-time code from the operator before it will hand over the group + // key. Not an error to shout about — a step in joining. + if (err.reason === 'code_required') setNeedsCode(true); setError(err.message); setStatus('error'); } @@ -902,7 +928,7 @@ function GroupPage({ groupId, group, token, username, userId }) { transportRef.current = null; } }; - }, [groupId, token]); + }, [groupId, token, retryKey]); const downloadFile = useCallback(async (entry) => { const transport = transportRef.current; @@ -1079,6 +1105,17 @@ function GroupPage({ groupId, group, token, username, userId }) { `} </div> ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} + ${needsCode && html` + <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}> + <h4>${t('group.join_code_title')}</h4> + <p class="settings-hint">${t('group.join_code_hint')}</p> + <div style="display:flex;gap:8px"> + <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace" + value=${codeInput} onInput=${e => setCodeInput(e.target.value)} required /> + <button class="admin-btn" type="submit">${t('group.join_code_btn')}</button> + </div> + </form> + `} ${dlState && html` <div class="dl-bar"> <span class="dl-name">${dlState.name}</span> @@ -1224,7 +1261,8 @@ function GroupPage({ groupId, group, token, username, userId }) { ${tab === 'members' && html` <${MembersPanel} groupId=${groupId} group=${group} token=${token} - transportRef=${transportRef} gekRef=${gekRef} /> + transportRef=${transportRef} gekRef=${gekRef} + isNodeAdmin=${isNodeAdmin} userId=${userId} /> `} `} ${status === 'offline' && html` @@ -1365,13 +1403,41 @@ function _b64ToU8(b64) { // ── Members Panel ──────────────────────────────────────────────────────── -function MembersPanel({ groupId, group, token, transportRef, gekRef }) { +function MembersPanel({ groupId, group, token, transportRef, gekRef, + isNodeAdmin, userId }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); const [loading, setLoading] = useState(true); const [inviteUser, setInviteUser] = useState(''); const [inviting, setInviting] = useState(false); const [error, setError] = useState(''); + const [inviteCode, setInviteCode] = useState(null); + const [pairCode, setPairCode] = useState(''); + const [pairStatus, setPairStatus] = useState(''); + const [pairing, setPairing] = useState(false); + + // Pairing lives here rather than in Settings because this is where a live + // connection to the node exists — and it is offered only when the node itself + // says this account is its operator (is_node_admin comes from the authenticated + // handshake_ack, not from the hub). + const doPair = useCallback(async (e) => { + e.preventDefault(); + const code = pairCode.trim(); + if (!code) return; + setPairing(true); + setPairStatus(''); + try { + const transport = transportRef && transportRef.current; + if (!transport || !transport.connected) throw new Error('Not connected to the node'); + await transport.pairOperator(userId, code); + setPairCode(''); + setPairStatus('paired'); + } catch (err) { + setPairStatus(err.message); + } finally { + setPairing(false); + } + }, [pairCode, transportRef, userId]); const loadMembers = useCallback(() => { setLoading(true); @@ -1393,36 +1459,34 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) { if (!inviteUser.trim()) return; setInviting(true); setError(''); + setInviteCode(null); try { const transport = transportRef && transportRef.current; const username = inviteUser.trim(); - - // Fetch invitee's public keys (hub = public key directory) - const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); - const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0)); - - // Get raw GEK from the active transport connection - if (!transport || !transport.connected || !transport.gekRaw) { - throw new Error('Not connected to node or no GEK available'); + if (!transport || !transport.connected) { + throw new Error('Not connected to the node — it must be online to invite'); } - const gekBytes = transport.gekRaw; - // Wrap GEK for invitee and store on node via P2P. - // The node requires the operator's Ed25519 signature to accept the bundle - // (C5b), so inviting from a browser that is not the node operator's will be - // refused by the node — deliberately: only the operator decides what is - // stored on their machine. + // The hub is asked for the account id, and nothing else. It is no longer + // asked for the invitee's public key: the node wraps the group key itself, + // for a key the invitee proves possession of when they connect (H3). A hub + // that answered with the wrong account here would produce an invite whose + // code it never learns — the code goes to a human, out of band. + const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); + const signFn = (_sessionKeys && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript) : null; - const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes); - await transport.storeGekBundle(pubkeys.user_id, groupId, bundle, signFn); + const result = await transport.createInvite( + account.user_id, groupId, username, signFn); - // Add member on hub (membership management only) + // Membership on the hub is what lets them reach the node at all; the code + // is what gets them the key. await hubFetch(`/v1/groups/${groupId}/members/${username}`, { method: 'POST', token, body: {}, }); + setInviteCode({ username, code: result.code, expires: result.expires_at }); setInviteUser(''); loadMembers(); } catch (err) { @@ -1430,7 +1494,7 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) { } finally { setInviting(false); } - }, [groupId, token, inviteUser, loadMembers]); + }, [groupId, token, inviteUser, loadMembers, transportRef]); if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; @@ -1461,6 +1525,15 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) { <form class="invite-form" onSubmit=${doInvite}> <h4>${t('members.invite_title')}</h4> ${error && html`<p class="error-msg">${error}</p>`} + ${inviteCode && html` + <div class="success-msg" style="margin-bottom:8px"> + <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> + <p style="font-family:monospace;font-size:1.4em;letter-spacing:2px;margin:6px 0"> + ${inviteCode.code} + </p> + <p>${t('members.invite_code_hint')}</p> + </div> + `} <div style="display:flex;gap:8px"> <input type="text" placeholder="${t('members.username_placeholder')}" value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required /> @@ -1470,6 +1543,24 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) { </div> </form> `} + ${isNodeAdmin && html` + <form class="invite-form" onSubmit=${doPair}> + <h4>${t('members.pair_title')}</h4> + <p class="settings-hint">${t('members.pair_hint')}</p> + ${pairStatus && html` + <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}> + ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus} + </p> + `} + <div style="display:flex;gap:8px"> + <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace" + value=${pairCode} onInput=${e => setPairCode(e.target.value)} required /> + <button class="admin-btn" type="submit" disabled=${pairing}> + ${pairing ? '...' : t('members.pair_btn')} + </button> + </div> + </form> + `} </div> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index d18eeae..21bf05d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -314,6 +314,31 @@ async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, bin return new Uint8Array(sig); } +// ── Join / pairing transcript ─────────────────────────────────────────────── + +// Mirrors meshbay_common/join.py. Signing both of our public keys together binds +// the X25519 key to the Ed25519 identity the node pins, so the node can safely +// wrap the group key for a key that came over the wire instead of one fetched +// from the hub's directory (H3). nonce_node ties it to this connection. +const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1'); + +function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) { + const enc = new TextEncoder(); + const body = _lenPrefixed([ + enc.encode(nodePkB64), + enc.encode(groupId), + enc.encode(userId), + enc.encode(pkEdB64), + enc.encode(pkXB64), + nonceNode, + enc.encode(String(ts)), + ]); + const out = new Uint8Array(JOIN_PREFIX.length + body.length); + out.set(JOIN_PREFIX, 0); + out.set(body, JOIN_PREFIX.length); + return out; +} + function constantTimeEqual(a, b) { if (a.length !== b.length) return false; let diff = 0; @@ -333,5 +358,5 @@ window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile, generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding, - verifyNodeSignature, constantTimeEqual, + joinTranscript, verifyNodeSignature, constantTimeEqual, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index b0de4b9..c279fd1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -240,6 +240,22 @@ const en = { 'members.invite_title': 'Invite member', 'members.username_placeholder': 'Username', 'members.invite_btn': 'Invite', + 'members.pair_title': 'Pair this browser with your node', + 'members.pair_hint': 'Your node only accepts operator actions — invites, file ' + + 'deletion — from a browser it has been paired with. Run ' + + '`meshbay-node operator pair` on the node and type the code here. The code ' + + 'never passes through the hub, which is what stops the hub from claiming to ' + + 'be you.', + 'members.pair_btn': 'Pair', + 'members.pair_success': 'This browser is now paired with the node.', + 'members.invite_code_ready': 'Invitation code for {user} — send it to them the way ' + + 'you normally talk. It works once, and it never passes through the hub.', + 'members.invite_code_hint': 'They enter it the first time they open this group. ' + + 'You do not need to be online then.', + 'group.join_code_title': 'This node needs to recognise you', + 'group.join_code_hint': 'Ask whoever invited you for the one-time code, and enter ' + + 'it here. After that this browser is recognised and you will not be asked again.', + 'group.join_code_btn': 'Join', 'notif.title': 'Notifications', 'notif.empty': 'No notifications', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index e430201..81c4130 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -316,6 +316,21 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } font-size: 0.85em; } +.success-msg { + background: #16a34a20; + color: var(--success); + border: 1px solid var(--success); + border-radius: 6px; + padding: 8px 12px; + font-size: 0.85em; +} + +.settings-hint { + font-size: 0.85em; + color: var(--text-dim); + margin-bottom: 8px; +} + /* ── Group cards (9.7 prep) ───────────────────────────────────────────────── */ .group-grid { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 50304f3..0bffeae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -26,6 +26,31 @@ async function _pkFromSk(skPkcs8B64) { return pad ? b64 + '='.repeat(4 - pad) : b64; } +async function _pkEdFromSk(skPkcs8B64) { + const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); + const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'Ed25519' }, true, ['sign']); + const jwk = await crypto.subtle.exportKey('jwk', sk); + const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/'); + const pad = b64.length % 4; + return pad ? b64 + '='.repeat(4 - pad) : b64; +} + +const JOIN_REFUSALS = { + code_required: 'This node does not know this browser yet. Ask the node operator ' + + 'for a pairing code (meshbay-node operator pair).', + code_invalid: 'That pairing code is not valid — it may be mistyped, expired, ' + + 'already used, or issued for a different account.', + key_changed: 'This account is already paired with a different key on this node. ' + + 'If you reset your keys, the operator must unpin you before pairing again.', + not_authorized_for_group: 'The node does not list you as a member of this group. ' + + 'Being a member on the hub is not enough — ask the operator for an invite.', + no_gek: 'This group has no key yet. The node operator must run ' + + '`meshbay-node gek-init` for it.', + signature_invalid: 'The node rejected the signature over your keys.', + stale_request: 'Your clock is too far from the node\'s — check the system time.', + group_mismatch: 'The node refused a request naming a different group.', +}; + class MeshBayTransport { constructor(hubUrl, accessToken) { this._hubUrl = hubUrl; @@ -53,11 +78,14 @@ class MeshBayTransport { get sessionKeys() { return this._sessionKeys; } - async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username) { + async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, + userId, joinCode) { this._gekRaw = gekRaw || null; this._sessionKeys = sessionKeys || null; this._bundleKey = bundleKey || null; this._username = username || null; + this._userId = userId || null; + this._joinError = null; this._pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); @@ -191,8 +219,22 @@ class MeshBayTransport { } } + // No stored bundle: ask the node to recognise us and wrap the key itself. + // This is the normal path for anyone who joined after the invite redesign — + // no bundle is pre-stored for members any more. A code is needed only the + // first time this node sees this account. + if (!gekRaw && this._sessionKeys && userId) { + try { + gekRaw = await this.joinGroup(userId, groupId, joinCode); + } catch (e) { + // The UI turns this into "ask the operator for an invite code". + this._joinError = e; + } + } + if (!gekRaw) { - throw new Error('Node requires GEK proof but no GEK available'); + throw this._joinError + || new Error('Node requires GEK proof but no GEK available'); } const C = window.MeshBayCrypto; @@ -203,6 +245,9 @@ class MeshBayTransport { _extractDtlsFingerprint(this._rawAnswerSdp), ); const nonceNode = C.b64decode(reply.nonce); + // Kept for the life of the connection: a join_request is signed over it, + // which is what stops one being lifted onto another connection. + this._nonceNode = nonceNode; const gid = groupId || ''; const proof = await C.handshakeProof( @@ -249,6 +294,59 @@ class MeshBayTransport { 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`)); } + /** + * Pair this browser with the node using a one-time code (M3, and the same + * substitution as H3). + * + * The node has no way to know which key belongs to its operator unless someone + * tells it locally — asking the hub would let the hub name itself node + * administrator. The code comes from `meshbay-node operator pair`, over SSH, and + * the hub never sees it. + */ + async pairOperator(userId, code) { + if (!this._connected) throw new Error('Not connected to the node'); + if (!userId) throw new Error('Missing user id'); + if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) { + throw new Error('Identity keys unavailable in this browser — sign in again'); + } + if (!this._nonceNode || !this.nodePk) { + throw new Error('Handshake incomplete — reconnect and retry'); + } + + const C = window.MeshBayCrypto; + // Both public keys are derived from OUR OWN secret keys, never read back from + // the hub: signing a public key the directory handed us would reintroduce the + // substitution this whole mechanism exists to close. + const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); + const ts = Math.floor(Date.now() / 1000); + + // group_id is empty: operator authority is node-wide, not per group. + const transcript = C.joinTranscript( + this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript); + + const resp = await this._sendAndWait({ + type: 'join_request', + v: '0.1', + group_id: '', + pk_ed25519: pkEdB64, + pk_x25519: pkXB64, + code: code || '', + ts, + sig, + }); + + if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused'); + if (resp.type !== 'join_result' || !resp.ok) { + const reason = resp.reason || 'unknown'; + const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`); + err.reason = reason; + throw err; + } + return resp; + } + async fetchIndex() { const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); @@ -364,30 +462,84 @@ class MeshBayTransport { } /** - * Store a wrapped GEK bundle on the node for a member. + * Ask the node for a one-time pairing code admitting `userId` to this group. * - * Node-operator operation: the node answers with an admin challenge and only the - * pinned operator key is accepted. Any member used to be able to write bundles — - * including one addressed to the operator, which the node then auto-adopted as the - * live group key (finding C5b). + * This replaces wrapping the group key in the browser. We no longer fetch the + * invitee's public key from the hub, so the hub can no longer answer with its own + * and be handed the group key (H3). The node wraps the key later, itself, for a + * key the invitee proves possession of. + * + * Returns {code, expires_at} — the code is displayed once and passed to the + * invitee out of band. */ - async storeGekBundle(userId, groupId, bundle, signFn) { + async createInvite(userId, groupId, username, signFn) { const msg = await this._sendAndWait({ - type: 'gek_bundle_store', + type: 'invite_create', v: '0.1', user_id: userId, group_id: groupId, - pk_eph_b64: bundle.pk_eph_b64, - nonce_b64: bundle.nonce_b64, - wrapped_b64: bundle.wrapped_b64, + username: username || '', }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp(msg, 'gek_bundle_store', userId, signFn); + return this._authorizeAdminOp(msg, 'invite_create', userId, signFn); } return msg; } + /** + * Ask the node to recognise us and hand over the group key. + * + * Sent when we hold no GEK for a group. `code` is needed only the first time + * this node sees this account (and not at all in an open-join group). + */ + async joinGroup(userId, groupId, code) { + if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) { + throw new Error('Identity keys unavailable in this browser — sign in again'); + } + if (!this._nonceNode || !this.nodePk) { + throw new Error('Handshake incomplete — reconnect and retry'); + } + + const C = window.MeshBayCrypto; + const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); + const ts = Math.floor(Date.now() / 1000); + + const transcript = C.joinTranscript( + this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript); + + const resp = await this._sendAndWait({ + type: 'join_request', + v: '0.1', + group_id: groupId || '', + pk_ed25519: pkEdB64, + pk_x25519: pkXB64, + code: code || '', + ts, + sig, + }); + + if (resp.type === 'error') throw new Error(resp.detail || 'Join refused'); + if ((resp.type !== 'join_result' || !resp.ok) || !resp.gek) { + const reason = resp.reason || 'unknown'; + const err = new Error(JOIN_REFUSALS[reason] || `Join refused: ${reason}`); + // The UI reacts to `code_required` by asking for one; everything else is + // shown as-is. + err.reason = reason; + throw err; + } + + // Unwrap with our own secret key — the node wrapped for the public key we + // just proved we hold, so nobody else can open this. + const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0)); + const myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0)); + const gekRaw = await C.unwrapGEK(resp, skXRaw, myPkX); + this._gekRaw = gekRaw; + return gekRaw; + } + async storeKeypairBundle(bundleEnc) { const msg = await this._sendAndWait({ type: 'keypair_bundle_store', diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index b681355..04cb1d3 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -44,7 +44,10 @@ id = "" name = "Public Archive" shared_dir = "/home/user/Archive" quic_port = 19012 -visibility = "public" +visibility = "public" # discoverable on the hub +# join_policy = "open" # anyone the hub says is a member gets the group key, + # with no pairing code. Only for groups where that is + # genuinely intended: it means the hub can join too. [keystore] # unlock_file = "~/.config/meshbay/unlock.key" @@ -74,7 +77,15 @@ class GroupConfig: id: str = "" name: str = "" shared_dir: str = "" - visibility: str = "private" # public|private + visibility: str = "private" # public|private — discoverability, not admission + # Admission. "invite" (default) means a newcomer needs a one-time pairing code + # before the node wraps the group key for them; "open" means the node pins + # whoever turns up first (TOFU) and serves them. + # + # Deliberately read from THIS file and never from the hub: a hub that could + # declare a group open would walk into any group it liked. Being findable + # (`visibility`) and being open (`join_policy`) are different questions. + join_policy: str = "invite" # invite|open quic_port: int = 19010 # QUIC MNP port @@ -127,6 +138,7 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: name=g.get("name", ""), shared_dir=g.get("shared_dir", ""), visibility=g.get("visibility", "private"), + join_policy=g.get("join_policy", "invite"), quic_port=g.get("quic_port", cfg.node.quic_port), )) # Back-compat: single [group] section diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 46a1716..7f84abe 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -45,7 +45,8 @@ from meshbay_node.chat.store import ChatStore from meshbay_node.config import Config, DEFAULT_CONFIG_PATH, load_config, write_example_config from meshbay_node.hub_client import HubClient, HubConfig from meshbay_node.indexer import DirectoryIndexer -from meshbay_node.keystore import NodeKeys, load_or_create_keystore +from meshbay_node.keystore import load_or_create_keystore +from meshbay_node.roster import Roster from meshbay_node.transport import ( Denylist, QUIC_AVAILABLE, @@ -117,6 +118,7 @@ class NodeDaemon: self._chat_stores: dict[str, ChatStore] = {} self._audit_store: AuditStore | None = None self._bundle_store: BundleStore | None = None + self._roster: Roster | None = None self._indexers: list[DirectoryIndexer] = [] self._tasks: list[asyncio.Task] = [] self._hub: HubClient | None = None @@ -174,6 +176,14 @@ class NodeDaemon: await self._bundle_store.open() log.info("Bundle store opened: %s", data_dir / "bundles.db") + # 4b. Roster — who this node recognises and which keys are theirs. + # Node authority is established here, locally, and never learned from + # the hub: a hub that could name the operator's key could install + # itself as node administrator. + self._roster = Roster(db_path=data_dir / "roster.db") + await self._roster.open() + await self._roster.purge_expired() + # X25519 key material for GEK unwrapping from cryptography.hazmat.primitives import serialization sk_x_raw = keys.sk_x25519.private_bytes( @@ -223,6 +233,9 @@ class NodeDaemon: "shared_root": shared_root, "index": indexer.index, "visibility": group_cfg.visibility, + # Admission policy comes from node.toml, never from the hub: + # a hub that could declare a group open would be handed its key. + "join_policy": group_cfg.join_policy, } if not groups_ctx: @@ -272,12 +285,20 @@ class NodeDaemon: self._webrtc._ctx["pk_x25519_raw"] = pk_x_raw self._webrtc._ctx["pk_x25519_b64"] = keys.pk_x25519_b64 - admin_pk = self._resolve_admin_pk(keys) + self._webrtc._ctx["roster"] = self._roster + admin_pk = self._legacy_admin_pk() + paired = await self._roster.has_operator() if self._roster else False if admin_pk: self._webrtc._ctx["admin_pk_ed25519"] = admin_pk - log.info("Admin Ed25519 key pinned for node sovereignty") + self._webrtc._ctx["has_admin_authority"] = paired + if paired or admin_pk: + sources = ([] if not paired else ["paired operator"]) + \ + ([] if not admin_pk else ["node.toml admin_pk"]) + log.info("Node authority: %s", " + ".join(sources)) else: - log.warning("No admin_pk_ed25519 — admin operations disabled") + log.warning( + "No operator paired — invites and file deletion are " + "refused. Run: meshbay-node operator pair") log.info("WebRTC transport ready") else: log.warning("WebRTC not available (aiortc not installed)") @@ -358,6 +379,8 @@ class NodeDaemon: self._state["groups_ctx"] = groups_ctx self._state["audit_store"] = self._audit_store self._state["bundle_store"] = self._bundle_store + self._state["roster"] = self._roster + self._state["node_user_id"] = session.user_id self._state["webrtc"] = self._webrtc self._state["hub"] = hub self._state["pk_x25519_raw"] = pk_x_raw @@ -457,21 +480,25 @@ class NodeDaemon: log.warning("No unwrappable GEK bundle found for group %s", group_id[:8]) return None - def _resolve_admin_pk(self, keys: NodeKeys) -> Ed25519PublicKey | None: - """Resolve the admin Ed25519 public key: config → auto-pin from node keystore.""" - if self._config.admin_pk_ed25519: - try: - raw = base64.b64decode(self._config.admin_pk_ed25519) - return Ed25519PublicKey.from_public_bytes(raw) - except Exception as e: - log.error("Invalid admin_pk_ed25519 in config: %s", e) - return None + def _legacy_admin_pk(self) -> Ed25519PublicKey | None: + """ + The pre-roster way of naming the operator: `admin_pk_ed25519` in node.toml. - pk = keys.sk_ed25519.public_key() - from meshbay_common.crypto import pk_to_b64 - pk_b64 = pk_to_b64(pk) - log.info("Auto-pinning admin key from node keystore: %s", pk_b64[:16]) - return pk + Still honoured so a deployment configured that way keeps working, but no + longer the only path — and the auto-pin that used to stand in for it is + gone. It pinned the node's *keystore* key while the browser signed with the + user's *identity* key, so admin operations failed closed with a signature + error that looked like a bug elsewhere (finding M3). An operator now pairs + a browser with `meshbay-node operator pair`. + """ + if not self._config.admin_pk_ed25519: + return None + try: + raw = base64.b64decode(self._config.admin_pk_ed25519) + return Ed25519PublicKey.from_public_bytes(raw) + except Exception as e: + log.error("Invalid admin_pk_ed25519 in config: %s", e) + return None async def _on_index_change(self, indexer: DirectoryIndexer) -> None: """Called when a DirectoryIndexer detects file changes.""" @@ -554,6 +581,9 @@ class NodeDaemon: if self._bundle_store: await self._bundle_store.close() + if self._roster: + await self._roster.close() + for store in self._chat_stores.values(): await store.close() @@ -570,6 +600,60 @@ class NodeDaemon: log.info("Node stopped") +# ── CLI helpers ─────────────────────────────────────────────────────────────── + +def _daemon_api(cfg: Config, path: str, method: str = "GET", + timeout: int = 30) -> dict: + """ + Call the daemon's loopback API. + + The daemon owns the roster, the hub session and the live group contexts, so + the CLI asks it to act rather than opening its databases behind its back. It + also means every operator action goes through the same authorization as the + admin UI (the per-run session token, 11.5.3). + """ + import json as _json + import urllib.error + import urllib.parse + import urllib.request + + token_file = cfg.data_dir / "ui-token" + if not token_file.exists(): + print("Node is not running — start it with: meshbay-node") + sys.exit(1) + + sep = "&" if "?" in path else "?" + url = (f"http://127.0.0.1:{cfg.node.ui_port}{path}" + f"{sep}t={token_file.read_text().strip()}") + try: + req = urllib.request.Request(url, method=method) + with urllib.request.urlopen(req, timeout=timeout) as r: + return _json.loads(r.read()) + except urllib.error.HTTPError as e: + body = e.read().decode()[:300] + try: + detail = _json.loads(body).get("error", body) + except Exception: + detail = body + print(f"failed: {detail}") + sys.exit(1) + except Exception as e: + print(f"failed: {e}") + sys.exit(1) + + +def _resolve_group(cfg: Config, group: str | None) -> str: + """The group argument, or the only configured one.""" + if group: + return group + configured = [g.id for g in cfg.groups if g.id] + if len(configured) == 1: + return configured[0] + print("--group is required (several groups configured)" + if configured else "no group configured in node.toml") + sys.exit(1) + + # ── Entry point ─────────────────────────────────────────────────────────────── def main() -> None: @@ -577,19 +661,23 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", - choices=["init", "status", "ui", "gek-init", "calibrate-argon2"], + choices=["init", "status", "ui", "gek-init", "operator", + "calibrate-argon2"], help="init: write example config | status: node state and keys " - "| ui: print the admin UI URL | calibrate-argon2: benchmark") + "| ui: print the admin UI URL | operator pair: pair a " + "browser with this node | calibrate-argon2: benchmark") + parser.add_argument("subcommand", nargs="?", + help="'pair' for the operator command") parser.add_argument("--config", type=Path, default=None, help="Config file path") parser.add_argument("--group", default=None, - help="group id for gek-init (optional if only one is configured)") + help="group id (optional if only one is configured)") parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) args = parser.parse_args() # Query commands print a report; library logging would interleave with it. - quiet = args.command in ("status", "ui", "gek-init") + quiet = args.command in ("status", "ui", "gek-init", "operator") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -649,50 +737,79 @@ def main() -> None: for g in cfg.groups: print(f" group {g.name} [{g.visibility}] {g.id or '<no id>'}") print(f" {g.shared_dir or '<no shared_dir>'}") - if not cfg.admin_pk_ed25519: - print("admin key NOT pinned — file deletion and member invites will be") - print(" refused (node.toml: admin_pk_ed25519)") - return - - if args.command == "gek-init": - import json as _json - import urllib.error - import urllib.request + # Node authority: the roster is the source of truth, node.toml the legacy + # form. Read the DB directly so this reports correctly while the daemon is + # stopped — the state an operator is most often in when checking. + import asyncio as _asyncio - cfg = load_config(args.config or DEFAULT_CONFIG_PATH) - group_id = args.group - if not group_id: - if len(cfg.groups) == 1: - group_id = cfg.groups[0].id - else: - print("--group is required (several groups configured)") - sys.exit(1) + from meshbay_node.roster import Roster as _Roster - token_file = cfg.data_dir / "ui-token" - if not token_file.exists(): - print("Node is not running — start it with: meshbay-node") - sys.exit(1) + async def _read_roster() -> tuple[list, int]: + r = _Roster(db_path=cfg.data_dir / "roster.db") + await r.open() + try: + return (await r.list_members()), len(await r.list_invites()) + finally: + await r.close() - # The daemon holds the hub session and the live group contexts, so the CLI - # asks it to do the work rather than duplicating it. Same operation as the - # admin UI button — an operator on a headless host should never need a - # browser on that host to initialise a group key. - url = (f"http://127.0.0.1:{cfg.node.ui_port}/api/groups/{group_id}/gek" - f"?t={token_file.read_text().strip()}") try: - req = urllib.request.Request(url, method="POST") - with urllib.request.urlopen(req, timeout=60) as r: - out = _json.loads(r.read()) - except urllib.error.HTTPError as e: - print(f"failed: {e.code} {e.read().decode()[:300]}") - sys.exit(1) + members, pending = _asyncio.run(_read_roster()) + except Exception as e: + members, pending = [], 0 + print(f"roster <unreadable: {e}>") + + operators = [m for m in members if m["role"] == "operator" + and m["status"] == "active"] + if operators: + for op in operators: + print(f"operator {op.get('username') or op['user_id'][:8]}" + f" key {(op.get('pk_ed25519') or '')[:16]}…" + f" paired {op.get('pinned_at', '?')}") + elif cfg.admin_pk_ed25519: + print("operator node.toml admin_pk_ed25519 (legacy)") + print(" run `meshbay-node operator pair` to replace it") + else: + print("operator NONE PAIRED — file deletion and member invites are") + print(" refused. Run: meshbay-node operator pair") + if pending: + print(f"invites {pending} pending code(s)") + return + + if args.command == "gek-init": + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + group_id = _resolve_group(cfg, args.group) + out = _daemon_api(cfg, f"/api/groups/{group_id}/gek", + method="POST", timeout=60) print(f"GEK ready for {group_id}") - print(f" wrapped for {out.get('wrapped_count')}/{out.get('total_members')} members") + print(f" {out.get('authorized_members', 0)} authorized member(s) — each " + f"receives the key on connect") for err in out.get("errors") or []: print(f" ! {err}") return + if args.command == "operator": + if args.subcommand != "pair": + print("usage: meshbay-node operator pair") + sys.exit(1) + + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + out = _daemon_api(cfg, "/api/operator/pair", method="POST") + + from meshbay_node.roster import write_code_file + path = write_code_file(cfg.data_dir, out["code"], out.get("expires_at", "")) + + print(f"PAIRING CODE {out['code']}") + print(f"valid until {out.get('expires_at', '?')}") + print() + print("Sign in to the web app as this node's operator, open one of your") + print("groups, go to the Members tab and enter the code there.") + print("It works once, for that account only, and authorizes invites and") + print("file deletion from that browser.") + print() + print(f"also written to {path}") + return + if args.command == "ui": cfg = load_config(args.config or DEFAULT_CONFIG_PATH) token_file = cfg.data_dir / "ui-token" diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py new file mode 100644 index 0000000..f231792 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -0,0 +1,372 @@ +""" +Node roster — who this node recognises, and which keys are theirs. + +The node keeps its own answer to "may this person have the group key", derived from +what the operator authorized locally. It is deliberately NOT derived from the hub: +the hub decides group membership, and a hub that invents an account and mints a +token for it would otherwise collect the GEK on connect. Hub membership is an input +to the decision; it is not the decision. + +Three tables: + + identities — one row per person, not per group. Someone paired for one group + needs no code for the next one on the same node. + members — role and status per (group, user). + invites — one-time pairing codes, stored as a hash. The code itself exists + only in the operator's hands and the invitee's. + +The code is what binds a public key to an account without asking the hub +(finding H3). See `docs/invite-pairing-v1.md`. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import secrets +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import aiosqlite + +log = logging.getLogger(__name__) + +# Crockford base32 without I, L, O and U: no character pair a human can confuse +# when reading a code aloud or typing it from a phone screen. +_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy +DEFAULT_INVITE_TTL = 24 * 3600 # seconds + +_SCHEMA = """\ +CREATE TABLE IF NOT EXISTS identities ( + 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 +); + +CREATE TABLE IF NOT EXISTS members ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + status TEXT NOT NULL, + approved_by TEXT NOT NULL, + approved_at TEXT NOT NULL, + PRIMARY KEY (group_id, user_id) +); + +CREATE TABLE IF NOT EXISTS 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, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +); +""" + + +def generate_code() -> str: + """A fresh pairing code, formatted for a human to read out: XXXX-XXXX.""" + raw = "".join(secrets.choice(_ALPHABET) for _ in range(CODE_LEN)) + return f"{raw[:4]}-{raw[4:]}" + + +def normalize_code(code: str) -> str: + """ + Fold what a human typed onto what was generated. + + Crockford's rules: case-insensitive, dashes and spaces are decoration, and the + excluded letters map onto the digits they resemble. Someone reading a code over + the phone should not be able to get it wrong in a way we could have absorbed. + """ + out = [] + for ch in code.upper(): + if ch in "- \t": + continue + if ch in "IL": + out.append("1") + elif ch == "O": + out.append("0") + elif ch == "U": + out.append("V") + else: + out.append(ch) + return "".join(out) + + +def hash_code(code: str) -> str: + """ + Store codes hashed: a stolen roster DB must not yield usable invitations. + + SHA-256 rather than a password KDF on purpose — the input is 40 bits of + uniformly random secret, not a human-chosen string, so there is nothing for a + slow hash to defend. + """ + return hashlib.sha256(normalize_code(code).encode()).hexdigest() + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class Roster: + def __init__(self, db_path: Path): + self._db_path = db_path + self._db: aiosqlite.Connection | None = None + + async def open(self) -> None: + self._db_path.parent.mkdir(parents=True, exist_ok=True) + self._db = await aiosqlite.connect(str(self._db_path)) + self._db.row_factory = aiosqlite.Row + # WAL: the CLI writes invites (`operator pair`) while the daemon reads them. + await self._db.execute("PRAGMA journal_mode=WAL") + await self._db.executescript(_SCHEMA) + await self._db.commit() + + async def close(self) -> None: + if self._db: + await self._db.close() + self._db = None + + # ── Identities ─────────────────────────────────────────────────────────── + + async def pin_identity( + self, + user_id: str, + username: str, + pk_ed25519: str, + pk_x25519: str, + via: str, + ) -> None: + assert self._db + await self._db.execute( + "INSERT OR REPLACE INTO identities " + "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via) " + "VALUES (?, ?, ?, ?, ?, ?)", + (user_id, username, pk_ed25519, pk_x25519, _now(), via), + ) + await self._db.commit() + + async def get_identity(self, user_id: str) -> dict | None: + assert self._db + async with self._db.execute( + "SELECT * FROM identities WHERE user_id = ?", (user_id,) + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + async def unpin(self, user_id: str) -> bool: + assert self._db + cur = await self._db.execute( + "DELETE FROM identities WHERE user_id = ?", (user_id,)) + await self._db.commit() + return cur.rowcount > 0 + + async def list_identities(self) -> list[dict]: + assert self._db + async with self._db.execute( + "SELECT * FROM identities ORDER BY pinned_at" + ) as cur: + return [dict(r) for r in await cur.fetchall()] + + # ── Authority ──────────────────────────────────────────────────────────── + + async def operator_pks(self) -> list[str]: + """ + Base64 Ed25519 keys allowed to authorize admin operations on this node. + + Read fresh on every check rather than cached: an unpin must take effect at + once, and this runs only on admin operations, which are rare. + """ + assert self._db + async with self._db.execute( + "SELECT i.pk_ed25519 FROM identities i " + "JOIN members m ON m.user_id = i.user_id " + "WHERE m.role = 'operator' AND m.status = 'active'" + ) as cur: + return [r["pk_ed25519"] for r in await cur.fetchall()] + + async def has_operator(self) -> bool: + return bool(await self.operator_pks()) + + async def is_authorized(self, group_id: str, user_id: str) -> bool: + """ + May this person be handed the group key? + + The node's own answer, not the hub's. Hub membership is what lets someone + reach the node; this is what decides whether the key is wrapped for them — + otherwise a hub that invents an account and mints a token for it would be + served the GEK on connect. + + An operator is authorized for every group this node hosts: their authority + is node-wide and is recorded with an empty group_id. + """ + assert self._db + async with self._db.execute( + "SELECT 1 FROM members WHERE user_id = ? AND status = 'active' " + "AND (group_id = ? OR (group_id = '' AND role = 'operator')) LIMIT 1", + (user_id, group_id), + ) as cur: + return await cur.fetchone() is not None + + # ── Members ────────────────────────────────────────────────────────────── + + async def set_member( + self, + group_id: str, + user_id: str, + role: str, + status: str, + approved_by: str, + ) -> None: + assert self._db + await self._db.execute( + "INSERT OR REPLACE INTO members " + "(group_id, user_id, role, status, approved_by, approved_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (group_id, user_id, role, status, approved_by, _now()), + ) + await self._db.commit() + + async def get_member(self, group_id: str, user_id: str) -> dict | None: + assert self._db + async with self._db.execute( + "SELECT * FROM members WHERE group_id = ? AND user_id = ?", + (group_id, user_id), + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + async def list_members(self, group_id: str | None = None) -> list[dict]: + assert self._db + sql = ( + "SELECT m.*, i.username, i.pk_ed25519, i.pinned_at, i.pinned_via " + "FROM members m LEFT JOIN identities i ON i.user_id = m.user_id" + ) + args: tuple = () + if group_id is not None: + sql += " WHERE m.group_id = ?" + args = (group_id,) + async with self._db.execute(sql + " ORDER BY m.approved_at", args) as cur: + return [dict(r) for r in await cur.fetchall()] + + async def set_status(self, group_id: str, user_id: str, status: str) -> bool: + assert self._db + cur = await self._db.execute( + "UPDATE members SET status = ? WHERE group_id = ? AND user_id = ?", + (status, group_id, user_id), + ) + await self._db.commit() + return cur.rowcount > 0 + + # ── Invites ────────────────────────────────────────────────────────────── + + async def create_invite( + self, + group_id: str, + user_id: str, + role: str, + created_by: str, + ttl: int = DEFAULT_INVITE_TTL, + ) -> str: + """ + Issue a one-time code. Returns it in the clear — this is the only moment it + exists outside the operator's hands; only its hash is kept. + + Any earlier unused invite for the same person and group is dropped, so + re-inviting supersedes rather than accumulating valid codes. + """ + assert self._db + await self._db.execute( + "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL", + (group_id, user_id), + ) + code = generate_code() + expires = datetime.now(timezone.utc) + timedelta(seconds=ttl) + await self._db.execute( + "INSERT INTO invites " + "(code_hash, group_id, user_id, role, created_by, created_at, expires_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (hash_code(code), group_id, user_id, role, created_by, _now(), + expires.isoformat(timespec="seconds")), + ) + await self._db.commit() + return code + + async def consume_invite(self, code: str, user_id: str) -> dict | None: + """ + Redeem a code for `user_id`, or return None. + + Single use is enforced by the UPDATE's WHERE clause: two connections racing + the same code cannot both see `used_at IS NULL`, so exactly one wins. + """ + assert self._db + code_hash = hash_code(code) + async with self._db.execute( + "SELECT * FROM invites WHERE code_hash = ?", (code_hash,) + ) as cur: + row = await cur.fetchone() + if not row: + return None + + invite = dict(row) + if invite["used_at"] is not None: + return None + # A code is valid for exactly one account, so a leaked code cannot be + # redeemed by whoever finds it first. + if invite["user_id"] != user_id: + return None + if datetime.fromisoformat(invite["expires_at"]) < datetime.now(timezone.utc): + return None + + cur = await self._db.execute( + "UPDATE invites SET used_at = ? WHERE code_hash = ? AND used_at IS NULL", + (_now(), code_hash), + ) + await self._db.commit() + if cur.rowcount == 0: + return None + return invite + + async def list_invites(self, include_used: bool = False) -> list[dict]: + assert self._db + sql = "SELECT * FROM invites" + if not include_used: + sql += " WHERE used_at IS NULL" + async with self._db.execute(sql + " ORDER BY created_at") as cur: + return [dict(r) for r in await cur.fetchall()] + + async def purge_expired(self) -> int: + assert self._db + cur = await self._db.execute( + "DELETE FROM invites WHERE used_at IS NULL AND expires_at < ?", + (_now(),), + ) + await self._db.commit() + return cur.rowcount + + +async def open_roster(data_dir: Path) -> Roster: + roster = Roster(data_dir / "roster.db") + await roster.open() + return roster + + +def write_code_file(data_dir: Path, code: str, expires_at: str) -> Path: + """ + Leave the code in a file as well as on stdout. + + An operator working over SSH may not be able to copy out of their terminal, + and a code that can only be read off a scrolled-away screen is a dead end. + """ + path = data_dir / "pair-code" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{code}\nexpires {expires_at}\n") + os.chmod(path, 0o600) + return path diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 34a96bd..fe3ee2e 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -57,10 +57,16 @@ from meshbay_common.handshake import ( from meshbay_common.adminop import ( ADMIN_CHALLENGE_TTL, OP_FILE_DELETE, - OP_GEK_BUNDLE_STORE, + OP_INVITE_CREATE, admin_transcript, ) -from meshbay_common.crypto import pk_to_b64 +from meshbay_common.crypto import pk_to_b64, wrap_gek_aes +from meshbay_common.join import ( + JOIN_TTL, + ROLE_MEMBER, + ROLE_OPERATOR, + join_transcript, +) from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes from meshbay_common.protocol import MNP from meshbay_node.indexer import GroupIndex @@ -85,6 +91,14 @@ MAX_CONCURRENT_TRANSCODES = 2 # Bundle fetches are served in the pre-proof window (C4). Bounded and audited # until the native client removes remote keypair bundles entirely. MAX_PRE_PROOF_FETCHES = 4 +# Pairing codes carry 40 bits and are single-use, but a connection must not be +# allowed to sit there guessing. Failures are audited, so a grind is visible. +MAX_JOIN_ATTEMPTS = 5 +# Per-connection limits alone would not bind an attacker who can open connections +# at will — and the adversary who can mint tokens for any account is the hub. So +# failed pairings are also counted node-wide over a window. +MAX_JOIN_FAILURES_WINDOW = 20 +JOIN_FAILURE_WINDOW = 600 # seconds UPLOAD_DIR_NAME = ".uploads" # Conservative allowlist: also what keeps markup out of filenames, which the node admin # UI used to render unescaped (finding H2). @@ -218,6 +232,11 @@ class WebRTCPeerSession: self._username: str = "" self._pk_user: str = "" self._gek_challenge: bytes | None = None + # Same value as the GEK challenge, but kept for the life of the connection: + # a join_request is signed over it, and it must stay verifiable after the + # handshake clears the challenge (an operator pairs while already connected). + self._nonce_node: bytes = b"" + self._join_attempts = 0 self._nonce_client: bytes = b"" self._admin_ops: dict[str, dict] = {} # op_id → pending admin operation self._uploads: dict[str, dict] = {} # filename → {next_index, bytes} @@ -259,6 +278,12 @@ class WebRTCPeerSession: asyncio.ensure_future(self._do_gek_bundle_fetch()) else: asyncio.ensure_future(self._do_keypair_bundle_fetch()) + elif mtype == MNP.JOIN_REQUEST and self._nonce_node: + # Valid both before the GEK proof (a new member has no GEK to prove + # with) and after it (an operator pairing a browser is already + # connected). Authority comes from the pairing code and the + # signature, never from the session state. + asyncio.ensure_future(self._do_join_request(msg)) elif self._user_id is None: self._send({"type": "error", "detail": "Handshake required"}) elif mtype == MNP.INDEX_SYNC: @@ -277,8 +302,8 @@ class WebRTCPeerSession: self._do_file_delete(msg) elif mtype == MNP.ADMIN_RESPONSE: self._do_admin_response(msg) - elif mtype == MNP.GEK_BUNDLE_STORE: - self._do_gek_bundle_store(msg) + elif mtype == MNP.INVITE_CREATE: + self._do_invite_create(msg) elif mtype == MNP.KEYPAIR_BUNDLE_STORE: asyncio.ensure_future(self._do_keypair_bundle_store(msg)) elif mtype == MNP.STREAM_REQUEST: @@ -359,6 +384,7 @@ class WebRTCPeerSession: return self._gek_challenge = os.urandom(NONCE_LEN) + self._nonce_node = self._gek_challenge self._send({ "type": MNP.HANDSHAKE_CHALLENGE, "v": MNP_VERSION, @@ -472,49 +498,40 @@ class WebRTCPeerSession: else: self._send({"type": MNP.GEK_BUNDLE_RESP, "v": MNP_VERSION, "found": False}) - def _do_gek_bundle_store(self, msg: dict) -> None: + def _do_invite_create(self, msg: dict) -> None: """ - Request to store a wrapped GEK bundle for a target user. - - Finding C5b: this used to write whatever any authenticated member sent, with - INSERT OR REPLACE semantics, and then auto-activate the bundle if it was - addressed to the node operator. Since the operator's X25519 public key is - public — the node even hands it out in handshake_ack — any member could wrap - a GEK of their own choosing for the operator and make the node adopt it, - locking every legitimate member out of the group and taking over the key. + Issue a one-time pairing code for someone the operator wants to admit. - Storing a bundle is now a node-operator operation gated by an Ed25519 - challenge, and nothing arriving over MNP can activate a GEK: activation - happens only through the local admin UI or the CLI. + Replaces the old invite path, where the inviter fetched the invitee's + public key from the hub and wrapped the group key for whatever came back + (H3). The node now needs nothing but a name: it will wrap the key itself, + later, for a key the invitee proves they hold. """ - bundle_store = self._ctx.get("bundle_store") - if not bundle_store: - self._send({"type": "error", "detail": "Bundle store not available"}) + roster = self._ctx.get("roster") + if roster is None: + self._send({"type": "error", "detail": "Roster not available"}) return - target_user_id = msg.get("user_id", "") + invitee_id = msg.get("user_id", "") group_id = msg.get("group_id") or self._group_id - pk_eph = msg.get("pk_eph_b64", "") - nonce = msg.get("nonce_b64", "") - wrapped = msg.get("wrapped_b64", "") - - if not target_user_id or not pk_eph or not nonce or not wrapped or not group_id: - self._send({"type": "error", "detail": "Missing bundle fields"}) + if not invitee_id or not group_id: + self._send({"type": "error", "detail": "Missing user_id or group_id"}) + return + if group_id != self._group_id: + self._send({"type": "error", "detail": "Wrong group for this session"}) return - if not self._ctx.get("admin_pk_ed25519"): + if not self._has_admin_authority(): self._send({ "type": "error", - "detail": "No admin key pinned — bundle storage refused", + "detail": "No operator paired — run `meshbay-node operator pair`", }) return - self._issue_admin_challenge(OP_GEK_BUNDLE_STORE, target_user_id, { + self._issue_admin_challenge(OP_INVITE_CREATE, invitee_id, { "group_id": group_id, - "user_id": target_user_id, - "pk_eph_b64": pk_eph, - "nonce_b64": nonce, - "wrapped_b64": wrapped, + "user_id": invitee_id, + "username": str(msg.get("username", ""))[:64], }) async def _do_keypair_bundle_fetch(self) -> None: @@ -560,6 +577,240 @@ class WebRTCPeerSession: "detail": "keypair_bundle_stored", }) + # ── Pairing and join (H3, M3) ──────────────────────────────────────────── + + def _join_refuse(self, reason: str, audit_detail: str = "") -> None: + self._join_attempts += 1 + # Node-wide window, shared across connections: reconnecting must not reset + # the budget. + now = time.time() + failures = [t for t in self._ctx.get("join_failures", []) + if now - t < JOIN_FAILURE_WINDOW] + failures.append(now) + self._ctx["join_failures"] = failures + self._audit_join("join_refused", audit_detail or reason) + self._send({ + "type": MNP.JOIN_RESULT, + "v": MNP_VERSION, + "ok": False, + "reason": reason, + }) + + def _audit_join(self, event: str, detail: str) -> None: + audit = self._ctx.get("audit_store") + if not audit: + return + self._remote_ip = self._remote_ip or _get_remote_ip(self._pc) + asyncio.ensure_future(audit.log_event( + user_id=self._user_id or getattr(self, "_pending_sub", "unknown"), + event=event, + ip=self._remote_ip, + username=self._username or getattr(self, "_pending_username", ""), + group_id=self._group_id or getattr(self, "_pending_group", "") or "", + detail=detail, + )) + + async def _do_join_request(self, msg: dict) -> None: + """ + Pin an identity, or recognise one already pinned. + + The client signs its own Ed25519 and X25519 keys together with the node's + nonce, so the identity key vouches for the encryption key — that is what + will make it safe for the node to wrap the GEK for a key that arrived over + the wire instead of one fetched from the hub's directory (H3). + + A first pairing needs a one-time code, which the hub never sees. Afterwards + the pin is the credential and a changed key is refused outright, the same + rule the client applies to `pk_node` (11.5.8). + """ + roster = self._ctx.get("roster") + if roster is None: + self._send({"type": "error", "detail": "Roster not available"}) + return + + if self._join_attempts >= MAX_JOIN_ATTEMPTS: + self._send({"type": "error", "detail": "Too many attempts"}) + return + + now = time.time() + recent = [t for t in self._ctx.get("join_failures", []) + if now - t < JOIN_FAILURE_WINDOW] + if len(recent) >= MAX_JOIN_FAILURES_WINDOW: + self._audit_join("join_throttled", f"{len(recent)} failures in window") + self._send({"type": "error", "detail": "Pairing temporarily locked"}) + return + + user_id = self._user_id or getattr(self, "_pending_sub", "") + username = self._username or getattr(self, "_pending_username", "") + if not user_id: + self._send({"type": "error", "detail": "Handshake required"}) + return + + pk_ed_b64 = msg.get("pk_ed25519", "") + pk_x_b64 = msg.get("pk_x25519", "") + code = msg.get("code", "") + ts = msg.get("ts", 0) + + try: + pk_ed_raw = base64.b64decode(pk_ed_b64) + pk_x_raw = base64.b64decode(pk_x_b64) + if len(pk_ed_raw) != 32 or len(pk_x_raw) != 32: + raise ValueError + pk_ed = Ed25519PublicKey.from_public_bytes(pk_ed_raw) + except Exception: + self._join_refuse("invalid_keys") + return + + if not isinstance(ts, int) or abs(time.time() - ts) > JOIN_TTL: + self._join_refuse("stale_request") + return + + # An empty group_id means operator pairing, which is node-wide. Anything + # else must be the group this connection authenticated to — a signature + # obtained for one group must not name another. + group_id = msg.get("group_id", "") or "" + session_group = self._group_id or getattr(self, "_pending_group", "") or "" + if group_id and group_id != session_group: + self._join_refuse("group_mismatch") + return + + transcript = join_transcript( + node_pk_b64=self._node_pk_b64(), + group_id=group_id, + user_id=user_id, + pk_ed25519_b64=pk_ed_b64, + pk_x25519_b64=pk_x_b64, + nonce_node=self._nonce_node, + ts=ts, + ) + try: + sig = base64.b64decode(msg.get("sig", "")) + except Exception: + self._join_refuse("invalid_signature_encoding") + return + if not self._verify_sig(pk_ed, transcript, sig): + self._join_refuse("signature_invalid") + return + + known = await roster.get_identity(user_id) + if known: + if known["pk_ed25519"] != pk_ed_b64 or known["pk_x25519"] != pk_x_b64: + # The blocking warning, raised where it matters: whoever this is + # holds a different key than the person the operator paired. + self._join_refuse( + "key_changed", + f"pinned={known['pk_ed25519'][:16]} presented={pk_ed_b64[:16]}") + return + member = await roster.get_member(group_id, user_id) + await self._join_ok( + user_id, pk_x_raw, session_group, + role=member["role"] if member else "", + recognised=True, + ) + return + + if not code: + if self._group_join_policy(session_group) == "open": + # An open-join group admits anyone the hub calls a member, so a + # code would protect nothing — the hub can walk in through the + # front door. Pin what turns up and say so in the audit log. + await self._pin_and_admit( + roster, user_id, username, pk_ed_b64, pk_x_b64, + group_id=session_group, role=ROLE_MEMBER, + approved_by="open-join", via="tofu") + await self._join_ok(user_id, pk_x_raw, session_group, + role=ROLE_MEMBER, recognised=False) + return + self._join_refuse("code_required") + return + + invite = await roster.consume_invite(code, user_id) + if not invite: + self._join_refuse("code_invalid") + return + + await self._pin_and_admit( + roster, user_id, username, pk_ed_b64, pk_x_b64, + group_id=invite["group_id"], role=invite["role"], + approved_by=invite["created_by"], via="code") + await self._join_ok(user_id, pk_x_raw, invite["group_id"], + role=invite["role"], recognised=False) + + def _group_join_policy(self, group_id: str) -> str: + """ + Admission policy for a group, read from the node's own configuration. + + Never from the hub: a hub that could declare a group open would be handed + the key to it (§3.4 of docs/invite-pairing-v1.md). + """ + gctx = (self._ctx.get("groups") or {}).get(group_id) or {} + return gctx.get("join_policy", "invite") + + async def _pin_and_admit( + self, roster, user_id: str, username: str, pk_ed_b64: str, pk_x_b64: str, + *, group_id: str, role: str, approved_by: str, via: str, + ) -> None: + await roster.pin_identity( + user_id=user_id, username=username, + pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via=via, + ) + await roster.set_member( + group_id=group_id, user_id=user_id, role=role, + status="active", approved_by=approved_by, + ) + if role == ROLE_OPERATOR: + self._ctx["has_admin_authority"] = True + + log.info("Identity pinned (%s): user=%s role=%s", via, user_id[:8], role) + self._audit_join("join_pinned", f"role={role} via={via}") + + async def _join_ok( + self, user_id: str, pk_x_raw: bytes, group_id: str, + *, role: str, recognised: bool, + ) -> None: + """ + Answer a join, wrapping the group key for the key the caller just proved. + + This is the H3 fix. The inviter used to fetch the invitee's public key from + the hub and wrap the GEK for whatever came back, so a hub that answered + with its own key was handed the group key by an honest member following the + protocol exactly. The node now wraps for a key that arrived from its owner + over an authenticated channel, bound to a pinned identity. + """ + reply = { + "type": MNP.JOIN_RESULT, + "v": MNP_VERSION, + "ok": True, + "recognised": recognised, + "role": role, + } + + roster = self._ctx["roster"] + if group_id and not await roster.is_authorized(group_id, user_id): + # Pinned on this node, but not admitted to this group. Hub membership + # alone must not produce a key. + reply["gek"] = False + reply["reason"] = "not_authorized_for_group" + self._send(reply) + self._audit_join("join_no_gek", f"group={group_id[:8]} not authorized") + return + + gctx = (self._ctx.get("groups") or {}).get(group_id) or {} + gek = gctx.get("gek") + if not gek: + reply["gek"] = False + reply["reason"] = "no_gek" + self._send(reply) + return + + bundle = wrap_gek_aes(gek, pk_x_raw) + reply["gek"] = True + reply["pk_eph_b64"] = bundle["pk_eph_b64"] + reply["nonce_b64"] = bundle["nonce_b64"] + reply["wrapped_b64"] = bundle["wrapped_b64"] + self._send(reply) + self._audit_join("gek_wrapped", f"group={group_id[:8]}") + def _audit_pre_proof_fetch(self, mtype: str) -> None: """Record bundle access made before the GEK proof (C4).""" audit = self._ctx.get("audit_store") @@ -903,9 +1154,8 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "File not found"}) return - admin_pk = self._ctx.get("admin_pk_ed25519") has_uploader_pk = bool(entry.uploader_pk) - if not admin_pk and not has_uploader_pk: + if not self._has_admin_authority() and not has_uploader_pk: self._send({"type": "error", "detail": "No authorized key for deletion"}) return @@ -956,6 +1206,43 @@ class WebRTCPeerSession: except Exception: return False + def _has_admin_authority(self) -> bool: + """ + Cheap synchronous pre-check: is there anyone who could authorize this? + + Only decides whether to issue a challenge at all — the gate is + `_verify_admin_sig`. The flag is set at startup and refreshed in-process + when an operator pairs. + """ + return bool(self._ctx.get("admin_pk_ed25519") + or self._ctx.get("has_admin_authority")) + + async def _verify_admin_sig(self, transcript: bytes, sig: bytes) -> bool: + """ + Check a signature against every key holding node-operator authority. + + Read from the roster on each call rather than cached: revoking a paired + browser must take effect immediately, and admin operations are rare enough + that a SQLite read costs nothing. `admin_pk_ed25519` in node.toml is still + honoured so an existing deployment keeps working until its operator pairs + (M3) — it is the legacy form of the same statement. + """ + legacy = self._ctx.get("admin_pk_ed25519") + if self._verify_sig(legacy, transcript, sig): + return True + + roster = self._ctx.get("roster") + if roster is None: + return False + for pk_b64 in await roster.operator_pks(): + try: + pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_b64)) + except Exception: + continue + if self._verify_sig(pk, transcript, sig): + return True + return False + def _do_admin_response(self, msg: dict) -> None: op_id = msg.get("op_id", "") sig_b64 = msg.get("signature", "") @@ -985,14 +1272,15 @@ class WebRTCPeerSession: ) if pending["op"] == OP_FILE_DELETE: - self._admin_exec_file_delete(pending, transcript, sig_bytes) - elif pending["op"] == OP_GEK_BUNDLE_STORE: asyncio.ensure_future( - self._admin_exec_bundle_store(pending, transcript, sig_bytes)) + self._admin_exec_file_delete(pending, transcript, sig_bytes)) + elif pending["op"] == OP_INVITE_CREATE: + asyncio.ensure_future( + self._admin_exec_invite_create(pending, transcript, sig_bytes)) else: self._send({"type": "error", "detail": "Unknown admin operation"}) - def _admin_exec_file_delete( + async def _admin_exec_file_delete( self, pending: dict, transcript: bytes, sig: bytes, ) -> None: file_id = pending["subject"] @@ -1012,7 +1300,7 @@ class WebRTCPeerSession: # Node operator, or the user who uploaded this file — verified by the key # recorded at upload time, never by a JWT claim (the hub controls those). - if not (self._verify_sig(self._ctx.get("admin_pk_ed25519"), transcript, sig) + if not (await self._verify_admin_sig(transcript, sig) or self._verify_sig(uploader_pk, transcript, sig)): self._send({"type": "error", "detail": "Signature verification failed"}) self._audit("admin_auth_failed", f"file_delete:{file_id[:16]}") @@ -1020,33 +1308,46 @@ class WebRTCPeerSession: self._exec_file_delete(ctx, file_id, entry) - async def _admin_exec_bundle_store( + async def _admin_exec_invite_create( self, pending: dict, transcript: bytes, sig: bytes, ) -> None: # Node operator only. A group admin who does not run the node has no - # authority over what this node stores (draft-v4 §4.2.x, deny by default). - if not self._verify_sig(self._ctx.get("admin_pk_ed25519"), transcript, sig): + # authority over who this node admits (deny by default). Delegation is + # designed but deferred — see §6.2 of docs/invite-pairing-v1.md. + if not await self._verify_admin_sig(transcript, sig): self._send({"type": "error", "detail": "Signature verification failed"}) - self._audit("admin_auth_failed", f"gek_bundle_store:{pending['subject'][:16]}") + self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}") return - payload = pending["payload"] - bundle_store = self._ctx.get("bundle_store") - if not bundle_store: - self._send({"type": "error", "detail": "Bundle store not available"}) + roster = self._ctx.get("roster") + if roster is None: + self._send({"type": "error", "detail": "Roster not available"}) return - await bundle_store.store( - payload["group_id"], payload["user_id"], - payload["pk_eph_b64"], payload["nonce_b64"], payload["wrapped_b64"], + payload = pending["payload"] + code = await roster.create_invite( + group_id=payload["group_id"], + user_id=payload["user_id"], + role=ROLE_MEMBER, + created_by=self._user_id or "", ) - log.info("GEK bundle stored: group=%s user=%s", + invites = await roster.list_invites() + expires = next( + (i["expires_at"] for i in invites + if i["user_id"] == payload["user_id"] + and i["group_id"] == payload["group_id"]), "") + + log.info("Invite created: group=%s user=%s", payload["group_id"][:8], payload["user_id"][:8]) - self._audit("gek_bundle_store", f"target={payload['user_id'][:8]}") + self._audit("invite_create", f"target={payload['user_id'][:8]}") + # The code exists in the clear exactly here and in the operator's hands. self._send({ - "type": "ack", "v": MNP_VERSION, - "detail": "gek_bundle_stored", + "type": MNP.INVITE_RESULT, + "v": MNP_VERSION, + "code": code, + "expires_at": expires, "user_id": payload["user_id"], + "username": payload.get("username", ""), }) def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None: diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 21e4445..e671b72 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -24,6 +24,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from meshbay_node import __version__ from meshbay_common.crypto import generate_gek, wrap_gek_aes +from meshbay_common.join import ROLE_OPERATOR log = logging.getLogger(__name__) @@ -217,11 +218,61 @@ def create_ui_app(state: dict) -> FastAPI: ], } + # ── Operator pairing (localhost only) ────────────────────────────────── + + @app.post("/api/operator/pair") + async def operator_pair(): + """ + Issue a one-time code that pairs a browser as this node's operator. + + The code is the whole point: it binds the operator's browser identity key + to their account without asking the hub, which is what stops a hub from + naming itself node administrator (M3, and the same substitution as H3). + It is returned once and stored only as a hash. + """ + roster = state.get("roster") + user_id = state.get("node_user_id") + if not roster or not user_id: + return JSONResponse({"error": "Node not connected to hub yet"}, 503) + + code = await roster.create_invite( + group_id="", # operator authority is node-wide + user_id=user_id, + role=ROLE_OPERATOR, + created_by="local-cli", + ) + invites = await roster.list_invites() + expires = next((i["expires_at"] for i in invites + if i["user_id"] == user_id and i["role"] == ROLE_OPERATOR), "") + return {"code": code, "expires_at": expires, "user_id": user_id} + + @app.get("/api/roster") + async def api_roster(): + roster = state.get("roster") + if not roster: + return {"identities": [], "members": [], "pending_invites": 0} + return { + "identities": await roster.list_identities(), + "members": await roster.list_members(), + "pending_invites": len(await roster.list_invites()), + } + # ── GEK initialization (operator only, localhost) ────────────────────── @app.post("/api/groups/{group_id}/gek") async def init_gek(group_id: str): - """Generate GEK, wrap for all group members, store, and activate.""" + """ + Generate the group key and activate it. + + It used to be wrapped here for every member, using public keys fetched from + the hub — which is H3 with the node as the victim instead of the inviter: a + hub answering with its own key was handed the group key by the node itself. + + Nothing is pre-wrapped for members now. Each member's copy is produced when + they connect, for a key they proved they hold (`join_request`). Only the + node's own copy is stored, so the daemon can reload the key across restarts + without the operator's browser. + """ groups_ctx = state.get("groups_ctx", {}) if group_id not in groups_ctx: return JSONResponse({"error": "Group not hosted on this node"}, 404) @@ -234,48 +285,15 @@ def create_ui_app(state: dict) -> FastAPI: if not bundle_store: return JSONResponse({"error": "Bundle store not available"}, 503) - await hub.ensure_fresh_token() - session = hub._session - members_resp = await hub._http.get( - f"/v1/groups/{group_id}/members", - headers=session.auth_headers, - ) - if not members_resp.is_success: - return JSONResponse( - {"error": f"Failed to fetch members: {members_resp.status_code}"}, 502) - members = members_resp.json().get("members", []) - if not members: - return JSONResponse({"error": "No members in group"}, 400) - existing_gek = groups_ctx[group_id].get("gek") gek = existing_gek or generate_gek() + errors: list[str] = [] - wrapped_count = 0 - errors = [] - for member in members: - username = member["username"] - user_id = member["user_id"] - try: - pk_data = await hub.get_user_pubkeys(username) - pk_x_raw = base64.b64decode(pk_data["pk_x25519"]) - bundle = wrap_gek_aes(gek, pk_x_raw) - await bundle_store.store( - group_id, user_id, - bundle["pk_eph_b64"], bundle["nonce_b64"], bundle["wrapped_b64"], - ) - wrapped_count += 1 - log.info("GEK wrapped for %s (%s)", username, user_id[:8]) - except Exception as e: - errors.append(f"{username}: {e}") - log.warning("Failed to wrap GEK for %s: %s", username, e) + roster = state.get("roster") + authorized = len(await roster.list_members(group_id)) if roster else 0 - if wrapped_count == 0: - return JSONResponse( - {"error": "Failed to wrap GEK for any member", "details": errors}, 500) - - # Also store a copy wrapped for the node keystore X25519 key - # so the daemon can reload GEK on restart without the operator's browser keys - config = state.get("config") + # Store a copy wrapped for the node keystore X25519 key so the daemon can + # reload the GEK on restart without the operator's browser keys. node_user_id = hub._session.user_id if hub._session else None pk_x_node_raw = state.get("pk_x25519_raw") if pk_x_node_raw and node_user_id: @@ -286,13 +304,14 @@ def create_ui_app(state: dict) -> FastAPI: node_bundle["pk_eph_b64"], node_bundle["nonce_b64"], node_bundle["wrapped_b64"], ) - log.info("GEK also wrapped for node keystore (daemon reload)") + log.info("GEK wrapped for node keystore (daemon reload)") except Exception as e: + errors.append(f"node keystore: {e}") log.warning("Failed to wrap GEK for node keystore: %s", e) groups_ctx[group_id]["gek"] = gek - log.info("GEK initialized for group %s — wrapped for %d/%d members", - group_id[:8], wrapped_count, len(members)) + log.info("GEK initialized for group %s — %d authorized member(s) will " + "receive it on connect", group_id[:8], authorized) webrtc = state.get("webrtc") if webrtc and "groups" in webrtc._ctx and group_id in webrtc._ctx["groups"]: @@ -301,8 +320,7 @@ def create_ui_app(state: dict) -> FastAPI: return { "status": "ok", "group_id": group_id, - "wrapped_count": wrapped_count, - "total_members": len(members), + "authorized_members": authorized, "errors": errors, } @@ -585,8 +603,8 @@ async function initGEK(groupId) {{ const resp = await fetch('/api/groups/' + groupId + '/gek?t=' + TOKEN, {{ method: 'POST' }}); const data = await resp.json(); if (resp.ok) {{ - if (status) status.textContent = 'GEK initialized — wrapped for ' - + data.wrapped_count + '/' + data.total_members + ' members'; + if (status) status.textContent = 'GEK initialized — ' + + data.authorized_members + ' authorized member(s) get it on connect'; if (status) status.style.color = '#22c55e'; setTimeout(() => location.reload(), 2000); }} else {{ diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py new file mode 100644 index 0000000..e0492ae --- /dev/null +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -0,0 +1,504 @@ +""" +Roster and operator pairing (M3, and the mechanism that will close H3). + +Negative assertions, per the posture set in Phase 11.5: each test states an attack +or a mistake that must not work. The one to keep an eye on is +`test_daemon_does_not_auto_pin_keystore_key` — the auto-pin is what made node +sovereignty inert as shipped, and it fails closed, so nothing else in the suite +notices if it comes back. + +See `docs/invite-pairing-v1.md`. +""" + +import base64 +import time +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + +from meshbay_common.crypto import generate_gek, pk_to_b64, unwrap_gek_aes +from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR, join_transcript +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster, hash_code, normalize_code +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +async def roster(tmp_path): + r = Roster(db_path=tmp_path / "roster.db") + await r.open() + yield r + await r.close() + + +def _keypair_full(): + """(sk_ed, pk_ed_b64, pk_x_b64, sk_x) — the X25519 secret is needed to unwrap.""" + sk_ed = Ed25519PrivateKey.generate() + sk_x = X25519PrivateKey.generate() + pk_ed_b64 = pk_to_b64(sk_ed.public_key()) + pk_x_b64 = base64.b64encode( + sk_x.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ).decode() + return sk_ed, pk_ed_b64, pk_x_b64, sk_x + + +def _keypair(): + sk_ed, pk_ed_b64, pk_x_b64, _ = _keypair_full() + return sk_ed, pk_ed_b64, pk_x_b64 + + +def _session(tmp_path: Path, roster, user_id: str = "grenet", + group_id: str | None = None, gek: bytes | None = None, + join_policy: str = "invite") -> WebRTCPeerSession: + """A peer session with the join path wired and sending stubbed out.""" + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "shared_root": shared_root, + "index": index, + "sk_node": index.sk_node, + "roster": roster, + } + if group_id: + session._ctx["groups"] = { + group_id: { + "gek": gek, + "shared_root": shared_root, + "index": index, + "join_policy": join_policy, + }, + } + session._group_id = group_id + session._user_id = user_id + session._username = user_id + session._pk_user = "" + session._uploads = {} + session._join_attempts = 0 + session._nonce_node = b"\x11" * 32 + session._remote_ip = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="", user_id="grenet", + group_id="", nonce=None, ts=None): + ts = int(time.time()) if ts is None else ts + transcript = join_transcript( + node_pk_b64=session._node_pk_b64(), + group_id=group_id, + user_id=user_id, + pk_ed25519_b64=pk_ed_b64, + pk_x25519_b64=pk_x_b64, + nonce_node=nonce if nonce is not None else session._nonce_node, + ts=ts, + ) + return { + "type": "join_request", + "group_id": group_id, + "pk_ed25519": pk_ed_b64, + "pk_x25519": pk_x_b64, + "code": code, + "ts": ts, + "sig": base64.b64encode(sk_ed.sign(transcript)).decode(), + } + + +def _last(session): + return session.sent[-1] if session.sent else {} + + +# ── Roster ──────────────────────────────────────────────────────────────────── + +async def test_invite_is_single_use(roster): + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + assert await roster.consume_invite(code, "grenet") is not None + assert await roster.consume_invite(code, "grenet") is None, ( + "a pairing code must not be redeemable twice") + + +async def test_invite_is_bound_to_one_account(roster): + """A leaked code must be useless to whoever finds it.""" + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + assert await roster.consume_invite(code, "eve") is None + assert await roster.consume_invite(code, "grenet") is not None + + +async def test_expired_invite_is_refused(roster): + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli", ttl=-1) + assert await roster.consume_invite(code, "grenet") is None + + +async def test_reinvite_supersedes_the_previous_code(roster): + first = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + second = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + assert await roster.consume_invite(first, "grenet") is None + assert await roster.consume_invite(second, "grenet") is not None + + +async def test_codes_are_not_stored_in_the_clear(roster, tmp_path): + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + rows = await roster.list_invites() + assert rows and rows[0]["code_hash"] != normalize_code(code) + assert rows[0]["code_hash"] == hash_code(code) + + +def test_code_normalization_absorbs_human_error(): + """Someone reading a code aloud must not be able to get it wrong.""" + assert normalize_code("k7m2-qx4p") == normalize_code("K7M2QX4P") + assert normalize_code("O1IL") == "0111" + assert normalize_code(" k7m2 qx4p ") == "K7M2QX4P" + + +async def test_operator_pks_reflect_unpinning(roster): + _, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli") + assert await roster.operator_pks() == [pk_ed_b64] + + await roster.unpin("grenet") + assert await roster.operator_pks() == [], ( + "authority must disappear with the pin, without a daemon restart") + + +# ── Join / pairing over MNP ─────────────────────────────────────────────────── + +async def test_pairing_with_a_valid_code_pins_the_identity(tmp_path, roster): + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code)) + + assert _last(session).get("ok") is True + pinned = await roster.get_identity("grenet") + assert pinned["pk_ed25519"] == pk_ed_b64 + assert await roster.operator_pks() == [pk_ed_b64] + + +async def test_pairing_without_a_code_is_refused(tmp_path, roster): + """Fails closed: an unknown identity gets nothing until someone authorizes it.""" + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + + await session._do_join_request(_join_msg(session, sk_ed, pk_ed_b64, pk_x_b64)) + + assert _last(session).get("ok") is False + assert _last(session).get("reason") == "code_required" + assert await roster.get_identity("grenet") is None + + +async def test_wrong_code_pins_nothing(tmp_path, roster): + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="ZZZZ-ZZZZ")) + + assert _last(session).get("reason") == "code_invalid" + assert await roster.get_identity("grenet") is None + + +async def test_signature_must_cover_the_presented_keys(tmp_path, roster): + """ + The heart of it: the X25519 key is only trustworthy because the Ed25519 + identity signed it. Swapping in another encryption key after signing must fail. + """ + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + + msg = _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code) + _, _, attacker_pk_x = _keypair() + msg["pk_x25519"] = attacker_pk_x + + await session._do_join_request(msg) + + assert _last(session).get("reason") == "signature_invalid" + assert await roster.get_identity("grenet") is None + + +async def test_join_cannot_be_replayed_onto_another_connection(tmp_path, roster): + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + + # Signed against a nonce this connection never issued. + msg = _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + nonce=b"\x99" * 32) + await session._do_join_request(msg) + + assert _last(session).get("reason") == "signature_invalid" + assert await roster.get_identity("grenet") is None + + +async def test_pinned_identity_presenting_a_new_key_is_refused(tmp_path, roster): + """ + 11.5.8's rule, applied to people: a changed key is refused outright rather + than warned about, and clearing it is a deliberate operator action. + """ + session = _session(tmp_path, roster) + _, old_pk_ed, old_pk_x = _keypair() + await roster.pin_identity("grenet", "grenet", old_pk_ed, old_pk_x, "code") + + sk_ed2, new_pk_ed, new_pk_x = _keypair() + await session._do_join_request( + _join_msg(session, sk_ed2, new_pk_ed, new_pk_x, code="ANY-CODE")) + + assert _last(session).get("reason") == "key_changed" + assert (await roster.get_identity("grenet"))["pk_ed25519"] == old_pk_ed + + +async def test_attempts_are_bounded(tmp_path, roster): + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + + for _ in range(6): + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA")) + + assert any(m.get("detail") == "Too many attempts" for m in session.sent), ( + "a connection must not be able to sit there guessing codes") + + +async def test_failures_are_counted_across_connections(tmp_path, roster): + """ + The adversary who can mint a token for any account is the hub, and it can + reconnect at will — so a per-connection budget alone would bound nothing. + """ + shared_ctx = None + for _ in range(6): + session = _session(tmp_path, roster) + if shared_ctx is None: + shared_ctx = session._ctx + else: + session._ctx = shared_ctx # same node, new connection + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + for _ in range(4): + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA")) + + assert any(m.get("detail") == "Pairing temporarily locked" + for m in session.sent), ( + "reconnecting must not reset the pairing budget") + + +async def test_group_id_cannot_name_another_group(tmp_path, roster): + session = _session(tmp_path, roster) + session._group_id = "a" * 32 + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, group_id="b" * 32)) + + assert _last(session).get("reason") == "group_mismatch" + + +# ── H3: the node wraps the group key, and only for people it admitted ───────── + +GROUP = "g" * 32 + + +async def test_node_wraps_the_gek_for_the_key_the_member_proved(tmp_path, roster): + """ + The H3 fix. Nobody fetches a public key from the hub: the node encrypts the + group key for the X25519 key the joiner signed with their pinned identity, so + a hub substituting a key of its own has nothing to substitute into. + """ + gek = generate_gek() + session = _session(tmp_path, roster, user_id="bob", group_id=GROUP, gek=gek) + sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full() + + code = await roster.create_invite(GROUP, "bob", ROLE_MEMBER, "grenet") + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="bob", group_id=GROUP)) + + reply = _last(session) + assert reply["ok"] is True and reply["gek"] is True + + pk_x_raw = base64.b64decode(pk_x_b64) + sk_x_raw = sk_x.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + assert unwrap_gek_aes(reply, sk_x_raw, pk_x_raw) == gek + + +async def test_hub_membership_alone_yields_no_key(tmp_path, roster): + """ + A hub can invent an account, add it to a group and mint it a token. What it + cannot do is put it on the node's roster — so the key never leaves. + """ + gek = generate_gek() + session = _session(tmp_path, roster, user_id="eve", group_id=GROUP, gek=gek) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + + # Pinned on this node (say, for another group) but never admitted to this one. + await roster.pin_identity("eve", "eve", pk_ed_b64, pk_x_b64, "code") + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, + user_id="eve", group_id=GROUP)) + + reply = _last(session) + assert reply.get("gek") is False + assert reply.get("reason") == "not_authorized_for_group" + assert "wrapped_b64" not in reply + + +async def test_open_join_group_admits_without_a_code(tmp_path, roster): + """§3.4: where anyone may join, a code protects nothing and is not required.""" + gek = generate_gek() + session = _session(tmp_path, roster, user_id="newcomer", group_id=GROUP, + gek=gek, join_policy="open") + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, + user_id="newcomer", group_id=GROUP)) + + reply = _last(session) + assert reply["ok"] is True and reply["gek"] is True + pinned = await roster.get_identity("newcomer") + assert pinned["pinned_via"] == "tofu" + + +async def test_invite_only_group_still_demands_a_code(tmp_path, roster): + """Being public (discoverable) is not being open (admitting anyone).""" + gek = generate_gek() + session = _session(tmp_path, roster, user_id="newcomer", group_id=GROUP, + gek=gek, join_policy="invite") + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, + user_id="newcomer", group_id=GROUP)) + + assert _last(session).get("reason") == "code_required" + assert await roster.get_identity("newcomer") is None + + +async def test_unknown_group_is_invite_only(tmp_path, roster): + """ + Fail closed: a group whose policy the node cannot read is treated as + invite-only, never as open. + """ + session = _session(tmp_path, roster, user_id="newcomer") + session._group_id = "unconfigured-group" + assert session._group_join_policy("unconfigured-group") == "invite" + assert session._group_join_policy("") == "invite" + + +def test_join_policy_is_carried_from_node_config(): + """ + The policy reaches the transport from node.toml. If it ever came from the hub + instead, a hub could declare any group open and be handed its key. + """ + daemon_src = (Path(__file__).parent.parent + / "src" / "meshbay_node" / "daemon.py").read_text() + assert '"join_policy": group_cfg.join_policy' in daemon_src + + config_src = (Path(__file__).parent.parent + / "src" / "meshbay_node" / "config.py").read_text() + assert "join_policy" in config_src, "GroupConfig must carry the admission policy" + + +async def test_revoked_member_stops_receiving_the_key(tmp_path, roster): + """ + Wrapping on demand is what makes revocation work. A stored bundle survived + revocation; this does not. (Rotating the GEK is still required — the + ex-member has the old one.) + """ + gek = generate_gek() + session = _session(tmp_path, roster, user_id="bob", group_id=GROUP, gek=gek) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP, "bob", ROLE_MEMBER, "active", "grenet") + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, + user_id="bob", group_id=GROUP)) + assert _last(session)["gek"] is True + + await roster.set_status(GROUP, "bob", "revoked") + session.sent.clear() + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, + user_id="bob", group_id=GROUP)) + assert _last(session).get("gek") is False + + +# ── M3: where node authority comes from ─────────────────────────────────────── + +async def test_admin_signature_verified_against_the_paired_key(tmp_path, roster): + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli") + + transcript = b"meshbay:admin:v1 whatever" + assert await session._verify_admin_sig(transcript, sk_ed.sign(transcript)) + + stranger = Ed25519PrivateKey.generate() + assert not await session._verify_admin_sig( + transcript, stranger.sign(transcript)) + + +async def test_unpinned_operator_loses_authority_immediately(tmp_path, roster): + """No caching: revoking a paired browser must not need a daemon restart.""" + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli") + + transcript = b"meshbay:admin:v1 whatever" + assert await session._verify_admin_sig(transcript, sk_ed.sign(transcript)) + + await roster.unpin("grenet") + assert not await session._verify_admin_sig(transcript, sk_ed.sign(transcript)) + + +def test_daemon_does_not_auto_pin_keystore_key(): + """ + M3: the daemon used to auto-pin its own keystore key as the admin key, while + the browser signs with the user's identity key. Different keys, so every + privileged operation failed closed with a signature error that looked like a + bug elsewhere — and the demo only worked because a deploy script overwrote it. + + Authority now comes from the roster, or from an explicit node.toml value. + """ + source = (Path(__file__).parent.parent + / "src" / "meshbay_node" / "daemon.py").read_text() + assert "Auto-pinning admin key" not in source + assert "_resolve_admin_pk" not in source, ( + "the auto-pin resolver is back — node authority must be established " + "locally by pairing, never inferred from the node's own keystore (M3)") + + +def test_admin_authority_is_never_fetched_from_the_hub(): + """ + The fix M3 invites: ask the hub which key belongs to the operator. That would + hand a malicious hub the node — the same substitution as H3, one level deeper. + """ + source = (Path(__file__).parent.parent + / "src" / "meshbay_node" / "daemon.py").read_text() + admin_region = source[source.find("_legacy_admin_pk"):] + assert "pubkeys" not in admin_region.split("def ")[1], ( + "node authority must never be resolved through a hub lookup") diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 9299bf4..6bb680c 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -216,16 +216,35 @@ def test_daemon_sets_no_global_chat_store(tmp_path): # ── H2: node admin UI escaping ─────────────────────────────────────────────── -def test_gek_bundle_store_requires_admin_challenge(tmp_path): +def test_no_member_can_hand_the_node_key_material(tmp_path): """ - C5b: gek_bundle_store used to write whatever any authenticated member sent. - It must now answer with a challenge and store nothing until a valid - node-operator signature arrives. + C5b, strengthened by the invite redesign (docs/invite-pairing-v1.md). + + This test used to assert that `gek_bundle_store` answered with an admin + challenge and stored nothing without an operator signature. The message is now + gone entirely: the node holds the GEK and wraps it itself, so no member ever + submits key material, authorized or not. Deleting the path is a stronger + guarantee than gating it, which is why the assertion changed rather than the + behaviour regressing. """ + from meshbay_common.protocol import MNP as _MNP + + assert not hasattr(_MNP, "GEK_BUNDLE_STORE"), ( + "the member-supplied bundle message is back — the node must never accept " + "key material over MNP (C5b)" + ) + + source = (Path(__file__).parent.parent + / "src" / "meshbay_node" / "transport" / "webrtc_server.py").read_text() + assert "_do_gek_bundle_store" not in source + assert "_admin_exec_bundle_store" not in source + + +def test_unknown_message_stores_nothing(tmp_path): + """A peer sending the retired message must not reach any storage path.""" session = _session(tmp_path, "ordinary-member") session._group_id = None session._admin_ops = {} - session._ctx["admin_pk_ed25519"] = Ed25519PrivateKey.generate().public_key() stored = [] @@ -234,27 +253,13 @@ def test_gek_bundle_store_requires_admin_challenge(tmp_path): stored.append(args) session._ctx["bundle_store"] = _Store() - session._do_gek_bundle_store({ + session._handle_message({ + "type": "gek_bundle_store", "user_id": "victim", "group_id": "g" * 32, "pk_eph_b64": "AA==", "nonce_b64": "AA==", "wrapped_b64": "AA==", }) - assert stored == [], "bundle written without operator authorization (C5b)" - assert any(m.get("type") == "admin_challenge" for m in session.sent) - - -def test_gek_bundle_store_refused_without_pinned_admin_key(tmp_path): - """C5b: deny by default — no pinned key means no privileged operation.""" - session = _session(tmp_path, "ordinary-member") - session._group_id = None - session._admin_ops = {} - session._ctx["bundle_store"] = object() - - session._do_gek_bundle_store({ - "user_id": "victim", "group_id": "g" * 32, - "pk_eph_b64": "AA==", "nonce_b64": "AA==", "wrapped_b64": "AA==", - }) - assert any(m.get("type") == "error" for m in session.sent) + assert stored == [], "a retired message type still reached the bundle store" def test_gek_auto_activation_is_gone(): @@ -287,7 +292,7 @@ def test_admin_transcript_is_domain_separated(): @pytest.mark.parametrize("field,value", [ - ("op", "gek_bundle_store"), + ("op", "invite_create"), ("subject", "file-2"), ("node_pk_b64", "OTHERNODE"), ("group_id", "h" * 32), @@ -320,15 +325,15 @@ def test_admin_signature_does_not_transfer_between_operations(tmp_path): H5: the concrete attack. A signature collected to delete a file must not authorize storing a GEK bundle. """ - from meshbay_common.adminop import OP_FILE_DELETE, OP_GEK_BUNDLE_STORE + from meshbay_common.adminop import OP_FILE_DELETE, OP_INVITE_CREATE sk_admin = Ed25519PrivateKey.generate() delete_transcript = _transcript(op=OP_FILE_DELETE) signature = sk_admin.sign(delete_transcript) - store_transcript = _transcript(op=OP_GEK_BUNDLE_STORE) + invite_transcript = _transcript(op=OP_INVITE_CREATE) with pytest.raises(Exception): - sk_admin.public_key().verify(signature, store_transcript) + sk_admin.public_key().verify(signature, invite_transcript) def test_admin_challenge_expires(tmp_path): @@ -573,3 +578,4 @@ def test_admin_ui_escapes_filenames(tmp_path): assert payload not in html, "filename rendered unescaped — stored XSS (H2)" assert "<img" in html, "filename should appear escaped" + diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 59b48ac..07bdbea 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -31,6 +31,7 @@ from meshbay_common.crypto import ( wrap_gek, wrap_gek_aes, unwrap_gek, + unwrap_gek_aes, ) from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes from meshbay_common.protocol import MNP @@ -42,10 +43,12 @@ from meshbay_common.handshake import ( ) from meshbay_common.adminop import ( OP_FILE_DELETE, - OP_GEK_BUNDLE_STORE, + OP_INVITE_CREATE, admin_transcript, ) +from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR, join_transcript from meshbay_node.bundle_store import BundleStore +from meshbay_node.roster import Roster from meshbay_node.indexer import DirectoryIndexer from meshbay_node.transport.webrtc_server import WebRTCTransport @@ -184,9 +187,30 @@ async def _handshake_with_gek_proof(channel, received, sk_hub, gek, groups=None, return msg -async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None, - group_id=TEST_GROUP): - """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue).""" +def _token(sk_hub, jwt_sub, peer_id, group_id, pk_user="test"): + """A hub-issued user token, as the browser would present it.""" + sk_h_pem = sk_hub.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + now = int(time.time()) + return jwt.encode({ + "iss": "test-hub", "sub": jwt_sub, + "pk_user": pk_user, "hub_id": "test-hub", + "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600, + "groups": [group_id], "scope": "user", + }, sk_h_pem, algorithm="EdDSA") + + +async def _open_channel(transport, peer_id): + """ + Signaling only: a live DataChannel with no MNP handshake performed. + + Separate from `_setup_peer` because someone joining a group for the first time + cannot complete the handshake — they have no GEK to prove — and the join has to + happen in that window. + """ pc = RTCPeerConnection() q = asyncio.Queue() buf = bytearray() @@ -215,6 +239,13 @@ async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_us answer_sdp, _ = await transport.handle_offer(pc.localDescription.sdp, peer_id) await pc.setRemoteDescription(RTCSessionDescription(sdp=answer_sdp, type="answer")) await asyncio.wait_for(ready.wait(), timeout=5.0) + return pc, ch, q + + +async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None, + group_id=TEST_GROUP): + """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue).""" + pc, ch, q = await _open_channel(transport, peer_id) pk_user = "test" if sk_user: @@ -223,18 +254,7 @@ async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_us serialization.Encoding.Raw, serialization.PublicFormat.Raw) ).decode() - sk_h_pem = sk_hub.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ) - now = int(time.time()) - token = jwt.encode({ - "iss": "test-hub", "sub": jwt_sub, - "pk_user": pk_user, "hub_id": "test-hub", - "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600, - "groups": [group_id], "scope": "user", - }, sk_h_pem, algorithm="EdDSA") + token = _token(sk_hub, jwt_sub, peer_id, group_id, pk_user) msg = await _do_mnp_handshake(ch, q, token, gek, pc, group_id) assert msg["type"] == MNP.HANDSHAKE_ACK @@ -1059,71 +1079,114 @@ def x25519_keypair(): @pytest.mark.asyncio -async def test_gek_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir, - tmp_path, x25519_keypair): - """GEK bundle stored on node via DataChannel, then fetched during handshake.""" +async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_dir, + tmp_path, x25519_keypair): + """ + The whole invite flow over a real DataChannel, end to end. + + The operator asks for a code; the invitee — who has never held the group key + and therefore cannot complete the GEK proof — redeems it in the pre-proof + window and the node wraps the key for the X25519 key they just proved they + hold. At no point is a public key fetched from the hub, which is the point: + that lookup was H3. + """ hub_pk_pem = _hub_pk_pem(sk_hub) indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) await indexer.initial_scan() - bundle_store = BundleStore(db_path=tmp_path / "bundles.db") - await bundle_store.open() + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() transport = WebRTCTransport( sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, shared_root=shared_dir, index=indexer.index, stun_servers=[], ) - transport._ctx["bundle_store"] = bundle_store + transport._ctx["roster"] = roster + transport._ctx["has_admin_authority"] = True + transport._ctx["groups"] = { + TEST_GROUP: {"gek": gek, "shared_root": shared_dir, "index": indexer.index}, + } - # Storing a bundle is a node-operator operation (C5b): the node challenges and - # only the pinned admin key is accepted. + # A paired operator, as `meshbay-node operator pair` would have left it. sk_admin = Ed25519PrivateKey.generate() - transport._ctx["admin_pk_ed25519"] = sk_admin.public_key() + admin_pk_b64 = pk_to_b64(sk_admin.public_key()) + await roster.pin_identity("user-001", "grenet", admin_pk_b64, "AA==", "code") + await roster.set_member("", "user-001", ROLE_OPERATOR, "active", "local-cli") pc_admin, ch_admin, q_admin = await _setup_peer( transport, sk_hub, gek, "peer-admin") - sk_x_raw, pk_x_raw = x25519_keypair - bundle = wrap_gek(gek, pk_x_raw) - + # 1. The operator asks the node for an invitation code. ch_admin.send(_pack({ - "type": MNP.GEK_BUNDLE_STORE, - "v": MNP_VERSION, - "user_id": "user-002", - "group_id": "g", - "pk_eph_b64": bundle["pk_eph_b64"], - "nonce_b64": bundle["nonce_b64"], - "wrapped_b64": bundle["wrapped_b64"], + "type": MNP.INVITE_CREATE, "v": MNP_VERSION, + "user_id": "user-002", "group_id": TEST_GROUP, "username": "bob", })) - challenge_msg = await asyncio.wait_for(q_admin.get(), timeout=5.0) assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE - assert challenge_msg["op"] == OP_GEK_BUNDLE_STORE + assert challenge_msg["op"] == OP_INVITE_CREATE assert challenge_msg["subject"] == "user-002" - signature = sk_admin.sign(_transcript_from(challenge_msg)) ch_admin.send(_pack({ "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, "op_id": challenge_msg["op_id"], - "signature": base64.b64encode(signature).decode(), + "signature": base64.b64encode( + sk_admin.sign(_transcript_from(challenge_msg))).decode(), })) + invite = await asyncio.wait_for(q_admin.get(), timeout=5.0) + assert invite["type"] == MNP.INVITE_RESULT + code = invite["code"] + assert code and len(code) == 9 # XXXX-XXXX - ack = await asyncio.wait_for(q_admin.get(), timeout=5.0) - assert ack["type"] == "ack" - assert ack["detail"] == "gek_bundle_stored" + # 2. Bob connects. He cannot prove GEK possession — he has never had it — so + # he redeems the code in the pre-proof window instead. + sk_x_raw, pk_x_raw = x25519_keypair + sk_bob_ed = Ed25519PrivateKey.generate() + pc_bob, ch_bob, q_bob = await _open_channel(transport, "peer-bob") - # Verify bundle was persisted - stored = await bundle_store.fetch("g", "user-002") - assert stored is not None - assert stored["pk_eph_b64"] == bundle["pk_eph_b64"] + nonce_c = os.urandom(NONCE_LEN) + ch_bob.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": _token(sk_hub, "user-002", "peer-bob", TEST_GROUP), + "group_id": TEST_GROUP, + "nonce": base64.b64encode(nonce_c).decode(), + })) + challenge = await asyncio.wait_for(q_bob.get(), timeout=5.0) + assert challenge["type"] == MNP.HANDSHAKE_CHALLENGE + nonce_s = base64.b64decode(challenge["nonce"]) - # Unwrap to verify it's correct - recovered = unwrap_gek(stored, sk_x_raw, pk_x_raw) - assert recovered == gek + pk_ed_b64 = pk_to_b64(sk_bob_ed.public_key()) + pk_x_b64 = base64.b64encode(pk_x_raw).decode() + ts = int(time.time()) + transcript = join_transcript( + node_pk_b64=pk_to_b64(sk_node.public_key()), + group_id=TEST_GROUP, user_id="user-002", + pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64, + nonce_node=nonce_s, ts=ts, + ) + ch_bob.send(_pack({ + "type": MNP.JOIN_REQUEST, "v": MNP_VERSION, + "group_id": TEST_GROUP, + "pk_ed25519": pk_ed_b64, "pk_x25519": pk_x_b64, + "code": code, "ts": ts, + "sig": base64.b64encode(sk_bob_ed.sign(transcript)).decode(), + })) - await bundle_store.close() + result = await asyncio.wait_for(q_bob.get(), timeout=5.0) + assert result["type"] == MNP.JOIN_RESULT + assert result["ok"] is True + assert result["gek"] is True + assert result["role"] == ROLE_MEMBER + + # 3. The key really is the group key, and only Bob's secret opens it. + assert unwrap_gek_aes(result, sk_x_raw, pk_x_raw) == gek + + # 4. The code is spent. + assert await roster.consume_invite(code, "user-002") is None + + await roster.close() await pc_admin.close() + await pc_bob.close() await transport.close_all() @@ -1420,10 +1483,13 @@ async def test_gek_not_auto_activated_on_bundle_store(sk_node, sk_hub, gek, shar pc_admin, ch_admin, q_admin = await _setup_peer( transport, sk_hub, gek, "peer-setup-admin") - # An ordinary member wraps a key of their choosing for the operator's public key. + # An ordinary member wraps a key of their choosing for the operator's public + # key and offers it to the node. The message that used to carry this no longer + # exists (the node wraps the GEK itself now), so it reaches no handler at all — + # a stronger outcome than the admin challenge this test used to assert. node_bundle = wrap_gek_aes(attacker_gek, pk_x_raw) ch_admin.send(_pack({ - "type": MNP.GEK_BUNDLE_STORE, + "type": "gek_bundle_store", "v": MNP_VERSION, "user_id": "node-operator", "group_id": "g", @@ -1432,12 +1498,8 @@ async def test_gek_not_auto_activated_on_bundle_store(sk_node, sk_hub, gek, shar "wrapped_b64": node_bundle["wrapped_b64"], })) - # The node demands an operator signature instead of storing and adopting it. - reply = await asyncio.wait_for(q_admin.get(), timeout=5.0) - assert reply["type"] == MNP.ADMIN_CHALLENGE - assert reply["op"] == OP_GEK_BUNDLE_STORE - - await asyncio.sleep(0.2) + await asyncio.sleep(0.5) + assert q_admin.empty(), "the retired bundle message still gets a response" assert transport._ctx.get("gek") == gek, "group key was seized over MNP (C5b)" assert await bundle_store.fetch("g", "node-operator") is None |