diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 02:06:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 02:06:35 +0200 |
| commit | 419af660eeb8a4403e37a63c54a15586d04d5a8e (patch) | |
| tree | b0c0cdd831a5fb2864d5bedf581681aa0ec86023 /packages/meshbay-cli/src/meshbay_cli/client/roster.py | |
| parent | 47583c5e405f3011a526df73cc9485c46598f214 (diff) | |
| download | meshbay-419af660eeb8a4403e37a63c54a15586d04d5a8e.tar.gz | |
feat(cli): chat — Tier 2 check on the signing device, interactive room
Each message's signing device is checked against the group's roster as the
page checks it (a port of transport-roster.js, held to it by the parity
test): chains of countersignatures walked from each account's roots, the
keys accepted per account and node kept in the store, the roster read again
once before a key is called changed. Only "changed" is said, under the
message; --json carries every verdict.
chat with no group opens the interactive chat: the groups, then one group's
conversation IRC-style with pushed messages arriving live and a line to
write in; Esc or /part goes back to the groups, /quit or Ctrl+Q quits. Like
the bare command, it opens on the sign-in screen when nothing is stored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/roster.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/roster.py | 152 |
1 files changed, 152 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/roster.py b/packages/meshbay-cli/src/meshbay_cli/client/roster.py new file mode 100644 index 0000000..cce5a14 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/roster.py @@ -0,0 +1,152 @@ +""" +Who signed a chat message, checked rather than taken on the node's word: Tier 2 +of docs/MESHBAY_DESIGN.md §3.3, as `static/transport-roster.js` does it. + +A message's signature proves *a device* wrote it. Whether that device belongs +to the account the node names is answered here, from two things: the group's +roster — each device with the already-pinned key that countersigned it and the +signature, relayed by the node, which asserts nothing — and the keys this +machine accepted for each account on each node before (kept in the store). + +The property, exactly: once this machine 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. + + pinned seen before, the same key — nothing to say + linked new, countersigned by a key already pinned for the account + first first sight of the account: trust on first use + changed a key this account has not shown before and nothing evidences + unknown the roster could not be read +""" + +import base64 +from dataclasses import dataclass, field + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from meshbay_common import MNP_VERSION +from meshbay_common.device import device_add_transcript +from meshbay_common.groupbox import PURPOSE_ROSTER, unseal + +from .account import Account +from .session import GroupSession + + +@dataclass +class AccountDevices: + username: str + all: list[str] + verified: list[str] + chain: dict[str, str] = field(default_factory=dict) # device → its countersigner + unevidenced: list[str] = field(default_factory=list) + + +def _signed(pk_b64: str, sig_b64, transcript: bytes) -> bool: + try: + sig = base64.b64decode(sig_b64) if isinstance(sig_b64, str) else bytes(sig_b64) + Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_b64)).verify(sig, transcript) + return True + except Exception: + return False + + +def verify_roster(payload: dict, node_pk: str) -> dict[str, AccountDevices]: + """ + Each account's devices, walked outwards from its roots. + + A root names **no** countersigner: an operator code admitted it. One that + names a countersigner and carries no proof is not a root but unevidenced — + a node writing the roster could otherwise launder any key in by mentioning + it, which is also the honest reading of a device pinned before the evidence + was kept. + """ + per: dict[str, list[dict]] = {} + for d in payload.get("devices") or []: + per.setdefault(d["user_id"], []).append(d) + node = payload.get("node_pk") or node_pk + out = {} + for user_id, rows in per.items(): + verified = [d["pk_ed25519"] for d in rows if not d.get("added_by_pk")] + pending = [d for d in rows if d.get("added_by_pk")] + chain: dict[str, str] = {} + progress = True + while progress and pending: + progress = False + for d in list(pending): + if d["added_by_pk"] not in verified: + continue + try: + nonce = base64.b64decode(d.get("add_nonce") or "") + transcript = device_add_transcript( + node_pk_b64=node, user_id=user_id, pk_ed25519_b64=d["pk_ed25519"], + pk_x25519_b64=d["pk_x25519"], nonce_node=nonce, + ts=int(d.get("add_ts") or 0)) + except Exception: + continue + if _signed(d["added_by_pk"], d.get("add_sig") or "", transcript): + verified.append(d["pk_ed25519"]) + chain[d["pk_ed25519"]] = d["added_by_pk"] + pending.remove(d) + progress = True + out[user_id] = AccountDevices(rows[0].get("username") or "", + [d["pk_ed25519"] for d in rows], verified, chain, + [d["pk_ed25519"] for d in pending]) + return out + + +def judge(known: list[str] | None, entry: AccountDevices | None, + device: str) -> tuple[str, list[str] | None]: + """ + The status of `device` for an account, and the keys to keep if it stands. + Decides and writes nothing, so a verdict on a stale roster can be taken + again on a fresh one without half a set pinned first. + """ + if known and device in known: + return "pinned", None + if not known: + # First sight pins everything the node says, not the verified subset: an + # alarm on a legitimate second device whose evidence predates it being + # kept is an alarm nobody reads. + pin = entry.all if entry else None + return ("first" if entry and device in entry.all else "changed"), pin + if entry and device in entry.verified and entry.chain.get(device) in known: + return "linked", sorted(set(known) | {device}) + return "changed", None + + +class RosterCheck: + """One connection's roster, read once, and read again once per account and + device before "changed" is said — a member who joined since it was read is + otherwise taken for a substitution.""" + + def __init__(self, account: Account, session: GroupSession): + self.account = account + self.session = session + self._roster: dict[str, AccountDevices] | None = None + self._rechecked: set[tuple[str, str]] = set() + + async def roster(self, *, fresh: bool = False) -> dict[str, AccountDevices]: + if self._roster is None or fresh: + s = self.session + resp = await s.ch.request({"type": "group_roster_req", "v": MNP_VERSION, + "group_id": s.group_id}, "group_roster_resp") + payload = unseal(s.gek, PURPOSE_ROSTER, "group_roster_resp", s.group_id, resp) + self._roster = verify_roster(payload, s.node_pk) + return self._roster + + async def status(self, user_id: str, device: str) -> str: + try: + roster = await self.roster() + except Exception: + return "unknown" + known = self.account.account_pins(self.session.node_pk, user_id) + verdict, pin = judge(known, roster.get(user_id), device) + if verdict == "changed" and (user_id, device) not in self._rechecked: + self._rechecked.add((user_id, device)) + try: + roster = await self.roster(fresh=True) + verdict, pin = judge(known, roster.get(user_id), device) + except Exception: + pass # the verdict on the copy we had stands + if pin is not None: + await self.account.set_account_pins(self.session.node_pk, user_id, pin) + return verdict |