""" Node roster — who this node recognises, and which keys are theirs. The node keeps its own answer to "may this person have the group key", derived from what the operator authorized locally. It is deliberately NOT derived from the hub: the hub decides group membership, and a hub that invents an account and mints a token for it would otherwise collect the GEK on connect. Hub membership is an input to the decision; it is not the decision. Three tables: identities — one row per person, not per group. Someone paired for one group needs no code for the next one on the same node. members — role and status per (group, user). invites — one-time pairing codes, stored as a hash. The code itself exists only in the operator's hands and the invitee's. The code is what binds a public key to an account without asking the hub (finding H3). See `docs/invite-pairing-v1.md`. """ from __future__ import annotations import hashlib import logging import os import secrets from datetime import datetime, timedelta, timezone from pathlib import Path import aiosqlite log = logging.getLogger(__name__) # Crockford base32 without I, L, O and U: no character pair a human can confuse # when reading a code aloud or typing it from a phone screen. _ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy # Two different rhythms, so two different lifetimes. # # An invitation crosses a human conversation: it is sent by mail or message and # answered whenever the other person next looks. A day is not enough — the code # dies over a weekend and someone has to be at a browser, with the node online, to # issue another one. # # Operator pairing crosses an SSH session: the code is printed and typed minutes # later. There is no reason for it to outlive the sitting. # # The longer window costs little: a code is single use, bound to one account, # never seen by the hub, and 40 bits do not fall to guessing in a week against the # node-wide lockout. DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing _SCHEMA = """\ CREATE TABLE IF NOT EXISTS identities ( user_id TEXT PRIMARY KEY, username TEXT NOT NULL, pk_ed25519 TEXT NOT NULL, pk_x25519 TEXT NOT NULL, pinned_at TEXT NOT NULL, pinned_via TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS members ( group_id TEXT NOT NULL, user_id TEXT NOT NULL, role TEXT NOT NULL, status TEXT NOT NULL, approved_by TEXT NOT NULL, approved_at TEXT NOT NULL, PRIMARY KEY (group_id, user_id) ); CREATE TABLE IF NOT EXISTS 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 ); """ def generate_code() -> str: """A fresh pairing code, formatted for a human to read out: XXXX-XXXX.""" raw = "".join(secrets.choice(_ALPHABET) for _ in range(CODE_LEN)) return f"{raw[:4]}-{raw[4:]}" def normalize_code(code: str) -> str: """ Fold what a human typed onto what was generated. Crockford's rules: case-insensitive, dashes and spaces are decoration, and the excluded letters map onto the digits they resemble. Someone reading a code over the phone should not be able to get it wrong in a way we could have absorbed. """ out = [] for ch in code.upper(): if ch in "- \t": continue if ch in "IL": out.append("1") elif ch == "O": out.append("0") elif ch == "U": out.append("V") else: out.append(ch) return "".join(out) def hash_code(code: str) -> str: """ Store codes hashed: a stolen roster DB must not yield usable invitations. SHA-256 rather than a password KDF on purpose — the input is 40 bits of uniformly random secret, not a human-chosen string, so there is nothing for a slow hash to defend. """ return hashlib.sha256(normalize_code(code).encode()).hexdigest() def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") class Roster: def __init__(self, db_path: Path): self._db_path = db_path self._db: aiosqlite.Connection | None = None async def open(self) -> None: self._db_path.parent.mkdir(parents=True, exist_ok=True) self._db = await aiosqlite.connect(str(self._db_path)) self._db.row_factory = aiosqlite.Row # WAL: the CLI writes invites (`operator pair`) while the daemon reads them. await self._db.execute("PRAGMA journal_mode=WAL") await self._db.executescript(_SCHEMA) # invites.username was added after the first deployments: the name is what # the operator types, and it cannot be recovered from the JWT because the # hub does not put one there. CREATE TABLE IF NOT EXISTS will not add a # column to a table that already exists. async with self._db.execute("PRAGMA table_info(invites)") as cur: columns = {r[1] for r in await cur.fetchall()} if "username" not in columns: await self._db.execute( "ALTER TABLE invites ADD COLUMN username TEXT NOT NULL DEFAULT ''") await self._db.commit() async def close(self) -> None: if self._db: await self._db.close() self._db = None # ── Identities ─────────────────────────────────────────────────────────── async def pin_identity( self, user_id: str, username: str, pk_ed25519: str, pk_x25519: str, via: str, ) -> None: 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), ) await self._db.commit() async def get_identity(self, user_id: str) -> dict | None: assert self._db async with self._db.execute( "SELECT * FROM identities WHERE user_id = ?", (user_id,) ) as cur: row = await cur.fetchone() return dict(row) if row else None async def unpin(self, user_id: str) -> bool: assert self._db cur = await self._db.execute( "DELETE FROM identities WHERE user_id = ?", (user_id,)) await self._db.commit() return cur.rowcount > 0 async def list_identities(self) -> list[dict]: assert self._db async with self._db.execute( "SELECT * FROM identities ORDER BY pinned_at" ) as cur: return [dict(r) for r in await cur.fetchall()] # ── Authority ──────────────────────────────────────────────────────────── async def operator_pks(self) -> list[str]: """ Base64 Ed25519 keys allowed to authorize admin operations on this node. Read fresh on every check rather than cached: an unpin must take effect at once, and this runs only on admin operations, which are rare. """ assert self._db 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'" ) as cur: return [r["pk_ed25519"] for r in await cur.fetchall()] async def has_operator(self) -> bool: return bool(await self.operator_pks()) async def is_authorized(self, group_id: str, user_id: str) -> bool: """ May this person be handed the group key? The node's own answer, not the hub's. Hub membership is what lets someone reach the node; this is what decides whether the key is wrapped for them — otherwise a hub that invents an account and mints a token for it would be served the GEK on connect. An operator is authorized for every group this node hosts: their authority is node-wide and is recorded with an empty group_id. """ assert self._db async with self._db.execute( "SELECT 1 FROM members WHERE user_id = ? AND status = 'active' " "AND (group_id = ? OR (group_id = '' AND role = 'operator')) LIMIT 1", (user_id, group_id), ) as cur: return await cur.fetchone() is not None # ── Members ────────────────────────────────────────────────────────────── async def set_member( self, group_id: str, user_id: str, role: str, status: str, approved_by: str, ) -> None: assert self._db await self._db.execute( "INSERT OR REPLACE INTO members " "(group_id, user_id, role, status, approved_by, approved_at) " "VALUES (?, ?, ?, ?, ?, ?)", (group_id, user_id, role, status, approved_by, _now()), ) await self._db.commit() async def get_member(self, group_id: str, user_id: str) -> dict | None: assert self._db async with self._db.execute( "SELECT * FROM members WHERE group_id = ? AND user_id = ?", (group_id, user_id), ) as cur: row = await cur.fetchone() return dict(row) if row else None async def list_members(self, group_id: str | None = None) -> list[dict]: assert self._db sql = ( "SELECT m.*, i.username, i.pk_ed25519, i.pinned_at, i.pinned_via " "FROM members m LEFT JOIN identities i ON i.user_id = m.user_id" ) args: tuple = () if group_id is not None: sql += " WHERE m.group_id = ?" args = (group_id,) async with self._db.execute(sql + " ORDER BY m.approved_at", args) as cur: return [dict(r) for r in await cur.fetchall()] async def set_status(self, group_id: str, user_id: str, status: str) -> bool: assert self._db cur = await self._db.execute( "UPDATE members SET status = ? WHERE group_id = ? AND user_id = ?", (status, group_id, user_id), ) await self._db.commit() return cur.rowcount > 0 # ── Invites ────────────────────────────────────────────────────────────── async def create_invite( self, group_id: str, user_id: str, role: str, created_by: str, ttl: int = DEFAULT_INVITE_TTL, username: str = "", ) -> str: """ Issue a one-time code. Returns it in the clear — this is the only moment it exists outside the operator's hands; only its hash is kept. Any earlier unused invite for the same person and group is dropped, so re-inviting supersedes rather than accumulating valid codes. """ 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), ) code = generate_code() expires = datetime.now(timezone.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 (?, ?, ?, ?, ?, ?, ?, ?)", (hash_code(code), group_id, user_id, username, role, created_by, _now(), expires.isoformat(timespec="seconds")), ) await self._db.commit() return code async def consume_invite(self, code: str, user_id: str) -> dict | None: """ Redeem a code for `user_id`, or return None. 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. """ assert self._db code_hash = hash_code(code) async with self._db.execute( "SELECT * FROM invites WHERE code_hash = ?", (code_hash,) ) as cur: row = await cur.fetchone() if not row: 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: return None if datetime.fromisoformat(invite["expires_at"]) < datetime.now(timezone.utc): return None cur = await self._db.execute( "UPDATE invites SET used_at = ? WHERE code_hash = ? AND used_at IS NULL", (_now(), code_hash), ) await self._db.commit() if cur.rowcount == 0: return None return invite async def list_invites(self, include_used: bool = False) -> list[dict]: assert self._db sql = "SELECT * FROM invites" if not include_used: sql += " WHERE used_at IS NULL" async with self._db.execute(sql + " ORDER BY created_at") as cur: return [dict(r) for r in await cur.fetchall()] async def purge_expired(self) -> int: assert self._db cur = await self._db.execute( "DELETE FROM invites WHERE used_at IS NULL AND expires_at < ?", (_now(),), ) await self._db.commit() return cur.rowcount async def open_roster(data_dir: Path) -> Roster: roster = Roster(data_dir / "roster.db") await roster.open() return roster def write_code_file(data_dir: Path, code: str, expires_at: str, name: str = "pair-code") -> Path: """ Leave the code in a file as well as on stdout. An operator working over SSH may not be able to copy out of their terminal, and a code that can only be read off a scrolled-away screen is a dead end. Pairing and invitation codes go to different files so one does not overwrite the other. """ path = data_dir / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(f"{code}\nexpires {expires_at}\n") os.chmod(path, 0o600) return path