diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/roster.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 257 |
1 files changed, 245 insertions, 12 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 6bda56b..226b784 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -52,15 +52,42 @@ CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy # node-wide lockout. DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing +# A device-add code is read off one screen and typed into another, in one +# sitting. An hour is comfort, not security: the code is bound to the requesting +# keys by its hash, so a longer window widens nothing an attacker can use. +DEFAULT_DEVICE_REQUEST_TTL = 3600 _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 +-- silently overwrite the first (INSERT OR REPLACE). See docs/desktop-client-v1.md §4. CREATE TABLE IF NOT EXISTS identities ( - user_id TEXT PRIMARY KEY, - username TEXT NOT NULL, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + pk_ed25519 TEXT NOT NULL, + pk_x25519 TEXT NOT NULL, + pinned_at TEXT NOT NULL, + pinned_via TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + -- Which already-pinned key countersigned this one into existence. Empty for + -- the first device of an account, which an operator code admitted. + added_by_pk TEXT NOT NULL DEFAULT '', + revoked_at TEXT, + PRIMARY KEY (user_id, pk_ed25519) +); + +-- A device asking to be added, waiting for an existing one to approve it. +-- `code_hash` binds the code to the keys: sha256(code ‖ pk_ed ‖ pk_x). The +-- approver looks the request up by recomputing that, so a node returning +-- different keys produces no match and the client refuses before signing. +CREATE TABLE IF NOT EXISTS device_requests ( + code_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', pk_ed25519 TEXT NOT NULL, pk_x25519 TEXT NOT NULL, - pinned_at TEXT NOT NULL, - pinned_via TEXT NOT NULL + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS members ( @@ -131,6 +158,11 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") +def _iso_in(seconds: int) -> str: + return (datetime.now(timezone.utc) + + timedelta(seconds=seconds)).isoformat(timespec="seconds") + + class Roster: def __init__(self, db_path: Path): self._db_path = db_path @@ -152,8 +184,54 @@ class Roster: if "username" not in columns: await self._db.execute( "ALTER TABLE invites ADD COLUMN username TEXT NOT NULL DEFAULT ''") + + await self._migrate_identities_to_devices() await self._db.commit() + async def _migrate_identities_to_devices(self) -> None: + """ + Widen `identities` from one key per person to one row per device. + + `CREATE TABLE IF NOT EXISTS` leaves an existing table alone, so a roster + written before device linking still has `user_id` as its sole primary + key — where a second device would overwrite the first rather than being + refused. SQLite cannot change a primary key in place, so the table is + rebuilt. + + Existing pins are carried over untouched and become each account's first + device. Nobody has to re-pair. + """ + assert self._db + async with self._db.execute("PRAGMA table_info(identities)") as cur: + info = list(await cur.fetchall()) + columns = {r[1] for r in info} + # `pk` is the column's position in the primary key, 0 when not part of it. + key_columns = {r[1] for r in info if r[5]} + + if key_columns == {"user_id", "pk_ed25519"} and "revoked_at" in columns: + return + + log.info("Roster: widening identities to one row per device") + for column, decl in (("label", "TEXT NOT NULL DEFAULT ''"), + ("added_by_pk", "TEXT NOT NULL DEFAULT ''"), + ("revoked_at", "TEXT")): + if column not in columns: + await self._db.execute( + f"ALTER TABLE identities ADD COLUMN {column} {decl}") + + if key_columns != {"user_id", "pk_ed25519"}: + await self._db.execute("ALTER TABLE identities RENAME TO identities_old") + await self._db.executescript(_SCHEMA) + await self._db.execute( + "INSERT OR IGNORE INTO identities " + "(user_id, username, pk_ed25519, pk_x25519, pinned_at, " + " pinned_via, label, added_by_pk, revoked_at) " + "SELECT user_id, username, pk_ed25519, pk_x25519, pinned_at, " + " pinned_via, label, added_by_pk, revoked_at " + "FROM identities_old") + await self._db.execute("DROP TABLE identities_old") + log.info("Roster: identities rebuilt, existing pins preserved") + async def close(self) -> None: if self._db: await self._db.close() @@ -161,6 +239,12 @@ class Roster: # ── Identities ─────────────────────────────────────────────────────────── + # How many devices one person may hold on this node. A chain of devices + # inherits the weakness of its weakest ancestor — whoever cracks a browser's + # keypair bundle can add one — so the answer to "how many" is visibility and + # a ceiling, not cryptography. + MAX_DEVICES_PER_USER = 5 + async def pin_identity( self, user_id: str, @@ -168,25 +252,90 @@ class Roster: pk_ed25519: str, pk_x25519: str, via: str, + *, + label: str = "", + added_by_pk: str = "", ) -> None: + """ + Record a device for an account. + + `INSERT OR REPLACE` on (user_id, pk_ed25519) now updates *that device* + rather than overwriting whatever key the person had before — which is + what it did while `user_id` was the whole primary key, silently, and + would have become a hole the moment a second device was legitimate. + """ assert self._db await self._db.execute( "INSERT OR REPLACE INTO identities " - "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via) " - "VALUES (?, ?, ?, ?, ?, ?)", - (user_id, username, pk_ed25519, pk_x25519, _now(), via), + "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via, " + " label, added_by_pk, revoked_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)", + (user_id, username, pk_ed25519, pk_x25519, _now(), via, + label, added_by_pk), ) await self._db.commit() async def get_identity(self, user_id: str) -> dict | None: + """ + This account's oldest live device. + + Kept for callers that only need "is this person known here" — the + operator pin, `status`, attribution. Anything deciding whether a *key* + is admitted must use `find_device`, or a second device is refused where + the first is not. + """ + assert self._db + async with self._db.execute( + "SELECT * FROM identities WHERE user_id = ? AND revoked_at IS NULL " + "ORDER BY pinned_at LIMIT 1", (user_id,) + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + async def find_device(self, user_id: str, pk_ed25519: str) -> dict | None: + """The device with this exact key, if it is live. None if revoked.""" assert self._db async with self._db.execute( - "SELECT * FROM identities WHERE user_id = ?", (user_id,) + "SELECT * FROM identities WHERE user_id = ? AND pk_ed25519 = ? " + "AND revoked_at IS NULL", (user_id, pk_ed25519) ) as cur: row = await cur.fetchone() return dict(row) if row else None + async def list_devices(self, user_id: str, + include_revoked: bool = False) -> list[dict]: + assert self._db + sql = "SELECT * FROM identities WHERE user_id = ?" + if not include_revoked: + sql += " AND revoked_at IS NULL" + async with self._db.execute(sql + " ORDER BY pinned_at", + (user_id,)) as cur: + return [dict(r) for r in await cur.fetchall()] + + async def revoke_device(self, user_id: str, pk_ed25519: str) -> bool: + """ + Retire one device, leaving the account's others alone. + + Marked rather than deleted: a revoked key must stay refused, and a row + that is gone is a key the node would happily pin again on the next + device-add — which is the laptop somebody just reported lost. + """ + assert self._db + cur = await self._db.execute( + "UPDATE identities SET revoked_at = ? " + "WHERE user_id = ? AND pk_ed25519 = ? AND revoked_at IS NULL", + (_now(), user_id, pk_ed25519)) + await self._db.commit() + return cur.rowcount > 0 + async def unpin(self, user_id: str) -> bool: + """ + Forget an account entirely — every device it holds. + + Deliberately all of them: `member unpin` is what an operator runs when + someone must start over, and leaving one device behind would let the + person walk back in with a key the operator meant to forget. + """ assert self._db cur = await self._db.execute( "DELETE FROM identities WHERE user_id = ?", (user_id,)) @@ -196,10 +345,84 @@ class Roster: async def list_identities(self) -> list[dict]: assert self._db async with self._db.execute( - "SELECT * FROM identities ORDER BY pinned_at" + "SELECT * FROM identities WHERE revoked_at IS NULL " + "ORDER BY pinned_at" ) as cur: return [dict(r) for r in await cur.fetchall()] + # ── Device requests ────────────────────────────────────────────────────── + + async def file_device_request( + self, user_id: str, username: str, pk_ed25519: str, pk_x25519: str, + code_hash: str, ttl: int = DEFAULT_DEVICE_REQUEST_TTL, + ) -> str: + """ + Record a device waiting to be approved. Returns its expiry. + + The node stores only `code_hash`, which the new device computed over the + code **and its own keys**. That binding is what stops the node itself + from substituting a key: an approver recomputes the hash from the code + they typed and the keys they were handed, and a mismatch means no + request is found. + """ + assert self._db + expires = _iso_in(ttl) + await self._db.execute( + "INSERT OR REPLACE INTO device_requests " + "(code_hash, user_id, username, pk_ed25519, pk_x25519, created_at, " + " expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (code_hash, user_id, username, pk_ed25519, pk_x25519, _now(), expires)) + await self._db.commit() + return expires + + async def take_device_request(self, code_hash: str, + user_id: str) -> dict | None: + """ + Claim a pending request by its hash, for this account only. + + Single use and scoped to the account: a request filed for one person + cannot be redeemed by another even with the code, and a code that has + been spent is gone. + """ + assert self._db + async with self._db.execute( + "SELECT * FROM device_requests WHERE code_hash = ? AND user_id = ? " + "AND expires_at > ?", (code_hash, user_id, _now()) + ) as cur: + row = await cur.fetchone() + if row is None: + return None + await self._db.execute( + "DELETE FROM device_requests WHERE code_hash = ?", (code_hash,)) + await self._db.commit() + return dict(row) + + async def list_device_requests(self, user_id: str) -> list[dict]: + """ + This account's pending requests, hashes included. + + The hash is what the approver matches against, so it has to travel. + Handing it out is safe: it is `sha256(code ‖ keys)` over 40 bits of + secret the node does not hold, and knowing the code authorizes nothing + on its own — only a countersignature by an already-pinned key does. + """ + assert self._db + async with self._db.execute( + "SELECT * FROM device_requests WHERE user_id = ? AND expires_at > ? " + "ORDER BY created_at", (user_id, _now()) + ) as cur: + return [dict(r) for r in await cur.fetchall()] + + async def pending_device_requests(self, user_id: str) -> int: + """How many this account has waiting. For display and for a ceiling.""" + assert self._db + async with self._db.execute( + "SELECT COUNT(*) AS n FROM device_requests WHERE user_id = ? " + "AND expires_at > ?", (user_id, _now()) + ) as cur: + row = await cur.fetchone() + return int(row["n"]) if row else 0 + # ── Authority ──────────────────────────────────────────────────────────── async def operator_pks(self) -> list[str]: @@ -213,7 +436,10 @@ class Roster: async with self._db.execute( "SELECT i.pk_ed25519 FROM identities i " "JOIN members m ON m.user_id = i.user_id " - "WHERE m.role = 'operator' AND m.status = 'active'" + "WHERE m.role = 'operator' AND m.status = 'active' " + # An operator with two browsers has two keys and both may sign; a + # retired one must not. + "AND i.revoked_at IS NULL" ) as cur: return [r["pk_ed25519"] for r in await cur.fetchall()] @@ -369,12 +595,19 @@ class Roster: async def purge_expired(self) -> int: assert self._db + now = _now() cur = await self._db.execute( "DELETE FROM invites WHERE used_at IS NULL AND expires_at < ?", - (_now(),), + (now,), ) + removed = cur.rowcount + # Device requests expire too, and an abandoned one left lying about is + # a row an approver could still be shown. + cur = await self._db.execute( + "DELETE FROM device_requests WHERE expires_at < ?", (now,)) + removed += cur.rowcount await self._db.commit() - return cur.rowcount + return removed async def open_roster(data_dir: Path) -> Roster: |