From f15efd23f66c521ca9206789482bb38e7326eeb4 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 01:27:21 +0200 Subject: feat(node)!: the node wraps the group key — closes H3 and M3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite flow fetched the invitee's pk_x25519 from the hub and wrapped the GEK for whatever came back (app.js:1466, and gek-init did the same server-side). The hub is the key directory, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. No forgery, no injection, nothing for the client to notice. That was H3. The fix is not safety numbers. Nobody reads the directory any more: - the node holds the GEK and wraps it itself, on every connection, for the X25519 key the joiner signed with their Ed25519 identity in one transcript (meshbay:join:v1), so the identity key vouches for the encryption key; - identities are bound to accounts by a one-time code the hub never sees — 40 bits, single use, one account, bounded per connection AND node-wide; - the node's own roster decides who may receive the key. Hub membership lets someone reach a node; it no longer gets them anything. A hub that invents an account and mints it a token is answered not_authorized_for_group. Safety numbers would have made substitution detectable by a human who checks, at the moment there is nothing to check against — first contact. Removing the lookup makes it impossible, and costs the user one code to pass along. M3 falls out of the same work. The daemon auto-pinned its own keystore key as admin_pk_ed25519 while the browser signs with the user identity key, so every privileged operation failed closed with a signature error that looked like a bug somewhere else; the demo only worked because a deploy script overwrote the value. Authority now comes from the roster, established locally by `operator pair`. Asking the hub for the operator's key — the obvious-looking fix — would have let the hub install itself as node administrator. BREAKING: gek_bundle_store is deleted, not gated. No member hands the node key material at all, so C5b becomes structural rather than an authorization to check. Existing stored bundles are still served, so current deployments keep working. Also: - join_policy (invite|open) is read from node.toml, never from the hub — a hub able to declare a group open would be handed its key. Unknown group ⇒ invite. - admin signatures are verified against the roster on every check, so unpinning takes effect without a restart. admin_pk_ed25519 stays readable as legacy. - two C5b tests were rewritten, deliberately: they asserted that gek_bundle_store demanded an operator signature, and the message is gone. They now assert the stronger property. The file says not to fix these tests, so this is the record of why they changed. - a slice-1 bug found while writing slice 2: connect() never passed skEdB64, so pairing would have failed at runtime with no test able to catch it. Tests: 152 node+common here, including an end-to-end DataChannel run where a member who has never held the group key redeems a code in the pre-proof window and receives the key wrapped for a key only they can open. Design: docs/invite-pairing-v1.md Co-Authored-By: Claude Opus 5 --- packages/meshbay-node/src/meshbay_node/roster.py | 372 +++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 packages/meshbay-node/src/meshbay_node/roster.py (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 new file mode 100644 index 0000000..f231792 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -0,0 +1,372 @@ +""" +Node roster — who this node recognises, and which keys are theirs. + +The node keeps its own answer to "may this person have the group key", derived from +what the operator authorized locally. It is deliberately NOT derived from the hub: +the hub decides group membership, and a hub that invents an account and mints a +token for it would otherwise collect the GEK on connect. Hub membership is an input +to the decision; it is not the decision. + +Three tables: + + identities — one row per person, not per group. Someone paired for one group + needs no code for the next one on the same node. + members — role and status per (group, user). + invites — one-time pairing codes, stored as a hash. The code itself exists + only in the operator's hands and the invitee's. + +The code is what binds a public key to an account without asking the hub +(finding H3). See `docs/invite-pairing-v1.md`. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import secrets +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import aiosqlite + +log = logging.getLogger(__name__) + +# Crockford base32 without I, L, O and U: no character pair a human can confuse +# when reading a code aloud or typing it from a phone screen. +_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy +DEFAULT_INVITE_TTL = 24 * 3600 # seconds + +_SCHEMA = """\ +CREATE TABLE IF NOT EXISTS identities ( + user_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + pk_ed25519 TEXT NOT NULL, + pk_x25519 TEXT NOT NULL, + pinned_at TEXT NOT NULL, + pinned_via TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS members ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + status TEXT NOT NULL, + approved_by TEXT NOT NULL, + approved_at TEXT NOT NULL, + PRIMARY KEY (group_id, user_id) +); + +CREATE TABLE IF NOT EXISTS invites ( + code_hash TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +); +""" + + +def generate_code() -> str: + """A fresh pairing code, formatted for a human to read out: XXXX-XXXX.""" + raw = "".join(secrets.choice(_ALPHABET) for _ in range(CODE_LEN)) + return f"{raw[:4]}-{raw[4:]}" + + +def normalize_code(code: str) -> str: + """ + Fold what a human typed onto what was generated. + + Crockford's rules: case-insensitive, dashes and spaces are decoration, and the + excluded letters map onto the digits they resemble. Someone reading a code over + the phone should not be able to get it wrong in a way we could have absorbed. + """ + out = [] + for ch in code.upper(): + if ch in "- \t": + continue + if ch in "IL": + out.append("1") + elif ch == "O": + out.append("0") + elif ch == "U": + out.append("V") + else: + out.append(ch) + return "".join(out) + + +def hash_code(code: str) -> str: + """ + Store codes hashed: a stolen roster DB must not yield usable invitations. + + SHA-256 rather than a password KDF on purpose — the input is 40 bits of + uniformly random secret, not a human-chosen string, so there is nothing for a + slow hash to defend. + """ + return hashlib.sha256(normalize_code(code).encode()).hexdigest() + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class Roster: + def __init__(self, db_path: Path): + self._db_path = db_path + self._db: aiosqlite.Connection | None = None + + async def open(self) -> None: + self._db_path.parent.mkdir(parents=True, exist_ok=True) + self._db = await aiosqlite.connect(str(self._db_path)) + self._db.row_factory = aiosqlite.Row + # 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) + await self._db.commit() + + async def close(self) -> None: + if self._db: + await self._db.close() + self._db = None + + # ── Identities ─────────────────────────────────────────────────────────── + + async def pin_identity( + self, + user_id: str, + username: str, + pk_ed25519: str, + pk_x25519: str, + via: str, + ) -> None: + assert self._db + await self._db.execute( + "INSERT OR REPLACE INTO identities " + "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via) " + "VALUES (?, ?, ?, ?, ?, ?)", + (user_id, username, pk_ed25519, pk_x25519, _now(), via), + ) + await self._db.commit() + + async def get_identity(self, user_id: str) -> dict | None: + assert self._db + async with self._db.execute( + "SELECT * FROM identities WHERE user_id = ?", (user_id,) + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + async def unpin(self, user_id: str) -> bool: + assert self._db + cur = await self._db.execute( + "DELETE FROM identities WHERE user_id = ?", (user_id,)) + await self._db.commit() + return cur.rowcount > 0 + + async def list_identities(self) -> list[dict]: + assert self._db + async with self._db.execute( + "SELECT * FROM identities ORDER BY pinned_at" + ) as cur: + return [dict(r) for r in await cur.fetchall()] + + # ── Authority ──────────────────────────────────────────────────────────── + + async def operator_pks(self) -> list[str]: + """ + Base64 Ed25519 keys allowed to authorize admin operations on this node. + + Read fresh on every check rather than cached: an unpin must take effect at + once, and this runs only on admin operations, which are rare. + """ + assert self._db + async with self._db.execute( + "SELECT i.pk_ed25519 FROM identities i " + "JOIN members m ON m.user_id = i.user_id " + "WHERE m.role = 'operator' AND m.status = 'active'" + ) as cur: + return [r["pk_ed25519"] for r in await cur.fetchall()] + + async def has_operator(self) -> bool: + return bool(await self.operator_pks()) + + async def is_authorized(self, group_id: str, user_id: str) -> bool: + """ + May this person be handed the group key? + + The node's own answer, not the hub's. Hub membership is what lets someone + reach the node; this is what decides whether the key is wrapped for them — + otherwise a hub that invents an account and mints a token for it would be + served the GEK on connect. + + An operator is authorized for every group this node hosts: their authority + is node-wide and is recorded with an empty group_id. + """ + assert self._db + async with self._db.execute( + "SELECT 1 FROM members WHERE user_id = ? AND status = 'active' " + "AND (group_id = ? OR (group_id = '' AND role = 'operator')) LIMIT 1", + (user_id, group_id), + ) as cur: + return await cur.fetchone() is not None + + # ── Members ────────────────────────────────────────────────────────────── + + async def set_member( + self, + group_id: str, + user_id: str, + role: str, + status: str, + approved_by: str, + ) -> None: + assert self._db + await self._db.execute( + "INSERT OR REPLACE INTO members " + "(group_id, user_id, role, status, approved_by, approved_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (group_id, user_id, role, status, approved_by, _now()), + ) + await self._db.commit() + + async def get_member(self, group_id: str, user_id: str) -> dict | None: + assert self._db + async with self._db.execute( + "SELECT * FROM members WHERE group_id = ? AND user_id = ?", + (group_id, user_id), + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + async def list_members(self, group_id: str | None = None) -> list[dict]: + assert self._db + sql = ( + "SELECT m.*, i.username, i.pk_ed25519, i.pinned_at, i.pinned_via " + "FROM members m LEFT JOIN identities i ON i.user_id = m.user_id" + ) + args: tuple = () + if group_id is not None: + sql += " WHERE m.group_id = ?" + args = (group_id,) + async with self._db.execute(sql + " ORDER BY m.approved_at", args) as cur: + return [dict(r) for r in await cur.fetchall()] + + async def set_status(self, group_id: str, user_id: str, status: str) -> bool: + assert self._db + cur = await self._db.execute( + "UPDATE members SET status = ? WHERE group_id = ? AND user_id = ?", + (status, group_id, user_id), + ) + await self._db.commit() + return cur.rowcount > 0 + + # ── Invites ────────────────────────────────────────────────────────────── + + async def create_invite( + self, + group_id: str, + user_id: str, + role: str, + created_by: str, + ttl: int = DEFAULT_INVITE_TTL, + ) -> str: + """ + Issue a one-time code. Returns it in the clear — this is the only moment it + exists outside the operator's hands; only its hash is kept. + + Any earlier unused invite for the same person and group is dropped, so + re-inviting supersedes rather than accumulating valid codes. + """ + assert self._db + await self._db.execute( + "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL", + (group_id, user_id), + ) + 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(), + expires.isoformat(timespec="seconds")), + ) + await self._db.commit() + return code + + async def consume_invite(self, code: str, user_id: str) -> dict | None: + """ + Redeem a code for `user_id`, or return None. + + Single use is enforced by the UPDATE's WHERE clause: two connections racing + the same code cannot both see `used_at IS NULL`, so exactly one wins. + """ + assert self._db + code_hash = hash_code(code) + async with self._db.execute( + "SELECT * FROM invites WHERE code_hash = ?", (code_hash,) + ) as cur: + row = await cur.fetchone() + if not row: + return None + + invite = dict(row) + if invite["used_at"] is not None: + return None + # A code is valid for exactly one account, so a leaked code cannot be + # redeemed by whoever finds it first. + if invite["user_id"] != user_id: + return None + if datetime.fromisoformat(invite["expires_at"]) < datetime.now(timezone.utc): + return None + + cur = await self._db.execute( + "UPDATE invites SET used_at = ? WHERE code_hash = ? AND used_at IS NULL", + (_now(), code_hash), + ) + await self._db.commit() + if cur.rowcount == 0: + return None + return invite + + async def list_invites(self, include_used: bool = False) -> list[dict]: + assert self._db + sql = "SELECT * FROM invites" + if not include_used: + sql += " WHERE used_at IS NULL" + async with self._db.execute(sql + " ORDER BY created_at") as cur: + return [dict(r) for r in await cur.fetchall()] + + async def purge_expired(self) -> int: + assert self._db + cur = await self._db.execute( + "DELETE FROM invites WHERE used_at IS NULL AND expires_at < ?", + (_now(),), + ) + await self._db.commit() + return cur.rowcount + + +async def open_roster(data_dir: Path) -> Roster: + roster = Roster(data_dir / "roster.db") + await roster.open() + return roster + + +def write_code_file(data_dir: Path, code: str, expires_at: str) -> Path: + """ + Leave the code in a file as well as on stdout. + + An operator working over SSH may not be able to copy out of their terminal, + and a code that can only be read off a scrolled-away screen is a dead end. + """ + path = data_dir / "pair-code" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{code}\nexpires {expires_at}\n") + os.chmod(path, 0o600) + return path -- cgit v1.2.3 From 8f6e2f724fd24a077de11d4a3b3ae069d369324d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 01:27:57 +0200 Subject: feat(node): operator surface — member list, invite, revoke, unpin over SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node admits people from its own roster, and until now a headless operator had no way to put anyone on it: pairing worked from the CLI, everything else needed a browser on a machine that does not have one. Absorbs milestones 14.3/14.4. member list who is admitted, role, status, when and how pinned member invite one-time code; the node wraps the key when they connect, so nobody has to be online then member revoke stop serving them the key member unpin forget the pin so they can pair again after a reset All of it goes through the daemon's loopback API with the per-run session token (11.5.3) — _daemon_api() in daemon.py, which also replaced three hand-rolled urllib blocks. `status` deliberately still reads the keystore, config and roster directly, so it works while the daemon is stopped. Two things the commands say out loud, because getting them wrong is silent: - revoke ends by telling the operator to rotate the key. The ex-member stops receiving it on their next connection, but they hold the current one, and "revoked" reads like it took the key back. - revoke/unpin refuse a username the roster does not know instead of acting on nobody. A typo must not look like success. Code lifetimes now differ by what the act is: 7 days for an invitation, which crosses a human conversation and gets answered whenever someone reads their messages, and 24 h for operator pairing, which is typed during the SSH session that printed it. Both configurable ([node] invite_ttl_hours, pair_ttl_hours). A day was long enough for the second and not for the first — a code that dies over a weekend means finding a browser to issue another one. The roster is also in the local admin UI, escaped: usernames come from the hub and land on the page that can re-key groups and read the audit log, so H2's rule covers them exactly as it covers filenames. Verified by driving the real CLI against a stub daemon over a socket, which is how the "known: " bug in the not-found path turned up. Tests: 89 node here (roster, endpoints, CLI routing, TTL config). Co-Authored-By: Claude Opus 5 --- packages/meshbay-node/src/meshbay_node/config.py | 14 ++ packages/meshbay-node/src/meshbay_node/daemon.py | 98 ++++++++++- packages/meshbay-node/src/meshbay_node/roster.py | 24 ++- .../src/meshbay_node/transport/webrtc_server.py | 2 + packages/meshbay-node/src/meshbay_node/ui/app.py | 162 +++++++++++++++++- packages/meshbay-node/tests/test_roster_pairing.py | 190 +++++++++++++++++++++ .../tests/test_security_regressions.py | 23 +++ 7 files changed, 499 insertions(+), 14 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/roster.py') diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 04cb1d3..f3752ea 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -29,6 +29,11 @@ username = "myusername" quic_port = 19010 # QUIC (MNP) — LAN, port-forwarded, hub-less direct access ui_port = 18000 # local admin UI (127.0.0.1 only) +# One-time codes. An invitation waits for someone to read their messages; an +# operator pairing code is typed during the SSH session that printed it. +invite_ttl_hours = 168 # 7 days +pair_ttl_hours = 24 + # Browser and native clients reach this node over WebRTC DataChannel via hub # signaling — no inbound port to open. QUIC is the optional direct path. @@ -70,6 +75,11 @@ class HubConfig: class NodeConfig: quic_port: int = 19010 ui_port: int = 18000 + # How long a one-time code stays usable. Invitations travel through a human + # conversation and are answered days later; operator pairing happens during + # the SSH session that printed it. + invite_ttl_hours: int = 168 # 7 days + pair_ttl_hours: int = 24 @dataclass @@ -129,6 +139,10 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: # in Phase 11.5 (findings C1, C6). Regenerate node.toml with `meshbay-node init`. cfg.node.quic_port = nd.get("quic_port", cfg.node.quic_port) cfg.node.ui_port = nd.get("ui_port", cfg.node.ui_port) + cfg.node.invite_ttl_hours = int( + nd.get("invite_ttl_hours", cfg.node.invite_ttl_hours)) + cfg.node.pair_ttl_hours = int( + nd.get("pair_ttl_hours", cfg.node.pair_ttl_hours)) # Multi-group: [[groups]] array if "groups" in raw: diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 7f84abe..930dabc 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -286,6 +286,8 @@ class NodeDaemon: self._webrtc._ctx["pk_x25519_b64"] = keys.pk_x25519_b64 self._webrtc._ctx["roster"] = self._roster + self._webrtc._ctx["invite_ttl"] = ( + self._config.node.invite_ttl_hours * 3600) admin_pk = self._legacy_admin_pk() paired = await self._roster.has_operator() if self._roster else False if admin_pk: @@ -662,12 +664,15 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", choices=["init", "status", "ui", "gek-init", "operator", - "calibrate-argon2"], + "member", "calibrate-argon2"], help="init: write example config | status: node state and keys " "| ui: print the admin UI URL | operator pair: pair a " - "browser with this node | calibrate-argon2: benchmark") + "browser with this node | member list|invite|revoke|unpin " + "| calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", - help="'pair' for the operator command") + help="'pair' for operator; list|invite|revoke|unpin for member") + parser.add_argument("target", nargs="?", + help="username, for member invite|revoke|unpin") parser.add_argument("--config", type=Path, default=None, help="Config file path") parser.add_argument("--group", default=None, @@ -677,7 +682,7 @@ def main() -> None: args = parser.parse_args() # Query commands print a report; library logging would interleave with it. - quiet = args.command in ("status", "ui", "gek-init", "operator") + quiet = args.command in ("status", "ui", "gek-init", "operator", "member") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -775,6 +780,91 @@ def main() -> None: print(f"invites {pending} pending code(s)") return + if args.command == "member": + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + sub = args.subcommand or "list" + + if sub == "list": + group = args.group or "" + out = _daemon_api( + cfg, f"/api/roster?group_id={group}" if group else "/api/roster") + identities = {i["user_id"]: i for i in out.get("identities", [])} + + members = out.get("members", []) + if not members: + print("no members admitted yet") + print("invite someone: meshbay-node member invite ") + for m in members: + ident = identities.get(m["user_id"], {}) + scope = m["group_id"][:8] if m["group_id"] else "node-wide" + print(f"{(ident.get('username') or m['user_id'])[:20]:20} " + f"{m['role']:9} {m['status']:8} {scope:10} " + f"pinned {ident.get('pinned_at', '?')} " + f"({ident.get('pinned_via', '?')})") + + invites = out.get("invites", []) + if invites: + print() + for i in invites: + print(f"pending invite user {i['user_id'][:12]} " + f"group {(i['group_id'] or 'node-wide')[:8]} " + f"expires {i['expires_at']}") + return + + if not args.target: + print(f"usage: meshbay-node member {sub} ") + sys.exit(1) + + if sub == "invite": + group_id = _resolve_group(cfg, args.group) + out = _daemon_api( + cfg, f"/api/groups/{group_id}/invites?username={args.target}", + method="POST") + from meshbay_node.roster import write_code_file + path = write_code_file(cfg.data_dir, out["code"], + out.get("expires_at", ""), name="invite-code") + print(f"INVITATION CODE {out['code']}") + print(f"valid until {out.get('expires_at', '?')}") + print() + print(f"Send it to {args.target} however you normally talk. It works") + print("once, for that account only, and never passes through the hub.") + print("They enter it the first time they open the group — you do not") + print("need to be online then.") + print() + print(f"also written to {path}") + 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) + + if sub == "revoke": + group_id = _resolve_group(cfg, args.group) + out = _daemon_api( + cfg, f"/api/members/{match['user_id']}/revoke?group_id={group_id}", + method="POST") + print(f"{args.target} revoked from {group_id[:8]}") + print("They stop receiving the group key on their next connection.") + print("They still hold the current one — rotate it:") + print(f" meshbay-node gek-init --group {group_id}") + return + + if sub == "unpin": + _daemon_api(cfg, f"/api/members/{match['user_id']}/unpin", method="POST") + print(f"{args.target} unpinned — they can pair again with a new key") + print(f"issue a code: meshbay-node member invite {args.target}") + return + + print("usage: meshbay-node member list|invite|revoke|unpin") + sys.exit(1) + if args.command == "gek-init": cfg = load_config(args.config or DEFAULT_CONFIG_PATH) 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 f231792..dab1497 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -36,7 +36,22 @@ log = logging.getLogger(__name__) # when reading a code aloud or typing it from a phone screen. _ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy -DEFAULT_INVITE_TTL = 24 * 3600 # seconds + +# Two different rhythms, so two different lifetimes. +# +# An invitation crosses a human conversation: it is sent by mail or message and +# answered whenever the other person next looks. A day is not enough — the code +# dies over a weekend and someone has to be at a browser, with the node online, to +# issue another one. +# +# Operator pairing crosses an SSH session: the code is printed and typed minutes +# later. There is no reason for it to outlive the sitting. +# +# The longer window costs little: a code is single use, bound to one account, +# never seen by the hub, and 40 bits do not fall to guessing in a week against the +# node-wide lockout. +DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations +DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing _SCHEMA = """\ CREATE TABLE IF NOT EXISTS identities ( @@ -358,14 +373,17 @@ async def open_roster(data_dir: Path) -> Roster: return roster -def write_code_file(data_dir: Path, code: str, expires_at: str) -> Path: +def write_code_file(data_dir: Path, code: str, expires_at: str, + name: str = "pair-code") -> Path: """ Leave the code in a file as well as on stdout. An operator working over SSH may not be able to copy out of their terminal, and a code that can only be read off a scrolled-away screen is a dead end. + Pairing and invitation codes go to different files so one does not overwrite + the other. """ - path = data_dir / "pair-code" + path = data_dir / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(f"{code}\nexpires {expires_at}\n") os.chmod(path, 0o600) 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 fe3ee2e..10dfcb0 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -70,6 +70,7 @@ from meshbay_common.join import ( from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes from meshbay_common.protocol import MNP from meshbay_node.indexer import GroupIndex +from meshbay_node.roster import DEFAULT_INVITE_TTL log = logging.getLogger(__name__) @@ -1330,6 +1331,7 @@ class WebRTCPeerSession: user_id=payload["user_id"], role=ROLE_MEMBER, created_by=self._user_id or "", + ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL), ) 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 e671b72..2f73868 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -24,7 +24,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from meshbay_node import __version__ from meshbay_common.crypto import generate_gek, wrap_gek_aes -from meshbay_common.join import ROLE_OPERATOR +from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR log = logging.getLogger(__name__) @@ -235,11 +235,14 @@ def create_ui_app(state: dict) -> FastAPI: if not roster or not user_id: return JSONResponse({"error": "Node not connected to hub yet"}, 503) + config = state.get("config") + ttl = (config.node.pair_ttl_hours if config else 24) * 3600 code = await roster.create_invite( group_id="", # operator authority is node-wide user_id=user_id, role=ROLE_OPERATOR, created_by="local-cli", + ttl=ttl, ) invites = await roster.list_invites() expires = next((i["expires_at"] for i in invites @@ -247,16 +250,85 @@ def create_ui_app(state: dict) -> FastAPI: return {"code": code, "expires_at": expires, "user_id": user_id} @app.get("/api/roster") - async def api_roster(): + async def api_roster(group_id: str = ""): roster = state.get("roster") if not roster: - return {"identities": [], "members": [], "pending_invites": 0} + return {"identities": [], "members": [], "invites": []} return { "identities": await roster.list_identities(), - "members": await roster.list_members(), - "pending_invites": len(await roster.list_invites()), + "members": await roster.list_members(group_id or None), + "invites": await roster.list_invites(), } + @app.post("/api/groups/{group_id}/invites") + async def create_invite(group_id: str, username: str): + """ + Issue an invitation code from the CLI, without a browser. + + The hub is asked for the account id and nothing else — never for a key. + A hub that answered with the wrong account would produce an invite whose + code it never learns, since the code goes to a human out of band. + """ + roster = state.get("roster") + groups_ctx = state.get("groups_ctx", {}) + if not roster: + return JSONResponse({"error": "Roster not available"}, 503) + if group_id not in groups_ctx: + return JSONResponse({"error": "Group not hosted on this node"}, 404) + + hub = state.get("hub") + if not hub or not hub._session: + return JSONResponse({"error": "Hub not connected"}, 503) + try: + account = await hub.get_user_pubkeys(username) + except Exception as e: + return JSONResponse({"error": f"Unknown user {username!r}: {e}"}, 404) + + config = state.get("config") + ttl = (config.node.invite_ttl_hours if config else 168) * 3600 + code = await roster.create_invite( + group_id=group_id, + user_id=account["user_id"], + role=ROLE_MEMBER, + created_by="local-cli", + ttl=ttl, + ) + invites = await roster.list_invites() + expires = next((i["expires_at"] for i in invites + if i["user_id"] == account["user_id"] + and i["group_id"] == group_id), "") + return {"code": code, "expires_at": expires, + "username": username, "user_id": account["user_id"]} + + @app.post("/api/members/{user_id}/revoke") + async def revoke_member(user_id: str, group_id: str): + """ + Stop serving the group key to someone. + + Takes effect on their next connection: the key is wrapped on demand, so + there is no stored bundle left behind that would outlive this. Rotating + the group key is still required — they hold the current one. + """ + roster = state.get("roster") + if not roster: + return JSONResponse({"error": "Roster not available"}, 503) + if not await roster.set_status(group_id, user_id, "revoked"): + return JSONResponse({"error": "No such member in that group"}, 404) + log.info("Member revoked: user=%s group=%s", user_id[:8], group_id[:8]) + return {"status": "revoked", "user_id": user_id, "group_id": group_id, + "reminder": "rotate the group key: meshbay-node gek-init"} + + @app.post("/api/members/{user_id}/unpin") + async def unpin_member(user_id: str): + """Forget a pinned identity, so the person can pair again with a new key.""" + roster = state.get("roster") + if not roster: + return JSONResponse({"error": "Roster not available"}, 503) + if not await roster.unpin(user_id): + return JSONResponse({"error": "No such pinned identity"}, 404) + log.info("Identity unpinned: user=%s", user_id[:8]) + return {"status": "unpinned", "user_id": user_id} + # ── GEK initialization (operator only, localhost) ────────────────────── @app.post("/api/groups/{group_id}/gek") @@ -376,7 +448,17 @@ def create_ui_app(state: dict) -> FastAPI: @app.get("/", response_class=HTMLResponse) async def root(): - return _render_page(state) + # Roster reads are async and the page renderer is not, so gather here. + roster = state.get("roster") + roster_view = None + if roster: + identities = {i["user_id"]: i for i in await roster.list_identities()} + roster_view = { + "identities": identities, + "members": await roster.list_members(), + "invites": await roster.list_invites(), + } + return _render_page(state, roster_view) @app.get("/audit", response_class=HTMLResponse) async def audit_page(): @@ -395,7 +477,70 @@ def _fmt_size(n: int) -> str: return f"{n / (1024 * 1024 * 1024):.2f} GB" -def _render_page(state: dict) -> str: +def _render_roster(roster_view: dict | None) -> str: + """ + Who this node recognises, and which keys are theirs. + + Every value here is escaped: usernames come from the hub and pass through the + roster, so they are attacker-influenced text on the operator's own admin page + (the H2 rule applies to them exactly as it does to filenames). + """ + if roster_view is None: + return '

Roster unavailable

' + + identities = roster_view["identities"] + rows = "" + for m in roster_view["members"]: + ident = identities.get(m["user_id"], {}) + scope = escape(m["group_id"][:8]) if m["group_id"] else "node-wide" + status_color = "#22c55e" if m["status"] == "active" else "#ef4444" + rows += ( + f"{escape(str(ident.get('username') or m['user_id']))}" + f"{escape(str(m['role']))}" + f"" + f"{escape(str(m['status']))}" + f"{scope}" + f"{escape(str(ident.get('pk_ed25519', ''))[:16])}…" + f"{escape(str(ident.get('pinned_at', '?')))} " + f"({escape(str(ident.get('pinned_via', '?')))})" + ) + if not rows: + rows = ('Nobody admitted yet — ' + 'run meshbay-node member invite <username>') + + invite_rows = "" + for i in roster_view["invites"]: + invite_rows += ( + f"{escape(str(i['user_id'])[:16])}" + f"{escape(str(i['group_id'][:8] or 'node-wide'))}" + f"{escape(str(i['role']))}" + f"{escape(str(i['expires_at']))}" + ) + invites_html = "" + if invite_rows: + invites_html = f""" +
Pending invitations + + + {invite_rows} +
AccountGroupRoleExpires
+
""" + + return f""" + + + + {rows} +
UserRoleStatusScopeIdentity keyPinned
+ {invites_html} +

+ Codes are issued from the CLI: meshbay-node operator pair, + meshbay-node member invite <username>. They never pass + through the hub. +

""" + + +def _render_page(state: dict, roster_view: dict | None = None) -> str: token_js = json.dumps(state.get("ui_token", "")) status = state.get("status", "starting") indexes = state.get("indexes", {}) @@ -557,6 +702,9 @@ def _render_page(state: dict) -> str: {peers_html} +

Roster

+ {_render_roster(roster_view)} +

Groups

{groups_html or '

No groups configured

'} diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index e0492ae..11704ce 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -445,6 +445,52 @@ async def test_revoked_member_stops_receiving_the_key(tmp_path, roster): assert _last(session).get("gek") is False +# ── Code lifetimes ──────────────────────────────────────────────────────────── + +async def test_invitations_outlive_pairing_codes(roster): + """ + An invitation crosses a human conversation; a pairing code crosses an SSH + session. A day was long enough for the second and not for the first — a code + that dies over a weekend means someone has to be at a browser to reissue it. + """ + from meshbay_node.roster import DEFAULT_INVITE_TTL, DEFAULT_PAIR_TTL + + assert DEFAULT_INVITE_TTL == 7 * 24 * 3600 + assert DEFAULT_PAIR_TTL == 24 * 3600 + assert DEFAULT_INVITE_TTL > DEFAULT_PAIR_TTL + + +def test_code_lifetimes_are_configurable(tmp_path): + """The operator decides, not the default.""" + from meshbay_node.config import load_config + + path = tmp_path / "node.toml" + path.write_text( + '[hub]\nurl = "https://example.org"\nusername = "grenet"\n' + "[node]\ninvite_ttl_hours = 72\npair_ttl_hours = 2\n" + ) + cfg = load_config(path) + assert cfg.node.invite_ttl_hours == 72 + assert cfg.node.pair_ttl_hours == 2 + + default = load_config(tmp_path / "missing.toml") + assert default.node.invite_ttl_hours == 168 + assert default.node.pair_ttl_hours == 24 + + +async def test_expiry_is_enforced_at_redemption(tmp_path, roster): + """Purging is housekeeping; the check that matters happens on use.""" + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli", ttl=-1) + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code)) + + assert _last(session).get("reason") == "code_invalid" + assert await roster.get_identity("grenet") is None + + # ── M3: where node authority comes from ─────────────────────────────────────── async def test_admin_signature_verified_against_the_paired_key(tmp_path, roster): @@ -475,6 +521,150 @@ async def test_unpinned_operator_loses_authority_immediately(tmp_path, roster): assert not await session._verify_admin_sig(transcript, sk_ed.sign(transcript)) +# ── Operator surface (slice 3) ──────────────────────────────────────────────── + +def _ui_client(tmp_path, roster, **extra): + from fastapi.testclient import TestClient + + from meshbay_node.config import Config + from meshbay_node.ui.app import create_ui_app + + state = { + "status": "running", "groups_ctx": {GROUP: {"gek": b"k" * 32}}, + "indexes": {}, "ui_token": "tok", "roster": roster, + "node_user_id": "grenet", "config": Config(), + } + state.update(extra) + return TestClient(create_ui_app(state)), state + + +async def test_revoke_endpoint_stops_authorization(tmp_path, roster): + client, _ = _ui_client(tmp_path, roster) + _, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP, "bob", ROLE_MEMBER, "active", "grenet") + assert await roster.is_authorized(GROUP, "bob") + + resp = client.post(f"/api/members/bob/revoke?group_id={GROUP}&t=tok") + assert resp.status_code == 200 + assert "gek-init" in resp.json()["reminder"], ( + "revocation must remind the operator to rotate the key they still hold") + assert not await roster.is_authorized(GROUP, "bob") + + +async def test_unpin_endpoint_allows_repairing(tmp_path, roster): + client, _ = _ui_client(tmp_path, roster) + _, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code") + + assert client.post("/api/members/bob/unpin?t=tok").status_code == 200 + assert await roster.get_identity("bob") is None + assert client.post("/api/members/bob/unpin?t=tok").status_code == 404 + + +async def test_operator_surface_needs_the_session_token(tmp_path, roster): + """11.5.3 applies to every one of these: they change who may hold the key.""" + client, _ = _ui_client(tmp_path, roster) + for path in ("/api/roster", + "/api/operator/pair", + f"/api/members/bob/revoke?group_id={GROUP}", + "/api/members/bob/unpin", + f"/api/groups/{GROUP}/invites?username=bob"): + method = client.get if path == "/api/roster" else client.post + assert method(path).status_code == 403, f"{path} reachable without a token" + + +async def test_cli_invite_asks_the_hub_for_an_account_never_a_key(tmp_path, roster): + """ + The CLI resolves a username to an account id through the hub, and stops there. + A key fetched from the hub is what H3 was; an account id is not a secret and + a wrong one produces an invite whose code the hub never learns. + """ + class _Hub: + _session = object() + + async def get_user_pubkeys(self, username): + return {"user_id": f"id-of-{username}", + "pk_x25519": "SHOULD-NOT-BE-USED", + "pk_ed25519": "SHOULD-NOT-BE-USED"} + + client, _ = _ui_client(tmp_path, roster, hub=_Hub()) + resp = client.post(f"/api/groups/{GROUP}/invites?username=bob&t=tok") + assert resp.status_code == 200 + body = resp.json() + assert body["user_id"] == "id-of-bob" + + invites = await roster.list_invites() + assert [i["user_id"] for i in invites] == ["id-of-bob"] + # Whatever the hub said about keys was never stored anywhere. + assert "SHOULD-NOT-BE-USED" not in str(invites) + assert await roster.get_identity("id-of-bob") is None + + +def _run_cli(monkeypatch, tmp_path, argv, responses): + """Drive the real CLI with the daemon API stubbed, capturing the calls.""" + import sys as _sys + + from meshbay_node import daemon as _daemon + + calls = [] + + def fake_api(cfg, path, method="GET", timeout=30): + calls.append((method, path)) + for key, value in responses.items(): + if key in path: + return value + return {} + + monkeypatch.setattr(_daemon, "_daemon_api", fake_api) + + conf = tmp_path / "node.toml" + conf.write_text( + f'data_dir = "{tmp_path}"\n' + '[hub]\nurl = "https://example.org"\nusername = "grenet"\n' + f'[[groups]]\nid = "{GROUP}"\nname = "demo"\n' + f'shared_dir = "{tmp_path}"\n' + ) + monkeypatch.setattr(_sys, "argv", + ["meshbay-node", *argv, "--config", str(conf)]) + try: + _daemon.main() + except SystemExit as e: + calls.append(("exit", e.code)) + return calls + + +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": []} + + calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "bob"], + {"/api/roster": roster_reply, + "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"}}) + 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_daemon_does_not_auto_pin_keystore_key(): """ M3: the daemon used to auto-pin its own keystore key as the admin key, while diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 6bb680c..dcd9cf6 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -579,3 +579,26 @@ def test_admin_ui_escapes_filenames(tmp_path): assert payload not in html, "filename rendered unescaped — stored XSS (H2)" assert "<img" in html, "filename should appear escaped" + +def test_admin_ui_escapes_roster_usernames(tmp_path): + """ + H2 again, for the roster: usernames originate at the hub and land on the + operator's own admin page, which can re-key groups and read the audit log. + """ + from meshbay_node.ui.app import _render_page + + payload = '' + html = _render_page( + {"status": "running", "groups_ctx": {}, "indexes": {}}, + { + "identities": {"u1": {"user_id": "u1", "username": payload, + "pk_ed25519": "AAA", "pinned_at": "now", + "pinned_via": "code"}}, + "members": [{"group_id": "", "user_id": "u1", "role": "operator", + "status": "active"}], + "invites": [], + }, + ) + + assert payload not in html, "username rendered unescaped — stored XSS (H2)" + assert "<img" in html -- cgit v1.2.3 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 --- .../src/meshbay_hub/static/transport.js | 10 +++++ packages/meshbay-node/src/meshbay_node/daemon.py | 12 ++---- packages/meshbay-node/src/meshbay_node/roster.py | 18 ++++++-- .../src/meshbay_node/transport/webrtc_server.py | 13 +++++- packages/meshbay-node/src/meshbay_node/ui/app.py | 26 ++++++++++++ packages/meshbay-node/tests/test_roster_pairing.py | 48 ++++++++++++++-------- .../meshbay-node/tests/test_webrtc_transport.py | 10 ++++- 7 files changed, 105 insertions(+), 32 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/roster.py') 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 ` 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, -- cgit v1.2.3