summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 17:51:48 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 17:51:48 +0200
commitf0984e86d9cb596a282ce6feb7cfc2f075b2794b (patch)
tree192198a5d596a06d15095bb1d3540dd4a8d397c8
parent9fa2117de1caf4d713cc0b7a310b9549467738c3 (diff)
downloadmeshbay-f0984e86d9cb596a282ce6feb7cfc2f075b2794b.tar.gz
feat!: identity keys per node — C4's blast radius drops to one operator
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 <noreply@anthropic.com>
-rw-r--r--docs/meshbay-draft-v5.md71
-rw-r--r--docs/per-node-identity-v1.md215
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js142
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js109
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js43
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py21
-rw-r--r--packages/meshbay-hub/tests/test_node_auth.py3
-rw-r--r--packages/meshbay-hub/tests/test_node_ws_auth.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py4
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py34
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py36
17 files changed, 493 insertions, 303 deletions
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 <user>` 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 <user>` 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):