diff options
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/config.py | 9 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 257 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 329 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_device_linking.py | 414 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 36 |
6 files changed, 1023 insertions, 24 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 42ebcfd..c0326f0 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -36,6 +36,8 @@ ui_port = 18000 # local admin UI (127.0.0.1 only) # operator pairing code is typed during the SSH session that printed it. invite_ttl_hours = 168 # 7 days pair_ttl_hours = 24 +# How long a new device may wait for one of your existing devices to approve it. +device_request_ttl_minutes = 60 # How many people may watch a video at once. One ffmpeg runs per viewer for as # long as they watch — it remuxes rather than re-encodes, so it costs little CPU @@ -105,6 +107,10 @@ class NodeConfig: # the SSH session that printed it. invite_ttl_hours: int = 168 # 7 days pair_ttl_hours: int = 24 + # A device-add code is read off one screen and typed into another, in one + # sitting. Comfort rather than security: the code is bound to the requesting + # keys by its hash, so a longer window widens nothing an attacker can use. + device_request_ttl_minutes: int = 60 # How many people may watch a video at the same time. One ffmpeg runs per # viewer for as long as they watch, so this is the knob that decides when # the node answers "server busy" — see MAX_CONCURRENT_TRANSCODES in @@ -252,6 +258,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: nd.get("invite_ttl_hours", cfg.node.invite_ttl_hours)) cfg.node.pair_ttl_hours = int( nd.get("pair_ttl_hours", cfg.node.pair_ttl_hours)) + cfg.node.device_request_ttl_minutes = int( + nd.get("device_request_ttl_minutes", + cfg.node.device_request_ttl_minutes)) cfg.node.max_concurrent_streams = _positive( nd.get("max_concurrent_streams", cfg.node.max_concurrent_streams), cfg.node.max_concurrent_streams, "max_concurrent_streams") diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 21331f2..f4dcca5 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -314,6 +314,8 @@ class NodeDaemon: self._webrtc._ctx["daemon_state"] = self._state self._webrtc._ctx["invite_ttl"] = ( self._config.node.invite_ttl_hours * 3600) + self._webrtc._ctx["device_request_ttl"] = ( + self._config.node.device_request_ttl_minutes * 60) paired = await self._roster.has_operator() if self._roster else False self._webrtc._ctx["has_admin_authority"] = paired if paired: 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: diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 4ec841f..9d16f82 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -65,6 +65,12 @@ from meshbay_common.adminop import ( admin_transcript, ) from meshbay_common.crypto import pk_to_b64, wrap_gek_aes +from meshbay_common.device import ( + DEVICE_TTL, + device_add_transcript, + device_code_hash, + device_request_transcript, +) from meshbay_common.join import ( JOIN_TTL, ROLE_MEMBER, @@ -453,6 +459,16 @@ class WebRTCPeerSession: self._do_invite_create(msg) elif mtype == MNP.MEMBER_REVOKE: self._do_member_revoke(msg) + elif mtype == MNP.DEVICE_REQUEST and self._nonce_node: + self._spawn(self._do_device_request(msg)) + elif mtype == MNP.DEVICE_LOOKUP: + self._spawn(self._do_device_lookup(msg)) + elif mtype == MNP.DEVICE_ADD: + self._spawn(self._do_device_add(msg)) + elif mtype == MNP.DEVICE_LIST: + self._spawn(self._do_device_list(msg)) + elif mtype == MNP.DEVICE_REVOKE: + self._spawn(self._do_device_revoke(msg)) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -901,15 +917,31 @@ class WebRTCPeerSession: self._join_refuse("signature_invalid") return - known = await roster.get_identity(user_id) + # One person may hold several devices here — a browser and a desktop + # client are two keys on one account. So the question is not "is this + # THE key" but "is this ONE OF this account's live devices". + device = await roster.find_device(user_id, pk_ed_b64) + if device and device["pk_x25519"] != pk_x_b64: + # The Ed25519 key is pinned but arrives with a different encryption + # key. The join transcript signs both together, so this is either a + # client that regenerated half its identity or something splicing + # two messages; either way the pair is not the one admitted. + self._join_refuse( + "key_changed", + f"pinned x25519={device['pk_x25519'][:16]} presented={pk_x_b64[:16]}") + return + known = device + if not known and await roster.list_devices(user_id): + # The account is known here but this key is not one of its devices. + # Not an error to shout about: it is a second browser or a new + # client, and the way in is a device-add approved by a device that + # is already trusted — no operator, no new invitation code. + self._join_refuse( + "unknown_device", + f"presented={pk_ed_b64[:16]} — approve it from a device already " + f"paired with this node") + return if known: - if known["pk_ed25519"] != pk_ed_b64 or known["pk_x25519"] != pk_x_b64: - # The blocking warning, raised where it matters: whoever this is - # holds a different key than the person the operator paired. - self._join_refuse( - "key_changed", - f"pinned={known['pk_ed25519'][:16]} presented={pk_ed_b64[:16]}") - return # An operator's row is node-wide (empty group), so a lookup for the # group they happen to be opening finds nothing. Fall back to it, or # the client is told it has no role on a node it administers. @@ -956,6 +988,287 @@ class WebRTCPeerSession: await self._join_ok(user_id, pk_x_raw, session_group or invite["group_id"], role=invite["role"], recognised=False) + + # ── Device linking ─────────────────────────────────────────────────────── + # + # A person may hold several devices on one node. The authority admitting a + # new one is a key the node already pinned — never the hub, which has stored + # no user keys since 2026-08-14 and therefore cannot countersign anything. + # See docs/desktop-client-v1.md §4. + + async def _do_device_request(self, msg: dict) -> None: + """ + A new device files itself as pending, bound to a code it displays. + + Served in the pre-proof window: by construction the caller holds no key + this node knows, so there is nothing yet to prove. Filing is inert — + nothing is admitted until an existing device countersigns. + """ + roster = self._ctx.get("roster") + if roster is None or not self._user_id or not self._nonce_node: + self._send({"type": "error", "detail": "Not ready for a device request"}) + return + + if not self._spend_device_attempt(): + return + + pk_ed_b64 = str(msg.get("pk_ed25519", "")) + pk_x_b64 = str(msg.get("pk_x25519", "")) + code_hash = str(msg.get("code_hash", "")) + if not (pk_ed_b64 and pk_x_b64 and code_hash): + self._send({"type": "error", "detail": "Missing device keys or code"}) + return + + # The account must already be known here. Anti-spam rather than a + # security boundary: the filing key is unpinned by construction, so this + # bounds the table, not the trust. + existing = await roster.list_devices(self._user_id) + if not existing: + self._send({"type": "error", + "detail": "This account has no device on this node yet — " + "an invitation code is what admits the first"}) + return + if len(existing) >= roster.MAX_DEVICES_PER_USER: + self._send({"type": "error", + "detail": f"Already {len(existing)} devices, which is the " + f"limit. Revoke one first."}) + return + + ts = int(msg.get("ts", 0)) + if abs(time.time() - ts) > DEVICE_TTL: + self._send({"type": "error", "detail": "Device request expired"}) + return + + transcript = device_request_transcript( + node_pk_b64=self._node_pk_b64(), user_id=self._user_id, + pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64, + code_hash=code_hash, nonce_node=self._nonce_node, ts=ts) + try: + pk_ed = Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_ed_b64)) + sig = base64.b64decode(msg.get("sig", "")) + except Exception: + self._send({"type": "error", "detail": "Invalid device key encoding"}) + return + if not self._verify_sig(pk_ed, transcript, sig): + # Proof of possession, and nothing more: this says the caller holds + # the keys, never that they belong to this account. + self._send({"type": "error", "detail": "Device signature invalid"}) + return + + ttl = int(self._ctx.get("device_request_ttl") or 3600) + expires = await roster.file_device_request( + user_id=self._user_id, username=self._username or "", + pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, + code_hash=code_hash, ttl=ttl) + self._audit("device_request", f"{pk_ed_b64[:16]}") + log.info("Device request filed for %s (%s)", self._user_id[:8], + pk_ed_b64[:16]) + self._send({"type": MNP.DEVICE_REQUEST_ACK, "v": MNP_VERSION, + "expires_at": expires}) + + async def _do_device_lookup(self, msg: dict) -> None: + """ + List this account's pending device requests, each with its code hash. + + **The node never learns the code**, which is what makes it unable to + substitute a key. It answers with candidates; the approver recomputes + `sha256(code ‖ keys)` for each and keeps the one that matches. A node + offering fabricated keys would have to produce a hash matching + `sha256(code ‖ fabricated)` — and it does not know the code. + + An earlier version of this took the hash from the client and looked the + request up by it. That is circular: the client cannot compute the hash + without already knowing the keys it is asking about. + """ + roster = self._ctx.get("roster") + if roster is None or not self._user_id: + self._send({"type": "error", "detail": "Roster not available"}) + return + + pending = await roster.list_device_requests(self._user_id) + self._send({ + "type": MNP.DEVICE_LOOKUP_RESULT, "v": MNP_VERSION, + "requests": [ + {"pk_ed25519": r["pk_ed25519"], "pk_x25519": r["pk_x25519"], + "code_hash": r["code_hash"], "created_at": r["created_at"]} + for r in pending + ], + }) + + async def _do_device_add(self, msg: dict) -> None: + """ + Admit a device, countersigned by one this node already pinned. + + The whole control is in `_verify_device_signer`: the signature must + verify against a **live device of this same account**. The hub holds no + user keys and so cannot produce one. + """ + roster = self._ctx.get("roster") + if roster is None or not self._user_id or not self._nonce_node: + self._send({"type": "error", "detail": "Not ready to add a device"}) + return + if not self._spend_device_attempt(): + return + + pk_ed_b64 = str(msg.get("pk_ed25519", "")) + pk_x_b64 = str(msg.get("pk_x25519", "")) + ts = int(msg.get("ts", 0)) + if not (pk_ed_b64 and pk_x_b64): + self._send({"type": "error", "detail": "Missing device keys"}) + return + if abs(time.time() - ts) > DEVICE_TTL: + self._send({"type": "error", "detail": "Approval expired"}) + return + + transcript = device_add_transcript( + node_pk_b64=self._node_pk_b64(), user_id=self._user_id, + pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64, + nonce_node=self._nonce_node, ts=ts) + signer = await self._verify_device_signer(roster, transcript, + msg.get("sig", "")) + if signer is None: + self._audit("device_add_refused", pk_ed_b64[:16]) + self._send({"type": "error", + "detail": "Not signed by a device already paired here"}) + return + + devices = await roster.list_devices(self._user_id) + if len(devices) >= roster.MAX_DEVICES_PER_USER: + self._send({"type": "error", "detail": "Device limit reached"}) + return + + # Spend the request. Single use: an approval cannot be replayed, and a + # code that was used is gone whatever else happens next. + code_hash = str(msg.get("code_hash", "")) + if code_hash and not await roster.take_device_request( + code_hash, self._user_id): + self._send({"type": "error", + "detail": "That request is no longer pending"}) + return + + await roster.pin_identity( + user_id=self._user_id, username=self._username or "", + pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via="device", + label=str(msg.get("label", ""))[:64], added_by_pk=signer) + self._audit("device_added", f"{pk_ed_b64[:16]} by {signer[:16]}") + log.info("Device added for %s: %s (approved by %s)", + self._user_id[:8], pk_ed_b64[:16], signer[:16]) + self._send({"type": MNP.DEVICE_ADD_ACK, "v": MNP_VERSION, + "pk_ed25519": pk_ed_b64}) + + async def _do_device_list(self, msg: dict) -> None: + """This account's devices. Anyone may read their own, nobody else's.""" + roster = self._ctx.get("roster") + if roster is None or not self._user_id: + self._send({"type": "error", "detail": "Roster not available"}) + return + devices = await roster.list_devices(self._user_id) + pending = await roster.pending_device_requests(self._user_id) + self._send({ + "type": MNP.DEVICE_LIST_RESULT, "v": MNP_VERSION, + "pending": pending, + "devices": [ + {"pk_ed25519": d["pk_ed25519"], "label": d.get("label", ""), + "pinned_at": d["pinned_at"], "pinned_via": d["pinned_via"], + "added_by_pk": d.get("added_by_pk", ""), + "is_this_one": d["pk_ed25519"] == self._pinned_pk} + for d in devices + ], + }) + + async def _do_device_revoke(self, msg: dict) -> None: + """ + Retire one of this account's devices — a lost laptop. + + Countersigned like an addition, by a live device of the same account. + The last one cannot go: an account with no device on this node can only + return through an operator's invitation code, and doing that to yourself + by accident is not a mistake worth allowing. + """ + roster = self._ctx.get("roster") + if roster is None or not self._user_id or not self._nonce_node: + self._send({"type": "error", "detail": "Not ready"}) + return + if not self._spend_device_attempt(): + return + + target = str(msg.get("pk_ed25519", "")) + ts = int(msg.get("ts", 0)) + if not target: + self._send({"type": "error", "detail": "Missing device key"}) + return + if abs(time.time() - ts) > DEVICE_TTL: + self._send({"type": "error", "detail": "Request expired"}) + return + + victim = await roster.find_device(self._user_id, target) + if victim is None: + self._send({"type": "error", "detail": "No such device"}) + return + + transcript = device_add_transcript( + node_pk_b64=self._node_pk_b64(), user_id=self._user_id, + pk_ed25519_b64=target, pk_x25519_b64=victim["pk_x25519"], + nonce_node=self._nonce_node, ts=ts) + signer = await self._verify_device_signer(roster, transcript, + msg.get("sig", "")) + if signer is None: + self._send({"type": "error", + "detail": "Not signed by a device already paired here"}) + return + + if len(await roster.list_devices(self._user_id)) <= 1: + self._send({"type": "error", + "detail": "This is your only device here — removing it " + "would need an operator code to come back"}) + return + + await roster.revoke_device(self._user_id, target) + self._audit("device_revoked", f"{target[:16]} by {signer[:16]}") + log.info("Device revoked for %s: %s", self._user_id[:8], target[:16]) + self._send({"type": MNP.DEVICE_ADD_ACK, "v": MNP_VERSION, + "revoked": target}) + + async def _verify_device_signer(self, roster, transcript: bytes, + sig_b64: str) -> str | None: + """ + The pinned key that signed this, or None. + + Every live device of the account is tried, because any of them may + approve. A revoked one is not in the list — that is the point of marking + rather than deleting: a lost laptop must stop being able to admit its + replacement. + """ + try: + sig = base64.b64decode(sig_b64) + except Exception: + return None + for device in await roster.list_devices(self._user_id): + try: + pk = Ed25519PublicKey.from_public_bytes( + base64.b64decode(device["pk_ed25519"])) + except Exception: + continue + if self._verify_sig(pk, transcript, sig): + return device["pk_ed25519"] + return None + + def _spend_device_attempt(self) -> bool: + """ + Bound guessing on this connection, as the join path does. + + A code is 40 bits, single use and bound to the keys it names, so this is + depth rather than the control — but an unbounded loop over the lookup is + still a free oracle, and a burst of failures belongs in the audit log. + """ + self._device_attempts = getattr(self, "_device_attempts", 0) + 1 + if self._device_attempts > 5: + self._audit("device_attempts_exceeded", str(self._device_attempts)) + self._send({"type": "error", + "detail": "Too many device attempts on this connection"}) + return False + return True + def _group_join_policy(self, group_id: str) -> str: """ Admission policy for a group, read from the node's own configuration. diff --git a/packages/meshbay-node/tests/test_device_linking.py b/packages/meshbay-node/tests/test_device_linking.py new file mode 100644 index 0000000..3580ff8 --- /dev/null +++ b/packages/meshbay-node/tests/test_device_linking.py @@ -0,0 +1,414 @@ +""" +One person, several devices on one node. + +Identity keys are per node, so a browser and a desktop client are two keys on +one account. Admitting the second must not need an operator — that friction is +what would make "the native client must not prevent web use" fail — and must not +be something the hub or the node itself can do. + +The controls, and the tests that hold them: + + * **A key the node already pinned countersigns.** The hub has stored no user + keys since 2026-08-14, so it cannot produce that signature. + * **The code is hashed together with the requesting keys**, so the node cannot + answer an approver with a substituted key: the approver recomputes the hash + and finds nothing. + * **Nothing rests on a human comparing digits.** Phase 12.1 dropped that + ritual as "correct, unusable as the default"; it must not come back here. + +Everything below is written as "this does not work". +""" + +import base64 +import time +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from conftest import one_root +from meshbay_common.crypto import pk_to_b64 +from meshbay_common.device import ( + device_add_transcript, + device_code_hash, + device_request_transcript, +) +from meshbay_common.join import ROLE_MEMBER +from meshbay_common.protocol import MNP +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import generate_code, normalize_code, open_roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +GROUP = "g" * 32 +NONCE = b"\x11" * 32 + + +@pytest.fixture +async def roster(tmp_path): + r = await open_roster(tmp_path) + yield r + await r.close() + + +def _keys(): + sk_ed = Ed25519PrivateKey.generate() + sk_x = Ed25519PrivateKey.generate() # stand-in; only its b64 is used + return sk_ed, pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key()) + + +async def _session(tmp_path: Path, roster, user_id: str = "alice"): + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "roots": one_root(shared), "index": index, "sk_node": index.sk_node, + "roster": roster, "device_request_ttl": 3600, + "groups": {GROUP: {"gek": b"\x01" * 32, "index": index, + "roots": one_root(shared), "join_policy": "invite"}}, + } + session._group_id = GROUP + session._user_id = user_id + session._username = user_id + session._pk_user = "" + session._pinned_pk = "" + session._uploads = {} + session._nonce_node = NONCE + session._remote_ip = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _last(session): + return session.sent[-1] if session.sent else {} + + +async def _file_request(session, sk_new, pk_ed, pk_x, code): + """A new device asks to be added, signing over its own keys.""" + code_hash = device_code_hash(normalize_code(code), pk_ed, pk_x) + ts = int(time.time()) + transcript = device_request_transcript( + node_pk_b64=session._node_pk_b64(), user_id=session._user_id, + pk_ed25519_b64=pk_ed, pk_x25519_b64=pk_x, code_hash=code_hash, + nonce_node=NONCE, ts=ts) + await session._do_device_request({ + "pk_ed25519": pk_ed, "pk_x25519": pk_x, "code_hash": code_hash, + "ts": ts, "sig": base64.b64encode(sk_new.sign(transcript)).decode(), + }) + return code_hash + + +async def _approve(session, sk_signer, pk_ed, pk_x, code_hash=""): + ts = int(time.time()) + transcript = device_add_transcript( + node_pk_b64=session._node_pk_b64(), user_id=session._user_id, + pk_ed25519_b64=pk_ed, pk_x25519_b64=pk_x, nonce_node=NONCE, ts=ts) + await session._do_device_add({ + "pk_ed25519": pk_ed, "pk_x25519": pk_x, "ts": ts, + "code_hash": code_hash, + "sig": base64.b64encode(sk_signer.sign(transcript)).decode(), + }) + + +async def _match_by_code(session, code): + """ + What an approving client does: list what is pending and recompute. + + The code never reaches the node. The client hashes it against each + candidate's keys and keeps the row that matches — so a node offering + fabricated keys produces no match, having no way to compute a hash over a + code it does not know. + """ + await session._do_device_lookup({}) + listed = _last(session) + if listed.get("type") != MNP.DEVICE_LOOKUP_RESULT: + return None + for req in listed.get("requests", []): + expect = device_code_hash(normalize_code(code), req["pk_ed25519"], + req["pk_x25519"]) + if expect == req["code_hash"]: + return req + return None + + +# ── The happy path, so the refusals mean something ─────────────────────────── + +async def test_an_existing_device_admits_a_new_one(tmp_path, roster): + sk_old, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + code = generate_code() + await _file_request(session, sk_new, pk_new_ed, pk_new_x, code) + assert _last(session)["type"] == MNP.DEVICE_REQUEST_ACK + + match = await _match_by_code(session, code) + assert match is not None, "the approver could not find the pending request" + assert match["pk_ed25519"] == pk_new_ed + + await _approve(session, sk_old, pk_new_ed, pk_new_x, + code_hash=match["code_hash"]) + + assert _last(session)["type"] == MNP.DEVICE_ADD_ACK + devices = await roster.list_devices("alice") + assert {d["pk_ed25519"] for d in devices} == {pk_old_ed, pk_new_ed} + added = next(d for d in devices if d["pk_ed25519"] == pk_new_ed) + assert added["added_by_pk"] == pk_old_ed, "provenance is not recorded" + + +async def test_both_devices_then_open_the_group(tmp_path, roster): + """The point of the whole exercise: web and native at the same time.""" + sk_old, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + await roster.set_member(GROUP, "alice", ROLE_MEMBER, "active", "grenet") + _, pk_new_ed, pk_new_x = _keys() + await roster.pin_identity("alice", "alice", pk_new_ed, pk_new_x, "device", + added_by_pk=pk_old_ed) + + for pk in (pk_old_ed, pk_new_ed): + assert await roster.find_device("alice", pk) is not None + assert await roster.is_authorized(GROUP, "alice") + + +# ── What must not work ─────────────────────────────────────────────────────── + +async def test_the_request_alone_admits_nothing(tmp_path, roster): + """Filing is inert. A node that pinned here would let anyone with a hub + token join any account that has ever used it.""" + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert await roster.find_device("alice", pk_new_ed) is None + assert [d["pk_ed25519"] for d in await roster.list_devices("alice")] == \ + [pk_old_ed] + + +async def test_the_new_device_cannot_approve_itself(tmp_path, roster): + """ + Otherwise anyone the hub can mint a token for walks in: the request is + self-signed by construction, so self-approval would be no control at all. + """ + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + await _approve(session, sk_new, pk_new_ed, pk_new_x) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_new_ed) is None + + +async def test_a_stranger_cannot_approve(tmp_path, roster): + """A key belonging to somebody else, or to nobody, is not this account's.""" + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_bob, pk_bob_ed, pk_bob_x = _keys() + await roster.pin_identity("bob", "bob", pk_bob_ed, pk_bob_x, "code") + _, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _approve(session, sk_bob, pk_new_ed, pk_new_x) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_new_ed) is None + + +async def test_a_revoked_device_cannot_admit_its_replacement(tmp_path, roster): + """ + The lost laptop. Marking rather than deleting is what makes this hold: a + deleted row is a key the node would pin again on the next device-add. + """ + sk_lost, pk_lost_ed, pk_lost_x = _keys() + _, pk_keep_ed, pk_keep_x = _keys() + await roster.pin_identity("alice", "alice", pk_lost_ed, pk_lost_x, "code") + await roster.pin_identity("alice", "alice", pk_keep_ed, pk_keep_x, "device") + await roster.revoke_device("alice", pk_lost_ed) + + _, pk_new_ed, pk_new_x = _keys() + session = await _session(tmp_path, roster) + await _approve(session, sk_lost, pk_new_ed, pk_new_x) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_new_ed) is None + + +async def test_an_account_with_no_device_here_cannot_file(tmp_path, roster): + """The first device is admitted by an operator's invitation code. Letting + this path serve that purpose would bypass the roster entirely.""" + sk_new, pk_new_ed, pk_new_x = _keys() + session = await _session(tmp_path, roster, user_id="nobody") + + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert _last(session)["type"] == "error" + assert "invitation code" in _last(session)["detail"] + + +async def test_a_signature_by_the_wrong_key_is_not_a_request(tmp_path, roster): + """Proof of possession: the request must be signed by the keys it presents.""" + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_other, _, _ = _keys() + _, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _file_request(session, sk_other, pk_new_ed, pk_new_x, generate_code()) + + assert _last(session)["type"] == "error" + assert "signature" in _last(session)["detail"].lower() + + +# ── The code binds the keys ────────────────────────────────────────────────── + +async def test_the_node_cannot_substitute_the_keys(tmp_path, roster): + """ + The load-bearing property. The hash covers the code **and** the requesting + keys, so an approver looking a request up with different keys finds nothing + — and never signs. This is what replaces "compare these digits". + """ + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + _, pk_evil_ed, pk_evil_x = _keys() + + session = await _session(tmp_path, roster) + code = generate_code() + await _file_request(session, sk_new, pk_new_ed, pk_new_x, code) + + # A node that answered with keys of its own choosing would have to produce a + # hash matching sha256(code ‖ those keys) — over a code it never receives. + forged = device_code_hash(normalize_code(code), pk_evil_ed, pk_evil_x) + real = (await _match_by_code(session, code))["code_hash"] + + assert forged != real, "substituted keys produced a matching hash" + # And the client's own matching would reject the substitution outright. + await session._do_device_lookup({}) + offered = _last(session)["requests"] + assert all(r["pk_ed25519"] != pk_evil_ed for r in offered) + + +async def test_a_wrong_code_finds_nothing(tmp_path, roster): + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert await _match_by_code(session, generate_code()) is None + + +async def test_a_code_is_spent_once(tmp_path, roster): + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + sk_old_signer, pk_old_ed2, pk_old_x2 = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed2, pk_old_x2, "device") + session = await _session(tmp_path, roster) + code = generate_code() + await _file_request(session, sk_new, pk_new_ed, pk_new_x, code) + + match = await _match_by_code(session, code) + await _approve(session, sk_old_signer, pk_new_ed, pk_new_x, + code_hash=match["code_hash"]) + assert _last(session)["type"] == MNP.DEVICE_ADD_ACK + + # Spent: the same approval cannot be replayed. + await _approve(session, sk_old_signer, pk_new_ed, pk_new_x, + code_hash=match["code_hash"]) + assert _last(session)["type"] == "error" + + +async def test_another_account_cannot_redeem_your_code(tmp_path, roster): + """Scoped to the account as well as to the keys.""" + _, pk_a_ed, pk_a_x = _keys() + await roster.pin_identity("alice", "alice", pk_a_ed, pk_a_x, "code") + _, pk_b_ed, pk_b_x = _keys() + await roster.pin_identity("bob", "bob", pk_b_ed, pk_b_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + alice = await _session(tmp_path, roster, user_id="alice") + code = generate_code() + await _file_request(alice, sk_new, pk_new_ed, pk_new_x, code) + + bob = await _session(tmp_path, roster, user_id="bob") + + # Scoped to the account: Bob is not offered Alice's pending request at all, + # so the code buys him nothing even if he has it. + assert await _match_by_code(bob, code) is None + + +async def test_guessing_is_bounded_on_a_connection(tmp_path, roster): + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + session = await _session(tmp_path, roster) + + sk_new, pk_new_ed, pk_new_x = _keys() + for _ in range(8): + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert "Too many device attempts" in _last(session)["detail"] + + +# ── Limits and revocation ──────────────────────────────────────────────────── + +async def test_the_device_ceiling_holds(tmp_path, roster): + """ + A chain of devices inherits the weakness of its weakest ancestor, so the + answer to "how many" is a ceiling and visibility, not cryptography. + """ + sk_first, pk_first_ed, pk_first_x = _keys() + await roster.pin_identity("alice", "alice", pk_first_ed, pk_first_x, "code") + for _ in range(roster.MAX_DEVICES_PER_USER - 1): + _, pk_ed, pk_x = _keys() + await roster.pin_identity("alice", "alice", pk_ed, pk_x, "device") + + session = await _session(tmp_path, roster) + sk_new, pk_new_ed, pk_new_x = _keys() + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert _last(session)["type"] == "error" + assert "limit" in _last(session)["detail"] + + +async def test_your_last_device_cannot_be_revoked(tmp_path, roster): + """Removing it would need an operator's code to come back, and doing that + to yourself by accident is not a mistake worth allowing.""" + sk_only, pk_only_ed, pk_only_x = _keys() + await roster.pin_identity("alice", "alice", pk_only_ed, pk_only_x, "code") + session = await _session(tmp_path, roster) + + ts = int(time.time()) + transcript = device_add_transcript( + node_pk_b64=session._node_pk_b64(), user_id="alice", + pk_ed25519_b64=pk_only_ed, pk_x25519_b64=pk_only_x, + nonce_node=NONCE, ts=ts) + await session._do_device_revoke({ + "pk_ed25519": pk_only_ed, "ts": ts, + "sig": base64.b64encode(sk_only.sign(transcript)).decode()}) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_only_ed) is not None + + +async def test_unpinning_an_account_takes_every_device(tmp_path, roster): + """`member unpin` is what an operator runs when someone must start over. + Leaving one device would let them walk back in with a forgotten key.""" + for _ in range(3): + _, pk_ed, pk_x = _keys() + await roster.pin_identity("alice", "alice", pk_ed, pk_x, "device") + + assert len(await roster.list_devices("alice")) == 3 + await roster.unpin("alice") + assert await roster.list_devices("alice") == [] diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 9e45dbc..61d53ac 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -247,10 +247,16 @@ async def test_join_cannot_be_replayed_onto_another_connection(tmp_path, roster) assert await roster.get_identity("grenet") is None -async def test_pinned_identity_presenting_a_new_key_is_refused(tmp_path, roster): +async def test_a_key_this_node_never_pinned_is_refused(tmp_path, roster): """ - 11.5.8's rule, applied to people: a changed key is refused outright rather - than warned about, and clearing it is a deliberate operator action. + 11.5.8's rule, applied to people: an unrecognised key does not get in, and + a code cannot talk its way past that. + + What changed with device linking (2026-08-18) is the way back, not the + refusal. This used to be `key_changed` and needed an operator to unpin; now + it is `unknown_device` and the person approves the new key from a device + already paired here. Nothing is pinned either way, which is the part that + matters. """ session = _session(tmp_path, roster) _, old_pk_ed, old_pk_x = _keypair() @@ -260,8 +266,30 @@ async def test_pinned_identity_presenting_a_new_key_is_refused(tmp_path, roster) await session._do_join_request( _join_msg(session, sk_ed2, new_pk_ed, new_pk_x, code="ANY-CODE")) + assert _last(session).get("reason") == "unknown_device" + assert await roster.find_device("grenet", new_pk_ed) is None + assert [d["pk_ed25519"] for d in await roster.list_devices("grenet")] == \ + [old_pk_ed] + + +async def test_a_pinned_key_arriving_with_a_different_x25519_is_refused( + tmp_path, roster): + """ + The join transcript signs both keys together, so a pinned Ed25519 key + presenting a different encryption key is either a client that regenerated + half its identity or two messages spliced. Either way the pair is not the + one admitted, and the group key must not be wrapped for it. + """ + session = _session(tmp_path, roster) + sk_ed, pk_ed, pk_x = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed, pk_x, "code") + + _, _, other_pk_x = _keypair() + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed, other_pk_x, code="ANY-CODE")) + assert _last(session).get("reason") == "key_changed" - assert (await roster.get_identity("grenet"))["pk_ed25519"] == old_pk_ed + assert (await roster.find_device("grenet", pk_ed))["pk_x25519"] == pk_x async def test_attempts_are_bounded(tmp_path, roster): |