aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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):