diff options
Diffstat (limited to 'packages/meshbay-node')
3 files changed, 66 insertions, 8 deletions
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): |