diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-23 17:31:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-23 17:31:06 +0200 |
| commit | d30e95b2ce1ffe9dc4655855406f2784b5f7af34 (patch) | |
| tree | 91d86ffd59eb3593a76139ab264e1eb5da1ae687 /packages/meshbay-node/src/meshbay_node | |
| parent | 339cb427f886a0177014126bb684335837eff067 (diff) | |
| download | meshbay-d30e95b2ce1ffe9dc4655855406f2784b5f7af34.tar.gz | |
feat(node): invitation links — a code bound to no account until redeemed
New invite kind "link": member of one group, once, never operator, not
spendable by an active member, capped at 20 per group, cancellable by
handle. Signed ops invite_link_create / invite_cancel, loopback routes,
and the known-device join path now accepts a link code. Adds the plan,
docs/invite-links.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 6 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 37 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 134 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 116 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 8 |
5 files changed, 276 insertions, 25 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index c16e49f..34d186a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -2350,7 +2350,11 @@ def main() -> None: if invites: print() for i in invites: - print(f"pending invite user {i['user_id'][:12]} " + # A link names nobody until it is used, so its handle is what + # identifies it — and what `cancel` takes. + who = (f"link {i['invite_id']}" if i.get("kind") == "link" + else f"user {i['user_id'][:12]}") + print(f"pending invite {who} " f"group {(i['group_id'] or 'node-wide')[:8]} " f"expires {i['expires_at']}") return diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index b680d43..e6b2d1f 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -41,7 +41,7 @@ from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_node.roots import RootError, RootSet, off_disk -from meshbay_node.roster import Roster +from meshbay_node.roster import LinkInviteLimit, Roster log = logging.getLogger(__name__) @@ -236,6 +236,41 @@ async def create_invite(state: dict, group_id: str, username: str, *, "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/invite-links.md §3.5). + """ + 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} + + async def revoke_member(state: dict, user_id: str, group_id: str) -> dict: """ Stop serving the group key to someone. diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 9533f25..8360df3 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -29,6 +29,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path import aiosqlite +from meshbay_common.join import ROLE_MEMBER from meshbay_common.paths import fold log = logging.getLogger(__name__) @@ -58,6 +59,21 @@ DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing # keys by its hash, so a longer window widens nothing an attacker can use. DEFAULT_DEVICE_REQUEST_TTL = 3600 +# What an invitation is bound to. An `account` code names its user_id when it is +# issued; a `link` code names nobody until it is redeemed, because a link goes to +# someone who may not have an account yet (docs/invite-links.md). The kind is a +# column rather than an empty user_id so that "bound to nobody" can never be +# read as "bound to anybody" — the mistake AV1 records, once per codebase. +KIND_ACCOUNT = "account" +KIND_LINK = "link" +# Unredeemed links one group may hold. A link is a bearer code at this node, so +# the number outstanding is the number of strangers who could walk in with one. +MAX_LINK_INVITES_PER_GROUP = 20 + + +class LinkInviteLimit(Exception): + """The group already holds as many unredeemed links as it may.""" + _SCHEMA = """\ -- One row per DEVICE, not per person. A browser and a desktop client are two -- keys belonging to one account, and `user_id` alone as the key made the second @@ -146,7 +162,9 @@ CREATE TABLE IF NOT EXISTS invites ( created_by TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, - used_at TEXT + used_at TEXT, + kind TEXT NOT NULL DEFAULT 'account', + invite_id TEXT NOT NULL DEFAULT '' ); """ @@ -221,6 +239,14 @@ class Roster: if "username" not in columns: await self._db.execute( "ALTER TABLE invites ADD COLUMN username TEXT NOT NULL DEFAULT ''") + # The same, for invitation links. Every row that predates them was + # issued to an account, which is what the default says. + if "kind" not in columns: + await self._db.execute( + "ALTER TABLE invites ADD COLUMN kind TEXT NOT NULL DEFAULT 'account'") + if "invite_id" not in columns: + await self._db.execute( + "ALTER TABLE invites ADD COLUMN invite_id TEXT NOT NULL DEFAULT ''") await self._migrate_identities_to_devices() await self._db.commit() @@ -1067,20 +1093,72 @@ class Roster: """ 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), + "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL " + "AND kind = ?", + (group_id, user_id, KIND_ACCOUNT), ) code = generate_code() expires = datetime.now(UTC) + timedelta(seconds=ttl) await self._db.execute( "INSERT INTO invites (code_hash, group_id, user_id, username, role, " - "created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "created_by, created_at, expires_at, kind) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (hash_code(code), group_id, user_id, username, role, created_by, _now(), - expires.isoformat(timespec="seconds")), + expires.isoformat(timespec="seconds"), KIND_ACCOUNT), ) await self._db.commit() return code + async def create_link_invite( + self, group_id: str, created_by: str, ttl: int = DEFAULT_INVITE_TTL, + ) -> tuple[str, str, str]: + """ + Issue a code bound to no account: `(code, invite_id, expires_at)`. + + Always a member of one named group — never node-wide, never an operator, + because whoever holds it is admitted without being named. `invite_id` + is a handle for cancelling it, random and unrelated to the code, so it + can be shown and sent about where the code must not be. + """ + assert self._db + if not group_id: + raise ValueError("an invitation link names a group") + async with self._db.execute( + "SELECT COUNT(*) FROM invites WHERE group_id = ? AND kind = ? " + "AND used_at IS NULL AND expires_at > ?", + (group_id, KIND_LINK, _now()), + ) as cur: + (outstanding,) = await cur.fetchone() + if outstanding >= MAX_LINK_INVITES_PER_GROUP: + raise LinkInviteLimit( + f"this group already has {outstanding} unredeemed invitation links; " + "cancel one or wait for one to be used or to expire") + code = generate_code() + invite_id = secrets.token_hex(16) + expires = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat(timespec="seconds") + await self._db.execute( + "INSERT INTO invites (code_hash, group_id, user_id, username, role, " + "created_by, created_at, expires_at, kind, invite_id) " + "VALUES (?, ?, '', '', ?, ?, ?, ?, ?, ?)", + (hash_code(code), group_id, ROLE_MEMBER, created_by, _now(), expires, + KIND_LINK, invite_id), + ) + await self._db.commit() + return code, invite_id, expires + + async def cancel_invite(self, group_id: str, invite_id: str) -> bool: + """Take back an unredeemed link of this group. A redeemed one stays: it + is the record that the join happened.""" + assert self._db + if not invite_id: + return False + cur = await self._db.execute( + "DELETE FROM invites WHERE kind = ? AND invite_id = ? AND group_id = ? " + "AND used_at IS NULL", + (KIND_LINK, invite_id, group_id), + ) + await self._db.commit() + return cur.rowcount > 0 + async def drop_invites(self, group_id: str, user_id: str) -> int: """ Cancel any code this person has not redeemed yet for this group. @@ -1090,16 +1168,22 @@ class Roster: """ assert self._db cur = await self._db.execute( - "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL", - (group_id, user_id), + "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL " + "AND kind = ?", + (group_id, user_id, KIND_ACCOUNT), ) await self._db.commit() return cur.rowcount - async def consume_invite(self, code: str, user_id: str) -> dict | None: + async def consume_invite(self, code: str, user_id: str, + group_id: str = "") -> dict | None: """ Redeem a code for `user_id`, or return None. + `group_id` is the group the redeeming connection authenticated to. An + account code ignores it, as it always has; a link requires it, and it + must be the link's own group. + 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. """ @@ -1113,19 +1197,35 @@ class Roster: 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: + if invite["used_at"] is not None or not user_id: return None if datetime.fromisoformat(invite["expires_at"]) < datetime.now(UTC): return None - cur = await self._db.execute( - "UPDATE invites SET used_at = ? WHERE code_hash = ? AND used_at IS NULL", - (_now(), code_hash), - ) + if invite["kind"] == KIND_ACCOUNT: + # 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 + update = ("UPDATE invites SET used_at = ? WHERE code_hash = ? " + "AND used_at IS NULL", (_now(), code_hash)) + elif invite["kind"] == KIND_LINK: + # Whoever holds it — so everything else about it is fixed: a member, + # of the group it names, reached through that group, by somebody not + # already in it. A row that says anything else is not honoured. + if (invite["role"] != ROLE_MEMBER or not invite["group_id"] + or invite["group_id"] != group_id): + return None + member = await self.get_member(group_id, user_id) + if member and member["status"] == "active": + return None + update = ("UPDATE invites SET used_at = ?, user_id = ? WHERE code_hash = ? " + "AND used_at IS NULL", (_now(), user_id, code_hash)) + invite["user_id"] = user_id + else: + return None + + cur = await self._db.execute(*update) await self._db.commit() if cur.rowcount == 0: return None 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 809c5c5..95b628f 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -55,7 +55,9 @@ from meshbay_common.adminop import ( OP_GEK_ROTATE, OP_GROUP_ATTACH, OP_GROUP_DETACH, + OP_INVITE_CANCEL, OP_INVITE_CREATE, + OP_INVITE_LINK_CREATE, OP_MEMBER_REVOKE, OP_MEMBER_UNPIN, OP_MUSICBRAINZ_ENABLED, @@ -147,6 +149,7 @@ from meshbay_node.roots import ( off_disk, safe_subdir, ) +from meshbay_node.roster import KIND_ACCOUNT, KIND_LINK from meshbay_node.transfers import TransferSlots from meshbay_node.transport.wire import index_sync_message @@ -181,6 +184,9 @@ USER_BLOB_ACCOUNT_MAX = 8 * 1024 * 1024 _USER_BLOB_KIND_RE = re.compile( r"^(playlists|playlist:[A-Za-z0-9_-]{1,64})$") +# An invitation link's handle, as `roster.create_link_invite` mints it. +_INVITE_ID_RE = re.compile(r"[0-9a-f]{32}") + # Chat link-preview results, kept in memory only (docs/MESHBAY_DESIGN.md §6.5: # the node # produces enrichment on demand and keeps nothing durable — the asking device @@ -704,6 +710,10 @@ class WebRTCPeerSession: self._do_admin_response(msg) elif mtype == MNP.INVITE_CREATE: self._do_invite_create(msg) + elif mtype == MNP.INVITE_LINK_CREATE: + self._do_invite_link_create(msg) + elif mtype == MNP.INVITE_CANCEL: + self._do_invite_cancel(msg) elif mtype == MNP.MEMBER_REVOKE: self._do_member_revoke(msg) elif mtype == MNP.DEVICE_REQUEST: @@ -1228,6 +1238,44 @@ class WebRTCPeerSession: "username": str(msg.get("username", ""))[:64], }) + def _do_invite_link_create(self, msg: dict) -> None: + """ + Issue a code bound to no account, for an invitation link — into the + group this connection authenticated to, and no other: a link names its + group, so the operator signs for exactly that one (docs/invite-links.md). + """ + group_id = self._group_id or "" + if not group_id: + self._send({"type": "error", "detail": "No group on this connection"}) + return + if msg.get("group_id") and msg["group_id"] != group_id: + self._send({"type": "error", "detail": "Wrong group for this session"}) + return + if not self._has_admin_authority(): + self._send({ + "type": "error", + "detail": "No operator paired — run `meshbay-node operator pair`", + }) + return + self._issue_admin_challenge( + OP_INVITE_LINK_CREATE, f"link:{group_id}", {"group_id": group_id}) + + def _do_invite_cancel(self, msg: dict) -> None: + """Take back an unredeemed link of this group, by its handle.""" + group_id = self._group_id or "" + invite_id = str(msg.get("invite_id", "")) + if not group_id: + self._send({"type": "error", "detail": "No group on this connection"}) + return + if not _INVITE_ID_RE.fullmatch(invite_id): + self._send({"type": "error", "detail": "Not an invitation id"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge( + OP_INVITE_CANCEL, invite_id, {"group_id": group_id, "invite_id": invite_id}) + async def _do_keypair_bundle_fetch(self) -> None: """Serve the caller's encrypted keypair bundle during the handshake window.""" bundle_store = self._ctx.get("bundle_store") @@ -1561,14 +1609,21 @@ class WebRTCPeerSession: # re-invited). Without this gate a stale roster row lets them # back in without proving they received the new code. pending_invite = any( - i["user_id"] == user_id + i["kind"] == KIND_ACCOUNT + and i["user_id"] == user_id and i["group_id"] in (session_group, "") for i in await roster.list_invites()) - if pending_invite: + # Or they bring a link for this group: somebody already pinned here + # through another group, which is the ordinary case for a link, or + # somebody removed from it and invited back. Only when they are not + # an active member — a member opening the group leaves the link for + # whoever it was meant for. + active = bool(member) and member.get("status") == "active" + if pending_invite or (code and not active): if not code: self._join_refuse("code_required") return - invite = await roster.consume_invite(code, user_id) + invite = await roster.consume_invite(code, user_id, session_group) if not invite: self._join_refuse("code_invalid") return @@ -1580,7 +1635,8 @@ class WebRTCPeerSession: self._audit_join( "join_pinned", f"group={invite['group_id'][:8]} role={invite['role']} " - "via=code (device already known)") + f"via={'link' if invite['kind'] == KIND_LINK else 'code'} " + "(device already known)") member = (await roster.get_member(session_group, user_id) or await roster.get_member(invite["group_id"], user_id)) @@ -1609,7 +1665,7 @@ class WebRTCPeerSession: self._join_refuse("code_required") return - invite = await roster.consume_invite(code, user_id) + invite = await roster.consume_invite(code, user_id, session_group) if not invite: self._join_refuse("code_invalid") return @@ -1620,7 +1676,8 @@ class WebRTCPeerSession: # left the roster nameless and `member revoke <name>` 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") + approved_by=invite["created_by"], + via="link" if invite["kind"] == KIND_LINK else "code") # The roster row comes from the invitation; the key comes from the # connection. An operator pairing is node-wide (empty group), but they # redeemed the code while opening a group and expect to read it — and @@ -5969,6 +6026,12 @@ class WebRTCPeerSession: elif pending["op"] == OP_INVITE_CREATE: self._spawn( self._admin_exec_invite_create(pending, transcript, sig_bytes)) + elif pending["op"] == OP_INVITE_LINK_CREATE: + self._spawn( + self._admin_exec_invite_link_create(pending, transcript, sig_bytes)) + elif pending["op"] == OP_INVITE_CANCEL: + self._spawn( + self._admin_exec_invite_cancel(pending, transcript, sig_bytes)) elif pending["op"] == OP_GEK_ROTATE: self._spawn( self._admin_exec_gek_rotate(pending, transcript, sig_bytes)) @@ -6106,6 +6169,47 @@ class WebRTCPeerSession: "username": result.get("username", ""), }) + async def _admin_exec_invite_link_create( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", "invite_link_create") + return + try: + result = await self._run_op( + ops.create_link_invite, pending["payload"]["group_id"], + created_by=self._user_id or "") + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("invite_link_create", f"invite={result['invite_id'][:8]}") + self._send({ + "type": MNP.INVITE_LINK_RESULT, + "v": MNP_VERSION, + "code": result["code"], + "invite_id": result["invite_id"], + "expires_at": result["expires_at"], + "group_id": result["group_id"], + }) + + async def _admin_exec_invite_cancel( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"invite_cancel:{pending['subject'][:8]}") + return + payload = pending["payload"] + try: + await self._run_op(ops.cancel_invite, payload["group_id"], payload["invite_id"]) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("invite_cancel", f"invite={payload['invite_id'][:8]}") + self._send({"type": "ack", "v": MNP_VERSION, "detail": "invite_cancelled", + "invite_id": payload["invite_id"]}) + async def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None: file_path, refusal = await off_disk(ctx["roots"], _locate, ctx["roots"], entry) if refusal == ROOT_NOT_SERVED: diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 888487c..f6b93ea 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -322,6 +322,14 @@ def create_ui_app(state: dict) -> FastAPI: async def create_invite(group_id: str, username: str): return await _op(lambda: ops.create_invite(state, group_id, username)) + @app.post("/api/groups/{group_id}/invite-links") + async def create_link_invite(group_id: str): + return await _op(lambda: ops.create_link_invite(state, group_id)) + + @app.delete("/api/groups/{group_id}/invite-links/{invite_id}") + async def cancel_invite(group_id: str, invite_id: str): + return await _op(lambda: ops.cancel_invite(state, group_id, invite_id)) + @app.get("/api/resolve") async def resolve_user(username: str): return await _op(lambda: ops.resolve_user(state, username)) |