diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 116 |
1 files changed, 110 insertions, 6 deletions
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: |