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 | |
| 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')
10 files changed, 710 insertions, 25 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index 1271c4e..77e6fac 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -236,5 +236,10 @@ __version__ = "0.15.0" # *wrong* one outright and treats an absent one as "this node cannot prove # itself early", which it discovers from the node's answer and never from the # version number. `MNP_MIN_SUPPORTED` does not move. +# +# The same version adds invitation links: `invite_link_create` / +# `invite_link_result` and `invite_cancel`, a code bound to no account until it +# is redeemed. Additive in the same way — a 3.3 node answers `unknown message +# type`, and no client can hold a link code for a node that could not issue one. MNP_VERSION = "3.4" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 20b5080..23040be 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -36,6 +36,13 @@ ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1" OP_FILE_DELETE = "file_delete" OP_DIR_DELETE = "dir_delete" OP_INVITE_CREATE = "invite_create" +# An invitation bound to no account (docs/invite-links.md). Its own op rather +# than `invite_create` with an empty subject: that subject is the invitee, and +# what the operator is shown before signing has to name the outcome (H5) — here +# "a link into this group", `link:<group_id>`. +OP_INVITE_LINK_CREATE = "invite_link_create" +# Taking back an unredeemed link, by the handle it was issued with. +OP_INVITE_CANCEL = "invite_cancel" OP_MEMBER_REVOKE = "member_revoke" # Rotating the group key is what actually takes it away from a revoked member: # revocation stops the node serving the *next* key, and they still hold the diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 5374742..4df06bf 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -153,6 +153,8 @@ class MNP: JOIN_REQUEST = "join_request" # client → node: pair/recognise this identity JOIN_RESULT = "join_result" # node → client: outcome + wrapped GEK INVITE_CREATE = "invite_create" # operator → node: issue a pairing code + INVITE_LINK_CREATE = "invite_link_create" # operator → node: a code bound to no account + INVITE_CANCEL = "invite_cancel" # operator → node: take back an unredeemed link MEMBER_REVOKE = "member_revoke" # operator → node: stop serving the key MEMBER_REVOKE_ACK = "member_revoke_ack" MEMBER_UNPIN = "member_unpin" # operator → node: forget an identity @@ -222,6 +224,7 @@ class MNP: GEK_ROTATE = "gek_rotate" # operator → node: new group key GEK_ROTATE_ACK = "gek_rotate_ack" INVITE_RESULT = "invite_result" # node → operator: the code, once + INVITE_LINK_RESULT = "invite_link_result" # node → operator: link code + handle, once NODE_STATUS = "node_status" # operator → node: list all groups + roots NODE_STATUS_ACK = "node_status_ack" # node → operator: full status ROOT_ADD = "root_add" # operator → node: add a directory to a group 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)) diff --git a/packages/meshbay-node/tests/test_link_invites.py b/packages/meshbay-node/tests/test_link_invites.py new file mode 100644 index 0000000..f5081d2 --- /dev/null +++ b/packages/meshbay-node/tests/test_link_invites.py @@ -0,0 +1,270 @@ +""" +Invitation links: a code bound to no account until somebody redeems it. + +A link is sent to someone who may not have an account yet, so its code cannot +name one. That makes it a bearer code at the node — the hub's ticket, bound to +a verified address, is what decides who can reach the node at all +(docs/invite-links.md §3). Everything here is a way a bearer code could be made +to mean more than "one new member of this group, once", or a way an unbound row +could leak into the code paths written for bound ones. The second family is the +one to watch: `user_id = ''` must never read as "anyone" (AV1). +""" + +import sqlite3 +from datetime import UTC, datetime, timedelta + +import pytest +from meshbay_common.crypto import generate_gek, unwrap_gek_aes +from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR +from meshbay_node.roster import ( + KIND_ACCOUNT, + KIND_LINK, + MAX_LINK_INVITES_PER_GROUP, + LinkInviteLimit, + Roster, + hash_code, +) +from test_roster_pairing import _join_msg, _keypair, _keypair_full, _last, _session, _x_raw + +GROUP_A = "a" * 32 +GROUP_B = "b" * 32 + + +@pytest.fixture +async def roster(tmp_path): + r = Roster(db_path=tmp_path / "roster.db") + await r.open() + yield r + await r.close() + + +async def _link(roster, group_id=GROUP_B): + code, invite_id, _expires = await roster.create_link_invite(group_id, "cbesson") + return code, invite_id + + +# ── The roster ─────────────────────────────────────────────────────────────── + +async def test_a_link_is_redeemed_once_and_then_names_its_redeemer(roster): + code, _ = await _link(roster) + invite = await roster.consume_invite(code, "alice", group_id=GROUP_B) + assert invite and invite["role"] == ROLE_MEMBER and invite["group_id"] == GROUP_B + assert invite["user_id"] == "alice" + + assert await roster.consume_invite(code, "mallory", group_id=GROUP_B) is None + used = [i for i in await roster.list_invites(include_used=True) + if i["code_hash"] == hash_code(code)] + assert used[0]["user_id"] == "alice" and used[0]["used_at"] + + +async def test_a_link_is_good_for_its_own_group_only(roster): + code, _ = await _link(roster, GROUP_B) + for other in (GROUP_A, ""): + assert await roster.consume_invite(code, "alice", group_id=other) is None + # Not spent by the refusals. + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) + + +async def test_an_active_member_cannot_spend_somebody_elses_link(roster): + await roster.set_member(GROUP_B, "bob", ROLE_MEMBER, "active", "cbesson") + code, _ = await _link(roster) + assert await roster.consume_invite(code, "bob", group_id=GROUP_B) is None + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) + + +async def test_a_link_never_carries_operator_authority(roster, tmp_path): + code, _ = await _link(roster) + rows = [i for i in await roster.list_invites() if i["kind"] == KIND_LINK] + assert rows and all(r["role"] == ROLE_MEMBER for r in rows) + + # Even a row edited on disk to say otherwise is refused, not honoured. + con = sqlite3.connect(tmp_path / "roster.db") + con.execute("UPDATE invites SET role = ? WHERE code_hash = ?", + (ROLE_OPERATOR, hash_code(code))) + con.commit() + con.close() + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None + + +async def test_an_empty_account_on_a_bound_invite_is_nobody(roster, tmp_path): + """ + AV1's shape: an account invitation whose `user_id` is empty must match no + one — not whoever turns up. Only `kind = 'link'` makes a row a bearer code. + """ + code = await roster.create_invite(GROUP_B, "", ROLE_MEMBER, "cbesson") + for who in ("alice", ""): + assert await roster.consume_invite(code, who, group_id=GROUP_B) is None + + +async def test_bound_invitations_and_links_do_not_cancel_each_other(roster): + code, invite_id = await _link(roster) + # Re-inviting an account supersedes that account's earlier code — and must + # not take the group's unredeemed links with it. + await roster.create_invite(GROUP_B, "carol", ROLE_MEMBER, "cbesson") + await roster.create_invite(GROUP_B, "carol", ROLE_MEMBER, "cbesson") + # Cancelling "nobody's" invitations is not cancelling the links. + assert await roster.drop_invites(GROUP_B, "") == 0 + assert invite_id in {i["invite_id"] for i in await roster.list_invites()} + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) + + +async def test_links_are_capped_per_group(roster): + codes = [(await _link(roster, GROUP_B))[0] for _ in range(MAX_LINK_INVITES_PER_GROUP)] + with pytest.raises(LinkInviteLimit): + await _link(roster, GROUP_B) + # Another group has its own allowance. + await _link(roster, GROUP_A) + # A redeemed link is no longer outstanding, so it frees a place. + assert await roster.consume_invite(codes[0], "alice", group_id=GROUP_B) + await _link(roster, GROUP_B) + + +async def test_a_link_needs_a_group(roster): + with pytest.raises(ValueError): + await roster.create_link_invite("", "cbesson") + + +async def test_cancel_takes_back_an_unredeemed_link_of_its_own_group(roster): + code, invite_id = await _link(roster, GROUP_B) + assert not await roster.cancel_invite(GROUP_A, invite_id) + assert await roster.cancel_invite(GROUP_B, invite_id) + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None + + code, invite_id = await _link(roster, GROUP_B) + await roster.consume_invite(code, "alice", group_id=GROUP_B) + assert not await roster.cancel_invite(GROUP_B, invite_id), ( + "a redeemed link is the record of the join, not something to cancel") + + +async def test_an_expired_link_is_refused(roster): + code, _, _ = await roster.create_link_invite(GROUP_B, "cbesson", ttl=-1) + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None + + +async def test_a_roster_from_before_links_opens_and_keeps_its_codes(tmp_path): + """The two columns arrive by ALTER TABLE; an existing code stays a bound one.""" + path = tmp_path / "old.db" + con = sqlite3.connect(path) + con.execute("""CREATE TABLE 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, expires_at TEXT NOT NULL, used_at TEXT)""") + expires = (datetime.now(UTC) + timedelta(days=1)).isoformat(timespec="seconds") + con.execute("INSERT INTO invites VALUES (?, ?, ?, '', ?, 'op', ?, ?, NULL)", + (hash_code("K7P2-9WQX"), GROUP_B, "alice", ROLE_MEMBER, + datetime.now(UTC).isoformat(), expires)) + con.commit() + con.close() + + r = Roster(db_path=path) + await r.open() + try: + [row] = await r.list_invites() + assert row["kind"] == KIND_ACCOUNT + assert await r.consume_invite("K7P2-9WQX", "mallory", group_id=GROUP_B) is None + assert await r.consume_invite("K7P2-9WQX", "alice", group_id=GROUP_B) + finally: + await r.close() + + +# ── The join ───────────────────────────────────────────────────────────────── + +async def test_a_newcomer_joins_with_a_link_code(tmp_path, roster): + gek = generate_gek() + code, _ = await _link(roster) + sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full() + session = _session(tmp_path, roster, user_id="alice", group_id=GROUP_B, gek=gek) + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="alice", group_id=GROUP_B)) + + reply = _last(session) + assert reply["ok"] is True and reply["gek"] is True + assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek + member = await roster.get_member(GROUP_B, "alice") + assert member and member["role"] == ROLE_MEMBER + + +async def test_a_link_code_opens_no_other_group(tmp_path, roster): + code, _ = await _link(roster, GROUP_A) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + session = _session(tmp_path, roster, user_id="alice", group_id=GROUP_B, + gek=generate_gek()) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="alice", group_id=GROUP_B)) + assert _last(session).get("reason") == "code_invalid" + assert await roster.get_member(GROUP_A, "alice") is None + + +async def test_someone_already_pinned_elsewhere_joins_with_a_link(tmp_path, roster): + """The common case: known to this node through another group.""" + gek_b = generate_gek() + sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP_A, "grenet", ROLE_MEMBER, "active", "cbesson") + code, _ = await _link(roster, GROUP_B) + + session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, gek=gek_b) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="grenet", group_id=GROUP_B)) + + reply = _last(session) + assert reply["ok"] is True and reply["gek"] is True + assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek_b + assert await roster.get_member(GROUP_B, "grenet") + + +async def test_a_member_opening_the_group_with_a_link_leaves_it_unspent(tmp_path, roster): + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP_B, "grenet", ROLE_MEMBER, "active", "cbesson") + code, _ = await _link(roster, GROUP_B) + + session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, + gek=generate_gek()) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="grenet", group_id=GROUP_B)) + assert _last(session)["ok"] is True + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) + + +async def test_a_known_device_with_a_wrong_code_is_told_so(tmp_path, roster): + """Not the flat `not_authorized_for_group`: a code was offered and refused, + and the refusal counts against the attempt budget like any other.""" + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("eve", "eve", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP_A, "eve", ROLE_MEMBER, "active", "cbesson") + session = _session(tmp_path, roster, user_id="eve", group_id=GROUP_B, + gek=generate_gek()) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA", + user_id="eve", group_id=GROUP_B)) + assert _last(session).get("reason") == "code_invalid" + assert session._join_attempts == 1 + + +async def test_someone_removed_can_come_back_with_a_link(tmp_path, roster): + """A revoked member still has a member row here. A link sent to bring them + back must work — it is what the operator asked for.""" + gek = generate_gek() + sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP_B, "grenet", ROLE_MEMBER, "revoked", "cbesson") + session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, gek=gek) + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, user_id="grenet", group_id=GROUP_B)) + assert _last(session).get("reason") == "not_authorized_for_group" + + code, _ = await _link(roster, GROUP_B) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="grenet", group_id=GROUP_B)) + reply = _last(session) + assert reply["ok"] is True and reply["gek"] is True + assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek + assert (await roster.get_member(GROUP_B, "grenet"))["status"] == "active" diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 0d2aa3a..c0dc6d1 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -29,7 +29,9 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( from meshbay_common import MNP_VERSION from meshbay_common.adminop import ( OP_FILE_DELETE, + OP_INVITE_CANCEL, OP_INVITE_CREATE, + OP_INVITE_LINK_CREATE, admin_transcript, ) from meshbay_common.crypto import ( @@ -1406,6 +1408,153 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di await transport.close_all() +async def _bearer_join(transport, sk_hub, user_id, peer_id, code, x25519_keypair, + expect_node_pk): + """A newcomer's first connection with a link code, as the browser makes it: + the challenge must prove the node key the link named before the code goes.""" + sk_x_raw, pk_x_raw = x25519_keypair + sk_ed = Ed25519PrivateKey.generate() + pc, ch, q = await _open_channel(transport, peer_id) + nonce_c = os.urandom(NONCE_LEN) + ch.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": _token(sk_hub, user_id, peer_id, TEST_GROUP), + "group_id": TEST_GROUP, "nonce": base64.b64encode(nonce_c).decode(), + })) + challenge = await asyncio.wait_for(q.get(), timeout=5.0) + nonce_s = base64.b64decode(challenge["nonce"]) + assert challenge["node_pk"] == expect_node_pk + Ed25519PublicKey.from_public_bytes(base64.b64decode(expect_node_pk)).verify( + base64.b64decode(challenge["sig"]), + challenge_transcript(TEST_GROUP, nonce_c, nonce_s, webrtc_binding( + _extract_dtls_fp(pc.localDescription.sdp), + _extract_dtls_fp(pc.remoteDescription.sdp)))) + + pk_ed_b64 = pk_to_b64(sk_ed.public_key()) + pk_x_b64 = base64.b64encode(pk_x_raw).decode() + ts = int(time.time()) + ch.send(_pack({ + "type": MNP.JOIN_REQUEST, "v": MNP_VERSION, "group_id": TEST_GROUP, + "pk_ed25519": pk_ed_b64, "pk_x25519": pk_x_b64, "code": code, "ts": ts, + "sig": base64.b64encode(sk_ed.sign(join_transcript( + node_pk_b64=challenge["node_pk"], group_id=TEST_GROUP, user_id=user_id, + pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64, + nonce_node=nonce_s, ts=ts))).decode(), + })) + result = await asyncio.wait_for(q.get(), timeout=5.0) + return pc, result, sk_x_raw, pk_x_raw + + +@pytest.mark.asyncio +async def test_a_link_is_issued_signed_redeemed_once_and_cancellable( + sk_node, sk_hub, gek, shared_dir, tmp_path, x25519_keypair): + """ + Invitation links over a real DataChannel (docs/invite-links.md §3.4). + + The operator signs `invite_link_create` naming the outcome, `link:<group>`; + the first account to bring the code is admitted and handed the key; the + second is refused; a link not yet used can be taken back, by its handle, + and is then refused too. Nothing is registered on the hub: there is no + account to register until somebody redeems it. + """ + indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=_hub_pk_pem(sk_hub), gek=gek, + roots=one_root(shared_dir), index=indexer.index, stun_servers=[], + ) + transport._ctx["roster"] = roster + transport._ctx["has_admin_authority"] = True + transport._ctx["groups"] = { + TEST_GROUP: {"gek": gek, "roots": one_root(shared_dir), "index": indexer.index}, + } + hub = _InviteHub() + transport._ctx["daemon_state"] = { + "roster": roster, "groups_ctx": transport._ctx["groups"], "hub": hub, + } + sk_admin = Ed25519PrivateKey.generate() + await roster.pin_identity("user-001", "grenet", pk_to_b64(sk_admin.public_key()), + "AA==", "code") + await roster.set_member("", "user-001", ROLE_OPERATOR, "active", "local-cli") + pc_admin, ch_admin, q_admin = await _setup_peer(transport, sk_hub, gek, "peer-admin") + node_pk = pk_to_b64(sk_node.public_key()) + peers = [pc_admin] + + async def signed(request: dict, op: str, subject: str) -> dict: + ch_admin.send(_pack({"v": MNP_VERSION, **request})) + challenge = await asyncio.wait_for(q_admin.get(), timeout=5.0) + assert challenge["type"] == MNP.ADMIN_CHALLENGE, challenge + assert (challenge["op"], challenge["subject"]) == (op, subject) + ch_admin.send(_pack({ + "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, "op_id": challenge["op_id"], + "signature": base64.b64encode( + sk_admin.sign(_transcript_from(challenge))).decode(), + })) + return await asyncio.wait_for(q_admin.get(), timeout=5.0) + + try: + link = await signed({"type": MNP.INVITE_LINK_CREATE, "group_id": TEST_GROUP}, + OP_INVITE_LINK_CREATE, f"link:{TEST_GROUP}") + assert link["type"] == MNP.INVITE_LINK_RESULT, link + assert len(link["code"]) == 9 and len(link["invite_id"]) == 32 + assert hub.added == [], "a link registers nobody on the hub" + + pc, result, sk_x_raw, pk_x_raw = await _bearer_join( + transport, sk_hub, "user-003", "peer-first", link["code"], x25519_keypair, + node_pk) + peers.append(pc) + assert result["ok"] is True and result["gek"] is True, result + assert unwrap_gek_aes(result, sk_x_raw, pk_x_raw) == gek + + pc, result, _, _ = await _bearer_join( + transport, sk_hub, "user-004", "peer-second", link["code"], x25519_keypair, + node_pk) + peers.append(pc) + assert result.get("reason") == "code_invalid" + + spare = await signed({"type": MNP.INVITE_LINK_CREATE, "group_id": TEST_GROUP}, + OP_INVITE_LINK_CREATE, f"link:{TEST_GROUP}") + done = await signed({"type": MNP.INVITE_CANCEL, "invite_id": spare["invite_id"]}, + OP_INVITE_CANCEL, spare["invite_id"]) + assert done.get("detail") == "invite_cancelled", done + pc, result, _, _ = await _bearer_join( + transport, sk_hub, "user-005", "peer-late", spare["code"], x25519_keypair, + node_pk) + peers.append(pc) + assert result.get("reason") == "code_invalid" + finally: + await roster.close() + for pc in peers: + await pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_a_link_needs_the_operator(sk_node, sk_hub, gek, shared_dir, tmp_path): + """A member who is not the operator is refused before any challenge is issued.""" + indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=_hub_pk_pem(sk_hub), gek=gek, + roots=one_root(shared_dir), index=indexer.index, stun_servers=[], + ) + transport._ctx["roster"] = roster + transport._ctx["has_admin_authority"] = False + pc, ch, q = await _setup_peer(transport, sk_hub, gek, "peer-member") + try: + ch.send(_pack({"type": MNP.INVITE_LINK_CREATE, "v": MNP_VERSION})) + reply = await asyncio.wait_for(q.get(), timeout=5.0) + assert reply["type"] == "error" and "operator" in reply["detail"] + assert await roster.list_invites() == [] + finally: + await roster.close() + await pc.close() + await transport.close_all() + @pytest.mark.asyncio async def test_gek_bundle_fetch_during_handshake(sk_node, sk_hub, gek, shared_dir, tmp_path, x25519_keypair): |