diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 188 |
1 files changed, 188 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 1f6dd8f..c4a24c5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -897,6 +897,8 @@ class MeshBayTransport { // and keeping a stale set would silently seal under a retired key. this._chatKeys = null; this._chatKeysInFlight = null; + this._roster = null; + this._rosterInFlight = null; // Not gated on a version any more: a node that reached this point speaks // MNP 2.0, where identifying the device is what makes chat possible at // all. `check_version` refused anything older before we got here. @@ -1636,6 +1638,87 @@ class MeshBayTransport { } /** + * Who is in this group and which device keys they hold — verified here, not + * taken on the node's word. + * + * Tier 2 of `desktop-client-v1.md` §4.8. The node relays, for each device, + * the already-pinned key that countersigned it and the signature itself; this + * walks that from each account's first device outwards and keeps only the + * devices it could actually reach. A device the node asserts but cannot + * evidence is reported as unverified rather than dropped — the reader is + * shown a gap, never a silent absence. + * + * The property this buys, stated exactly: once a client has seen an account, + * a node that later substitutes a key for it is **detected**. It buys nothing + * at first sight, where there is nothing to compare against — that boundary + * is `per-node-identity-v1.md`'s and does not move. + */ + async groupRoster() { + if (this._roster) return this._roster; + if (this._rosterInFlight) return this._rosterInFlight; + + this._rosterInFlight = (async () => { + const resp = await this._sendAndWait({ + type: 'group_roster_req', v: '2.0', group_id: this._groupId || '', + }); + if (resp.type === 'error') throw new Error(resp.detail); + const payload = msgpack_decode(await window.MeshBayCrypto.openGroup( + this._gekRaw, 'roster', 'group_roster_resp', this._groupId || '', resp)); + this._roster = await _verifyRoster(payload, this.nodePk); + return this._roster; + })(); + try { + return await this._rosterInFlight; + } finally { + this._rosterInFlight = null; + } + } + + /** + * How this client regards `devicePk` as a device of `userId`. + * + * 'pinned' seen before, and the same key — nothing to say + * 'linked' new, and countersigned by a key already pinned for it + * 'first' first sight of this account: trust on first use + * 'changed' a key this account has not shown before and cannot evidence + * + * Only `changed` is worth a person's attention, and it is the one notice + * §4.8 budgets for. `first` is not an alarm — every account is new once, and + * treating that as a warning is how a warning stops being read. + */ + async accountDeviceStatus(userId, devicePk) { + let roster; + try { + roster = await this.groupRoster(); + } catch { + return 'unknown'; + } + const known = await _readPinnedAccount(this.nodePk, userId); + const entry = roster.byAccount.get(userId); + if (known && known.includes(devicePk)) return 'pinned'; + 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. + if (entry) await _writePinnedAccount(this.nodePk, userId, entry.all); + return entry && entry.all.includes(devicePk) ? 'first' : 'changed'; + } + 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. + await _writePinnedAccount(this.nodePk, userId, + [...new Set([...known, devicePk])]); + return 'linked'; + } + return 'changed'; + } + + /** * Every chat epoch key for this group, fetched once per connection. * * Every epoch, not just the current one — that is what lets a device linked @@ -1708,6 +1791,11 @@ class MeshBayTransport { try { const plain = msgpack_decode( await C.openChat(epochKey, gid, epoch, deviceB64, nonce, ct)); + // The signature proves *a device* wrote this. Whether that device belongs + // to the account the node named is a separate question, and one this + // client answers for itself from the roster (Tier 2) rather than taking + // `sender_id` on trust. `changed` is the only value worth a notice. + const trust = await this.accountDeviceStatus(base.sender_id, deviceB64); return { ...base, payload: String(plain.text || ''), @@ -1715,6 +1803,7 @@ class MeshBayTransport { thread_id: plain.thread_id ?? base.thread_id, device: deviceB64, verified: true, + trust, }; } catch { return { ...base, payload: '', unreadable: 'decrypt' }; @@ -3160,6 +3249,105 @@ class MeshBayTransport { } /** + * 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 |