diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/roster.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 81 |
1 files changed, 78 insertions, 3 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index af87f92..2288ae4 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -75,6 +75,22 @@ CREATE TABLE IF NOT EXISTS identities ( -- 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 '', + -- The countersignature itself, and the two fields needed to rebuild what it + -- signed. `added_by_pk` alone says *which* key approved and proves nothing: + -- a third party cannot check a signature it does not have. And the + -- transcript binds `nonce_node` — the approving connection's handshake + -- nonce — so even a stored signature is unverifiable without it. + -- + -- This is what Tier 2 needs (docs/desktop-client-v1.md §4.8): relayed with + -- the roster, it lets a member verify for themselves that a second device + -- belongs to an account whose first device they have already pinned, + -- instead of taking the node's word. Verified and discarded until + -- 2026-09-07; a device pinned before that has no evidence and is + -- trust-on-first-use only, which the client is told rather than left to + -- infer. + add_sig TEXT NOT NULL DEFAULT '', + add_nonce TEXT NOT NULL DEFAULT '', + add_ts INTEGER NOT NULL DEFAULT 0, revoked_at TEXT, PRIMARY KEY (user_id, pk_ed25519) ); @@ -231,6 +247,22 @@ class Roster: # `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]} + # The countersignature evidence (Tier 2), added 2026-09-07. Done + # **before** the early return below, which fires on any roster already + # widened to one row per device — i.e. on every node that has run since + # 2026-08-18, which is all of them. Putting these inside that branch + # would have meant they never arrived, and the symptom would have been a + # roster response whose devices all read as unverifiable. + for column in ("add_sig", "add_nonce"): + if column not in columns: + await self._db.execute( + f"ALTER TABLE identities ADD COLUMN {column} " + f"TEXT NOT NULL DEFAULT ''") + if "add_ts" not in columns: + await self._db.execute( + "ALTER TABLE identities ADD COLUMN add_ts INTEGER NOT NULL " + "DEFAULT 0") + if key_columns == {"user_id", "pk_ed25519"} and "revoked_at" in columns: return @@ -278,6 +310,9 @@ class Roster: *, label: str = "", added_by_pk: str = "", + add_sig: str = "", + add_nonce: str = "", + add_ts: int = 0, ) -> None: """ Record a device for an account. @@ -291,10 +326,10 @@ class Roster: await self._db.execute( "INSERT OR REPLACE INTO identities " "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via, " - " label, added_by_pk, revoked_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)", + " label, added_by_pk, add_sig, add_nonce, add_ts, revoked_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)", (user_id, username, pk_ed25519, pk_x25519, _now(), via, - label, added_by_pk), + label, added_by_pk, add_sig, add_nonce, add_ts), ) await self._db.commit() @@ -367,6 +402,46 @@ class Roster: await self._db.commit() return cur.rowcount > 0 + async def group_devices(self, group_id: str) -> list[dict]: + """ + Every live device of every active member of one group, with the evidence + that admitted it. + + For Tier 2 (`docs/desktop-client-v1.md` §4.8), and therefore + **member-visible** — unlike `list_identities`, which answers the + operator. Two consequences of that, and both are the price of the + feature rather than oversights: + + - it tells every member of a group how many devices each other member + holds, and their public keys. It stays inside the group, and the hub + is not involved; + - it is scoped to *this* group. A person in two groups on one node is + not disclosed to the second by being in the first. + + `add_sig`/`add_nonce`/`add_ts` are empty for a device pinned before the + evidence was kept, and for the first device of any account — which an + operator code admitted, not a countersignature. Both read as + "trust on first use" to a client, which is what they are; the client + must not silently treat an absent signature as a valid one. + """ + assert self._db + async with self._db.execute( + "SELECT i.user_id, i.username, i.pk_ed25519, i.pk_x25519, " + " i.added_by_pk, i.add_sig, i.add_nonce, i.add_ts, i.pinned_at " + "FROM identities i " + "JOIN members m ON m.user_id = i.user_id " + "WHERE m.group_id = ? AND m.status = 'active' " + " AND i.revoked_at IS NULL " + "ORDER BY i.user_id, i.pinned_at", (group_id,) + ) as cur: + rows = await cur.fetchall() + return [ + {"user_id": r[0], "username": r[1], "pk_ed25519": r[2], + "pk_x25519": r[3], "added_by_pk": r[4], "add_sig": r[5], + "add_nonce": r[6], "add_ts": r[7], "pinned_at": r[8]} + for r in rows + ] + async def list_identities(self) -> list[dict]: assert self._db async with self._db.execute( |