summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py9
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py257
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py329
-rw-r--r--packages/meshbay-node/tests/test_device_linking.py414
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py36
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):