summaryrefslogtreecommitdiffstats
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.py257
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: