aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 21:04:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 21:04:56 +0200
commit3bd31db9d4fa2352f1095dcc630c77915d0774b8 (patch)
tree5674ddabdf4037ee5b7c2d1bc682e941d7a7da01 /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parent7fa74d722108ca4338d14db4ecba53800df86fca (diff)
downloadmeshbay-3bd31db9d4fa2352f1095dcc630c77915d0774b8.tar.gz
feat(chat): Tier 2 — a member verifies another member's device itself
Chat messages have been signed by the sending device since MNP 2.0, but a reader had no way to know that the device belonged to the account the node named: the signature proved *a device*, and `sender_id` was still the node's word. This closes that for any account a client has already seen. **What was blocking it was not effort — the evidence was not being kept.** `_do_device_add` verified the countersignature that admits a second device and stored only `added_by_pk`: *which* key approved, never the proof. And `device_add_transcript` binds `nonce_node`, the approving connection's handshake nonce, so even a stored signature was unverifiable by anyone who had not been on that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added before the migration's early return — which fires on every roster widened since 2026-08-18, i.e. all of them, so putting them inside it would have meant they never arrived. `group_roster_req`/`resp` relays, sealed under a new groupbox purpose and answered to **any member of the group**, every live device of every active member with the evidence that admitted it. The node decides nothing: it hands over evidence and the client walks the chain from each account's root outwards (`_verifyRoster`). That is deliberate — the node is the party the property holds against, so it is not asked to assert trust. Two holes the tests caught while this was being built: - "no signature" was being treated as a trust root, so a node that writes the roster could put any key in an account's row and have it laundered straight into the verified set. A root is a device that names **no** countersigner. - pinning only the verified subset at first sight raised "key changed" on legitimate second devices whose countersignature predates this change. First sight pins everything the node says, because that is what trust-on-first-use means and an alarm that fires on normal events stops being read. The property, and it must not be rounded up: **once a client has seen an account, a node that later substitutes a key for it is detected. Nothing is gained at first sight**, where there is nothing to compare against — the same boundary `per-node-identity-v1.md` draws, unmoved. The cost, stated because it is real: the roster is member-visible, so every member learns how many devices the others hold and their public keys. It stays inside the group, the hub is not involved, and it is scoped per group. A member who cannot see the keys cannot check them. User-visible surface: one notice, "this account is using a key you have not seen before", in ten languages. Nothing else. 16 tests — 7 on the node (the evidence is stored, it verifies from the roster alone, a fabricated device carries none, another group's members are not disclosed), 9 running the shipped `_verifyRoster` under node against rosters built by the shipped Python: a chain of three in any order, a signature by the wrong key, one for another node, one for another account, and two fabricated devices signing each other admitting nothing. Tier 3 (operator-signed roster attestation) stays deferred, with nothing depending on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
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