diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js | 120 |
1 files changed, 120 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js new file mode 100644 index 0000000..ca0769d --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js @@ -0,0 +1,120 @@ +// 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" */ } +} + +/** + * 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); +} |