diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 02:17:45 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 02:18:07 +0200 |
| commit | 71df5857b213be893025c562977558ba79009c09 (patch) | |
| tree | 1e84b347b4b1b2b4ebfdc738d46ad4f6803e2eec /packages/meshbay-node/src/meshbay_node/ui/app.py | |
| parent | d7120761fe8cf406f374ef769db6e1f9bf1fe287 (diff) | |
| download | meshbay-71df5857b213be893025c562977558ba79009c09.tar.gz | |
fix(node): announce the node key in the challenge, and keep names in the roster
Both found by deploying the thing and running the workflow end to end. Neither
was reachable from the test suite, for the same reason in each case: the tests
knew something a real client cannot.
1. A first-time joiner had no way to learn node_pk.
join_request signs a transcript naming the node, and the node key was only
sent in handshake_ack — which an invited member cannot reach, having no GEK to
prove. joinGroup() therefore threw "handshake incomplete" and the browser path
for an invited member was broken. Every test built the transcript from a node
key it already had, so nothing noticed.
The challenge now carries node_pk. It is unverified at that point and never a
substitute for the ack: the ack still proves possession and signs the
transcript, the client checks the two values match and refuses a peer that
changed identity mid-handshake, and TOFU pinning is unchanged. A wrong value
only makes our own verification fail.
test_invite_then_join_delivers_the_gek now takes the key from the challenge
instead of from sk_node, so it proves a real client can learn it.
2. The roster pinned everyone without a name.
`_do_join_request` took the username from the session, which takes it from the
JWT — and the hub puts no username claim in a token. So identities were pinned
with an empty name and `member revoke <name>` could never match: the live node
answered "known: , ,". Invitations now carry the name (new invites.username
column, with a migration for the roster DBs already out there), and the CLI
resolves a name through the daemon: its own roster first, the hub as fallback
for identities pinned before this.
The harness that found them is QE/deploy/e2e.py — gitignored with the rest of
QE/, so it is not in this commit. It does the SPA's job in Python against the
live deployment: hub login, WebRTC via hub signaling, the unified handshake,
joining with a code, index, chunk download and MSE segments.
Verified against meshbay.org and the local node: an account registered from
scratch is invited by code, receives the group key wrapped for a key it proved it
holds, downloads and decrypts a file, streams 5 encrypted fMP4 segments,
reconnects with no code, and is refused after `member revoke`. The node audit log
shows invite_create → join_pinned(via=code) → gek_wrapped → handshake, then
join_no_gek once revoked.
Tests: 232 node+common.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui/app.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 26 |
1 files changed, 26 insertions, 0 deletions
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): """ |