diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 01:27:57 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 01:27:57 +0200 |
| commit | 8f6e2f724fd24a077de11d4a3b3ae069d369324d (patch) | |
| tree | 25b06d11f74b7b73e2056e1bb75c1b64af6c9650 /packages/meshbay-node/src/meshbay_node | |
| parent | f15efd23f66c521ca9206789482bb38e7326eeb4 (diff) | |
| download | meshbay-8f6e2f724fd24a077de11d4a3b3ae069d369324d.tar.gz | |
feat(node): operator surface — member list, invite, revoke, unpin over SSH
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 <username> one-time code; the node wraps the key when they
connect, so nobody has to be online then
member revoke <username> stop serving them the key
member unpin <username> 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: <nothing>" bug in the not-found path turned up.
Tests: 89 node here (roster, endpoints, CLI routing, TTL config).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
5 files changed, 286 insertions, 14 deletions
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 <username>") + 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} <username>") + 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 '<p class="muted">Roster unavailable</p>' + + 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"<tr><td>{escape(str(ident.get('username') or m['user_id']))}</td>" + f"<td>{escape(str(m['role']))}</td>" + f"<td><span class='badge' style='background:{status_color}'>" + f"{escape(str(m['status']))}</span></td>" + f"<td>{scope}</td>" + f"<td><code>{escape(str(ident.get('pk_ed25519', ''))[:16])}…</code></td>" + f"<td>{escape(str(ident.get('pinned_at', '?')))} " + f"({escape(str(ident.get('pinned_via', '?')))})</td></tr>" + ) + if not rows: + rows = ('<tr><td colspan="6" class="muted">Nobody admitted yet — ' + 'run <code>meshbay-node member invite <username></code></td></tr>') + + invite_rows = "" + for i in roster_view["invites"]: + invite_rows += ( + f"<tr><td><code>{escape(str(i['user_id'])[:16])}</code></td>" + f"<td>{escape(str(i['group_id'][:8] or 'node-wide'))}</td>" + f"<td>{escape(str(i['role']))}</td>" + f"<td>{escape(str(i['expires_at']))}</td></tr>" + ) + invites_html = "" + if invite_rows: + invites_html = f""" + <details style="margin-top:10px"><summary>Pending invitations</summary> + <table> + <thead><tr><th>Account</th><th>Group</th><th>Role</th><th>Expires</th></tr></thead> + <tbody>{invite_rows}</tbody> + </table> + </details>""" + + return f""" + <table> + <thead><tr><th>User</th><th>Role</th><th>Status</th><th>Scope</th> + <th>Identity key</th><th>Pinned</th></tr></thead> + <tbody>{rows}</tbody> + </table> + {invites_html} + <p class="muted" style="margin-top:8px"> + Codes are issued from the CLI: <code>meshbay-node operator pair</code>, + <code>meshbay-node member invite <username></code>. They never pass + through the hub. + </p>""" + + +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: <tbody>{peers_html}</tbody> </table> + <h2>Roster</h2> + {_render_roster(roster_view)} + <h2>Groups</h2> {groups_html or '<p class="muted">No groups configured</p>'} |