diff options
7 files changed, 105 insertions, 32 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 0bffeae..624c3c3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -248,6 +248,10 @@ class MeshBayTransport { // Kept for the life of the connection: a join_request is signed over it, // which is what stops one being lifted onto another connection. this._nonceNode = nonceNode; + // Announced in the challenge because joining needs it before the ack: a + // first-time member has no GEK, so they cannot complete the handshake that + // would prove this key. Unverified here; checked against the ack below. + this.nodePk = reply.node_pk || null; const gid = groupId || ''; const proof = await C.handshakeProof( @@ -281,6 +285,12 @@ class MeshBayTransport { // fails the GEK proof — this covers the case where an attacker HAS the GEK // (an ex-member, or a leaked key) and swaps the node underneath. // Strict refusal: a warning users can click through is decorative. + // The key announced in the challenge must be the one that just proved + // itself. A peer that changed identity mid-handshake is not one to trust + // with anything, including a join we may already have signed for it. + if (this.nodePk && this.nodePk !== ack.node_pk) { + throw new Error('Node identity changed during the handshake — refusing'); + } _checkNodePin(nodeId, ack.node_pk); this.nodePk = ack.node_pk; diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 930dabc..58fa99a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -835,15 +835,9 @@ def main() -> None: return # revoke and unpin both name a person; the daemon resolves the account. - roster_out = _daemon_api(cfg, "/api/roster") - match = next((i for i in roster_out.get("identities", []) - if i["username"] == args.target), None) - if not match: - known = ", ".join(i["username"] - for i in roster_out.get("identities", [])) - print(f"{args.target!r} is not pinned on this node") - print(f"known: {known or 'nobody yet'}") - sys.exit(1) + # It tries its own roster first and falls back to the hub, so a node that + # pinned someone before invitations carried a name is still manageable. + match = _daemon_api(cfg, f"/api/resolve?username={args.target}") if sub == "revoke": group_id = _resolve_group(cfg, args.group) diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index dab1497..6bda56b 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS invites ( code_hash TEXT PRIMARY KEY, group_id TEXT NOT NULL, user_id TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', role TEXT NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL, @@ -142,6 +143,15 @@ class Roster: # WAL: the CLI writes invites (`operator pair`) while the daemon reads them. await self._db.execute("PRAGMA journal_mode=WAL") await self._db.executescript(_SCHEMA) + # invites.username was added after the first deployments: the name is what + # the operator types, and it cannot be recovered from the JWT because the + # hub does not put one there. CREATE TABLE IF NOT EXISTS will not add a + # column to a table that already exists. + async with self._db.execute("PRAGMA table_info(invites)") as cur: + columns = {r[1] for r in await cur.fetchall()} + if "username" not in columns: + await self._db.execute( + "ALTER TABLE invites ADD COLUMN username TEXT NOT NULL DEFAULT ''") await self._db.commit() async def close(self) -> None: @@ -289,6 +299,7 @@ class Roster: role: str, created_by: str, ttl: int = DEFAULT_INVITE_TTL, + username: str = "", ) -> str: """ Issue a one-time code. Returns it in the clear — this is the only moment it @@ -305,10 +316,9 @@ class Roster: code = generate_code() expires = datetime.now(timezone.utc) + timedelta(seconds=ttl) await self._db.execute( - "INSERT INTO invites " - "(code_hash, group_id, user_id, role, created_by, created_at, expires_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (hash_code(code), group_id, user_id, role, created_by, _now(), + "INSERT INTO invites (code_hash, group_id, user_id, username, role, " + "created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (hash_code(code), group_id, user_id, username, role, created_by, _now(), expires.isoformat(timespec="seconds")), ) await self._db.commit() 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 10dfcb0..416e84c 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -390,6 +390,13 @@ class WebRTCPeerSession: "type": MNP.HANDSHAKE_CHALLENGE, "v": MNP_VERSION, "nonce": base64.b64encode(self._gek_challenge).decode(), + # Announced here because a first-time joiner needs it *before* the + # ack: join_request signs a transcript naming this node, and someone + # who has never held the GEK cannot complete the handshake to learn + # it. Unverified at this point — the ack proves it, the client checks + # the two match, and a wrong value only makes our own verification + # fail. It is never a substitute for the ack's proof and signature. + "node_pk": self._node_pk_b64(), }) def _do_handshake_response(self, msg: dict) -> None: @@ -731,7 +738,10 @@ class WebRTCPeerSession: return await self._pin_and_admit( - roster, user_id, username, pk_ed_b64, pk_x_b64, + # The name comes from the invitation, not from the token: the hub does + # not put a username claim in a JWT, so pinning from the session alone + # left the roster nameless and `member revoke <name>` unable to match. + 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"], @@ -1332,6 +1342,7 @@ class WebRTCPeerSession: role=ROLE_MEMBER, created_by=self._user_id or "", ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL), + username=payload.get("username", ""), ) invites = await roster.list_invites() expires = next( diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 2f73868..28654df 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -243,6 +243,7 @@ def create_ui_app(state: dict) -> FastAPI: role=ROLE_OPERATOR, created_by="local-cli", ttl=ttl, + username=(config.hub.username if config else ""), ) invites = await roster.list_invites() expires = next((i["expires_at"] for i in invites @@ -292,6 +293,7 @@ def create_ui_app(state: dict) -> FastAPI: role=ROLE_MEMBER, created_by="local-cli", ttl=ttl, + username=username, ) invites = await roster.list_invites() expires = next((i["expires_at"] for i in invites @@ -300,6 +302,30 @@ def create_ui_app(state: dict) -> FastAPI: return {"code": code, "expires_at": expires, "username": username, "user_id": account["user_id"]} + @app.get("/api/resolve") + async def resolve_user(username: str): + """ + Map a username to an account id for the CLI. + + The roster answers first — it is the node's own record. The hub is the + fallback for identities pinned before invitations carried a name, and for + people admitted through an open-join group. Only an account id comes back; + no key is ever taken from here. + """ + roster = state.get("roster") + if roster: + for ident in await roster.list_identities(): + if ident["username"] == username: + return {"user_id": ident["user_id"], "source": "roster"} + hub = state.get("hub") + if hub and hub._session: + try: + account = await hub.get_user_pubkeys(username) + return {"user_id": account["user_id"], "source": "hub"} + except Exception: + pass + return JSONResponse({"error": f"Unknown user {username!r}"}, 404) + @app.post("/api/members/{user_id}/revoke") async def revoke_member(user_id: str, group_id: str): """ diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 11704ce..d13225d 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -445,6 +445,22 @@ async def test_revoked_member_stops_receiving_the_key(tmp_path, roster): assert _last(session).get("gek") is False +# ── What a first-time joiner can know ───────────────────────────────────────── + +def test_challenge_carries_node_pk_in_source(): + """ + Belt and braces for the above: the field must be in the message the node + builds, whatever the surrounding handshake does. + """ + source = (Path(__file__).parent.parent + / "src" / "meshbay_node" / "transport" / "webrtc_server.py").read_text() + challenge = source[source.find("MNP.HANDSHAKE_CHALLENGE,"):] + challenge = challenge[:challenge.find("})")] + assert "node_pk" in challenge, ( + "the challenge must announce the node key — a first-time joiner cannot " + "learn it any other way, and join_request signs it") + + # ── Code lifetimes ──────────────────────────────────────────────────────────── async def test_invitations_outlive_pairing_codes(roster): @@ -635,35 +651,33 @@ def _run_cli(monkeypatch, tmp_path, argv, responses): def test_cli_member_commands_reach_the_right_endpoints(monkeypatch, tmp_path, capsys): - roster_reply = {"identities": [{"user_id": "u-bob", "username": "bob", - "pk_ed25519": "K", "pinned_at": "now", - "pinned_via": "code"}], - "members": [{"group_id": GROUP, "user_id": "u-bob", - "role": "member", "status": "active"}], - "invites": []} + resolved = {"user_id": "u-bob", "source": "roster"} calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "bob"], - {"/api/roster": roster_reply, + {"/api/resolve": resolved, "revoke": {"status": "revoked", "reminder": "gek-init"}}) assert ("POST", f"/api/members/u-bob/revoke?group_id={GROUP}") in calls # The operator is told the revocation does not take back the key they hold. assert "rotate" in capsys.readouterr().out.lower() calls = _run_cli(monkeypatch, tmp_path, ["member", "unpin", "bob"], - {"/api/roster": roster_reply, "unpin": {"status": "unpinned"}}) + {"/api/resolve": resolved, "unpin": {"status": "unpinned"}}) assert ("POST", "/api/members/u-bob/unpin") in calls -def test_cli_refuses_to_act_on_someone_it_does_not_know(monkeypatch, tmp_path, capsys): - """A typo must not silently do nothing — or worse, act on the wrong person.""" - calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "nobody"], - {"/api/roster": {"identities": [], "members": [], - "invites": []}}) - assert ("exit", 1) in calls - assert not any(method == "POST" for method, _ in calls), ( - "the CLI acted on the server despite not knowing who was meant") - assert "not pinned" in capsys.readouterr().out +def test_cli_resolves_a_name_before_acting(monkeypatch, tmp_path): + """ + The name has to be turned into an account first, and the node's own roster is + asked before the hub. A JWT carries no username, so an identity pinned without + an invitation has none — the hub fallback is what keeps it manageable. + """ + calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "bob"], + {"/api/resolve": {"user_id": "u-bob", "source": "hub"}, + "revoke": {"status": "revoked", "reminder": "gek-init"}}) + assert ("GET", "/api/resolve?username=bob") == calls[0], ( + "the CLI must resolve the name before acting on anyone") + assert ("POST", f"/api/members/u-bob/revoke?group_id={GROUP}") in calls def test_daemon_does_not_auto_pin_keystore_key(): """ diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 07bdbea..93cd3fd 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -1155,11 +1155,19 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di assert challenge["type"] == MNP.HANDSHAKE_CHALLENGE nonce_s = base64.b64decode(challenge["nonce"]) + # Bob signs a transcript naming the node, and he cannot complete the handshake + # that would prove its key — he has no GEK yet. So he has to be able to learn + # it from the challenge; taking it from the test's own knowledge of sk_node + # would hide the fact that a real client cannot. + assert challenge["node_pk"] == pk_to_b64(sk_node.public_key()), ( + "the challenge must announce the node key to a first-time joiner") + node_pk_b64 = challenge["node_pk"] + pk_ed_b64 = pk_to_b64(sk_bob_ed.public_key()) pk_x_b64 = base64.b64encode(pk_x_raw).decode() ts = int(time.time()) transcript = join_transcript( - node_pk_b64=pk_to_b64(sk_node.public_key()), + node_pk_b64=node_pk_b64, group_id=TEST_GROUP, user_id="user-002", pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64, nonce_node=nonce_s, ts=ts, |