aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/roster.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/roster.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py134
1 files changed, 117 insertions, 17 deletions
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