// 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); }