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
|
// SPDX-License-Identifier: LGPL-3.0-or-later
// Part of MeshBay's protocol layer, under the LGPL so that any client may use it.
// What a node says about who is in a group, checked rather than trusted: the
// roster's signatures, and the keys this browser pinned for each account.
/**
* Walk each account's devices outwards from the one nobody countersigned.
*
* A device is *verified* when a chain of real signatures reaches it from that
* account's root — the device an operator code admitted, which by definition
* has no countersignature and is the trust-on-first-use anchor. Anything the
* node lists but cannot evidence stays out of `verified`, so a substituted key
* is not laundered into the set merely by being mentioned.
*
* Devices pinned before the evidence was kept (2026-09-07) carry no signature
* and are treated exactly like a root: honest about what they are, rather than
* quietly accepted as verified.
*/
async function _verifyRoster(payload, nodePk) {
const C = window.MeshBayCrypto;
const byAccount = new Map();
const devices = payload.devices || [];
const per = new Map();
for (const d of devices) {
if (!per.has(d.user_id)) per.set(d.user_id, []);
per.get(d.user_id).push(d);
}
for (const [userId, list] of per) {
// Roots first: no countersigner, or one whose evidence was never stored.
const verified = [];
const chain = new Map();
const pending = [];
for (const d of list) {
// A root is a device that names **no** countersigner: an operator code
// admitted it, and there is nothing to verify.
//
// Naming one and carrying no proof is *not* a root, and treating it as
// one was a hole this file's tests caught: a node that writes the roster
// can put any key it likes in an account's row, and if "no signature"
// meant "root" it would have been laundered straight into `verified`.
// Such a device is unevidenced — which is also the honest reading of one
// pinned before the evidence was kept.
if (!d.added_by_pk) verified.push(d.pk_ed25519);
else pending.push(d);
}
// Then repeatedly admit anything countersigned by something already in.
let progress = true;
while (progress && pending.length) {
progress = false;
for (let i = pending.length - 1; i >= 0; i--) {
const d = pending[i];
if (!verified.includes(d.added_by_pk)) continue;
let ok = false;
try {
const transcript = C.deviceAddTranscript(
payload.node_pk || nodePk, userId, d.pk_ed25519, d.pk_x25519,
C.b64decode(d.add_nonce), d.add_ts);
ok = await C.verifyNodeSignature(d.added_by_pk, d.add_sig, transcript);
} catch { ok = false; }
if (ok) {
verified.push(d.pk_ed25519);
chain.set(d.pk_ed25519, d.added_by_pk);
pending.splice(i, 1);
progress = true;
}
}
}
byAccount.set(userId, {
username: (list[0] || {}).username || '',
all: list.map(d => d.pk_ed25519),
verified,
chain,
// Listed by the node and not reachable by any chain of signatures.
unevidenced: pending.map(d => d.pk_ed25519),
});
}
return { byAccount };
}
// Which device keys this browser has accepted for each account, per node.
// localStorage rather than a runtime capability: it is a per-viewer
// convenience whose loss costs one "first sight" and never a wrong answer —
// forgetting a pin makes the next key read as `first`, not as verified.
const _PIN_NS = 'meshbay_account_pins';
function _pinKey(nodePk, userId) {
return `${_PIN_NS}:${nodePk || ''}:${userId}`;
}
async function _readPinnedAccount(nodePk, userId) {
try {
const raw = localStorage.getItem(_pinKey(nodePk, userId));
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
async function _writePinnedAccount(nodePk, userId, keys) {
try {
localStorage.setItem(_pinKey(nodePk, userId), JSON.stringify(keys));
} catch { /* private window, or storage refused — one more "first sight" */ }
}
/**
* How `devicePk` stands against what is pinned for its account (`known`, null
* at first sight) and what the roster lists for it (`entry`): `{ status, pin }`,
* `pin` being the keys to keep if that answer stands. Decides and writes
* nothing, so a verdict reached on a stale roster can be taken again on a fresh
* one without having pinned half a set first. The statuses are
* `accountDeviceStatus`'s.
*/
function _judgeDevice(known, entry, devicePk) {
if (known && known.includes(devicePk)) return { status: 'pinned', pin: null };
if (!known) {
// First sight, so **everything the node says** is pinned — not only what
// a chain reaches. There is nothing to compare against yet: that is what
// trust-on-first-use means, and pinning only the verified subset would
// raise "key changed" on a legitimate second device whose
// countersignature simply predates it being kept. What TOFU buys is that
// a substitution *later* is visible; it cannot buy anything now.
const pin = entry ? entry.all : null;
return { status: entry && entry.all.includes(devicePk) ? 'first' : 'changed', pin };
}
if (entry && entry.verified.includes(devicePk)
&& entry.chain.get(devicePk)
&& known.includes(entry.chain.get(devicePk))) {
// Countersigned by a key we already trust for this account: a second
// device of someone we know, admitted without anybody comparing digits.
return { status: 'linked', pin: [...new Set([...known, devicePk])] };
}
return { status: 'changed', pin: null };
}
/**
* A wire payload as text.
*
* A plaintext message arrives as a string from the node; msgpack `bin` arrives
* as a Uint8Array. Both have to render.
*
* This function was deleted once, with an unrelated helper that sat next to it,
* and nothing complained: its only caller is inside `_openChatMessage`, whose
* rejection the chat panel swallows in a `.catch()` that just marks the page
* unloaded. The visible result was a conversation that rendered completely
* empty, with no error in the console and the node answering perfectly — found
* by `chat_send_probe.py`, not by reading this file.
*/
function _asText(payload) {
if (payload instanceof Uint8Array) return new TextDecoder().decode(payload);
if (payload == null) return '';
return String(payload);
}
|