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