diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops/members.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops/members.py | 314 |
1 files changed, 314 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops/members.py b/packages/meshbay-node/src/meshbay_node/ops/members.py new file mode 100644 index 0000000..2fd3899 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/ops/members.py @@ -0,0 +1,314 @@ +"""Who may use the node: pairing, invitations, revocation.""" + +from __future__ import annotations + +import logging +from urllib.parse import urlsplit + +from meshbay_common.crypto import pk_to_b64 +from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR + +from meshbay_node.ops.core import OpError, _group_ctx, _hub, _roster +from meshbay_node.roster import LinkInviteLimit + +log = logging.getLogger("meshbay_node.ops") + + +# ── Roster ─────────────────────────────────────────────────────────────────── + +async def read_roster(state: dict, group_id: str = "") -> dict: + roster = state.get("roster") + if not roster: + return {"identities": [], "members": [], "invites": []} + members = await roster.list_members(group_id or None) + members = [m for m in members if m.get("pk_ed25519") is not None] + return { + "identities": await roster.list_identities(), + "members": members, + "invites": await roster.list_invites(), + } + + +async def resolve_user(state: dict, username: str) -> dict: + """ + Map a username to an account id. + + 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 there. + """ + 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 + raise OpError(f"Unknown user {username!r}", status=404) + + +async def pair_operator(state: dict) -> dict: + """ + Issue a one-time code that pairs a browser as this node's operator. + + The code is the whole point: it binds the operator's browser identity key to + their account without asking the hub, which is what stops a hub from naming + itself node administrator (M3, and the same substitution as H3). Returned + once and stored only as a hash. + """ + roster = _roster(state) + user_id = state.get("node_user_id") + if not user_id: + raise OpError("Node not connected to hub yet", status=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, + username=(config.hub.username if config else ""), + ) + invites = await roster.list_invites() + expires = next((i["expires_at"] for i in invites + if i["user_id"] == user_id and i["role"] == ROLE_OPERATOR), "") + return {"code": code, "expires_at": expires, "user_id": user_id} + + +async def create_invite(state: dict, group_id: str, username: str, *, + user_id: str = "", + created_by: str = "local-cli") -> dict: + """ + Issue an invitation code. + + 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. + + When ``user_id`` is supplied directly (MNP path), the hub lookup is skipped. + """ + roster = _roster(state) + _group_ctx(state, group_id) + if not user_id: + hub = _hub(state) + try: + account = await hub.get_user_pubkeys(username) + except Exception as e: + raise OpError(f"Unknown user {username!r}: {e}", status=404) from e + user_id = account["user_id"] + + # Hub membership first, and fatal if it fails. + # + # `/v1/groups/mine` joins `GroupMember`, so someone who was never registered + # does not see the group at all and can never redeem the code. Creating the + # invite first and tolerating a failed registration — which is what this did + # — hands the operator a code that cannot work, and says nothing. Worse, an + # unreachable hub raised *after* the roster write, leaving a valid code + # nobody was ever given; every retry left another. + # + # Registering before the roster write means a failure costs nothing: no code + # exists to be orphaned. A membership row without an invite is harmless — + # without the code there is still no group key. + # + # The endpoint is idempotent (`if not mem: db.add(...)`, no 409), so the SPA + # registering the same membership again right after `createInvite` + # (group-settings.js) costs nothing either. + # + # Skipped only when there is no username to register with: the MNP path + # allows an empty one (`username || ''` in transport.js), and there the SPA + # is the one that registers. + if username: + try: + await _hub(state).add_group_member(group_id, username) + except Exception as e: + raise OpError( + f"Could not register {username!r} on the hub, so the invite " + f"could not be redeemed: {e}", status=502) from e + + 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=user_id, + role=ROLE_MEMBER, + created_by=created_by, + ttl=ttl, + username=username, + ) + + invites = await roster.list_invites() + expires = next((i["expires_at"] for i in invites + if i["user_id"] == user_id + and i["group_id"] == group_id), "") + return {"code": code, "expires_at": expires, + "username": username, "user_id": user_id} + + +async def create_link_invite(state: dict, group_id: str, *, + created_by: str = "local-cli") -> dict: + """ + Issue a code bound to no account, for an invitation link. + + Nothing is registered on the hub here, unlike `create_invite`: there is no + account to register yet. The hub half is a ticket the inviter's client asks + the hub for, bound to the invitee's address (docs/MESHBAY_DESIGN.md §7.3). + """ + roster = _roster(state) + _group_ctx(state, group_id) + config = state.get("config") + ttl = (config.node.invite_ttl_hours if config else 168) * 3600 + try: + code, invite_id, expires = await roster.create_link_invite( + group_id, created_by, ttl=ttl) + except LinkInviteLimit as e: + raise OpError(str(e), status=429) from e + log.info("Invitation link issued: group=%s invite=%s", group_id[:8], invite_id[:8]) + return {"code": code, "invite_id": invite_id, "expires_at": expires, + "group_id": group_id} + + +async def cancel_invite(state: dict, group_id: str, invite_id: str) -> dict: + """Take back an unredeemed invitation link. Unknown or spent is a refusal, + so a mistyped handle does not read as success.""" + roster = _roster(state) + _group_ctx(state, group_id) + if not await roster.cancel_invite(group_id, invite_id): + raise OpError("No unredeemed invitation link with that id in this group", + status=404) + log.info("Invitation link cancelled: group=%s invite=%s", group_id[:8], invite_id[:8]) + return {"cancelled": True, "invite_id": invite_id, "group_id": group_id} + + +def _invite_url(hub_url: str, group_id: str, ticket: str, node_pk_b64: str, code: str) -> str: + """ + An invitation link, in the one shape the hub and the interface also write + (docs/MESHBAY_DESIGN.md §3.4): everything after `#`, and the node key + URL-safe and unpadded. `test_invite_link_client.py` (hub) holds it to the hub's. + """ + parts = urlsplit(hub_url) + origin = f"{parts.scheme}://{parts.netloc}" + n = node_pk_b64.replace("+", "-").replace("/", "_").rstrip("=") + return f"{origin}/#/invite?v=1&g={group_id}&t={ticket}&n={n}&c={code}" + + +async def create_link_invitation(state: dict, group_id: str, email: str, *, + created_by: str = "local-cli") -> dict: + """ + A whole invitation link, from the operator's own machine: the node's code, + then the hub's ticket bound to `email`, then the link. + + In that order because the ticket names the code's handle. A ticket the hub + refuses takes the code back with it — a code nobody can reach the node with + would only hold one of the group's places. The hub is never asked to mail: + the operator sends the link. + """ + email = (email or "").strip() + if "@" not in email: + raise OpError("An invitation link is bound to an e-mail address", status=422) + hub = _hub(state) + sk_node = state.get("sk_node") + if sk_node is None: + raise OpError("Node key not loaded", status=503) + node = await create_link_invite(state, group_id, created_by=created_by) + try: + ticket = await hub.create_invite_link( + group_id, email, node["expires_at"], node["invite_id"]) + except Exception as e: + await _roster(state).cancel_invite(group_id, node["invite_id"]) + raise OpError(f"The hub refused the link, so none was made: {e}", + status=502) from e + return { + "link": _invite_url(hub.hub_url, group_id, ticket["ticket"], + pk_to_b64(sk_node.public_key()), node["code"]), + "expires_at": ticket["expires_at"], + "invite_id": node["invite_id"], + "email": email, + } + + +async def cancel_link_invitation(state: dict, group_id: str, invite_id: str) -> dict: + """ + Take a link back, both halves: the node's code first, which is what stops + anyone joining, then the hub's ticket — attempted even when the first half + finds nothing to cancel, so neither is left behind (the member-removal rule). + """ + roster = _roster(state) + _group_ctx(state, group_id) + node_cancelled = await roster.cancel_invite(group_id, invite_id) + hub_cancelled = False + hub = state.get("hub") + if hub and hub._session: + try: + for link in await hub.list_invite_links(group_id): + if link.get("node_invite_id") == invite_id and link.get("status") == "pending": + await hub.delete_invite_link(group_id, link["link_id"]) + hub_cancelled = True + except Exception as e: + log.warning("Invitation link %s: the hub half was not cancelled: %s", + invite_id[:8], e) + if not node_cancelled and not hub_cancelled: + raise OpError("No unredeemed invitation link with that id in this group", + status=404) + log.info("Invitation link cancelled: group=%s invite=%s node=%s hub=%s", + group_id[:8], invite_id[:8], node_cancelled, hub_cancelled) + return {"cancelled": True, "invite_id": invite_id, + "node": node_cancelled, "hub": hub_cancelled} + + +async def revoke_member(state: dict, user_id: str, group_id: str) -> dict: + """ + 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. + + **An unredeemed invite is a membership that has not happened yet**, so it is + revoked here too, and on its own it is enough for this to be a removal. A + member row appears only when a code is consumed: somebody invited to the + wrong group has none, this refused them with "no such member", and the + browser's removal — node half first, deliberately — died on that refusal + before it reached the hub half. They stayed a member on the hub, with a live + code, and the interface offered no other way to take either back. + """ + roster = _roster(state) + revoked = await roster.set_status(group_id, user_id, "revoked") + dropped = await roster.drop_invites(group_id, user_id) + if not revoked and not dropped: + raise OpError("No such member in that group", status=404) + log.info("Member revoked: user=%s group=%s member=%s invites_dropped=%d", + user_id[:8], group_id[:8], revoked, dropped) + return {"status": "revoked", "user_id": user_id, "group_id": group_id, + "was_member": revoked, "invites_dropped": dropped, + # Only what is true: somebody who never redeemed a code never held + # the key, and telling an operator to rotate it teaches them that + # the advice is noise. + "reminder": ("rotate the group key: meshbay-node gek rotate" + if revoked else "")} + + +async def unpin_member(state: dict, user_id: str) -> dict: + """Forget a pinned identity, so the person can pair again with a new key.""" + roster = _roster(state) + if not await roster.unpin(user_id): + raise OpError("No such pinned identity", status=404) + # Drop the stored keypair bundle too. Left behind, it is served to the next + # connection, which then cannot open it (the passphrase may have changed + # since) and dies in the identity step before it ever reaches the join the + # unpin was meant to enable. + bundle_store = state.get("bundle_store") + if bundle_store: + try: + await bundle_store.delete_keypair(user_id) + except Exception: + log.warning("unpin: could not drop keypair bundle for %s", user_id[:8]) + log.info("Identity unpinned: user=%s", user_id[:8]) + return {"status": "unpinned", "user_id": user_id} |