From 3ce051e134a432417fcaca4e8b5775d98f614a31 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 10:42:20 +0200 Subject: fix(node): group isolation, upload confinement, GEK seizure, admin challenge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11.5 — findings H1, C5a, H2, C5b, H5 (see second-review.md). Batched together because the node-side changes share webrtc_server.py and cannot be separated into working commits. H1 — cross-group chat leak. chat_store, the peer registry and the display-name cache were read from the shared transport context, and daemon.py hoisted the FIRST group's chat store onto it. On a node hosting several groups every group's messages went to one database, chat_history served them back to members of every other group, and chat broadcast reached all peers regardless of group. All three now resolve through _group_ctx(). C5a — upload confinement. Uploads landed in the shared root under a client-chosen name and overwrote whatever was there. Any member could destroy the operator's files, and by becoming the recorded uploader of the replaced file could then delete it through the uploader path, bypassing the Ed25519 admin challenge. Uploads now go to a per-user quarantine (.uploads/{user_id}/), refuse to overwrite, and enforce chunk ordering, a filename allowlist and a size cap. H2 — stored XSS in the node admin UI. Filenames chosen by any group member were interpolated raw into the localhost UI, which has no authentication, so script execution there equals control of the node admin API. Now html.escape() throughout, textContent in the audit table, plus CSP/nosniff/no-referrer. The CSP contains exfiltration but cannot stop injected inline script — escaping is the fix. C5b — group key seizure. gek_bundle_store wrote whatever any member sent and auto-activated bundles addressed to the node operator. The operator's X25519 public key is public (the node publishes it in handshake_ack), so any member could wrap a key of their choosing for it and take over the group, locking every legitimate member out. Storing now requires an operator signature and _try_activate_gek is removed: nothing arriving over MNP can set a live GEK. H5 — unbound signing oracle. The node challenged with 32 raw random bytes and the client signed them blind, so a signature named no operation, subject, node or time. New meshbay_common/adminop.py defines a length-prefixed, domain-separated transcript; both sides build it independently and the client refuses to sign when the announced op/subject do not match its request. BREAKING: a group admin who does not operate the node can no longer store GEK bundles on it. Invites must be performed by the node operator. Adds tests/test_security_regressions.py. Verified against pre-fix source via git stash. Three pre-existing tests asserted the vulnerable behaviour as a feature and were inverted: gek auto-activation, and the transport-wide chat_store in test_daemon. Tests: 109 node, 132 hub+common. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index ddff928..dee47ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -979,8 +979,10 @@ function GroupPage({ groupId, group, token, username, userId }) { const transport = transportRef.current; if (!transport || !transport.connected) return; try { + // Signs an explicit transcript built by transport.js, not opaque bytes from + // the node — see MeshBayCrypto.adminTranscript and finding H5. const signFn = (_sessionKeys && window.MeshBayKeys) - ? (challenge) => window.MeshBayKeys.signChallenge(_sessionKeys.skEdB64, challenge) + ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript) : null; await transport.deleteFile(entry.id, signFn); const indexMsg = await transport.fetchIndex(); @@ -1405,9 +1407,16 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) { } const gekBytes = transport.gekRaw; - // Wrap GEK for invitee and store on node via P2P + // 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. + 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); + await transport.storeGekBundle(pubkeys.user_id, groupId, bundle, signFn); // Add member on hub (membership management only) await hubFetch(`/v1/groups/${groupId}/members/${username}`, { -- cgit v1.2.3 From 38ad6c54f8c8a9180f0f7522dead38e57760d55e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 14:32:47 +0200 Subject: feat(client): pin node identities on first use — closes 11.5.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client verified the node's Ed25519 signature but did not remember which key it had seen, so a substituted node was caught only by its lack of the GEK. Trust On First Use: the node's public key is recorded per node_id on the first successful handshake and compared on every later one. A change is refused outright — strict, per operator decision. A warning users can click through is decorative, and this is the SSH known-hosts tradeoff taken deliberately. Scope, stated honestly: with C6 closed this is defence in depth, not the primary control. A substituted node already fails the GEK proof. Pinning covers the case where an attacker HAS the group key — an ex-member, or a leaked GEK — and swaps the node underneath, which the proof alone cannot distinguish from the real one. Strict refusal needs an escape hatch or it is a dead end: a node operator who reinstalls and loses their keystore generates a new pk_node and would otherwise lock out every member. Settings gains a "Node identities" section showing the pin count and clearing them, with copy telling the user to verify out of band first. Also exposed as MeshBayTransport.clearNodePin() for the native client. Tests: hub+common green; all five static JS files syntax-checked. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 20 ++++++++++++++++++++ packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 4 ++++ 2 files changed, 24 insertions(+) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index dee47ec..a006dfe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1978,6 +1978,15 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { const [currentNodeKey, setCurrentNodeKey] = useState(null); const [nodeKeyStatus, setNodeKeyStatus] = useState(''); const [nodeKeyLoading, setNodeKeyLoading] = useState(false); + const [pinCount, setPinCount] = useState( + () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); + + // 11.5.8: node identity pins are refused strictly on change, so users need a + // deliberate way to accept a legitimate rotation (operator reinstalled a node). + const clearPins = useCallback(() => { + window.MeshBayTransport?.clearNodePin?.(); + setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0); + }, []); useEffect(() => { hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) @@ -2068,6 +2077,17 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { `} +
+

${t('settings.node_pins')}

+

${t('settings.node_pins_hint')}

+
+ ${t('settings.node_pins_count', { n: pinCount })} + +
+
+

${t('settings.appearance')}

diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 3450735..b0de4b9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -120,6 +120,10 @@ const en = { 'settings.coming_soon': 'Coming soon.', 'settings.profile': 'Profile', 'settings.username': 'Username', + 'settings.node_pins': 'Node identities', + 'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.", + 'settings.node_pins_count': '{n} pinned', + 'settings.node_pins_clear': 'Clear pinned identities', 'settings.appearance': 'Appearance', 'settings.theme': 'Theme', 'settings.theme_light': 'Light', -- cgit v1.2.3 From f15efd23f66c521ca9206789482bb38e7326eeb4 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 01:27:21 +0200 Subject: feat(node)!: the node wraps the group key — closes H3 and M3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite flow fetched the invitee's pk_x25519 from the hub and wrapped the GEK for whatever came back (app.js:1466, and gek-init did the same server-side). The hub is the key directory, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. No forgery, no injection, nothing for the client to notice. That was H3. The fix is not safety numbers. Nobody reads the directory any more: - the node holds the GEK and wraps it itself, on every connection, for the X25519 key the joiner signed with their Ed25519 identity in one transcript (meshbay:join:v1), so the identity key vouches for the encryption key; - identities are bound to accounts by a one-time code the hub never sees — 40 bits, single use, one account, bounded per connection AND node-wide; - the node's own roster decides who may receive the key. Hub membership lets someone reach a node; it no longer gets them anything. A hub that invents an account and mints it a token is answered not_authorized_for_group. Safety numbers would have made substitution detectable by a human who checks, at the moment there is nothing to check against — first contact. Removing the lookup makes it impossible, and costs the user one code to pass along. M3 falls out of the same work. The daemon auto-pinned its own keystore key as admin_pk_ed25519 while the browser signs with the user identity key, so every privileged operation failed closed with a signature error that looked like a bug somewhere else; the demo only worked because a deploy script overwrote the value. Authority now comes from the roster, established locally by `operator pair`. Asking the hub for the operator's key — the obvious-looking fix — would have let the hub install itself as node administrator. BREAKING: gek_bundle_store is deleted, not gated. No member hands the node key material at all, so C5b becomes structural rather than an authorization to check. Existing stored bundles are still served, so current deployments keep working. Also: - join_policy (invite|open) is read from node.toml, never from the hub — a hub able to declare a group open would be handed its key. Unknown group ⇒ invite. - admin signatures are verified against the roster on every check, so unpinning takes effect without a restart. admin_pk_ed25519 stays readable as legacy. - two C5b tests were rewritten, deliberately: they asserted that gek_bundle_store demanded an operator signature, and the message is gone. They now assert the stronger property. The file says not to fix these tests, so this is the record of why they changed. - a slice-1 bug found while writing slice 2: connect() never passed skEdB64, so pairing would have failed at runtime with no test able to catch it. Tests: 152 node+common here, including an end-to-end DataChannel run where a member who has never held the group key redeems a code in the pre-proof window and receives the key wrapped for a key only they can open. Design: docs/invite-pairing-v1.md Co-Authored-By: Claude Opus 5 --- docs/invite-pairing-v1.md | 526 +++++++++++++++++++++ .../meshbay-common/src/meshbay_common/adminop.py | 9 +- packages/meshbay-common/src/meshbay_common/join.py | 63 +++ .../meshbay-common/src/meshbay_common/protocol.py | 8 +- .../meshbay-common/tests/test_js_python_parity.py | 65 ++- packages/meshbay-hub/src/meshbay_hub/static/app.js | 137 +++++- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 27 +- .../meshbay-hub/src/meshbay_hub/static/i18n.js | 16 + .../meshbay-hub/src/meshbay_hub/static/style.css | 15 + .../src/meshbay_hub/static/transport.js | 178 ++++++- packages/meshbay-node/src/meshbay_node/config.py | 16 +- packages/meshbay-node/src/meshbay_node/daemon.py | 231 ++++++--- packages/meshbay-node/src/meshbay_node/roster.py | 372 +++++++++++++++ .../src/meshbay_node/transport/webrtc_server.py | 413 +++++++++++++--- packages/meshbay-node/src/meshbay_node/ui/app.py | 110 +++-- packages/meshbay-node/tests/test_roster_pairing.py | 504 ++++++++++++++++++++ .../tests/test_security_regressions.py | 58 ++- .../meshbay-node/tests/test_webrtc_transport.py | 178 ++++--- 18 files changed, 2639 insertions(+), 287 deletions(-) create mode 100644 docs/invite-pairing-v1.md create mode 100644 packages/meshbay-common/src/meshbay_common/join.py create mode 100644 packages/meshbay-node/src/meshbay_node/roster.py create mode 100644 packages/meshbay-node/tests/test_roster_pairing.py (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') 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 ") +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 }) { `}
${error && html`
${error}
`} + ${needsCode && html` +
+

${t('group.join_code_title')}

+

${t('group.join_code_hint')}

+
+ setCodeInput(e.target.value)} required /> + +
+
+ `} ${dlState && html`
${dlState.name} @@ -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`

${t('explore.loading')}

`; @@ -1461,6 +1525,15 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {

${t('members.invite_title')}

${error && html`

${error}

`} + ${inviteCode && html` +
+

${t('members.invite_code_ready', { user: inviteCode.username })}

+

+ ${inviteCode.code} +

+

${t('members.invite_code_hint')}

+
+ `}
setInviteUser(e.target.value)} required /> @@ -1470,6 +1543,24 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
`} + ${isNodeAdmin && html` +
+

${t('members.pair_title')}

+

${t('members.pair_hint')}

+ ${pairStatus && html` +

+ ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus} +

+ `} +
+ setPairCode(e.target.value)} required /> + +
+
+ `}
`; } 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. + * + * 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. * - * 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). + * 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 - - 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 + def _legacy_admin_pk(self) -> Ed25519PublicKey | None: + """ + The pre-roster way of naming the operator: `admin_pk_ed25519` in node.toml. + + 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 ''}") print(f" {g.shared_dir or ''}") - 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 ") + + 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. - - 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. + Issue a one-time pairing code for someone the operator wants to admit. + + 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) - - if wrapped_count == 0: - return JSONResponse( - {"error": "Failed to wrap GEK for any member", "details": errors}, 500) + roster = state.get("roster") + authorized = len(await roster.list_members(group_id)) if roster else 0 - # 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"]) + + 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(), + })) - # Unwrap to verify it's correct - recovered = unwrap_gek(stored, sk_x_raw, pk_x_raw) - assert recovered == gek + 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 - await bundle_store.close() + # 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 -- cgit v1.2.3 From 9a483774e97f8612b00e3d92c4d5ebc00c21980a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 03:40:43 +0200 Subject: fix(client): refresh the token when the node says "not a member" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member added to a group after they signed in was refused by the node, told "Not a member of this group", and had no way forward but to log out and back in. The hub bakes `groups` into the access token at login and never pushes updates, so the token said they were in nothing while the database said otherwise. This lands on every newly invited member, at their first action, and the message tells them the opposite of the truth — toto2 was a member of newdemo on the hub and read that they were not. The refusal now carries a code the client can act on (`not_a_member`) rather than prose it would have to string-match, and the SPA refreshes the access token once and retries. Refreshing re-reads membership from the database, so the retry succeeds. Once per mount: if a fresh token still says not a member, that is the truth and it gets shown. The SPA had stored a refresh token since Phase 8 and never used it. It does now. Found in a browser, doing the ordinary thing — the automated run never sees it, because e2e.py logs in after being added to the group. Tests: 233 node+common, including a handshake test that the refusal carries the code, and the full e2e run against the live deployment. Co-Authored-By: Claude Opus 5 --- .../meshbay-common/src/meshbay_common/handshake.py | 18 ++++++++- packages/meshbay-common/tests/test_handshake.py | 26 ++++++++++++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 46 +++++++++++++++++----- .../src/meshbay_hub/static/transport.js | 7 +++- .../src/meshbay_node/transport/webrtc_server.py | 3 +- 5 files changed, 87 insertions(+), 13 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index 73a2858..223f064 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -53,7 +53,17 @@ NONCE_LEN = 32 class HandshakeError(Exception): - """Refusal, with a message safe to hand to the peer.""" + """ + Refusal, with a message safe to hand to the peer. + + `code` is the same refusal in a form a client can act on. The text is for a + human and may be reworded; matching on it from the client would be a string + comparison that breaks silently the day someone improves the wording. + """ + + def __init__(self, message: str, code: str = ""): + super().__init__(message) + self.code = code class DenylistLike(Protocol): @@ -170,7 +180,11 @@ def authorize_token( raise HandshakeError("Token revoked") if group_id not in decoded.get("groups", []): - raise HandshakeError("Not a member of this group") + # Almost always a token issued before the person was added to the group: + # `groups` is baked in at login and the hub does not push updates. The + # client refreshes and retries on this code rather than telling someone + # who *is* a member that they are not one. + raise HandshakeError("Not a member of this group", code="not_a_member") if hosted_groups is not None and group_id not in hosted_groups: raise HandshakeError("Group not hosted on this node") diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py index ba8788a..8981db8 100644 --- a/packages/meshbay-common/tests/test_handshake.py +++ b/packages/meshbay-common/tests/test_handshake.py @@ -197,3 +197,29 @@ def test_transcript_is_unambiguous(): def test_bindings_differ_by_transport(): """A WebRTC proof must not be replayable on a QUIC connection.""" assert webrtc_binding(b"\xaa" * 32, b"\xbb" * 32) != quic_binding(b"cert-der") + + +def test_membership_refusal_carries_a_code_a_client_can_act_on(): + """ + `groups` is baked into the token at login, so someone added to a group after + signing in is refused although they are a member. The client refreshes and + retries on this code — it must not have to match on the human wording, which + is exactly the kind of coupling that breaks when someone improves a message. + """ + import jwt as _jwt + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives import serialization + + sk = Ed25519PrivateKey.generate() + pem_priv = sk.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + pem_pub = sk.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + + token = _jwt.encode({"sub": "u1", "jti": "j1", "scope": "user", "groups": []}, + pem_priv, algorithm="EdDSA") + + with pytest.raises(HandshakeError) as excinfo: + authorize_token(token, pem_pub, group_id="g" * 32) + assert excinfo.value.code == "not_a_member" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 1ef878a..6fefe0f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -765,7 +765,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk return results; } -function GroupPage({ groupId, group, token, username, userId }) { +function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); const [cached, setCached] = useState(false); @@ -786,6 +786,9 @@ function GroupPage({ groupId, group, token, username, userId }) { const [retryKey, setRetryKey] = useState(0); const transportRef = useRef(null); const gekRef = useRef(null); + // One refresh per mount: if a fresh token still says we are not a member, we + // really are not, and retrying forever would hide that. + const refreshedRef = useRef(false); const submitJoinCode = useCallback((e) => { e.preventDefault(); @@ -902,14 +905,24 @@ 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'); + if (cancelled) return; + + // Our token predates being added to this group. Refresh once and retry + // rather than telling someone who was just invited that they are not a + // member — which is what the node honestly sees, and is useless to them. + if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) { + refreshedRef.current = true; + try { + if (await onRefreshAuth()) return; // new token → effect re-runs + } catch { /* fall through to the message below */ } } + + // 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'); } }; @@ -2653,6 +2666,20 @@ function App() { }, }; + // Group membership is baked into the access token at login and the hub does not + // push updates, so someone invited after they signed in carries a token that + // says they are in nothing. Refreshing re-reads membership from the database. + const refreshAuth = useCallback(async () => { + if (!user || !user.refreshToken) return null; + const data = await hubFetch('/v1/users/token/refresh', { + method: 'POST', body: { refresh_token: user.refreshToken }, + }); + const u = { ...user, token: data.access_token }; + setUser(u); + saveAuth(u); + return data.access_token; + }, [user]); + let page; if (route === '/login' || route === '/register') { page = route === '/register' @@ -2677,7 +2704,8 @@ function App() { const group = groups.find(g => g.id === groupId); page = html`<${GroupPage} groupId=${groupId} group=${group} token=${user.token} - username=${user.username} userId=${user.userId} />`; + username=${user.username} userId=${user.userId} + onRefreshAuth=${refreshAuth} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${AdminPage} token=${user.token} />` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 271d11f..c200674 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -314,8 +314,13 @@ class MeshBayTransport { // A node that answers a handshake with anything other than a challenge is not // running the mutual protocol. Accepting a bare handshake_ack here would let a // peer skip proving GEK possession entirely (C3/C6). - throw new Error( + const rejected = new Error( 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`)); + // `not_a_member` usually means our token predates being added to the group; + // the caller refreshes it and tries again rather than showing that to someone + // who was invited thirty seconds ago. + rejected.reason = reply.code || ''; + throw rejected; } /** 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 416e84c..46dda64 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -356,7 +356,8 @@ class WebRTCPeerSession: except HandshakeError as refusal: # HandshakeError messages are authored to be peer-safe, unlike arbitrary # exception text (L3) — the client needs to know *why* it was refused. - self._send({"type": "error", "detail": str(refusal)}) + self._send({"type": "error", "detail": str(refusal), + "code": getattr(refusal, "code", "")}) self._audit_auth_failed(group_id, str(refusal)) return -- cgit v1.2.3 From 8f9ac4d931e43358cb0e4e82085f49e916419a8b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 04:02:20 +0200 Subject: fix(client): recover identity keys from what the browser already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering in Firefox and coming back to it said "This browser does not hold your keys" — while both halves of those keys were on disk a few bytes apart. _sessionKeys lives in sessionStorage, which dies with the tab. The encrypted keypair bundle is in localStorage from registration, and the key that opens it is in IndexedDB from login, but nothing ever put the two together again: only the login path did, and a returning user is restored from stored auth without logging in. So closing a tab looked identical to never having registered there. Recovery now happens before connecting: bundle from localStorage, key from IndexedDB, public half derived from our own secret rather than read back from the hub. The bundle is also queued for backup to the node, which is what lets a second browser recover the same keys with the password. Not hardening, and not from the invite redesign — an oversight in session restore that the redesign made visible, because joining is now the first thing that needs those keys. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 6fefe0f..0aa74b4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -122,6 +122,45 @@ function _restoreSessionKeys() { } catch {} } +/** Public X25519 key from our own secret — never read back from the hub. */ +async function _pkXFromSk(skPkcs8B64) { + const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); + const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'X25519' }, true, ['deriveBits']); + 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; +} + +/** + * Recover our identity keys from what this browser already holds. + * + * sessionStorage dies with the tab, but the encrypted keypair bundle sits in + * localStorage from registration and the key that opens it is in IndexedDB from + * login. Without this, closing the tab looked exactly like never having + * registered here — "this browser does not hold your keys", while both halves + * were on disk a few bytes apart. + */ +async function _recoverLocalKeys(username) { + if (_sessionKeys || !username) return; + try { + if (!_bundleKey) _bundleKey = await _loadBundleKey(); + if (!_bundleKey || !window.MeshBayKeys) return; + const enc = localStorage.getItem(`meshbay_kp_${username}`); + if (!enc) return; + const keys = await window.MeshBayKeys.decryptBundleWithKey(enc, _bundleKey); + _sessionKeys = { + skXB64: keys.skX, + skEdB64: keys.skEd, + pkXB64: await _pkXFromSk(keys.skX), + }; + _pendingBundlePush = enc; // still to be backed up to a node + _saveSessionKeys(); + } catch (e) { + console.warn('[MeshBay] could not recover local keys:', e); + } +} + function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY)); @@ -824,6 +863,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { gekRef.current = null; if (!_bundleKey) _bundleKey = await _loadBundleKey(); _restoreSessionKeys(); + await _recoverLocalKeys(username); try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); if (cancelled) return; -- cgit v1.2.3 From 2caa93dbc06161b5d3f776a204ac8d921df92126 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 12:06:31 +0200 Subject: feat(client): make the key backup a choice, and raise the passphrase floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the multi-browser story made obvious. **The backup is now opt-out.** Keys are kept, encrypted with the passphrase, on every node whose group you join — that is what lets a second browser recover them, and it is finding C4: a PBKDF2-protected blob on other people's disks, attackable offline at the speed of PBKDF2, which is memory-light and therefore cheap on a GPU. Until now everybody paid that cost, including people who will only ever use one browser and get nothing back for it. Settings → "Use this account on other devices". Turning it off does not merely stop future uploads: the next connection to each node withdraws what that node already holds (new keypair_bundle_delete, which only ever deletes the caller's own, taken from the authenticated session and never from the message). The warning says plainly what it costs — clearing the browser then loses everything encrypted for that account, with no recovery, which is the point of choosing it. Default is on. Silent, unrecoverable key loss is worse for an ordinary user than an exposure the roadmap already tracks, but that is a judgement call and it is now visible and reversible instead of implicit. **Passphrase floor 8 → 12 characters, plus a strength estimate** shown while typing, with a refusal below ~60 bits. This number matters more here than in most applications: it is what stands between a node operator and your identity keys. It has to live in the client — with the password split (T1) the hub never sees a password and cannot enforce anything about one — so the UI says why it is asking, rather than nagging. The estimator is deliberately conservative and dependency-free: character classes and length, penalised for repetition and for the handful of patterns everyone tries. Verified against the live deployment: withdrawing the backup leaves a second browser unable to recover anything, which is exactly what it promises, and re-enabling restores it. Tests: 338. Co-Authored-By: Claude Opus 5 --- .../meshbay-common/src/meshbay_common/protocol.py | 1 + packages/meshbay-hub/src/meshbay_hub/static/app.js | 125 +++++++++++++++++++-- .../meshbay-hub/src/meshbay_hub/static/i18n.js | 21 +++- .../src/meshbay_hub/static/transport.js | 15 +++ .../meshbay-node/src/meshbay_node/bundle_store.py | 15 +++ .../src/meshbay_node/transport/webrtc_server.py | 24 ++++ 6 files changed, 190 insertions(+), 11 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 510813a..50e8663 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -54,6 +54,7 @@ class MNP: 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 + KEYPAIR_BUNDLE_DELETE = "keypair_bundle_delete" # client → node: withdraw own backup 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 diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 0aa74b4..94bf09e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -113,6 +113,57 @@ function _saveSessionKeys() { if (_sessionKeys) sessionStorage.setItem('meshbay_sk', JSON.stringify(_sessionKeys)); } catch {} } +/** + * Rough passphrase strength, in bits, and what it is up against. + * + * This number carries more weight here than in most applications. The encrypted + * keypair bundle is protected by PBKDF2-SHA512 (600k) and sits on every node + * whose group you join, so the people who host your groups can attack it offline + * (finding C4). PBKDF2 is memory-light, which is exactly what GPUs are good at. + * + * The estimate is deliberately conservative — character classes and length, with + * a penalty for repetition and for the handful of patterns everyone tries. It is + * a guide, not a guarantee, and it says so in the UI. + */ +function passwordBits(pw) { + if (!pw) return 0; + let pool = 0; + if (/[a-z]/.test(pw)) pool += 26; + if (/[A-Z]/.test(pw)) pool += 26; + if (/[0-9]/.test(pw)) pool += 10; + if (/[^A-Za-z0-9]/.test(pw)) pool += 32; + let bits = pw.length * Math.log2(pool || 1); + + const unique = new Set(pw).size; + if (unique < pw.length / 2) bits *= 0.6; // "aaaaaaaa", "abcabcabc" + if (/^[0-9]+$/.test(pw)) bits *= 0.5; // dates, PINs + if (/(password|motdepasse|azerty|qwerty|123456|meshbay)/i.test(pw)) bits *= 0.3; + return Math.round(bits); +} + +const PASSWORD_MIN_BITS = 60; // refuse below this +const PASSWORD_MIN_LEN = 12; + +// Whether this account backs its encrypted keys up to the nodes it joins. +// +// On: any browser recovers the same identity with the password — the ordinary +// multi-device expectation. Off: the keys exist only where they were generated, +// nothing is left on anyone's disk, and losing this browser's storage loses the +// account's content for good. That is a real choice, so it is the user's. +const KEY_BACKUP_PREFIX = 'mb_key_backup_'; + +function keyBackupEnabled(username) { + try { + return localStorage.getItem(KEY_BACKUP_PREFIX + username) !== '0'; + } catch { return true; } +} + +function setKeyBackupEnabled(username, on) { + try { + localStorage.setItem(KEY_BACKUP_PREFIX + username, on ? '1' : '0'); + } catch {} +} + function _restoreSessionKeys() { try { if (!_sessionKeys) { @@ -432,7 +483,14 @@ function RegisterPage() { const onSubmit = async (e) => { e.preventDefault(); if (password !== confirm) { setError(t('register.err_mismatch')); return; } - if (password.length < 8) { setError(t('register.err_min_len')); return; } + if (password.length < PASSWORD_MIN_LEN) { + setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return; + } + // The floor can only live here: with the password split (T1) the hub never + // sees the password, so it cannot enforce anything about it. + if (passwordBits(password) < PASSWORD_MIN_BITS) { + setError(t('register.err_too_weak')); return; + } setError(''); setLoading(true); try { @@ -480,6 +538,18 @@ function RegisterPage() { setPassword(e.target.value)} autocomplete="new-password" required minlength="8" /> + ${password && html` +
+
+
+
+

+ ${t('register.strength', { bits: passwordBits(password) })} +

+
+ `} setConfirm(e.target.value)} autocomplete="new-password" required /> @@ -909,14 +979,26 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { } } - // Push keypair bundle to node (new registration, localStorage → node) - if (_pendingBundlePush && transport.connected) { - try { - await transport.storeKeypairBundle(_pendingBundlePush); - try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} - _pendingBundlePush = null; - } catch (e) { - console.warn('[MeshBay] Bundle push to node deferred:', e.message); + // Back the encrypted keys up to the node, or withdraw them — whichever + // this account asked for. The local copy is only dropped once the node + // holds one, so turning backup off never strands anybody. + if (transport.connected) { + if (keyBackupEnabled(username)) { + if (_pendingBundlePush) { + try { + await transport.storeKeypairBundle(_pendingBundlePush); + try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} + _pendingBundlePush = null; + } catch (e) { + console.warn('[MeshBay] Bundle push to node deferred:', e.message); + } + } + } else { + try { + await transport.deleteKeypairBundle(); + } catch (e) { + console.warn('[MeshBay] Could not withdraw key backup:', e.message); + } } } @@ -2124,6 +2206,15 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { const [nodeKeyLoading, setNodeKeyLoading] = useState(false); const [pinCount, setPinCount] = useState( () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); + const [backup, setBackup] = useState(() => keyBackupEnabled(user.username)); + + // Takes effect on the next connection to each node: enabling uploads the + // encrypted bundle, disabling withdraws whatever that node already holds. + const toggleBackup = useCallback(() => { + const next = !backup; + setKeyBackupEnabled(user.username, next); + setBackup(next); + }, [backup, user.username]); // 11.5.8: node identity pins are refused strictly on change, so users need a // deliberate way to accept a legitimate rotation (operator reinstalled a node). @@ -2221,6 +2312,22 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { `}
+
+

${t('settings.key_backup')}

+

${t('settings.key_backup_hint')}

+
+ + ${backup ? t('settings.key_backup_on') : t('settings.key_backup_off')} + + +
+ ${!backup && html` +

${t('settings.key_backup_warning')}

+ `} +
+

${t('settings.node_pins')}

${t('settings.node_pins_hint')}

diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index c279fd1..702da0b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -38,7 +38,7 @@ const en = { 'register.title': 'Register', 'register.username': 'Username', 'register.email': 'Email', - 'register.password': 'Password (min 8 chars)', + 'register.password': 'Passphrase (min 12 chars)', 'register.confirm': 'Confirm password', 'register.submit': 'Register', 'register.loading': 'Creating account...', @@ -48,7 +48,11 @@ const en = { 'register.success_msg': 'You can now log in with your credentials.', 'register.go_login': 'Go to login', 'register.err_mismatch': 'Passwords do not match', - 'register.err_min_len': 'Password must be at least 8 characters', + 'register.err_min_len': 'Use at least {n} characters', + 'register.err_too_weak': 'Too easy to guess. Your passphrase is what protects ' + + 'your keys where they are stored — a few unrelated words work well.', + 'register.strength': 'Strength: about {bits} bits. This protects the copy of ' + + 'your keys kept on the nodes you join, so it is worth getting right.', // Home 'home.welcome': 'Welcome to MeshBay', @@ -120,6 +124,19 @@ const en = { 'settings.coming_soon': 'Coming soon.', 'settings.profile': 'Profile', 'settings.username': 'Username', + 'settings.key_backup': 'Use this account on other devices', + 'settings.key_backup_hint': 'Your keys can be kept — encrypted with your ' + + 'passphrase — on the nodes whose groups you join, so another browser can ' + + 'recover them. Turn this off and your keys stay only where they were ' + + 'created: nothing of yours sits on anyone else\'s disk, and only that ' + + 'browser can open your groups.', + 'settings.key_backup_on': 'Enabled — other browsers can recover your keys', + 'settings.key_backup_off': 'Disabled — keys stay in this browser only', + 'settings.key_backup_enable': 'Enable', + 'settings.key_backup_disable': 'Disable', + 'settings.key_backup_warning': 'With this off, clearing this browser\'s data ' + + 'loses access to everything encrypted for you. There is no recovery — that ' + + 'is the point. Copies already on nodes are withdrawn at the next connection.', 'settings.node_pins': 'Node identities', 'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.", 'settings.node_pins_count': '{n} pinned', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 5ead80e..b1a03ff 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -574,6 +574,21 @@ class MeshBayTransport { return gekRaw; } + /** + * Withdraw our key backup from this node. + * + * The counterpart of storeKeypairBundle: turning the setting off has to remove + * what is already stored, not merely stop adding to it — otherwise the blob + * stays on every node the account has ever joined (C4). + */ + async deleteKeypairBundle() { + const msg = await this._sendAndWait({ + type: 'keypair_bundle_delete', v: '0.1', + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + async storeKeypairBundle(bundleEnc) { const msg = await this._sendAndWait({ type: 'keypair_bundle_store', diff --git a/packages/meshbay-node/src/meshbay_node/bundle_store.py b/packages/meshbay-node/src/meshbay_node/bundle_store.py index e7c6981..4cf3236 100644 --- a/packages/meshbay-node/src/meshbay_node/bundle_store.py +++ b/packages/meshbay-node/src/meshbay_node/bundle_store.py @@ -99,6 +99,21 @@ class BundleStore: row = await cursor.fetchone() return row[0] if row else None + async def delete_keypair(self, user_id: str) -> bool: + """ + Drop someone's keypair bundle at their own request. + + Backing keys up here is what lets a second browser recover them with the + password — and it is also what puts a PBKDF2-protected blob on every node + whose group they join (finding C4). Someone who does not need the first + should be able to withdraw the second, and not merely stop adding to it. + """ + assert self._db + cur = await self._db.execute( + "DELETE FROM keypair_bundles WHERE user_id = ?", (user_id,)) + await self._db.commit() + return cur.rowcount > 0 + async def close(self) -> None: if self._db: await self._db.close() 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 46dda64..f659f56 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -307,6 +307,8 @@ class WebRTCPeerSession: self._do_invite_create(msg) elif mtype == MNP.KEYPAIR_BUNDLE_STORE: asyncio.ensure_future(self._do_keypair_bundle_store(msg)) + elif mtype == MNP.KEYPAIR_BUNDLE_DELETE: + asyncio.ensure_future(self._do_keypair_bundle_delete()) elif mtype == MNP.STREAM_REQUEST: asyncio.ensure_future(self._stream_video(msg)) else: @@ -823,6 +825,28 @@ class WebRTCPeerSession: self._send(reply) self._audit_join("gek_wrapped", f"group={group_id[:8]}") + async def _do_keypair_bundle_delete(self) -> None: + """ + Withdraw our own key backup from this node. + + Only ever our own: the user_id comes from the authenticated session, never + from the message. Someone who does not want a second browser should not be + leaving a PBKDF2-protected blob on every node they have ever joined (C4), + and turning the setting off has to remove what is already there — not just + stop adding to it. + """ + bundle_store = self._ctx.get("bundle_store") + if not bundle_store: + self._send({"type": "error", "detail": "Bundle store not available"}) + return + + removed = await bundle_store.delete_keypair(self._user_id) + if removed: + log.info("Keypair bundle withdrawn by user=%s", self._user_id[:8]) + self._audit("keypair_bundle_delete") + self._send({"type": "ack", "v": MNP_VERSION, + "detail": "keypair_bundle_deleted", "removed": removed}) + def _audit_pre_proof_fetch(self, mtype: str) -> None: """Record bundle access made before the GEK proof (C4).""" audit = self._ctx.get("audit_store") -- cgit v1.2.3 From 86563d5db0ae19fc58336b71a5c29a8712973590 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 12:42:51 +0200 Subject: feat(client): Argon2id for the keypair bundle, and remove the backup toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to yesterday's judgement, in the order they matter. **The toggle is gone.** Asked to make the remote key backup optional, I shipped a setting whose "off" position meant: no second browser, ever, and clearing your storage destroys the account. I wrote the warning that says so without drawing the conclusion. A control whose only effect is to break the ordinary case is not a control, and removing an exposure by removing the feature is not a fix. Every browser backs its keys up again, unconditionally. **The exposure is fixed where it actually lives: the KDF.** The keypair bundle rests on every node whose group its owner joins, protected by the passphrase alone (finding C4). It used PBKDF2-SHA512 at 600k — compute-only, which is exactly what a GPU eats. Measured on this machine: PBKDF2 600k costs 241 ms and Argon2id 64 MB/t=3 costs 322 ms, near enough the same honest work, except only one of them forces an attacker to find 64 MB per guess. So the bundle key is now Argon2id 64 MB / t=3 / p=1, via a vendored WebAssembly build (no external host — the CSP forbids one, and 12.2 will tighten it further). Parameters chosen by measurement through that build: 19 MB is OWASP's floor at 118 ms, 256 MB is 1.3 s and too slow for a phone, 64 MB sits where a login should. What this buys, stated honestly: cracking a bundle yields the owner's identity keys, and with them content on OTHER nodes and the ability to sign as them — not the content on the operator's own node, which they host in the clear by design. Argon2id raises that price steeply; it does not remove it, and a weak passphrase still loses. Hence the floor raised to 12 characters and ~60 bits in the same breath, which can only be enforced client-side: with the password split (T1) the hub never sees a passphrase. Migration is automatic and invisible. Bundles carry an "MBK2" marker; the old form is still readable, and is re-encrypted the first time a browser backs it up. Both keys are derived at sign-in, because which one a bundle needs is only known once it is read and the passphrase is deliberately not kept around. Two implementations of the KDF now exist — the browser's WASM and argon2-cffi in QE — so a parity test holds them byte-identical. A disagreement would not look like an error; it would look like an account nobody can open. keypair_bundle_delete stays, without a UI. It is the mechanism behind withdrawing your data from a node, exercised end to end, and it will belong to a deliberate "forget me on this node" action rather than a setting that quietly disables multi-device. Verified against the live deployment: the full workflow passes, including recovering keys on a second client from the passphrase alone. Tests: 341. Co-Authored-By: Claude Opus 5 --- docs/meshbay-draft-v5.md | 27 ++++- packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 4 + packages/meshbay-hub/src/meshbay_hub/static/app.js | 91 +++++--------- .../meshbay-hub/src/meshbay_hub/static/i18n.js | 13 -- .../src/meshbay_hub/static/keyderive.js | 112 ++++++++++++++---- .../src/meshbay_hub/static/vendor/PROVENANCE.md | 36 ++++++ .../src/meshbay_hub/static/vendor/argon2.min.js | 1 + .../src/meshbay_hub/static/vendor/argon2.wasm | Bin 0 -> 25725 bytes .../meshbay-hub/tests/test_bundle_kdf_parity.py | 131 +++++++++++++++++++++ 9 files changed, 315 insertions(+), 100 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.min.js create mode 100755 packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.wasm create mode 100644 packages/meshbay-hub/tests/test_bundle_kdf_parity.py (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index b4a9e45..6fdbbc6 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -348,10 +348,29 @@ separation, on-the-fly encryption. | Hub password verifier | Argon2id 256 MB over a PBKDF2-derived `auth_key` | | Browser bundle key | PBKDF2-SHA512, 600 000 iterations | -**Known weakness (C4).** Keypair bundles are protected only by PBKDF2 and are pushed to -every node whose group the user joins, while GEK and keypair bundle fetches are served in -the pre-proof window because the client needs its bundle to compute the proof. The window -is bounded (4 fetches) and audited. It closes properly when the native client stops +**Known weakness (C4), reduced 2026-08-14.** Keypair bundles are pushed to every node +whose group the user joins, and GEK and keypair bundle fetches are served in the pre-proof +window because the client needs its bundle to compute the proof. The window is bounded +(4 fetches) and audited. + +The bundle's own protection moved from PBKDF2-SHA512 to **Argon2id, 64 MB, t=3, p=1** +(`static/vendor/argon2.min.js`, WebAssembly, no external host). PBKDF2 is compute-only, +so 600k iterations cost an attacker with a GPU far less than the wall clock suggested: +measured on the dev machine, both take ~0.3 s honestly, but only one of them makes a +graphics card allocate 64 MB per guess. The two implementations — the browser's WASM and +`argon2-cffi` in QE — are held byte-identical by a parity test, because a disagreement +would present as an account nobody can open. + +What that changes, and what it does not. The bundle is what a node operator can take and +attack offline; cracking it yields the user's *identity keys*, and with them content on +**other** nodes and the ability to sign as that user — not the content on the operator's +own node, which they host in the clear by design. Argon2id raises the price of that +attack; it does not remove it, and a weak passphrase still loses. The client refuses +passphrases below 12 characters and ~60 bits, which can only be enforced there: with the +password split (T1) the hub never sees a passphrase. + +Bundles written before the change are still readable, and are re-encrypted the first time +their owner's browser backs them up. It closes properly when the native client stops storing keypair bundles remotely (Phase 13.3) — the material belongs on the user's own device, not on the hub *or* on other people's nodes. diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 5ad7329..f804ec5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -47,6 +47,10 @@ _HTML = """\
+ + diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 94bf09e..300059d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -144,24 +144,24 @@ function passwordBits(pw) { const PASSWORD_MIN_BITS = 60; // refuse below this const PASSWORD_MIN_LEN = 12; -// Whether this account backs its encrypted keys up to the nodes it joins. -// -// On: any browser recovers the same identity with the password — the ordinary -// multi-device expectation. Off: the keys exist only where they were generated, -// nothing is left on anyone's disk, and losing this browser's storage loses the -// account's content for good. That is a real choice, so it is the user's. -const KEY_BACKUP_PREFIX = 'mb_key_backup_'; - -function keyBackupEnabled(username) { - try { - return localStorage.getItem(KEY_BACKUP_PREFIX + username) !== '0'; - } catch { return true; } -} - -function setKeyBackupEnabled(username, on) { +/** + * Re-encrypt a bundle written under the old KDF before it is stored again. + * + * PBKDF2 bundles are still readable, but leaving one on a node keeps the weak + * protection alive for as long as it sits there. Any backup is an opportunity to + * replace it with the Argon2id form, and it costs nothing the user notices. + */ +async function _upgradedBundle(bundleEnc) { try { - localStorage.setItem(KEY_BACKUP_PREFIX + username, on ? '1' : '0'); - } catch {} + if (!window.MeshBayKeys || !_sessionKeys || !_bundleKey) return bundleEnc; + if (window.MeshBayKeys.bundleVersion(bundleEnc) === 2) return bundleEnc; + const b64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0)); + return await window.MeshBayKeys.encryptBundleWithKey( + b64(_sessionKeys.skEdB64), b64(_sessionKeys.skXB64), _bundleKey.v2); + } catch (e) { + console.warn('[MeshBay] bundle upgrade skipped:', e.message); + return bundleEnc; + } } function _restoreSessionKeys() { @@ -979,26 +979,18 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { } } - // Back the encrypted keys up to the node, or withdraw them — whichever - // this account asked for. The local copy is only dropped once the node - // holds one, so turning backup off never strands anybody. - if (transport.connected) { - if (keyBackupEnabled(username)) { - if (_pendingBundlePush) { - try { - await transport.storeKeypairBundle(_pendingBundlePush); - try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} - _pendingBundlePush = null; - } catch (e) { - console.warn('[MeshBay] Bundle push to node deferred:', e.message); - } - } - } else { - try { - await transport.deleteKeypairBundle(); - } catch (e) { - console.warn('[MeshBay] Could not withdraw key backup:', e.message); - } + // Back the encrypted keys up to the node. This is what lets any other + // browser recover them with the passphrase, which is the ordinary + // expectation; the protection that matters is the KDF guarding the + // bundle, not withholding the bundle. + if (transport.connected && _pendingBundlePush) { + try { + await transport.storeKeypairBundle( + await _upgradedBundle(_pendingBundlePush)); + try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} + _pendingBundlePush = null; + } catch (e) { + console.warn('[MeshBay] Bundle push to node deferred:', e.message); } } @@ -2206,15 +2198,6 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { const [nodeKeyLoading, setNodeKeyLoading] = useState(false); const [pinCount, setPinCount] = useState( () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); - const [backup, setBackup] = useState(() => keyBackupEnabled(user.username)); - - // Takes effect on the next connection to each node: enabling uploads the - // encrypted bundle, disabling withdraws whatever that node already holds. - const toggleBackup = useCallback(() => { - const next = !backup; - setKeyBackupEnabled(user.username, next); - setBackup(next); - }, [backup, user.username]); // 11.5.8: node identity pins are refused strictly on change, so users need a // deliberate way to accept a legitimate rotation (operator reinstalled a node). @@ -2312,22 +2295,6 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { `}
-
-

${t('settings.key_backup')}

-

${t('settings.key_backup_hint')}

-
- - ${backup ? t('settings.key_backup_on') : t('settings.key_backup_off')} - - -
- ${!backup && html` -

${t('settings.key_backup_warning')}

- `} -
-

${t('settings.node_pins')}

${t('settings.node_pins_hint')}

diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 702da0b..f0c5cef 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -124,19 +124,6 @@ const en = { 'settings.coming_soon': 'Coming soon.', 'settings.profile': 'Profile', 'settings.username': 'Username', - 'settings.key_backup': 'Use this account on other devices', - 'settings.key_backup_hint': 'Your keys can be kept — encrypted with your ' - + 'passphrase — on the nodes whose groups you join, so another browser can ' - + 'recover them. Turn this off and your keys stay only where they were ' - + 'created: nothing of yours sits on anyone else\'s disk, and only that ' - + 'browser can open your groups.', - 'settings.key_backup_on': 'Enabled — other browsers can recover your keys', - 'settings.key_backup_off': 'Disabled — keys stay in this browser only', - 'settings.key_backup_enable': 'Enable', - 'settings.key_backup_disable': 'Disable', - 'settings.key_backup_warning': 'With this off, clearing this browser\'s data ' - + 'loses access to everything encrypted for you. There is no recovery — that ' - + 'is the point. Copies already on nodes are withdrawn at the next connection.', 'settings.node_pins': 'Node identities', 'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.", 'settings.node_pins_count': '{n} pinned', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index 3d9c3c6..afa5d27 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -67,11 +67,34 @@ async function generateKeypairs() { // ── Password → AES key ──────────────────────────────────────────────────────── -/** - * Derive an AES-256 key from password + username using PBKDF2-SHA512. - * Used for encrypting the keypair bundle. - */ -async function deriveEncryptionKey(password, username) { +// Argon2id parameters for the keypair bundle. +// +// This is the one KDF in the browser that guards something an adversary can take +// away and attack at leisure: the bundle is stored on every node whose group its +// owner joins (finding C4). PBKDF2 was the wrong tool — it is compute-only, which +// is exactly what a GPU is good at, so 600k iterations bought far less than the +// wall-clock time suggested. +// +// 64 MB / t=3 / p=1 measured at ~320 ms through this WASM build on a desktop, so +// roughly a second on a modest phone — the most that belongs in a login. Memory +// is what matters here: at 64 MB per guess, a 24 GB card holds a few hundred in +// parallel instead of the effectively unbounded number PBKDF2 allows. +const ARGON2_MEM_KIB = 65536; // 64 MB +const ARGON2_TIME = 3; +const ARGON2_LANES = 1; + +// Bundles written before this carry no marker and are read with the old KDF. +// They are re-encrypted the first time their owner signs in (see upgradeBundle). +const BUNDLE_V2_MAGIC = 'MBK2'; + +function _argon2() { + const a = (typeof window !== 'undefined' && window.argon2) || globalThis.argon2; + if (!a) throw new Error('Argon2 unavailable — vendor/argon2.min.js did not load'); + return a; +} + +/** Legacy: PBKDF2-SHA512. Kept to read bundles written before the change. */ +async function deriveEncryptionKeyV1(password, username) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey( 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']); @@ -86,6 +109,27 @@ async function deriveEncryptionKey(password, username) { ); } +/** + * Derive the bundle key with Argon2id. + * + * The salt stays deterministic and domain-separated per user, as before: it is + * what lets the key be derived once at sign-in and kept, instead of holding the + * passphrase in memory to re-derive it whenever a bundle turns up. It is unique + * per account, so it does what a salt is for — no shared precomputation. + */ +async function deriveEncryptionKey(password, username) { + const enc = new TextEncoder(); + const salt = new Uint8Array(await crypto.subtle.digest( + 'SHA-256', enc.encode(`meshbay:bundle:v2:${username}`))).slice(0, 16); + const out = await _argon2().hash({ + pass: password, salt, + time: ARGON2_TIME, mem: ARGON2_MEM_KIB, parallelism: ARGON2_LANES, + hashLen: 32, type: _argon2().ArgonType.Argon2id, + }); + return crypto.subtle.importKey( + 'raw', out.hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +} + // ── Bundle encryption ───────────────────────────────────────────────────────── /** @@ -94,29 +138,42 @@ async function deriveEncryptionKey(password, username) { */ async function encryptBundle(skEdRaw, skXRaw, password, username) { const aesKey = await deriveEncryptionKey(password, username); + return encryptBundleWithKey(skEdRaw, skXRaw, aesKey); +} + +/** Same, when the key was already derived at sign-in. Always writes v2. */ +async function encryptBundleWithKey(skEdRaw, skXRaw, aesKey) { const nonce = crypto.getRandomValues(new Uint8Array(12)); const data = new TextEncoder().encode(JSON.stringify({ skEd: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))), skX: btoa(String.fromCharCode(...new Uint8Array(skXRaw))), })); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, data); - // Return base64(nonce || ciphertext) - const out = new Uint8Array(nonce.length + ct.byteLength); - out.set(nonce); - out.set(new Uint8Array(ct), nonce.length); + // base64( "MBK2" || nonce || ciphertext ). The marker is what tells a reader + // which KDF produced the key, so old bundles stay readable and new ones are + // never fed to the old derivation. + const magic = new TextEncoder().encode(BUNDLE_V2_MAGIC); + const out = new Uint8Array(magic.length + nonce.length + ct.byteLength); + out.set(magic); + out.set(nonce, magic.length); + out.set(new Uint8Array(ct), magic.length + nonce.length); return btoa(String.fromCharCode(...out)); } +function bundleVersion(bundleB64) { + try { + return atob(bundleB64).startsWith(BUNDLE_V2_MAGIC) ? 2 : 1; + } catch { return 1; } +} + /** * Decrypt a keypair bundle. Throws if password is wrong. */ async function decryptBundle(bundleB64, password, username) { - const aesKey = await deriveEncryptionKey(password, username); - const raw = Uint8Array.from(atob(bundleB64), c => c.charCodeAt(0)); - const nonce = raw.slice(0, 12); - const ct = raw.slice(12); - const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ct); - return JSON.parse(new TextDecoder().decode(plain)); + const key = bundleVersion(bundleB64) === 2 + ? await deriveEncryptionKey(password, username) + : await deriveEncryptionKeyV1(password, username); + return decryptBundleWithKey(bundleB64, key); } // ── Registration ────────────────────────────────────────────────────────────── @@ -165,11 +222,19 @@ async function registerUser(username, email, password) { * Decrypt a keypair bundle using a pre-derived AES-256 CryptoKey. * Used when the bundle is fetched from the node (bundleKey was derived at login). */ -async function decryptBundleWithKey(bundleB64, aesKey) { +async function decryptBundleWithKey(bundleB64, aesKeyOrPair) { + const v2 = bundleVersion(bundleB64) === 2; + // Callers derive both keys at sign-in and pass the pair, because which one a + // bundle needs is only known once it has been read — and the passphrase is + // deliberately not kept around to derive the other one later. + const key = (aesKeyOrPair && aesKeyOrPair.v2) + ? (v2 ? aesKeyOrPair.v2 : aesKeyOrPair.v1) + : aesKeyOrPair; const raw = Uint8Array.from(atob(bundleB64), c => c.charCodeAt(0)); - const nonce = raw.slice(0, 12); - const ct = raw.slice(12); - const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ct); + const off = v2 ? BUNDLE_V2_MAGIC.length : 0; + const nonce = raw.slice(off, off + 12); + const ct = raw.slice(off + 12); + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct); return JSON.parse(new TextDecoder().decode(plain)); } @@ -195,7 +260,12 @@ async function loginAndRecover(username, password) { const result = { accessToken: data.access_token, refreshToken: data.refresh_token, - bundleKey: await deriveEncryptionKey(password, username), + // Both, so a bundle written before the KDF changed can still be opened — + // and re-written with the new one on the next backup. + bundleKey: { + v2: await deriveEncryptionKey(password, username), + v1: await deriveEncryptionKeyV1(password, username), + }, }; // localStorage bundle = new registration, not yet pushed to node @@ -263,5 +333,5 @@ async function signBytes(skEdPkcs8B64, message) { window.MeshBayKeys = { registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signBytes, - deriveAuthKey, decryptBundleWithKey, + deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md b/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md new file mode 100644 index 0000000..35d742b --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md @@ -0,0 +1,36 @@ +# Vendored third-party assets + +The SPA is served under a CSP that forbids every external host, so anything it +uses has to live here. Each entry records exactly what was taken and from where, +so it can be checked or rebuilt without guesswork. + +## argon2.min.js + +| | | +|---|---| +| Package | `argon2-browser` 1.18.0 (npm) | +| Source | https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz | +| Tarball sha256 | `cdb11795a4971bde095fe6b836aa424de50c4558ed4b9505bc74111eee7f6d35` | +| Tarball sha1 (npm dist.shasum) | `f35820211e0a431aed7f82b9348477234be69bec` | +| File taken | `package/dist/argon2-bundled.min.js` | +| File sha256 | `77c64b946baf1a5116dc591f4b9965d636b1b455f75edd2d4a587cb75e01687b` | + +The bundled build carries the WebAssembly inline as base64, so there is no second +request and nothing to locate at runtime. + +**Why it is here at all:** WebCrypto has no memory-hard KDF. The encrypted +keypair bundle is protected by the passphrase alone and rests on every node whose +group its owner joins (finding C4), so PBKDF2 — compute-only, and therefore cheap +on a GPU — was the wrong tool for it. Measured through this build on the dev +machine: Argon2id 64 MB / t=3 / p=1 takes ~320 ms, against ~240 ms for +PBKDF2-SHA512 at 600k, for a memory cost a GPU cannot ignore. + +### argon2.wasm + +The same build's standalone WebAssembly, sha256 +`0c2149886c13e4eae4a6ca25ee71d47423c5c8740a874cf04ff816d1b2c901d7`. + +The browser never requests it — `argon2.min.js` carries the same bytes inline as +a data URL. It is kept because the cross-language parity test drives the vendored +library under node, where the emscripten loader takes its file path instead of the +inline copy, and a test that cannot run is a test that stops being true. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.min.js b/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.min.js new file mode 100644 index 0000000..607e16f --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.min.js @@ -0,0 +1 @@ +!function(A,I){"object"==typeof exports&&"object"==typeof module?module.exports=I():"function"==typeof define&&define.amd?define([],I):"object"==typeof exports?exports.argon2=I():A.argon2=I()}(this,(function(){return(()=>{var A,I,g={773:(A,I,g)=>{var B,Q="undefined"!=typeof self&&void 0!==self.Module?self.Module:{},C={};for(B in Q)Q.hasOwnProperty(B)&&(C[B]=Q[B]);var E,i,o,D,e=[];E="object"==typeof window,i="function"==typeof importScripts,o="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,D=!E&&!o&&!i;var n,t,a,r,s,y="";o?(y=i?g(967).dirname(y)+"/":"//",n=function(A,I){return r||(r=g(145)),s||(s=g(967)),A=s.normalize(A),r.readFileSync(A,I?null:"utf8")},a=function(A){var I=n(A,!0);return I.buffer||(I=new Uint8Array(I)),G(I.buffer),I},process.argv.length>1&&process.argv[1].replace(/\\/g,"/"),e=process.argv.slice(2),A.exports=Q,process.on("uncaughtException",(function(A){if(!(A instanceof V))throw A})),process.on("unhandledRejection",u),Q.inspect=function(){return"[Emscripten Module object]"}):D?("undefined"!=typeof read&&(n=function(A){return read(A)}),a=function(A){var I;return"function"==typeof readbuffer?new Uint8Array(readbuffer(A)):(G("object"==typeof(I=read(A,"binary"))),I)},"undefined"!=typeof scriptArgs?e=scriptArgs:void 0!==arguments&&(e=arguments),"undefined"!=typeof print&&("undefined"==typeof console&&(console={}),console.log=print,console.warn=console.error="undefined"!=typeof printErr?printErr:print)):(E||i)&&(i?y=self.location.href:"undefined"!=typeof document&&document.currentScript&&(y=document.currentScript.src),y=0!==y.indexOf("blob:")?y.substr(0,y.lastIndexOf("/")+1):"",n=function(A){var I=new XMLHttpRequest;return I.open("GET",A,!1),I.send(null),I.responseText},i&&(a=function(A){var I=new XMLHttpRequest;return I.open("GET",A,!1),I.responseType="arraybuffer",I.send(null),new Uint8Array(I.response)}),t=function(A,I,g){var B=new XMLHttpRequest;B.open("GET",A,!0),B.responseType="arraybuffer",B.onload=function(){200==B.status||0==B.status&&B.response?I(B.response):g()},B.onerror=g,B.send(null)}),Q.print||console.log.bind(console);var F,c,w=Q.printErr||console.warn.bind(console);for(B in C)C.hasOwnProperty(B)&&(Q[B]=C[B]);C=null,Q.arguments&&(e=Q.arguments),Q.thisProgram&&Q.thisProgram,Q.quit&&Q.quit,Q.wasmBinary&&(F=Q.wasmBinary),Q.noExitRuntime,"object"!=typeof WebAssembly&&u("no native wasm support detected");var h=!1;function G(A,I){A||u("Assertion failed: "+I)}var N,R,f="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0;function U(A){N=A,Q.HEAP8=new Int8Array(A),Q.HEAP16=new Int16Array(A),Q.HEAP32=new Int32Array(A),Q.HEAPU8=R=new Uint8Array(A),Q.HEAPU16=new Uint16Array(A),Q.HEAPU32=new Uint32Array(A),Q.HEAPF32=new Float32Array(A),Q.HEAPF64=new Float64Array(A)}Q.INITIAL_MEMORY;var M,Y=[],S=[],H=[],d=0,k=null,J=null;function u(A){throw Q.onAbort&&Q.onAbort(A),w(A+=""),h=!0,A="abort("+A+"). Build with -s ASSERTIONS=1 for more info.",new WebAssembly.RuntimeError(A)}function p(A){return A.startsWith("data:application/octet-stream;base64,")}function L(A){return A.startsWith("file://")}Q.preloadedImages={},Q.preloadedAudios={};var l,K="argon2.wasm";function q(A){try{if(A==K&&F)return new Uint8Array(F);if(a)return a(A);throw"both async and sync fetching of the wasm failed"}catch(A){u(A)}}function b(A){for(;A.length>0;){var I=A.shift();if("function"!=typeof I){var g=I.func;"number"==typeof g?void 0===I.arg?M.get(g)():M.get(g)(I.arg):g(void 0===I.arg?null:I.arg)}else I(Q)}}function x(A){try{return c.grow(A-N.byteLength+65535>>>16),U(c.buffer),1}catch(A){}}p(K)||(l=K,K=Q.locateFile?Q.locateFile(l,y):y+l);var m,X={a:function(A,I,g){R.copyWithin(A,I,I+g)},b:function(A){var I,g=R.length,B=2147418112;if((A>>>=0)>B)return!1;for(var Q=1;Q<=4;Q*=2){var C=g*(1+.2/Q);if(C=Math.min(C,A+100663296),x(Math.min(B,((I=Math.max(A,C))%65536>0&&(I+=65536-I%65536),I))))return!0}return!1}},W=(function(){var A={a:X};function I(A,I){var g,B=A.exports;Q.asm=B,U((c=Q.asm.c).buffer),M=Q.asm.k,g=Q.asm.d,S.unshift(g),function(A){if(d--,Q.monitorRunDependencies&&Q.monitorRunDependencies(d),0==d&&(null!==k&&(clearInterval(k),k=null),J)){var I=J;J=null,I()}}()}function g(A){I(A.instance)}function B(I){return function(){if(!F&&(E||i)){if("function"==typeof fetch&&!L(K))return fetch(K,{credentials:"same-origin"}).then((function(A){if(!A.ok)throw"failed to load wasm binary file at '"+K+"'";return A.arrayBuffer()})).catch((function(){return q(K)}));if(t)return new Promise((function(A,I){t(K,(function(I){A(new Uint8Array(I))}),I)}))}return Promise.resolve().then((function(){return q(K)}))}().then((function(I){return WebAssembly.instantiate(I,A)})).then(I,(function(A){w("failed to asynchronously prepare wasm: "+A),u(A)}))}if(d++,Q.monitorRunDependencies&&Q.monitorRunDependencies(d),Q.instantiateWasm)try{return Q.instantiateWasm(A,I)}catch(A){return w("Module.instantiateWasm callback failed with error: "+A),!1}F||"function"!=typeof WebAssembly.instantiateStreaming||p(K)||L(K)||"function"!=typeof fetch?B(g):fetch(K,{credentials:"same-origin"}).then((function(I){return WebAssembly.instantiateStreaming(I,A).then(g,(function(A){return w("wasm streaming compile failed: "+A),w("falling back to ArrayBuffer instantiation"),B(g)}))}))}(),Q.___wasm_call_ctors=function(){return(Q.___wasm_call_ctors=Q.asm.d).apply(null,arguments)},Q._argon2_hash=function(){return(Q._argon2_hash=Q.asm.e).apply(null,arguments)},Q._malloc=function(){return(W=Q._malloc=Q.asm.f).apply(null,arguments)}),T=(Q._free=function(){return(Q._free=Q.asm.g).apply(null,arguments)},Q._argon2_verify=function(){return(Q._argon2_verify=Q.asm.h).apply(null,arguments)},Q._argon2_error_message=function(){return(Q._argon2_error_message=Q.asm.i).apply(null,arguments)},Q._argon2_encodedlen=function(){return(Q._argon2_encodedlen=Q.asm.j).apply(null,arguments)},Q._argon2_hash_ext=function(){return(Q._argon2_hash_ext=Q.asm.l).apply(null,arguments)},Q._argon2_verify_ext=function(){return(Q._argon2_verify_ext=Q.asm.m).apply(null,arguments)},Q.stackAlloc=function(){return(T=Q.stackAlloc=Q.asm.n).apply(null,arguments)});function V(A){this.name="ExitStatus",this.message="Program terminated with exit("+A+")",this.status=A}function j(A){function I(){m||(m=!0,Q.calledRun=!0,h||(b(S),Q.onRuntimeInitialized&&Q.onRuntimeInitialized(),function(){if(Q.postRun)for("function"==typeof Q.postRun&&(Q.postRun=[Q.postRun]);Q.postRun.length;)A=Q.postRun.shift(),H.unshift(A);var A;b(H)}()))}A=A||e,d>0||(function(){if(Q.preRun)for("function"==typeof Q.preRun&&(Q.preRun=[Q.preRun]);Q.preRun.length;)A=Q.preRun.shift(),Y.unshift(A);var A;b(Y)}(),d>0||(Q.setStatus?(Q.setStatus("Running..."),setTimeout((function(){setTimeout((function(){Q.setStatus("")}),1),I()}),1)):I()))}if(Q.allocate=function(A,I){var g;return g=1==I?T(A.length):W(A.length),A.subarray||A.slice?R.set(A,g):R.set(new Uint8Array(A),g),g},Q.UTF8ToString=function(A,I){return A?function(A,I,g){for(var B=I+g,Q=I;A[Q]&&!(Q>=B);)++Q;if(Q-I>16&&A.subarray&&f)return f.decode(A.subarray(I,Q));for(var C="";I>10,56320|1023&D)}}else C+=String.fromCharCode((31&E)<<6|i)}else C+=String.fromCharCode(E)}return C}(R,A,I):""},Q.ALLOC_NORMAL=0,J=function A(){m||j(),m||(J=A)},Q.run=j,Q.preInit)for("function"==typeof Q.preInit&&(Q.preInit=[Q.preInit]);Q.preInit.length>0;)Q.preInit.pop()();j(),A.exports=Q,Q.unloadRuntime=function(){"undefined"!=typeof self&&delete self.Module,Q=c=M=N=R=void 0,delete A.exports}},631:function(A,I,g){var B,Q;"undefined"!=typeof self&&self,void 0===(Q="function"==typeof(B=function(){const A="undefined"!=typeof self?self:this,I={Argon2d:0,Argon2i:1,Argon2id:2};function B(I){if(B._promise)return B._promise;if(B._module)return Promise.resolve(B._module);let C;return C=A.process&&A.process.versions&&A.process.versions.node?Q().then((A=>new Promise((I=>{A.postRun=()=>I(A)})))):(A.loadArgon2WasmBinary?A.loadArgon2WasmBinary():Promise.resolve(g(721)).then((A=>function(A){const I=atob(A),g=new Uint8Array(new ArrayBuffer(I.length));for(let A=0;Afunction(I,g){return new Promise((B=>(A.Module={wasmBinary:I,wasmMemory:g,postRun(){B(Module)}},Q())))}(g,I?function(A){const I=1024,g=64*I,B=(1024*I*1024*2-64*I)/g,Q=Math.min(Math.max(Math.ceil(A*I/g),256)+256,B);return new WebAssembly.Memory({initial:Q,maximum:B})}(I):void 0))),B._promise=C,C.then((A=>(B._module=A,delete B._promise,A)))}function Q(){return A.loadArgon2WasmModule?A.loadArgon2WasmModule():Promise.resolve(g(773))}function C(A,I){return A.allocate(I,"i8",A.ALLOC_NORMAL)}function E(A,I){return C(A,new Uint8Array([...I,0]))}function i(A){if("string"!=typeof A)return A;if("function"==typeof TextEncoder)return(new TextEncoder).encode(A);if("function"==typeof Buffer)return Buffer.from(A);throw new Error("Don't know how to encode UTF8")}return{ArgonType:I,hash:function(A){const g=A.mem||1024;return B(g).then((B=>{const Q=A.time||1,o=A.parallelism||1,D=i(A.pass),e=E(B,D),n=D.length,t=i(A.salt),a=E(B,t),r=t.length,s=A.type||I.Argon2d,y=B.allocate(new Array(A.hashLen||24),"i8",B.ALLOC_NORMAL),F=A.secret?C(B,A.secret):0,c=A.secret?A.secret.byteLength:0,w=A.ad?C(B,A.ad):0,h=A.ad?A.ad.byteLength:0,G=A.hashLen||24,N=B._argon2_encodedlen(Q,g,o,r,G,s),R=B.allocate(new Array(N+1),"i8",B.ALLOC_NORMAL);let f,U,M;try{U=B._argon2_hash_ext(Q,g,o,e,n,a,r,y,G,R,N,s,F,c,w,h,19)}catch(A){f=A}if(0!==U||f){try{f||(f=B.UTF8ToString(B._argon2_error_message(U)))}catch(A){}M={message:f,code:U}}else{let A="";const I=new Uint8Array(G);for(let g=0;g{const B=i(A.pass),Q=E(g,B),o=B.length,D=A.secret?C(g,A.secret):0,e=A.secret?A.secret.byteLength:0,n=A.ad?C(g,A.ad):0,t=A.ad?A.ad.byteLength:0,a=E(g,i(A.encoded));let r,s,y,F=A.type;if(void 0===F){let g=A.encoded.split("$")[1];g&&(g=g.replace("a","A"),F=I[g]||I.Argon2d)}try{s=g._argon2_verify_ext(a,Q,o,D,e,n,t,F)}catch(A){r=A}if(s||r){try{r||(r=g.UTF8ToString(g._argon2_error_message(s)))}catch(A){}y={message:r,code:s}}try{g._free(Q),g._free(a)}catch(A){}if(r)throw y;return y}))},unloadRuntime:function(){B._module&&(B._module.unloadRuntime(),delete B._promise,delete B._module)}}})?B.apply(I,[]):B)||(A.exports=Q)},721:function(A,I){A.exports="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DwtBixEPC0GeEyEBCyABC48DAQF/IwBBgANrIgQkACAEQQA2AowBIARBjAFqIAEQBwJAIAFBwABNBEAgBEGQAWogARAYQQBIDQEgBEGQAWogBEGMAWpBBBAGQQBIDQEgBEGQAWogAiADEAZBAEgNASAEQZABaiAAIAEQEhoMAQsgBEGQAWpBwAAQGEEASA0AIARBkAFqIARBjAFqQQQQBkEASA0AIARBkAFqIAIgAxAGQQBIDQAgBEGQAWogBEFAa0HAABASQQBIDQAgACAEKQNANwAAIAAgBCkDSDcACCAAIAQpA1g3ABggACAEKQNQNwAQIABBIGohACABQSBrIgJBwQBPBEADQCAEIARBQGtBwAAQBSIBQUBrQcAAIAEQMUEASA0CIAAgASkDQDcAACAAIAEpA0g3AAggACAEKQNYNwAYIAAgBCkDUDcAECAAQSBqIQAgAkEgayICQcAASw0ACwsgBCAEQUBrQcAAEAUiAUFAayACIAEQMUEASA0AIAAgAUFAayACEAUaCyAEQZABakHwARAEIARBgANqJAALAwABC5kCACAARQRAQQAPCwJ/AkAgAAR/IAFB/wBNDQECQEGgHigCACgCAEUEQCABQYB/cUGAvwNGDQMMAQsgAUH/D00EQCAAIAFBP3FBgAFyOgABIAAgAUEGdkHAAXI6AABBAgwECyABQYCwA09BACABQYBAcUGAwANHG0UEQCAAIAFBP3FBgAFyOgACIAAgAUEMdkHgAXI6AAAgACABQQZ2QT9xQYABcjoAAUEDDAQLIAFBgIAEa0H//z9NBEAgACABQT9xQYABcjoAAyAAIAFBEnZB8AFyOgAAIAAgAUEGdkE/cUGAAXI6AAIgACABQQx2QT9xQYABcjoAAUEEDAQLC0HcHkEZNgIAQX8FQQELDAELIAAgAToAAEEBCwtQAQN/AkAgACgCACwAABAPRQRADAELA0AgACgCACICLAAAIQMgACACQQFqNgIAIAEgA2pBMGshASACLAABEA9FDQEgAUEKbCEBDAALAAsgAQu7AgACQCABQRRLDQACQAJAAkACQAJAAkACQAJAAkACQCABQQlrDgoAAQIDBAUGBwgJCgsgAiACKAIAIgFBBGo2AgAgACABKAIANgIADwsgAiACKAIAIgFBBGo2AgAgACABNAIANwMADwsgAiACKAIAIgFB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AUHjFEEDEBANACABQQNqIANBDGoQFSIBRQ0AIAAgAygCDDYCKCABQecUQQMQEA0AIAFBA2ogA0EMahAVIgFFDQAgACADKAIMIgQ2AjAgACAENgI0IAEtAABBJEcNACADIAc2AgwgACgCECADQQxqIAFBAWoQLCIBRQ0AIAAgAygCDDYCFCABLQAAQSRHDQAgAyAGNgIMIAAoAgAgA0EMaiABQQFqECwiAUUNACAAIAMoAgw2AgQgAEEANgJEIABCADcCPCAAQgA3AhggAEIANwIgIAAQHCICDQBBYEEAIAEtAAAbIQILIANBEGokACACCykBAn8DQCAAIAJBA3QiA2ogASADaikAADcDACACQQFqIgJBgAFHDQALCwwAIABBAEGACBALGgtlAQJ/IAAgAhAeIgIEfyACBUFdQQACfyAAKAIAIQRBACECIAAoAgQiAAR/A0AgAyACIARqLQAAIAEgAmotAABzciEDIAJBAWoiAiAARw0ACyADQQFrQQh2QQFxQQFrBUEACwsbCwtdAQJ/IwBB8AFrIgMkAEF/IQQCQCACRSAARSABRXJyIAFBwABLcg0AIAMgARAYQQBIDQAgAyACQcAAEAZBAEgNACADIAAgARASIQQLIANB8AEQBCADQfABaiQAIAQLCQAgACABNwAACxAAIwAgAGtBcHEiACQAIAALMwEBfyAAKAIUIgMgASACIAAoAhAgA2siASABIAJLGyIBEAUaIAAgACgCFCABajYCFCACC9oBAQR/IwBB0ABrIggkAAJAIABFBEBBYCEADAELIAggABAKIgk2AgwgCCAJNgIcIAggCRAJIgo2AhggCCAJEAkiCzYCCEEAIQkCQAJAIApFIAtFcg0AIAggAjYCFCAIIAE2AhAgCEEIaiAAIAcQLSIADQEgCCgCCCEJIAggCCgCDBAJIgA2AgggAEUNACAIIAY2AiwgCCAFNgIoIAggBDYCJCAIIAM2AiAgCEEIaiAJIAcQMCEADAELQWohAAsgCCgCGBAIIAgoAggQCCAJEAgLIAhB0ABqJAAgAAuQAgEDfyMAQdAAayIRJABBfiETAkAgCEEESQ0AIAgQCSISRQRAQWohEwwBCyARQQA2AkwgEUIANwJEIBEgAjYCPCARIAI2AjggESABNgI0IBEgADYCMCARIA82AiwgESAONgIoIBEgDTYCJCAR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IGxvbmcgb3IgdG9vIHNob3J0AE1pc3NpbmcgYXJndW1lbnRzAFRvbyBtYW55IGxhbmVzAFRvbyBmZXcgbGFuZXMAVG9vIG1hbnkgdGhyZWFkcwBOb3QgZW5vdWdoIHRocmVhZHMATWVtb3J5IGFsbG9jYXRpb24gZXJyb3IATWVtb3J5IGNvc3QgaXMgdG9vIHNtYWxsAFRpbWUgY29zdCBpcyB0b28gc21hbGwAYXJnb24yaQBBcmdvbjJpAFRoZSBwYXNzd29yZCBkb2VzIG5vdCBtYXRjaCB0aGUgc3VwcGxpZWQgaGFzaABPdXRwdXQgcG9pbnRlciBtaXNtYXRjaABPdXRwdXQgaXMgdG9vIGxvbmcAU2FsdCBpcyB0b28gbG9uZwBTZWNyZXQgaXMgdG9vIGxvbmcAUGFzc3dvcmQgaXMgdG9vIGxvbmcAQXNzb2NpYXRlZCBkYXRhIGlzIHRvbyBsb25nAFRocmVhZGluZyBmYWlsdXJlAE1lbW9yeSBjb3N0IGlzIHRvbyBsYXJnZQBUaW1lIGNvc3QgaXMgdG9vIGxhcmdlAFVua25vd24gZXJyb3IgY29kZQBhcmdvbjJpZABBcmdvbjJpZABFbmNvZGluZyBmYWlsZWQARGVjb2RpbmcgZmFpbGVkAGFyZ29uMmQAQXJnb24yZABBcmdvbjJfQ29udGV4dCBjb250ZXh0IGlzIE5VTEwAT3V0cHV0IHBvaW50ZXIgaXMgTlVMTABUaGUgYWxsb2NhdGUgbWVtb3J5IGNhbGxiYWNrIGlzIE5VTEwAVGhlIGZyZWUgbWVtb3J5IGNhbGxiYWNrIGlzIE5VTEwAT0sAJHY9ACx0PQAscD0AJG09AFRoZXJlIGlzIG5vIHN1Y2ggdmVyc2lvbiBvZiBBcmdvbjIAU2FsdCBwb2ludGVyIGlzIE5VTEwsIGJ1dCBzYWx0IGxlbmd0aCBpcyBub3QgMABTZWNyZXQgcG9pbnRlciBpcyBOVUxMLCBidXQgc2VjcmV0IGxlbmd0aCBpcyBub3QgMABQYXNzd29yZCBwb2ludGVyIGlzIE5VTEwsIGJ1dCBwYXNzd29yZCBsZW5ndGggaXMgbm90IDAAQXNzb2NpYXRlZCBkYXRhIHBvaW50ZXIgaXMgTlVMTCwgYnV0IGFkIGxlbmd0aCBpcyBub3QgMAAobnVsbCkAAACbCAAAuwcAAEkJAADACQAAsAkAAPAHAAAfCAAAMAgAAMkIAABvCgAA4AkAABYKAAA7CgAAQwgAACsLAADBCgAAkgoAAPQKAAACCAAAEQgAAFsJAABbCAAAdAkAAHQIAAAFCQAAdAcAAC0JAACeBwAA9AgAAGIHAAAYCQAAiAcAAOEIAABOBwAA/wkAAFwKAAABAEGkGAsBAgBByxgLBf//////AEGQGQtBEQAKABEREQAAAAAFAAAAAAAACQAAAAALAAAAAAAAAAARAA8KERERAwoHAAEACQsLAAAJBgsAAAsABhEAAAAREREAQeEZCyELAAAAAAAAAAARAAoKERERAAoAAAIACQsAAAAJAAsAAAsAQZsaCwEMAEGnGgsVDAAAAAAMAAAAAAkMAAAAAAAMAAAMAEHVGgsBDgBB4RoLFQ0AAAAEDQAAAAAJDgAAAAAADgAADgBBjxsLARAAQZsbCx4PAAAAAA8AAAAACRAAAAAAABAAABAAABIAAAASEhIAQdIbCw4SAAAAEhISAAAAAAAACQBBgxwLAQsAQY8cCxUKAAAAAAoAAAAACQsAAAAAAAsAAAsAQb0cCwEMAEHJHAsnDAAAAAAMAAAAAAkMAAAAAAAMAAAMAAAwMTIzNDU2Nzg5QUJDREVGAEHwHAsBAQBBoB4LAogPAEHYHgsDkBFQ"},145:()=>{},967:()=>{}},B={};function Q(A){var I=B[A];if(void 0!==I)return I.exports;var C=B[A]={exports:{}};return g[A].call(C.exports,C,C.exports,Q),C.exports}return I=Object.getPrototypeOf?A=>Object.getPrototypeOf(A):A=>A.__proto__,Q.t=function(g,B){if(1&B&&(g=this(g)),8&B)return g;if("object"==typeof g&&g){if(4&B&&g.__esModule)return g;if(16&B&&"function"==typeof g.then)return g}var C=Object.create(null);Q.r(C);var E={};A=A||[null,I({}),I([]),I(I)];for(var i=2&B&&g;"object"==typeof i&&!~A.indexOf(i);i=I(i))Object.getOwnPropertyNames(i).forEach((A=>E[A]=()=>g[A]));return E.default=()=>g,Q.d(C,E),C},Q.d=(A,I)=>{for(var g in I)Q.o(I,g)&&!Q.o(A,g)&&Object.defineProperty(A,g,{enumerable:!0,get:I[g]})},Q.o=(A,I)=>Object.prototype.hasOwnProperty.call(A,I),Q.r=A=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(A,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(A,"__esModule",{value:!0})},Q(631)})()})); \ No newline at end of file diff --git a/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.wasm b/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.wasm new file mode 100755 index 0000000..75c3111 Binary files /dev/null and b/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.wasm differ diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py new file mode 100644 index 0000000..c3c8ff9 --- /dev/null +++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py @@ -0,0 +1,131 @@ +""" +Cross-language parity for the keypair bundle KDF. + +The bundle is the one thing a user carries between browsers, and the passphrase +is all that stands between it and whoever holds the disk of a node they joined +(finding C4). It moved from PBKDF2-SHA512 to Argon2id for that reason — PBKDF2 is +compute-only, which is what makes it cheap on a GPU. + +Two implementations now have to agree byte for byte: the vendored WebAssembly the +browser runs, and `argon2-cffi` used by the QE harness. A disagreement would not +show up as an error — it would show up as a bundle nobody can open, which is +somebody's account gone. + +Skipped when node or argon2-cffi is missing; that is a coverage gap, not a pass. +""" + +import hashlib +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +VENDOR = STATIC / "vendor" + +try: + from argon2.low_level import Type, hash_secret_raw + HAVE_ARGON2 = True +except ImportError: + HAVE_ARGON2 = False + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None + or not (VENDOR / "argon2.min.js").exists() + or not HAVE_ARGON2, + reason="node, the vendored argon2, or argon2-cffi is unavailable", +) + +# Parameters must match keyderive.js. If someone tunes them there and not here, +# this test fails — which is the point: changing them silently orphans every +# bundle already written. +MEM_KIB, TIME_COST, LANES = 65536, 3, 1 + +CASES = ["alice", "grenet", "utilisateur-é", ""] +PASSWORDS = ["correct horse battery staple", "p", "üñïçø∂é ✓ 🔐"] + +_HARNESS = r""" +const fs = require('fs'), webcrypto = require('crypto').webcrypto; +global.self = global; global.crypto = webcrypto; +// The browser uses the copy inlined in the bundle; under node the emscripten +// loader looks for a file, so hand it the same bytes explicitly. +global.Module = { wasmBinary: fs.readFileSync(process.argv[2]) }; +const argon2 = require(process.argv[3]); + +(async () => { + const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8')); + const out = []; + for (const v of input) { + const salt = new Uint8Array(await webcrypto.subtle.digest( + 'SHA-256', new TextEncoder().encode(`meshbay:bundle:v2:${v.username}`) + )).slice(0, 16); + const r = await argon2.hash({ + pass: v.password, salt, + time: v.time, mem: v.mem, parallelism: v.lanes, + hashLen: 32, type: argon2.ArgonType.Argon2id, + }); + out.push(Buffer.from(r.hash).toString('hex')); + } + process.stdout.write(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def js_hashes(tmp_path_factory): + d = tmp_path_factory.mktemp("kdf") + harness = d / "harness.cjs" + harness.write_text(_HARNESS) + vectors = [ + {"username": u, "password": p, + "mem": MEM_KIB, "time": TIME_COST, "lanes": LANES} + for u in CASES for p in PASSWORDS + ] + payload = d / "vectors.json" + payload.write_text(json.dumps(vectors)) + + proc = subprocess.run( + ["node", str(harness), str(VENDOR / "argon2.wasm"), + str(VENDOR / "argon2.min.js"), str(payload)], + capture_output=True, text=True, timeout=300, + ) + if proc.returncode != 0: + pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}") + return vectors, json.loads(proc.stdout) + + +def _python_hash(username: str, password: str) -> str: + salt = hashlib.sha256(f"meshbay:bundle:v2:{username}".encode()).digest()[:16] + return hash_secret_raw( + password.encode(), salt, time_cost=TIME_COST, memory_cost=MEM_KIB, + parallelism=LANES, hash_len=32, type=Type.ID, + ).hex() + + +def test_bundle_key_matches_across_languages(js_hashes): + vectors, js = js_hashes + for i, v in enumerate(vectors): + assert js[i] == _python_hash(v["username"], v["password"]), ( + f"argon2id disagrees for username={v['username']!r} — a bundle " + f"written by one implementation would be unreadable by the other" + ) + + +def test_the_salt_separates_users(js_hashes): + """Two accounts with the same passphrase must not share a bundle key.""" + assert _python_hash("alice", "same passphrase") != \ + _python_hash("bob", "same passphrase") + + +def test_parameters_still_match_the_client(): + """ + The numbers live in keyderive.js; this test is the second copy. Tuning one + without the other orphans every bundle already written, so make it fail. + """ + source = (STATIC / "keyderive.js").read_text() + assert f"ARGON2_MEM_KIB = {MEM_KIB}" in source + assert f"ARGON2_TIME = {TIME_COST}" in source + assert f"ARGON2_LANES = {LANES}" in source + assert "meshbay:bundle:v2:" in source -- cgit v1.2.3 From f0984e86d9cb596a282ce6feb7cfc2f075b2794b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 17:51:48 +0200 Subject: feat!: identity keys per node — C4's blast radius drops to one operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One keypair was copied to every node its owner joined, so cracking the bundle on any single node yielded the identity used on all of them: their content on other operators' machines, and the ability to sign as them anywhere. That lateral reach was the part of C4 worth attacking. Each node now gets its own keypair, generated the first time its owner joins it and left with that node alone. An operator who cracks what sits on their own disk holds a key that is a stranger to every other node — and on their own node, one that unlocks nothing they did not already hold: they serve the content, the index and every byte of it by design. Nothing changes for the user. A first contact with a node already needed that operator's code, and the key is created in the same step; a second browser still recovers it from the node with the passphrase alone. Two operators can also no longer tell they host the same person by comparing keys. BREAKING, and deliberately without a compatibility path — the deployment is wiped for the next demo: - users.pk_ed25519 / pk_x25519 dropped (migration a7c31f9e40b2) - registration no longer sends or stores a key - PUT /v1/users/me/keys and regenerateKeys() gone; rotation is now `member unpin` plus a fresh code, decided on the machine that pinned it - /pubkeys returns an account id and a node's linking key. It was the directory H3 read, and nothing wraps for it any more - the pk_user JWT claim is gone That last one closed a live defect the inventory turned up: the node recorded pk_user as the uploader's identity and authorized deletion against it, so a hub issuing a token naming its own key could delete anyone's uploads on any node. Attribution now uses the key the node itself pinned. A simplification falls out. Registration generates nothing, so a scripted signup is a real account: `demo.py bootstrap` takes a wiped hub and node to a working demo with no browser, which was impossible while keys were born in one. Also fixes, found by running it on a wiped deployment: the key handed back on a join now belongs to the group the connection is for, not the group named in the invitation — an operator pairs node-wide but redeems the code while opening a group, and expects to read it. Tests: 343, including the two that state the property — a key pinned by one node is refused at another, and someone else's code does not admit it. Verified end to end against a wiped hub and node: bootstrap, pair, invite, join, download, stream, second browser, revoke. Design: docs/per-node-identity-v1.md Co-Authored-By: Claude Opus 5 --- docs/meshbay-draft-v5.md | 71 ++++--- docs/per-node-identity-v1.md | 215 +++++++++++++++++++++ .../meshbay-common/src/meshbay_common/handshake.py | 7 +- packages/meshbay-hub/src/meshbay_hub/api/nodes.py | 2 +- packages/meshbay-hub/src/meshbay_hub/api/users.py | 57 ++---- packages/meshbay-hub/src/meshbay_hub/auth.py | 7 +- .../a7c31f9e40b2_drop_user_identity_keys.py | 37 ++++ packages/meshbay-hub/src/meshbay_hub/db/models.py | 6 +- packages/meshbay-hub/src/meshbay_hub/static/app.js | 142 +++----------- .../src/meshbay_hub/static/keyderive.js | 109 ++++------- .../src/meshbay_hub/static/transport.js | 43 +++-- packages/meshbay-hub/tests/test_hub_api.py | 21 +- packages/meshbay-hub/tests/test_node_auth.py | 3 +- packages/meshbay-hub/tests/test_node_ws_auth.py | 2 +- .../meshbay-node/src/meshbay_node/hub_client.py | 4 +- .../src/meshbay_node/transport/webrtc_server.py | 34 +++- packages/meshbay-node/tests/test_roster_pairing.py | 36 ++++ 17 files changed, 493 insertions(+), 303 deletions(-) create mode 100644 docs/per-node-identity-v1.md create mode 100644 packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index ffc30c2..2a0f3b1 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -65,7 +65,7 @@ This replaces the informal assurances scattered through v4 §4.2.x and §13. | Client code integrity | ❌ **T3, accepted** | ❌ T3 | ✅ | ✅ | ✅ | | Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ | | Hub cannot obtain the group key | ✅ | ✅ **since H3 closed** — except in `join_policy = "open"` groups, where it can join legitimately | — | — | ✅ | -| Your identity keys stay yours | ✅ | ✅ | ⚠️ **offline attack on your keypair bundle** — see §7.1. Succeeds against a weak passphrase, and yields your content on *other* nodes plus the ability to sign as you | ✅ | ✅ | +| Your identity keys stay yours | ✅ | ✅ | ⚠️ **offline attack on the bundle they hold** — see §7.1. Succeeds against a weak passphrase, and yields the identity used **on that node only**: nothing on anyone else's | ✅ | ✅ | **The claim this project can make:** *the hub cannot read your content unless it ships you malicious client code.* Since H3 closed (2026-08-14) that is the **only** remaining path, @@ -92,11 +92,11 @@ even the hub."* Three reasons, all deliberate: - **Members and the operator read everything.** Inherent: "end-to-end" here describes *client ↔ node*, never *client ↔ client*. -One boundary deserves naming, because the table above hid it until 2026-08-14: an operator -hosts your content by design, but they should not be able to become *you*. They can try — -your keypair bundle sits on their disk, and a weak passphrase gives it up (§7.1). That is -the difference between a node operator reading what they host and a node operator reading -what other operators host. +One boundary deserves naming: an operator hosts your content by design, but they should not +be able to become *you*. They can still try — a bundle sits on their disk and a weak +passphrase gives it up — but since 2026-08-14 what it gives up is **the identity you use +with them**, which unlocks nothing they did not already have. Reading what they host is by +design; reading what *other* operators host is not, and no longer follows. --- @@ -319,9 +319,15 @@ Hub minimization was considered and **deferred** (decision D4). The hub keeps se the web UI and remains in the trusted path by choice. This section describes what it *does*, not an aspiration. -**Stores:** accounts (username, encrypted email, public keys, status, role), group -registry and membership, IP logs (1 year, legal), node registrations, refresh tokens, -notifications, moderation blocklist. +**Stores:** accounts (username, encrypted email, status, role), group registry and +membership, IP logs (1 year, legal), node registrations, refresh tokens, notifications, +moderation blocklist. + +**No longer stores user identity keys** (2026-08-14). `users.pk_ed25519` and +`users.pk_x25519` are dropped, `PUT /me/keys` is gone, and `/pubkeys` returns an account id +and a node's linking key — nothing to wrap for. Tokens carry no `pk_user` claim either: the +node used to record it as the uploader's identity, which let whoever issued tokens decide +who could delete a file. **Does not store:** file content, file names, private-group indexes, message content, private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling. @@ -371,19 +377,25 @@ separation, on-the-fly encryption. ### 7.1 The keypair bundle, and what it is worth to an attacker (C4) -The bundle carries a user's identity keys, encrypted under their passphrase. It is stored -on **every node whose group they join**, because that is what lets them open their account -from a second browser — the ordinary expectation, and the only mechanism available to a -browser that keeps nothing durable of its own. +A bundle carries **one node's** identity keys, encrypted under the owner's passphrase, and +is stored on that node. It is what lets a second browser open the same account there — the +ordinary expectation, and the only mechanism available to a browser that keeps nothing +durable of its own. + +The adversary is concrete: an operator holding their own node's disk, attacking offline at +their leisure. -So the adversary is concrete: an operator holding their own node's disk, attacking offline -at their leisure. +**What cracking one yields.** The identity that person uses **on that node** — where the +operator already holds the content, the index and every byte they serve. It is not a key +anywhere else: each node gets its own, generated the first time its owner joins it, and a +key one node pinned is a stranger to the next (which asks for a code, like any first +contact). Until 2026-08-14 a single identity was copied to every node joined, so one crack +yielded content on *other* operators' nodes and the ability to sign as that user anywhere — +that was the part worth attacking, and it is gone. -**What cracking one yields.** The user's identity keys — and with them, content on -**other** nodes and the ability to sign as that user. *Not* the content on the attacking -operator's own node, which they host in the clear by design. This is the one place where a -node operator can reach past the boundary the rest of the design gives them, and v5 did not -say so before 2026-08-14. +Two smaller consequences fall out. Two operators can no longer tell they host the same +person by comparing keys. And the hub publishes no user keys at all now (§6.1), so there is +no directory left to substitute from. **Why Argon2id.** PBKDF2 is compute-only, which is exactly what a GPU is good at. Measured on the development machine: PBKDF2-SHA512 600k costs 241 ms per guess on one core, @@ -417,8 +429,10 @@ anything about one. lives in IndexedDB for the session. - The pre-proof window that serves bundles is still bounded (4 fetches) and audited. -C4 closes properly when the native client stops storing bundles remotely (Phase 13.3): -the material belongs on the user's own device, not on the hub *or* on other people's nodes. +C4 is **reduced, not closed**: bundles still sit on disks their owner does not control, and +a weak passphrase still gives up the key for that node. It closes when the native client +stops storing them remotely (Phase 13.3) — the material belongs on the user's own device, +not on the hub *or* on other people's nodes. --- @@ -435,11 +449,14 @@ the material belongs on the user's own device, not on the hub *or* on other peop | Transport | WebRTC | WebRTC **+ QUIC** | | Positioning | **Convenience tier** — zero install | Recommended for sensitive use | -**Several browsers, one identity.** A browser keeps nothing durable the user controls, so -the account's keys are backed up — encrypted under the passphrase — to the nodes whose -groups it joins. Any other browser then recovers them with the passphrase alone: same -identity, same pin, no second pairing code. This is what makes the product behave the way -people expect, and it is also finding C4 (§7.1). The native client removes the need for it +**Several browsers, one identity per node.** A browser keeps nothing durable the user +controls, so the identity it creates for a node is left with that node, encrypted under the +passphrase. Any other browser recovers it there with the passphrase alone: same identity on +that node, same pin, no second code. Joining a *different* node creates a different key and +needs that operator's code, which is the first contact it has always needed. + +This is what makes the product behave the way people expect, and it is also finding C4 +(§7.1) — with a blast radius of one node. The native client removes the need for it entirely, which is a large part of why it exists. The SPA is not deprecated. It is the zero-install path and it stays. It must be labelled diff --git a/docs/per-node-identity-v1.md b/docs/per-node-identity-v1.md new file mode 100644 index 0000000..673fec1 --- /dev/null +++ b/docs/per-node-identity-v1.md @@ -0,0 +1,215 @@ +# MeshBay — Per-node identity + +> Status: **implemented 2026-08-14**, deployed and exercised end to end against a wiped +> hub and a wiped node. Written first as a proposal; §9 records what shipped. +> Reduces **C4** from "one crack yields the network" to "one crack yields one node". +> Removes the hub-published user keys, which stopped being load-bearing when H3 closed. +> Follows the v5 convention: every claim names the adversary it holds against. + +--- + +## 1. What this changes, in one paragraph + +Today one identity keypair is copied to every node its owner joins. Cracking the copy +stored on *any* node yields the identity used on *all* of them. This proposal gives each +node its own keypair, generated the first time its owner joins it. An operator who cracks +what sits on their own disk then holds a key that is worthless anywhere else — and on +their own node they already hold everything it could unlock. + +No new screen, no extra code to type, no change to how anyone signs in. + +--- + +## 2. Where we are + +Two things are **already per node**, which is what makes this cheap: + +| | Today | +|---|---| +| Roster pin | per node — each node pins the key it was shown (`roster.identities`) | +| Bundle storage | per node — each node has its own `bundles.db` | +| **Key material** | **global — the same keypair is copied into every node's database** | + +So the plumbing is in place and only the contents are shared. The change is: generate a +fresh keypair per node instead of reusing one. + +The global key exists because registration creates it (`keyderive.js registerUser`) and +publishes it to the hub, from where everything else used to fetch it. Since H3 closed, +almost nothing does. + +--- + +## 3. The design + +### 3.1 One keypair per node + +The first time a browser joins node N, it generates a keypair for N, encrypts it under the +passphrase-derived key, and stores it on N — which is the message it already sends +(`keypair_bundle_store`). N pins the public half through the ordinary join, using the +pairing code its operator already issues for a first contact. + +Nothing else in the join changes: same transcript, same code, same pin, same refusals. + +### 3.2 Flows + +**First join to a node** — unchanged from the user's side: + +``` +browser no key for node N yet → generate one +browser → N join_request {pk_ed25519_N, pk_x25519_N, code, sig} +N code valid → pins the pair, wraps the GEK for pk_x25519_N +browser → N keypair_bundle_store (that node's key, encrypted under the passphrase) +``` + +**Second browser, same node** — unchanged: + +``` +browser → N keypair_bundle_fetch → decrypt with the passphrase → same key as browser 1 +browser → N join_request (no code) → recognised +``` + +**Joining a second node** — already requires a code from that node's operator, so the new +key is generated in the same step. The user does nothing extra. + +**Operator pairing** — identical; the operator's key is per node like everyone else's. + +### 3.3 Where the browser keeps them + +A map `node_id → bundle` in IndexedDB, instead of one identity. A browser that has never +seen node N simply fetches N's bundle from N; it only ever needs the key of the node it is +talking to. + +--- + +## 4. What this fixes, and what it does not + +**Fixes: the blast radius.** An operator who cracks the bundle on their own disk gets the +key used with their own node. There they already control the content, the index and every +byte they serve — the key adds almost nothing. What disappears is the part that mattered: +reading that person's content on **other** operators' nodes, and signing as them anywhere +else. That is the whole of what made C4 more than a redundancy. + +**Fixes, incidentally: linkability.** Two operators can no longer tell they host the same +person by comparing keys. Today they can. + +**Does not fix:** a weak passphrase still gives up that node's key, and the bundles still +sit on disks their owner does not control. Only Phase 13.3 (native client, keys on the +device) removes that. C4 stays open, with a smaller consequence. + +**Does not change** the operator's ability to read what they host. That is by design and +stated in draft-v5 §2. + +--- + +## 5. Removing the hub-published user keys + +They were the directory H3 exploited. Since the node wraps the GEK itself, nothing wraps +anything for a key fetched from the hub. What remains is inventory. + +### 5.1 What still uses them + +| Use | Verdict | +|---|---| +| `_sessionKeys.pkXB64` set from `/pubkeys` (`app.js:974, 2768`) | **replaceable** — the browser can derive its own public half from its own secret (`_pkXFromSk`, already written) | +| Invite: username → `user_id` (`app.js:1602`, `ui/app.py`) | **keep the endpoint** — an account id is not a key, and it is how a name is resolved | +| Settings: `pk_node_ed25519` (`app.js:2210`) | **keep** — that is the node linking key, a different field | +| JWT claim `pk_user` (`auth.py:151`, filled from `user.pk_ed25519`) | **remove** — see 5.2, it is a live defect | + +### 5.2 A defect this uncovered + +`pk_user` travels in the JWT and the node records it as `uploader_pk` at upload +(`webrtc_server.py:1178`), then uses it to authorize deletion by the uploader +(`:1329-1340`). That key is chosen by the **hub**. A hub that issued a token naming its own +key could then delete that user's files on any node — deny-by-default was supposed to make +deletion node-authorized, and this is a hole in it. + +With per-node identity there is a better answer available for free: authorize deletion +against the key the **roster pinned**, which the node established locally and the hub never +touched. This should be fixed whether or not the rest of the proposal proceeds. + +### 5.3 What goes + +- `User.pk_ed25519`, `User.pk_x25519` columns (Alembic migration) +- `pk_user_ed25519` / `pk_user_x25519` in the registration body +- `PUT /v1/users/me/keys`, and `regenerateKeys()` in `keyderive.js` — rotation becomes + per node: `member unpin` plus a fresh code, which already exists +- the `pk_user` JWT claim, and `AuthorizedPeer.pk_user` +- the two key fields in the `/pubkeys` response; the endpoint stays for `user_id` and + `pk_node_ed25519` + +Old tokens keep working while they live (1 h): the node already reads the claim with +`.get()`, so its absence is not an error. + +--- + +## 6. Work plan + +| # | Slice | Where | Effort | +|---|---|---|---| +| 1 | `uploader_pk` from the roster pin, not the JWT | `webrtc_server.py` | small — and it is a fix on its own | +| 2 | Per-node keypair: generate at first join, store per node, keep a `node_id → bundle` map | `app.js`, `transport.js`, `keyderive.js` | **the bulk of it**, all client-side | +| 3 | Derive our own `pkX` locally instead of reading it back from the hub | `app.js` | small | +| 4 | Remove the published keys: columns, endpoint fields, registration body, JWT claim, `regenerateKeys` | `db/models.py`, `api/users.py`, `auth.py`, migration, `keyderive.js`, `handshake.py` | medium, touches the schema | +| 5 | Harness + docs: `e2e.py` per-node keys, draft-v5 §2/§7.1/§8.1, `second-review.md` | QE, docs | small | + +Slices 1 and 3 stand alone and could land first. Slice 4 is the only one with a migration. + +--- + +## 7. Risks and open questions + +**Existing users.** No big-bang migration: a bundle already on a node simply becomes that +node's key, and only *new* joins generate fresh ones. Someone already on three nodes keeps +one shared key across those three until they re-pair — the improvement applies going +forward. Forcing it would mean unpinning everyone, which is not worth it. + +**A browser that loses its map.** It refetches from the node it is connecting to; nothing +is lost, since a node's key is only needed with that node. + +**Is per node the right granularity, rather than per group?** Per node matches the roster, +which pins per account and not per group, and matches the trust boundary — the operator is +the adversary, and one operator may host several of your groups. Per group would multiply +keys with no adversary to justify them. + +**Does anything need one identity across nodes?** Nothing found. Chat identity and upload +attribution are per node; account identity on the hub is the username plus `auth_key`, which +is untouched. If a future feature needs a global identity — cross-node contacts, say — it +would need its own key, published deliberately, not this one reused by accident. + +--- + +## 8. How it gets tested + +- `e2e.py`: a member joins two groups on the node with one key (unchanged), and the + second-browser recovery still works. Add a check that the key stored on the node is the + one pinned there, not a global one +- a negative test: a key pinned by node A, presented to node B, is refused without a code — + which is the property the whole proposal buys +- `test_spa_ordering.py`: generation of the per-node key must happen before `joinGroup()`, + same class of ordering guard as the others +- the hub tests must fail if `/pubkeys` starts returning user keys again + + +--- + +## 9. What shipped + +All five slices, against a deployment wiped for the next demo — so no compatibility path +was kept and none is owed. + +| Slice | Outcome | +|---|---| +| 1 | `uploader_pk` comes from the roster pin (`_pinned_pk`), never from the token | +| 2 | Identity is created at first contact with a node and left there; `transport.js` fetches it or generates it, `app.js` no longer holds a global one | +| 3 | The browser derives its own public half; nothing is read back from the hub | +| 4 | `users.pk_ed25519` / `pk_x25519` dropped (migration `a7c31f9e40b2`), `PUT /me/keys` and `regenerateKeys()` gone, `pk_user` claim gone, `/pubkeys` reduced to an account id and the node linking key | +| 5 | Harness mirrors the client (recover, else generate, then leave the key with the node); tests for the property; docs | + +**A simplification worth noting.** Registration no longer generates anything, so a scripted +signup is now a real account — `demo.py bootstrap` brings a wiped deployment to a working +demo without a browser, which was impossible before. The old rule "only the admin can be +registered by script" is gone with the keys it existed for. + +**Tests added:** a key pinned by one node, presented to another, is refused as a first +contact; and someone else's code does not admit it either. That pair is the property this +whole change buys. diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index 223f064..fca218f 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -75,9 +75,13 @@ class AuthorizedPeer: user_id: str group_id: str username: str - pk_user: str jti: str + # No `pk_user`. The hub used to put a user key in the token and the node + # recorded it as the uploader's identity, which let whoever issued tokens + # decide who could delete a file. Identity keys are pinned by the node + # (see roster.py); the hub certifies accounts, not keys. + def handshake_transcript( role: str, @@ -193,7 +197,6 @@ def authorize_token( user_id=user_id, group_id=group_id, username=decoded.get("username", ""), - pk_user=decoded.get("pk_user", ""), jti=jti, ) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 6582a86..0770148 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -69,7 +69,7 @@ async def node_auth( group_ids = [gid for (gid,) in memberships.all()] access_token = issue_access_token( - user.id, user.pk_node_ed25519, ttl=3600, groups=group_ids, scope="node") + user.id, ttl=3600, groups=group_ids, scope="node") db.add(IPLog(user_id=user.id, event="node_auth", ip_address=client_ip(request))) await db.commit() diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 5a0f9dc..af9141c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -50,8 +50,6 @@ class RegisterRequest(BaseModel): email: str password: str | None = None # deprecated — legacy native clients auth_key: str | None = None # PBKDF2-derived, new clients - pk_user_ed25519: str # base64 raw 32B - pk_user_x25519: str # base64 raw 32B @field_validator("username") @classmethod @@ -120,8 +118,6 @@ async def register( pw_hash=pw_hash, pw_salt=pw_salt, pw_version=pw_ver, - pk_ed25519=body.pk_user_ed25519, - pk_x25519=body.pk_user_x25519, hub_id=hub_id, ) db.add(user) @@ -211,8 +207,7 @@ async def login( memberships = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == user.id)) group_ids = [gid for (gid,) in memberships.all()] - access_token = issue_access_token( - user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids) + access_token = issue_access_token(user.id, ttl=_ttl(), groups=group_ids) raw_rt, rt_hash = generate_refresh_token() family_id = str(uuid.uuid4()) @@ -277,8 +272,7 @@ async def token_refresh( memberships = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == user.id)) group_ids = [gid for (gid,) in memberships.all()] - new_access = issue_access_token( - user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids) + new_access = issue_access_token(user.id, ttl=_ttl(), groups=group_ids) await db.commit() return { @@ -324,39 +318,10 @@ async def register_node_key( return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519} -class RotateKeysRequest(BaseModel): - pk_user_ed25519: str # base64 raw 32B - pk_user_x25519: str # base64 raw 32B - - -@router.put("/me/keys") -async def rotate_browser_keys( - body: RotateKeysRequest, - current_user: User = Depends(require_user_scope), - db: AsyncSession = Depends(get_db), -): - for field, label in [ - (body.pk_user_ed25519, "Ed25519"), - (body.pk_user_x25519, "X25519"), - ]: - try: - raw = base64.b64decode(field) - if len(raw) != 32: - raise ValueError - except Exception: - raise HTTPException( - status_code=400, - detail=f"Invalid {label} public key (need 32 bytes base64)", - ) - - current_user.pk_ed25519 = body.pk_user_ed25519 - current_user.pk_x25519 = body.pk_user_x25519 - await db.commit() - return { - "status": "updated", - "pk_ed25519": body.pk_user_ed25519, - "pk_x25519": body.pk_user_x25519, - } +# Key rotation used to live here (`PUT /me/keys`). Identity keys are per node +# now, so rotating means `meshbay-node member unpin ` and pairing again with +# a fresh code — an operator decision on the machine that pinned it, not a hub +# call that silently changes what every node believes about someone. @router.get("/{username}/pubkeys") @@ -369,11 +334,13 @@ async def get_user_pubkeys( target = result.scalar_one_or_none() if not target: raise HTTPException(status_code=404, detail="User not found") + # Account lookup, not a key directory. `user_id` is how a username is resolved + # for an invitation, and `pk_node_ed25519` is a node's own linking key. The + # user identity keys this used to return were H3: whoever asked wrapped the + # group key for whatever came back. resp = { - "user_id": target.id, - "username": target.username, - "pk_ed25519": target.pk_ed25519, - "pk_x25519": target.pk_x25519, + "user_id": target.id, + "username": target.username, } if target.pk_node_ed25519: resp["pk_node_ed25519"] = target.pk_node_ed25519 diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index 563a1eb..28f13a5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -131,13 +131,17 @@ def current_pw_version() -> int: def issue_access_token( user_id: str, - pk_user: str, ttl: int = 3600, groups: list[str] | None = None, scope: str = "user", ) -> str: """ Issue a signed JWT access token. + + Carries no user key. It used to, and the node recorded that key as the + uploader's identity — so the party issuing tokens decided who could delete a + file. The hub certifies accounts; nodes pin keys. + Includes jti (UUID4) — required to prevent replay and enable revocation. Includes groups — list of group_ids the user is a member of (node-side authz). scope: "user" (browser, full access) or "node" (daemon, restricted). @@ -148,7 +152,6 @@ def issue_access_token( payload = { "iss": _hub_id, "sub": user_id, - "pk_user": pk_user, "hub_id": _hub_id, "jti": str(uuid.uuid4()), "iat": now, diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py new file mode 100644 index 0000000..c581e55 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py @@ -0,0 +1,37 @@ +"""drop_user_identity_keys + +The hub published `users.pk_ed25519` / `users.pk_x25519` as a key directory, and +the invite flow wrapped the group key for whatever it returned — finding H3. Since +the node wraps the group key itself, for a key its owner proves possession of, +nothing reads these columns. Identity keys are generated per node and pinned there +(`meshbay_node/roster.py`), so there is no hub-side key to publish at all. + +Downgrade restores the columns, but not their contents: the keys they held were +never the hub's to reproduce. + +Revision ID: a7c31f9e40b2 +Revises: 2041a4060b3c +Create Date: 2026-08-14 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = 'a7c31f9e40b2' +down_revision: Union[str, Sequence[str], None] = '2041a4060b3c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_column('users', 'pk_ed25519') + op.drop_column('users', 'pk_x25519') + + +def downgrade() -> None: + # Nullable on the way back: the previous schema required them, and nothing + # can invent a key that belonged to a user. + op.add_column('users', sa.Column('pk_ed25519', sa.String(64), nullable=True)) + op.add_column('users', sa.Column('pk_x25519', sa.String(64), nullable=True)) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index cdebd3c..a75217b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -42,8 +42,10 @@ class User(Base): pw_hash: Mapped[bytes] = mapped_column(nullable=False) pw_salt: Mapped[bytes] = mapped_column(nullable=False) pw_version: Mapped[int] = mapped_column(Integer, default=1) - pk_ed25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B - pk_x25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B + # No user identity keys here. The hub published them and the invite flow + # wrapped the group key for whatever it returned, which is finding H3; since + # the node does the wrapping, nothing reads a key from this directory. Keys + # are generated per node and pinned there (meshbay_node/roster.py). pk_node_ed25519: Mapped[str | None] = mapped_column(String(64), nullable=True) # node daemon key hub_id: Mapped[str] = mapped_column(String(128), nullable=False) role: Mapped[str] = mapped_column(String(16), default="user") # user|moderator|admin diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 300059d..3087e0e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -65,9 +65,10 @@ async function getAllCachedIndexes() { // ── Auth persistence ───────────────────────────────────────────────────────── -let _sessionKeys = null; +// The key that opens a node's keypair bundle, derived once at sign-in. There is +// no global identity to keep: identity keys belong to a node and are fetched from +// it (transport.js), so nothing of that kind lives here. 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; @@ -108,11 +109,6 @@ async function _clearKeyDB() { db.close(); } catch {} } -function _saveSessionKeys() { - try { - if (_sessionKeys) sessionStorage.setItem('meshbay_sk', JSON.stringify(_sessionKeys)); - } catch {} -} /** * Rough passphrase strength, in bits, and what it is up against. * @@ -144,35 +140,6 @@ function passwordBits(pw) { const PASSWORD_MIN_BITS = 60; // refuse below this const PASSWORD_MIN_LEN = 12; -/** - * Re-encrypt a bundle written under the old KDF before it is stored again. - * - * PBKDF2 bundles are still readable, but leaving one on a node keeps the weak - * protection alive for as long as it sits there. Any backup is an opportunity to - * replace it with the Argon2id form, and it costs nothing the user notices. - */ -async function _upgradedBundle(bundleEnc) { - try { - if (!window.MeshBayKeys || !_sessionKeys || !_bundleKey) return bundleEnc; - if (window.MeshBayKeys.bundleVersion(bundleEnc) === 2) return bundleEnc; - const b64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0)); - return await window.MeshBayKeys.encryptBundleWithKey( - b64(_sessionKeys.skEdB64), b64(_sessionKeys.skXB64), _bundleKey.v2); - } catch (e) { - console.warn('[MeshBay] bundle upgrade skipped:', e.message); - return bundleEnc; - } -} - -function _restoreSessionKeys() { - try { - if (!_sessionKeys) { - const sk = sessionStorage.getItem('meshbay_sk'); - if (sk) _sessionKeys = JSON.parse(sk); - } - } catch {} -} - /** Public X25519 key from our own secret — never read back from the hub. */ async function _pkXFromSk(skPkcs8B64) { const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); @@ -183,35 +150,6 @@ async function _pkXFromSk(skPkcs8B64) { return pad ? b64 + '='.repeat(4 - pad) : b64; } -/** - * Recover our identity keys from what this browser already holds. - * - * sessionStorage dies with the tab, but the encrypted keypair bundle sits in - * localStorage from registration and the key that opens it is in IndexedDB from - * login. Without this, closing the tab looked exactly like never having - * registered here — "this browser does not hold your keys", while both halves - * were on disk a few bytes apart. - */ -async function _recoverLocalKeys(username) { - if (_sessionKeys || !username) return; - try { - if (!_bundleKey) _bundleKey = await _loadBundleKey(); - if (!_bundleKey || !window.MeshBayKeys) return; - const enc = localStorage.getItem(`meshbay_kp_${username}`); - if (!enc) return; - const keys = await window.MeshBayKeys.decryptBundleWithKey(enc, _bundleKey); - _sessionKeys = { - skXB64: keys.skX, - skEdB64: keys.skEd, - pkXB64: await _pkXFromSk(keys.skX), - }; - _pendingBundlePush = enc; // still to be backed up to a node - _saveSessionKeys(); - } catch (e) { - console.warn('[MeshBay] could not recover local keys:', e); - } -} - function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY)); @@ -225,11 +163,8 @@ function saveAuth(auth) { localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); } else { localStorage.removeItem(AUTH_KEY); - _sessionKeys = null; _bundleKey = null; - _pendingBundlePush = null; _clearKeyDB(); - try { sessionStorage.removeItem('meshbay_sk'); } catch {} } } @@ -932,8 +867,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { setError(''); gekRef.current = null; if (!_bundleKey) _bundleKey = await _loadBundleKey(); - _restoreSessionKeys(); - await _recoverLocalKeys(username); try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); if (cancelled) return; @@ -942,14 +875,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { return; } - // 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; + // No keys are carried in: the transport fetches this node's identity + // from the node, or creates one there on a first join. + const sessionKeys = null; setStatus('connecting'); const nodeId = nodesData.nodes[0].node_id; @@ -963,34 +891,15 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { if (cancelled) return; setIsNodeAdmin(!!ack.is_node_admin); - // If transport recovered different session keys from node during handshake - if (transport.sessionKeys) { - const recovered = transport.sessionKeys; - if (!_sessionKeys || recovered.skXB64 !== _sessionKeys.skXB64) { - _sessionKeys = recovered; - if (!_sessionKeys.pkXB64) { - const pubkeys = await hubFetch( - `/v1/users/${username}/pubkeys`, { token }); - _sessionKeys.pkXB64 = pubkeys.pk_x25519; - } - _pendingBundlePush = null; - try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} - _saveSessionKeys(); - } - } - - // Back the encrypted keys up to the node. This is what lets any other - // browser recover them with the passphrase, which is the ordinary - // expectation; the protection that matters is the KDF guarding the - // bundle, not withholding the bundle. - if (transport.connected && _pendingBundlePush) { + // A first join to this node generated an identity for it; leave it with + // the node so any other browser can become the same person here with the + // passphrase. It is this node's key and no other's. + if (transport.connected && transport.newNodeBundle) { try { - await transport.storeKeypairBundle( - await _upgradedBundle(_pendingBundlePush)); - try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} - _pendingBundlePush = null; + await transport.storeKeypairBundle(transport.newNodeBundle); + transport.newNodeBundle = null; } catch (e) { - console.warn('[MeshBay] Bundle push to node deferred:', e.message); + console.warn('[MeshBay] could not leave our key with the node:', e.message); } } @@ -1134,8 +1043,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { try { // Signs an explicit transcript built by transport.js, not opaque bytes from // the node — see MeshBayCrypto.adminTranscript and finding H5. - const signFn = (_sessionKeys && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript) + // Signed with the identity this node pinned for us — the only one it + // will accept, and the only one we hold here. + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.deleteFile(entry.id, signFn); const indexMsg = await transport.fetchIndex(); @@ -1601,8 +1513,11 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, // 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) + // Signed with the identity this node pinned for us — the only one it + // will accept, and the only one we hold here. + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; const result = await transport.createInvite( account.user_id, groupId, username, signFn); @@ -2748,12 +2663,10 @@ function App() { const data = await window.MeshBayKeys.loginAndRecover(username, password); token = data.accessToken; refreshToken = data.refreshToken; + // The only thing sign-in produces: the key that opens a node's bundle. + // Which identity we use is decided per node, when we get there. _bundleKey = data.bundleKey; await _storeBundleKey(_bundleKey); - if (data.skXB64) { - _sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 }; - _pendingBundlePush = data.keypairBundleEnc; - } } else { const data = await hubFetch('/v1/users/login', { method: 'POST', @@ -2763,11 +2676,6 @@ function App() { refreshToken = data.refresh_token; } const me = await hubFetch('/v1/users/me', { token }); - if (_sessionKeys) { - const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); - _sessionKeys.pkXB64 = pubkeys.pk_x25519; - _saveSessionKeys(); - } const u = { username, userId: me.user_id, token, refreshToken, role: me.role }; setUser(u); saveAuth(u); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index af119c7..a27522d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -189,34 +189,43 @@ async function decryptBundle(bundleB64, password, username) { * Returns the raw private keys for immediate use after registration. */ async function registerUser(username, email, password) { - const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); - - const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']); - const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []); - const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto)); - const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); - - const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username); + // No keypair here any more. Identity keys are per node: one is generated the + // first time this account joins a given node, encrypted under the passphrase, + // and left with that node. So an operator who cracks what sits on their own + // disk holds a key that is worthless anywhere else — and on their own node, + // one that unlocks nothing they did not already have. + // + // It also means the hub stores no user key to publish, which is what H3 read. const authKey = await deriveAuthKey(password, username); const resp = await fetch(`${HUB}/v1/users/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username, - email, - auth_key: authKey, - pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)), - pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)), - }), + body: JSON.stringify({ username, email, auth_key: authKey }), }); if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`); + return { registered: true }; +} - // Store encrypted bundle locally — will be backed up to node on first group connect - try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {} - - return { skEdRaw, skXRaw, pkEdBytes, pkXBytes, keypairBundleEnc: encBundle }; +/** + * A fresh identity for one node, encrypted under the passphrase-derived key. + * + * Returns { skEdB64, skXB64, pkXB64, bundleEnc } — the bundle goes to that node + * and nowhere else, and is what any other browser fetches to become the same + * person there. + */ +async function generateNodeIdentity(bundleKey) { + const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); + const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); + const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, { name: 'X25519' }, true, []); + const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); + return { + skEdB64: b64(skEdRaw), + skXB64: b64(skXRaw), + pkXB64: b64(pkXBytes), + bundleEnc: await encryptBundleWithKey(skEdRaw, skXRaw, bundleKey.v2 || bundleKey), + }; } /** @@ -269,63 +278,17 @@ async function loginAndRecover(username, password) { }, }; - // localStorage bundle = new registration, not yet pushed to node - const bundleEnc = (typeof localStorage !== 'undefined' - && localStorage.getItem(`meshbay_kp_${username}`)) || null; - - if (bundleEnc) { - // Reuse the keys just derived — decryptBundle() would run the KDF again, - // and at these parameters that is another 0.6 s for nothing. - const keys = await decryptBundleWithKey(bundleEnc, result.bundleKey); - result.skEdB64 = keys.skEd; - result.skXB64 = keys.skX; - result.keypairBundleEnc = bundleEnc; - } - + // Nothing else to recover at sign-in. Identity keys belong to a node, so they + // are fetched from the node being connected to (or generated there on a first + // join) — see transport.js. All that is needed here is the key that opens them. return result; } -async function regenerateKeys(token, username, password) { - const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); +// regenerateKeys() removed. Rotating an identity is now per node: the operator +// runs `meshbay-node member unpin ` and issues a fresh code. A hub call +// that silently changed what every node believed about someone was the wrong +// shape for this. - const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']); - const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []); - const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto)); - const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); - - const resp = await fetch(`${HUB}/v1/users/me/keys`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ - pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)), - pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)), - }), - }); - - if (!resp.ok) throw new Error(`Key rotation failed: ${await resp.text()}`); - - const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username); - try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {} - - return { - skEdB64: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))), - skXB64: btoa(String.fromCharCode(...new Uint8Array(skXRaw))), - pkEdB64: btoa(String.fromCharCode(...pkEdBytes)), - pkXB64: btoa(String.fromCharCode(...pkXBytes)), - keypairBundleEnc: encBundle, - }; -} - -/** - * Sign an explicit byte string with the user's Ed25519 identity key. - * - * Takes bytes rather than a base64 blob from the wire: callers are expected to - * build the message themselves (see MeshBayCrypto.adminTranscript) so that the - * user's identity key is never applied to content the peer chose. Finding H5. - */ async function signBytes(skEdPkcs8B64, message) { const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0)); const sk = await crypto.subtle.importKey( @@ -335,6 +298,6 @@ async function signBytes(skEdPkcs8B64, message) { } window.MeshBayKeys = { - registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signBytes, + registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes, deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index b1a03ff..0a8796e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -78,6 +78,10 @@ class MeshBayTransport { get sessionKeys() { return this._sessionKeys; } + /** Set on a first join: the identity created for this node, still to be left with it. */ + get newNodeBundle() { return this._newNodeBundle || null; } + set newNodeBundle(v) { this._newNodeBundle = v; } + async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, userId, joinCode) { this._gekRaw = gekRaw || null; @@ -85,6 +89,7 @@ class MeshBayTransport { this._bundleKey = bundleKey || null; this._username = username || null; this._userId = userId || null; + this._newNodeBundle = null; this._joinError = null; this._pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], @@ -186,7 +191,11 @@ class MeshBayTransport { this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce); this.nodePk = reply.node_pk || null; - // Recover session keys from node if not available locally (P2P keypair bundle) + // Our identity for THIS node: fetched from it, or created if this is a + // first join. Keys are per node, so there is nothing to carry between + // them — and an operator who cracks the copy on their own disk gets a key + // that opens nothing anywhere else. + let fresh = false; if (!this._sessionKeys && this._bundleKey && window.MeshBayKeys) { const kpResp = await this._sendAndWait({ type: 'keypair_bundle_fetch', v: '0.1', @@ -196,11 +205,22 @@ class MeshBayTransport { kpResp.bundle_enc, this._bundleKey); const pkXB64 = await _pkFromSk(keys.skX); this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; + } else { + // This node has never seen us. Generate the identity we will use here + // and nowhere else; it is stored on this node once the join succeeds, + // which is what lets another browser become the same person here. + const id = await window.MeshBayKeys.generateNodeIdentity(this._bundleKey); + this._sessionKeys = { + skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64, + }; + this._newNodeBundle = id.bundleEnc; + fresh = true; } } - // Fetch wrapped GEK bundle from node (P2P only — hub never touches crypto) - if (!gekRaw && this._sessionKeys) { + // An identity this node already knows still needs its group key, which the + // node wraps on every connection. + if (!gekRaw && this._sessionKeys && !fresh) { const bundleResp = await this._sendAndWait({ type: 'gek_bundle_fetch', v: '0.1', }); @@ -211,22 +231,7 @@ class MeshBayTransport { gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX); this._gekRaw = gekRaw; } catch (e) { - console.warn('[MeshBay] GEK unwrap failed with local keys, trying node keypair bundle'); - if (this._bundleKey && window.MeshBayKeys) { - const kpResp = await this._sendAndWait({ - type: 'keypair_bundle_fetch', v: '0.1', - }); - if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) { - const keys = await window.MeshBayKeys.decryptBundleWithKey( - kpResp.bundle_enc, this._bundleKey); - const pkXB64 = await _pkFromSk(keys.skX); - this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; - const skXRaw2 = Uint8Array.from(atob(keys.skX), c => c.charCodeAt(0)); - const myPkX2 = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0)); - gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw2, myPkX2); - this._gekRaw = gekRaw; - } - } + console.warn('[MeshBay] stored GEK bundle did not open; joining instead'); } } } diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 7b75fd1..5a2cf86 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -144,8 +144,11 @@ async def test_jwt_offline_verify(client, hub_key_path): hub_pk_pem = r_pk.json()["pk_hub_pem"].encode() decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"]) - assert decoded["pk_user"] == pk_ed assert "jti" in decoded # mandatory + # The token carries no user key. It used to, and the node recorded it as the + # uploader's identity — so whoever issued tokens decided who could delete a + # file. The hub certifies accounts; nodes pin keys. + assert "pk_user" not in decoded @pytest.mark.asyncio @@ -194,11 +197,17 @@ async def test_refresh_token_rotation_old_rejected(client): @pytest.mark.asyncio async def test_get_user_pubkeys(client): + """ + The endpoint resolves an account; it is not a key directory any more. + + Publishing user identity keys is what finding H3 exploited — the invite flow + wrapped the group key for whatever came back. Keys are now generated per node + and pinned there, so there is nothing here to substitute. + """ pk_ed, pk_x, _ = _gen_user_keys() await client.post("/v1/users/register", json={ "username": "frank", "email": "frank@example.com", - "password": "frankpass99", - "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + "password": "frankpass99"}) login = await client.post("/v1/users/login", json={ "username": "frank", "password": "frankpass99"}) token = login.json()["access_token"] @@ -206,8 +215,10 @@ async def test_get_user_pubkeys(client): r = await client.get("/v1/users/frank/pubkeys", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 - assert r.json()["pk_ed25519"] == pk_ed - assert r.json()["pk_x25519"] == pk_x + body = r.json() + assert body["user_id"] and body["username"] == "frank" + assert "pk_ed25519" not in body, "user identity keys must not be published (H3)" + assert "pk_x25519" not in body, "user identity keys must not be published (H3)" # ── Nodes ───────────────────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_node_auth.py b/packages/meshbay-hub/tests/test_node_auth.py index e629d20..72ce412 100644 --- a/packages/meshbay-hub/tests/test_node_auth.py +++ b/packages/meshbay-hub/tests/test_node_auth.py @@ -214,7 +214,8 @@ async def test_node_scope_allows_pubkey_lookup(client): r = await client.get("/v1/users/op4/pubkeys", headers={"Authorization": f"Bearer {node_token}"}) assert r.status_code == 200 - assert "pk_ed25519" in r.json() + # An account id and the node's linking key — no user identity keys (H3). + assert "pk_ed25519" not in r.json() assert r.json()["pk_node_ed25519"] is not None diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py index def5e66..1391722 100644 --- a/packages/meshbay-hub/tests/test_node_ws_auth.py +++ b/packages/meshbay-hub/tests/test_node_ws_auth.py @@ -64,7 +64,7 @@ async def _announce_node(client, user: dict) -> str: def _node_token(user: dict) -> str: from meshbay_hub.auth import issue_access_token - return issue_access_token(user["user_id"], user["pk_ed"], scope="node") + return issue_access_token(user["user_id"], scope="node") @pytest.mark.asyncio diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 334107d..d8417e3 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -128,8 +128,8 @@ class HubClient: access_token = data["access_token"] decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"]) - assert decoded["pk_user"] == self._keys.pk_ed25519_b64, \ - "Hub returned token for wrong public key" + # No pk_user claim to check any more: tokens carry no key. What binds this + # token to this node is the Ed25519 challenge it was issued against. assert "jti" in decoded, "Hub token missing jti — hub is outdated" assert decoded.get("scope") == "node", \ "Expected node-scoped token" 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 f659f56..5ea6376 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -231,7 +231,9 @@ class WebRTCPeerSession: self._peer_id: str = peer_id self._remote_ip: str = "" self._username: str = "" - self._pk_user: str = "" + # Set from the roster: the key this node pinned for this account. Never + # from the JWT — the hub picks what goes in there. + self._pinned_pk: 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 @@ -377,7 +379,6 @@ class WebRTCPeerSession: self._pending_sub = peer.user_id self._pending_group = peer.group_id self._pending_username = peer.username - self._pending_pk_user = peer.pk_user gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx if not gctx.get("gek"): @@ -446,7 +447,7 @@ class WebRTCPeerSession: self._user_id = self._pending_sub self._group_id = self._pending_group self._username = self._pending_username - self._pk_user = self._pending_pk_user + asyncio.ensure_future(self._load_pinned_pk()) self._peer_registry()[self._user_id] = self @@ -747,7 +748,11 @@ class WebRTCPeerSession: roster, user_id, invite["username"] or 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"], + # The roster row comes from the invitation; the key comes from the + # connection. An operator pairing is node-wide (empty group), but they + # redeemed the code while opening a group and expect to read it — and + # is_authorized() already grants an operator every group on this node. + await self._join_ok(user_id, pk_x_raw, session_group or invite["group_id"], role=invite["role"], recognised=False) def _group_join_policy(self, group_id: str) -> str: @@ -1168,14 +1173,22 @@ class WebRTCPeerSession: self._register_uploader(ctx, rel_dir, filename) def _register_uploader(self, ctx: dict, rel_dir: str, filename: str) -> None: - """Tag the index entry with the uploader's identity after upload completes.""" + """ + Tag the index entry with the uploader's identity after upload completes. + + The key recorded here is the one this node pinned, not the one the token + carried. `pk_user` was a hub-chosen claim, and it decided who could later + delete the file: a hub issuing a token naming its own key could delete + anyone's uploads on any node. Deletion is supposed to be authorized by the + node, and this closes the last place where it was not. + """ idx = ctx.get("index") if not idx: return for entry in idx.entries: if entry.name == filename and entry.path == rel_dir: entry.uploader_id = self._user_id - entry.uploader_pk = self._pk_user + entry.uploader_pk = self._pinned_pk return def _do_file_delete(self, msg: dict) -> None: @@ -1242,6 +1255,15 @@ class WebRTCPeerSession: except Exception: return False + async def _load_pinned_pk(self) -> None: + """Remember which key this node pinned for the peer we just authenticated.""" + roster = self._ctx.get("roster") + if roster is None or not self._user_id: + return + ident = await roster.get_identity(self._user_id) + if ident: + self._pinned_pk = ident["pk_ed25519"] + def _has_admin_authority(self) -> bool: """ Cheap synchronous pre-check: is there anyone who could authorize this? diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index d13225d..665c060 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -461,6 +461,42 @@ def test_challenge_carries_node_pk_in_source(): "learn it any other way, and join_request signs it") +async def test_a_key_pinned_by_one_node_is_worthless_at_another(tmp_path, roster): + """ + The whole point of per-node identity: node A's operator who cracks the bundle + on their own disk holds a key node B has never seen. Presenting it there is a + first contact like any other — it needs a code from B's operator. + """ + gek = generate_gek() + node_b = _session(tmp_path, roster, user_id="bob", group_id=GROUP, gek=gek) + + # The key bob uses at node A. Node B's roster knows nothing about it. + sk_ed_a, pk_ed_a, pk_x_a = _keypair() + + await node_b._do_join_request( + _join_msg(node_b, sk_ed_a, pk_ed_a, pk_x_a, + user_id="bob", group_id=GROUP)) + + assert _last(node_b).get("reason") == "code_required" + assert await roster.get_identity("bob") is None + + +async def test_the_stolen_key_cannot_be_forced_in_with_someone_elses_code( + tmp_path, roster): + """And a code issued for another account does not help either.""" + gek = generate_gek() + session = _session(tmp_path, roster, user_id="eve", group_id=GROUP, gek=gek) + sk_ed, pk_ed, pk_x = _keypair() + code = await roster.create_invite(GROUP, "bob", ROLE_MEMBER, "grenet") + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed, pk_x, code=code, + user_id="eve", group_id=GROUP)) + + assert _last(session).get("reason") == "code_invalid" + assert await roster.get_identity("eve") is None + + # ── Code lifetimes ──────────────────────────────────────────────────────────── async def test_invitations_outlive_pairing_codes(roster): -- cgit v1.2.3