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
|