From 71df5857b213be893025c562977558ba79009c09 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 02:17:45 +0200 Subject: fix(node): announce the node key in the challenge, and keep names in the roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 --- packages/meshbay-node/src/meshbay_node/roster.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/roster.py') 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() -- cgit v1.2.3