From 3bd31db9d4fa2352f1095dcc630c77915d0774b8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 7 Sep 2026 21:04:56 +0200 Subject: feat(chat): Tier 2 — a member verifies another member's device itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat messages have been signed by the sending device since MNP 2.0, but a reader had no way to know that the device belonged to the account the node named: the signature proved *a device*, and `sender_id` was still the node's word. This closes that for any account a client has already seen. **What was blocking it was not effort — the evidence was not being kept.** `_do_device_add` verified the countersignature that admits a second device and stored only `added_by_pk`: *which* key approved, never the proof. And `device_add_transcript` binds `nonce_node`, the approving connection's handshake nonce, so even a stored signature was unverifiable by anyone who had not been on that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added before the migration's early return — which fires on every roster widened since 2026-08-18, i.e. all of them, so putting them inside it would have meant they never arrived. `group_roster_req`/`resp` relays, sealed under a new groupbox purpose and answered to **any member of the group**, every live device of every active member with the evidence that admitted it. The node decides nothing: it hands over evidence and the client walks the chain from each account's root outwards (`_verifyRoster`). That is deliberate — the node is the party the property holds against, so it is not asked to assert trust. Two holes the tests caught while this was being built: - "no signature" was being treated as a trust root, so a node that writes the roster could put any key in an account's row and have it laundered straight into the verified set. A root is a device that names **no** countersigner. - pinning only the verified subset at first sight raised "key changed" on legitimate second devices whose countersignature predates this change. First sight pins everything the node says, because that is what trust-on-first-use means and an alarm that fires on normal events stops being read. The property, and it must not be rounded up: **once a client has seen an account, a node that later substitutes a key for it is detected. Nothing is gained at first sight**, where there is nothing to compare against — the same boundary `per-node-identity-v1.md` draws, unmoved. The cost, stated because it is real: the roster is member-visible, so every member learns how many devices the others hold and their public keys. It stays inside the group, the hub is not involved, and it is scoped per group. A member who cannot see the keys cannot check them. User-visible surface: one notice, "this account is using a key you have not seen before", in ten languages. Nothing else. 16 tests — 7 on the node (the evidence is stored, it verifies from the roster alone, a fabricated device carries none, another group's members are not disclosed), 9 running the shipped `_verifyRoster` under node against rosters built by the shipped Python: a chain of three in any order, a signature by the wrong key, one for another node, one for another account, and two fabricated devices signing each other admitting nothing. Tier 3 (operator-signed roster attestation) stays deferred, with nothing depending on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr --- packages/meshbay-node/src/meshbay_node/roster.py | 81 ++++++- .../src/meshbay_node/transport/webrtc_server.py | 64 +++++- packages/meshbay-node/tests/test_group_roster.py | 242 +++++++++++++++++++++ 3 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 packages/meshbay-node/tests/test_group_roster.py (limited to 'packages/meshbay-node') diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index af87f92..2288ae4 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -75,6 +75,22 @@ CREATE TABLE IF NOT EXISTS identities ( -- Which already-pinned key countersigned this one into existence. Empty for -- the first device of an account, which an operator code admitted. added_by_pk TEXT NOT NULL DEFAULT '', + -- The countersignature itself, and the two fields needed to rebuild what it + -- signed. `added_by_pk` alone says *which* key approved and proves nothing: + -- a third party cannot check a signature it does not have. And the + -- transcript binds `nonce_node` — the approving connection's handshake + -- nonce — so even a stored signature is unverifiable without it. + -- + -- This is what Tier 2 needs (docs/desktop-client-v1.md §4.8): relayed with + -- the roster, it lets a member verify for themselves that a second device + -- belongs to an account whose first device they have already pinned, + -- instead of taking the node's word. Verified and discarded until + -- 2026-09-07; a device pinned before that has no evidence and is + -- trust-on-first-use only, which the client is told rather than left to + -- infer. + add_sig TEXT NOT NULL DEFAULT '', + add_nonce TEXT NOT NULL DEFAULT '', + add_ts INTEGER NOT NULL DEFAULT 0, revoked_at TEXT, PRIMARY KEY (user_id, pk_ed25519) ); @@ -231,6 +247,22 @@ class Roster: # `pk` is the column's position in the primary key, 0 when not part of it. key_columns = {r[1] for r in info if r[5]} + # The countersignature evidence (Tier 2), added 2026-09-07. Done + # **before** the early return below, which fires on any roster already + # widened to one row per device — i.e. on every node that has run since + # 2026-08-18, which is all of them. Putting these inside that branch + # would have meant they never arrived, and the symptom would have been a + # roster response whose devices all read as unverifiable. + for column in ("add_sig", "add_nonce"): + if column not in columns: + await self._db.execute( + f"ALTER TABLE identities ADD COLUMN {column} " + f"TEXT NOT NULL DEFAULT ''") + if "add_ts" not in columns: + await self._db.execute( + "ALTER TABLE identities ADD COLUMN add_ts INTEGER NOT NULL " + "DEFAULT 0") + if key_columns == {"user_id", "pk_ed25519"} and "revoked_at" in columns: return @@ -278,6 +310,9 @@ class Roster: *, label: str = "", added_by_pk: str = "", + add_sig: str = "", + add_nonce: str = "", + add_ts: int = 0, ) -> None: """ Record a device for an account. @@ -291,10 +326,10 @@ class Roster: await self._db.execute( "INSERT OR REPLACE INTO identities " "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via, " - " label, added_by_pk, revoked_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)", + " label, added_by_pk, add_sig, add_nonce, add_ts, revoked_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)", (user_id, username, pk_ed25519, pk_x25519, _now(), via, - label, added_by_pk), + label, added_by_pk, add_sig, add_nonce, add_ts), ) await self._db.commit() @@ -367,6 +402,46 @@ class Roster: await self._db.commit() return cur.rowcount > 0 + async def group_devices(self, group_id: str) -> list[dict]: + """ + Every live device of every active member of one group, with the evidence + that admitted it. + + For Tier 2 (`docs/desktop-client-v1.md` §4.8), and therefore + **member-visible** — unlike `list_identities`, which answers the + operator. Two consequences of that, and both are the price of the + feature rather than oversights: + + - it tells every member of a group how many devices each other member + holds, and their public keys. It stays inside the group, and the hub + is not involved; + - it is scoped to *this* group. A person in two groups on one node is + not disclosed to the second by being in the first. + + `add_sig`/`add_nonce`/`add_ts` are empty for a device pinned before the + evidence was kept, and for the first device of any account — which an + operator code admitted, not a countersignature. Both read as + "trust on first use" to a client, which is what they are; the client + must not silently treat an absent signature as a valid one. + """ + assert self._db + async with self._db.execute( + "SELECT i.user_id, i.username, i.pk_ed25519, i.pk_x25519, " + " i.added_by_pk, i.add_sig, i.add_nonce, i.add_ts, i.pinned_at " + "FROM identities i " + "JOIN members m ON m.user_id = i.user_id " + "WHERE m.group_id = ? AND m.status = 'active' " + " AND i.revoked_at IS NULL " + "ORDER BY i.user_id, i.pinned_at", (group_id,) + ) as cur: + rows = await cur.fetchall() + return [ + {"user_id": r[0], "username": r[1], "pk_ed25519": r[2], + "pk_x25519": r[3], "added_by_pk": r[4], "add_sig": r[5], + "add_nonce": r[6], "add_ts": r[7], "pinned_at": r[8]} + for r in rows + ] + async def list_identities(self) -> list[dict]: assert self._db async with self._db.execute( 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 4e4a23f..dfabe9b 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -99,7 +99,12 @@ from meshbay_common.device import ( device_hello_transcript, device_request_transcript, ) -from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_CHAT_KEYS, seal +from meshbay_common.groupbox import ( + PURPOSE_ACK, + PURPOSE_CHAT_KEYS, + PURPOSE_ROSTER, + seal, +) from meshbay_common.join import ( JOIN_TTL, ROLE_MEMBER, @@ -571,6 +576,8 @@ class WebRTCPeerSession: self._do_chat_epoch(msg) elif mtype == MNP.CHAT_KEYS_REQ: self._spawn(self._do_chat_keys_req(msg)) + elif mtype == MNP.GROUP_ROSTER_REQ: + self._spawn(self._do_group_roster_req(msg)) elif mtype == MNP.MEDIA_META_REQ: self._spawn(self._do_media_meta_request(msg)) elif mtype == MNP.SEASON_META_REQ: @@ -1456,10 +1463,23 @@ class WebRTCPeerSession: "detail": "That request is no longer pending"}) return + # The countersignature is **kept**, with the two fields needed to rebuild + # what it signed. Until 2026-09-07 it was verified here and thrown away, + # leaving only `added_by_pk` — which says *which* key approved and + # proves nothing to anyone else. `device_add_transcript` binds + # `nonce_node`, this connection's handshake nonce, so a stored signature + # without it is still unverifiable; that is why all three go in. + # + # This is what lets another member check for themselves that this device + # belongs to an account whose earlier device they have already pinned, + # instead of taking the node's word (Tier 2, desktop-client-v1.md §4.8). 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) + label=str(msg.get("label", ""))[:64], added_by_pk=signer, + add_sig=str(msg.get("sig", "")), + add_nonce=base64.b64encode(self._nonce_node).decode(), + add_ts=ts) 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]) @@ -2522,6 +2542,46 @@ class WebRTCPeerSession: self._broadcast_to_group({"type": MNP.CHAT_EPOCH_ACK, "v": MNP_VERSION, "epoch": result["epoch"]}) + async def _do_group_roster_req(self, msg: dict) -> None: + """ + Who is in this group, and which device keys they hold. + + Answers **any member**, not only the operator — that is the whole point. + A member verifies for themselves that a message came from a device + belonging to the account it claims, instead of taking the node's + `sender_id` on trust. What makes that possible is relayed here: each + device's key, which already-pinned key countersigned it, and the + signature plus the nonce and timestamp needed to rebuild what was + signed. + + Sealed under a GEK-derived subkey, for the same reason the index is: it + is the group's membership, and a peer that has not completed the + handshake has no business reading it. + + What this deliberately does not do is *decide* anything. The node hands + over evidence; the client checks the chain and keeps its own pins. A + node that lies here is caught by a client that has seen the account + before, which is the property Tier 2 buys and the reason the node is not + asked to assert trust. + """ + gctx = self._group_ctx() + gek = gctx.get("gek") + roster = self._ctx.get("roster") + if not gek: + self._send({"type": "error", "detail": "Group encryption not initialized"}) + return + if roster is None: + self._send({"type": "error", "detail": "Roster not available"}) + return + + devices = await roster.group_devices(self._group_id or "") + payload = {"devices": devices, + "node_pk": self._node_pk_b64()} + sealed = seal(gek, PURPOSE_ROSTER, MNP.GROUP_ROSTER_RESP, + self._group_id or "", payload) + self._send({"type": MNP.GROUP_ROSTER_RESP, "v": MNP_VERSION, + "group_id": self._group_id or "", **sealed}) + async def _do_chat_keys_req(self, msg: dict) -> None: """ Hand this member every chat epoch key the group has, sealed. diff --git a/packages/meshbay-node/tests/test_group_roster.py b/packages/meshbay-node/tests/test_group_roster.py new file mode 100644 index 0000000..d7ca7dc --- /dev/null +++ b/packages/meshbay-node/tests/test_group_roster.py @@ -0,0 +1,242 @@ +""" +Tier 2: a member verifies another member's device for themselves. + +`docs/desktop-client-v1.md` §4.8, and `docs/chat-sender-keys.md` §13, which +recorded why it could not ship with the encryption: **the evidence was not being +kept.** `_do_device_add` verified the countersignature and stored only +`added_by_pk` — *which* key approved, never the proof — and the transcript binds +`nonce_node`, the approving connection's handshake nonce, so even a stored +signature was unverifiable by anyone who was not on that connection. + +So the node half is two things: keep `(sig, nonce, ts)` beside the pin, and +relay them to any member of the group who asks. The node deliberately decides +nothing here — it hands over evidence, and the client walks the chain. A node +that lies is caught by a client that has seen the account before, which is the +property, and it is why trust is not something the node is asked to assert. + +What this does **not** claim, per the convention: nothing is gained at first +sight. A member who has never seen Alice has nothing to compare against. +""" + +import base64 +import time + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from conftest import one_root +from meshbay_common.crypto import generate_gek, pk_to_b64 +from meshbay_common.device import device_add_transcript +from meshbay_common.groupbox import PURPOSE_ROSTER, unseal +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 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()) + + +def _session(tmp_path, roster, gek, user_id="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 = { + "roster": roster, "sk_node": index.sk_node, + "groups": {GROUP: {"gek": gek, "index": index, + "roots": one_root(shared)}}, + } + session._group_id = GROUP + session._user_id = user_id + session._username = user_id + session._nonce_node = NONCE + session._pinned_pk = "" + session._device_confirmed = False + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +async def _add_device(session, roster, approver_sk, approver_pk, new_pk, new_px, + user_id="alice"): + """Run the real device-add path, so the evidence is stored the real way.""" + ts = int(time.time()) + transcript = device_add_transcript( + node_pk_b64=session._node_pk_b64(), user_id=user_id, + pk_ed25519_b64=new_pk, pk_x25519_b64=new_px, nonce_node=NONCE, ts=ts) + session._user_id = user_id + await session._do_device_add({ + "pk_ed25519": new_pk, "pk_x25519": new_px, "ts": ts, + "sig": base64.b64encode(approver_sk.sign(transcript)).decode(), + }) + return ts + + +# ── the evidence is kept ───────────────────────────────────────────────────── + +async def test_the_countersignature_is_stored_not_discarded(tmp_path, roster): + """ + The finding that blocked Tier 2. Before this, `add_sig` did not exist and + `added_by_pk` was all that survived — which proves nothing to a third party. + """ + sk_a, pk_a, px_a = _keys() + _sk_b, pk_b, px_b = _keys() + await roster.pin_identity("alice", "alice", pk_a, px_a, via="code") + await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER, + status="active", approved_by="op") + + session = _session(tmp_path, roster, generate_gek()) + ts = await _add_device(session, roster, sk_a, pk_a, pk_b, px_b) + + devices = {d["pk_ed25519"]: d for d in await roster.group_devices(GROUP)} + added = devices[pk_b] + assert added["added_by_pk"] == pk_a + assert added["add_sig"], "the countersignature was thrown away again" + assert added["add_ts"] == ts + assert base64.b64decode(added["add_nonce"]) == NONCE, ( + "without the nonce the stored signature is unverifiable — the " + "transcript binds it") + + +async def test_the_stored_evidence_actually_verifies(tmp_path, roster): + """ + The point of storing it. A third party rebuilds the transcript from the + roster alone and checks the signature — no access to the connection that + approved it, which is the whole difficulty. + """ + sk_a, pk_a, px_a = _keys() + _sk_b, pk_b, px_b = _keys() + await roster.pin_identity("alice", "alice", pk_a, px_a, via="code") + await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER, + status="active", approved_by="op") + session = _session(tmp_path, roster, generate_gek()) + await _add_device(session, roster, sk_a, pk_a, pk_b, px_b) + + devices = {d["pk_ed25519"]: d for d in await roster.group_devices(GROUP)} + d = devices[pk_b] + transcript = device_add_transcript( + node_pk_b64=session._node_pk_b64(), user_id="alice", + pk_ed25519_b64=d["pk_ed25519"], pk_x25519_b64=d["pk_x25519"], + nonce_node=base64.b64decode(d["add_nonce"]), ts=d["add_ts"]) + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PublicKey, + ) + pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(d["added_by_pk"])) + pk.verify(base64.b64decode(d["add_sig"]), transcript) # raises if wrong + + +async def test_a_first_device_has_no_evidence_and_says_so(tmp_path, roster): + """ + An operator code admitted it; there is no countersignature and there cannot + be. It must read as trust-on-first-use rather than as verified — a client + that treated an absent signature as a valid one would verify anything. + """ + _sk_a, pk_a, px_a = _keys() + await roster.pin_identity("alice", "alice", pk_a, px_a, via="code") + await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER, + status="active", approved_by="op") + + (d,) = await roster.group_devices(GROUP) + assert d["added_by_pk"] == "" and d["add_sig"] == "" + + +# ── the relay ──────────────────────────────────────────────────────────────── + +async def test_a_member_is_served_the_roster_sealed(tmp_path, roster): + """ + Any member, not only the operator — that is the point. Sealed under a + GEK-derived subkey for the same reason the index is. + """ + _sk_a, pk_a, px_a = _keys() + await roster.pin_identity("alice", "alice", pk_a, px_a, via="code") + await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER, + status="active", approved_by="op") + await roster.set_member(group_id=GROUP, user_id="bob", role=ROLE_MEMBER, + status="active", approved_by="op") + _sk_b, pk_b, px_b = _keys() + await roster.pin_identity("bob", "bob", pk_b, px_b, via="code") + + gek = generate_gek() + session = _session(tmp_path, roster, gek, user_id="bob") + await session._do_group_roster_req({}) + + resp = session.sent[-1] + assert resp["type"] == MNP.GROUP_ROSTER_RESP + assert "devices" not in resp, "the roster must not travel in clear" + payload = unseal(gek, PURPOSE_ROSTER, MNP.GROUP_ROSTER_RESP, GROUP, resp) + assert {d["user_id"] for d in payload["devices"]} == {"alice", "bob"} + assert payload["node_pk"], "the transcript needs the node key to rebuild" + + +async def test_a_revoked_device_is_not_relayed(tmp_path, roster): + """A retired laptop must stop being offered as one of the account's keys.""" + sk_a, pk_a, px_a = _keys() + _sk_b, pk_b, px_b = _keys() + await roster.pin_identity("alice", "alice", pk_a, px_a, via="code") + await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER, + status="active", approved_by="op") + session = _session(tmp_path, roster, generate_gek()) + await _add_device(session, roster, sk_a, pk_a, pk_b, px_b) + await roster.revoke_device("alice", pk_b) + + keys = {d["pk_ed25519"] for d in await roster.group_devices(GROUP)} + assert keys == {pk_a} + + +async def test_another_groups_members_are_not_disclosed(tmp_path, roster): + """ + Scoped to this group. A person in two groups on one node is not revealed to + the second by being in the first — the roster is member-visible, so its + scope *is* the privacy boundary. + """ + _sk_a, pk_a, px_a = _keys() + _sk_c, pk_c, px_c = _keys() + await roster.pin_identity("alice", "alice", pk_a, px_a, via="code") + await roster.pin_identity("carol", "carol", pk_c, px_c, via="code") + await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER, + status="active", approved_by="op") + await roster.set_member(group_id="h" * 32, user_id="carol", + role=ROLE_MEMBER, status="active", approved_by="op") + + users = {d["user_id"] for d in await roster.group_devices(GROUP)} + assert users == {"alice"} + + +async def test_a_substituted_key_carries_no_evidence(tmp_path, roster): + """ + The attack Tier 2 exists to detect, from the node's side of it. + + A node that invents a device for an account can put it in the roster — it + writes the roster. What it cannot do is produce a countersignature from a + key it does not hold, so the fabricated device arrives with `add_sig` empty + and no chain reaches it. The client is what refuses to walk to it; this + asserts the node cannot manufacture the evidence. + """ + _sk_a, pk_a, px_a = _keys() + _sk_evil, pk_evil, px_evil = _keys() + await roster.pin_identity("alice", "alice", pk_a, px_a, via="code") + await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER, + status="active", approved_by="op") + # The node simply writes a second device for Alice, as a malicious one would. + await roster.pin_identity("alice", "alice", pk_evil, px_evil, via="device") + + devices = {d["pk_ed25519"]: d for d in await roster.group_devices(GROUP)} + assert devices[pk_evil]["add_sig"] == "", ( + "a fabricated device cannot come with a countersignature — if this ever " + "holds evidence, the node has been handed a way to mint trust") -- cgit v1.2.3