aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js188
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