summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/roster.py
blob: cce5a14edcbfaff80599e1fc2b526e4f1a8f3eb7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
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