aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/account.py14
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/chat.py15
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/roster.py152
3 files changed, 177 insertions, 4 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/account.py b/packages/meshbay-cli/src/meshbay_cli/client/account.py
index 9bb85ce..96483e9 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/account.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/account.py
@@ -162,6 +162,8 @@ class Account:
"known_browser": r.get("known_browser") or previous.get("known_browser"),
# A node's key does not depend on who signed in.
"pins": previous.get("pins", {}) if previous.get("hub") == self.hub.url else {},
+ "account_pins": (previous.get("account_pins", {})
+ if previous.get("hub") == self.hub.url else {}),
}
if same_account and previous.get("browser_access"):
state["browser_access"] = previous["browser_access"]
@@ -341,6 +343,18 @@ class Account:
def pepper_version(self) -> int:
return int(self.secrets.get("pepper_version") or 1)
+ # The device keys this machine accepted for each account, per node (Tier 2,
+ # `roster.py`): public keys, kept with the other pins. Losing them costs one
+ # "first sight", never a wrong answer.
+
+ def account_pins(self, node_pk: str, user_id: str) -> list[str] | None:
+ return self.state.get("account_pins", {}).get(node_pk, {}).get(user_id)
+
+ async def set_account_pins(self, node_pk: str, user_id: str, keys: list[str]) -> None:
+ def put(state: dict, secrets: dict) -> None:
+ state.setdefault("account_pins", {}).setdefault(node_pk, {})[user_id] = keys
+ await self.update(put)
+
def pin(self, node_id: str) -> str | None:
return self.state.get("pins", {}).get(node_id)
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/chat.py b/packages/meshbay-cli/src/meshbay_cli/client/chat.py
index bb0c83f..60629e2 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/chat.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/chat.py
@@ -13,9 +13,10 @@ shown as written by anyone — then the epoch key, then the content. A message
that does not open is kept and marked, never dropped: a gap the reader can see
is honest, a conversation quietly missing a message is not.
-Not done here, and done by the application: checking that the signing device
-is one the group's roster evidences for the account the node names (Tier 2,
-design §3.3). The name shown is the one sealed in the message by its sender.
+Then whose device signed it: the node names an account (`sender_id`), and
+`roster.RosterCheck` answers whether the signing device is one this machine
+accepted for that account, or one countersigned into it since (Tier 2, design
+§3.3). The name shown is the one sealed in the message by its sender.
"""
import time
@@ -27,6 +28,7 @@ from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, unseal
from ..errors import NODE, USAGE, CliError
from .keys import b64, unb64
+from .roster import RosterCheck
from .session import GroupSession
# The node answers at most this many messages per request.
@@ -45,6 +47,7 @@ class Message:
sender: str # the name sealed by its sender, else the node's
text: str
unreadable: str = "" # why it could not be read: signature, epoch, ...
+ trust: str = "" # roster.RosterCheck's verdict on the signing device
class ChatKeys:
@@ -85,7 +88,8 @@ async def history(session: GroupSession, count: int) -> list[dict]:
return rows[-count:]
-def open_row(row: dict, keys: ChatKeys, group_id: str) -> Message:
+async def open_row(row: dict, keys: ChatKeys, group_id: str,
+ check: RosterCheck | None = None) -> Message:
sender = str(row.get("sender_name") or row.get("sender_id") or "")
msg = Message(int(row.get("id") or 0), float(row.get("timestamp") or 0), sender, "")
if (row.get("format") or 0) != FORMAT_SEALED:
@@ -110,6 +114,9 @@ def open_row(row: dict, keys: ChatKeys, group_id: str) -> Message:
return msg
msg.text = str(payload.get("text") or "")
msg.sender = str(payload.get("sender_name") or sender)
+ if check is not None:
+ msg.trust = await check.status(str(row.get("sender_id") or ""),
+ b64(bytes(device)))
return msg
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