aboutsummaryrefslogtreecommitdiffstats
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/roster.py81
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py64
-rw-r--r--packages/meshbay-node/tests/test_group_roster.py242
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")