From b7bf11c077bdd165400cf8d80c5cd1ad4248d854 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 25 Sep 2026 11:28:31 +0200 Subject: refactor(client): split transport.js into classic scripts transport.js keeps the core (connection, reconnect, leases, dispatch). Chat, media, admin, upload and device methods move, cut as text, into transport-*.js scripts that hand a class of their own to extendTransport, which copies each method onto MeshBayTransport.prototype; the codec, roster checks, node pins and the rewrap fan-out move as they were. Both shells load them after transport.js. Every prototype member, class property and top-level function has the same source text as before. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/transport-devices.js | 342 +++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js new file mode 100644 index 0000000..4291cf8 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js @@ -0,0 +1,342 @@ +// An account's devices and what they hold: joining a group, linking a device, +// the identity bundles and the per-account blobs (playlists). +// +// Methods of MeshBayTransport, copied onto its prototype by extendTransport +// (transport.js, which the shell loads first). + +extendTransport(class { + /** + * Who is in this group and which device keys they hold — verified here, not + * taken on the node's word. + * + * Tier 2 of `docs/MESHBAY_DESIGN.md` §3.3. 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 `docs/MESHBAY_DESIGN.md` §3.2'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 + * docs/MESHBAY_DESIGN.md §3.3 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'; + } + + /** + * Ask the node to recognise us and hand over the group key. + * + * Sent when we hold no GEK for a group. `code` is needed only the first time + * this node sees this account (and not at all in an open-join group). + */ + async joinGroup(userId, groupId, code) { + if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) { + throw new Error('Identity keys unavailable in this browser — sign in again'); + } + if (!this._nonceNode || !this.nodePk) { + throw new Error('Handshake incomplete — reconnect and retry'); + } + + const C = window.MeshBayCrypto; + const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); + const ts = Math.floor(Date.now() / 1000); + + const transcript = C.joinTranscript( + this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript); + + const resp = await this._sendAndWait({ + type: 'join_request', + v: '0.1', + group_id: groupId || '', + pk_ed25519: pkEdB64, + pk_x25519: pkXB64, + code: code || '', + ts, + sig, + }); + + if (resp.type === 'error') throw new Error(resp.detail || 'Join refused'); + if ((resp.type !== 'join_result' || !resp.ok) || !resp.gek) { + const reason = resp.reason || 'unknown'; + const err = new Error(JOIN_REFUSALS[reason] || `Join refused: ${reason}`); + // The UI reacts to `code_required` by asking for one; everything else is + // shown as-is. + err.reason = reason; + throw err; + } + + // Unwrap with our own secret key — the node wrapped for the public key we + // just proved we hold, so nobody else can open this. + const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0)); + const myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0)); + const gekRaw = await C.unwrapGEK(resp, skXRaw, myPkX); + this._gekRaw = gekRaw; + // What the node's roster says this identity is, which is not what the hub + // says: `operator` here means this browser's key was paired with the node, + // not merely that the account owns it. + this.memberRole = resp.role || ''; + return gekRaw; + } + + // ── Device linking ───────────────────────────────────────────────────── + + /** + * Ask to be added, and return the code to show the person. + * + * They read it off this screen and type it into a device already paired with + * this node. The code is hashed together with our own keys, so that other + * device cannot be handed a substituted key and sign for it by mistake. + */ + async requestDeviceAdd(userId) { + if (!this._sessionKeys || !this._sessionKeys.skEdB64) { + throw new Error('Identity keys unavailable in this browser — sign in again'); + } + if (!this._nonceNode || !this.nodePk) { + throw new Error('Handshake incomplete — reconnect and retry'); + } + const C = window.MeshBayCrypto; + const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); + + // 40 bits from the platform CSPRNG, in the same alphabet as a pairing code + // so it reads and types the same way. + const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + const bytes = crypto.getRandomValues(new Uint8Array(8)); + const raw = Array.from(bytes, b => alphabet[b % alphabet.length]).join(''); + const code = `${raw.slice(0, 4)}-${raw.slice(4)}`; + + const codeHash = await C.deviceCodeHash( + C.normalizeCode(code), pkEdB64, pkXB64); + const ts = Math.floor(Date.now() / 1000); + const transcript = C.deviceRequestTranscript( + this.nodePk, userId, pkEdB64, pkXB64, codeHash, this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, transcript); + + const resp = await this._sendAndWait({ + type: 'device_add_request', v: '0.1', + pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig, + }); + if (resp.type === 'error') throw new Error(resp.detail || 'Refused'); + return { code, expiresAt: resp.expires_at }; + } + + /** + * Approve a device waiting with this code. + * + * The node is a mailbox: it is asked for a request matching + * sha256(code ‖ keys), and the keys in that hash came from the device that + * filed it. A node returning something else produces no match, so there is + * nothing to sign and nothing for a person to misread. + */ + async approveDevice(userId, code) { + if (!this._sessionKeys || !this._sessionKeys.skEdB64) { + throw new Error('Identity keys unavailable in this browser — sign in again'); + } + if (!this._nonceNode || !this.nodePk) { + throw new Error('Handshake incomplete — reconnect and retry'); + } + const C = window.MeshBayCrypto; + const normalized = C.normalizeCode(code); + + // The code never leaves this browser. The node lists what is pending, each + // with the hash the requesting device computed over the code and its own + // keys; we recompute and keep the one that matches. A node offering + // fabricated keys would have to produce a hash matching sha256(code ‖ + // fabricated) — and it does not know the code. + const listed = await this._sendAndWait({ type: 'device_lookup', v: '0.1' }); + if (listed.type === 'error') throw new Error(listed.detail || 'Not found'); + + let match = null; + for (const req of listed.requests || []) { + const expect = await C.deviceCodeHash( + normalized, req.pk_ed25519, req.pk_x25519); + if (expect === req.code_hash) { match = req; break; } + } + if (!match) { + throw new Error('No device is waiting with that code'); + } + return this._countersign(userId, match.code_hash, + match.pk_ed25519, match.pk_x25519); + } + + async _countersign(userId, codeHash, pkEdB64, pkXB64) { + const C = window.MeshBayCrypto; + const ts = Math.floor(Date.now() / 1000); + const transcript = C.deviceAddTranscript( + this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, transcript); + const resp = await this._sendAndWait({ + type: 'device_add', v: '0.1', + pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig, + }); + if (resp.type === 'error') throw new Error(resp.detail || 'Refused'); + return resp; + } + + async listDevices() { + const resp = await this._sendAndWait({ type: 'device_list', v: '0.1' }); + if (resp.type === 'error') throw new Error(resp.detail || 'Refused'); + return { devices: resp.devices || [], pending: resp.pending || 0 }; + } + + /** Retire a device — a lost laptop. Countersigned like an addition. */ + async revokeDevice(userId, pkEdB64, pkXB64) { + const C = window.MeshBayCrypto; + const ts = Math.floor(Date.now() / 1000); + const transcript = C.deviceAddTranscript( + this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, transcript); + const resp = await this._sendAndWait({ + type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig, + }); + if (resp.type === 'error') throw new Error(resp.detail || 'Refused'); + return resp; + } + + /** + * Withdraw our key backup from this node. + * + * The counterpart of storeKeypairBundle: turning the setting off has to remove + * what is already stored, not merely stop adding to it — otherwise the blob + * stays on every node the account has ever joined (C4). + */ + async deleteKeypairBundle() { + const msg = await this._sendAndWait({ + type: 'keypair_bundle_delete', v: '0.1', + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async storeKeypairBundle(bundleEnc, recoveryEnc) { + const msg = await this._sendAndWait({ + type: 'keypair_bundle_store', + v: '0.1', + bundle_enc: bundleEnc, + // MNP 0.14, optional: the recovery-wrapped copy. Omitted for a plain + // re-backup; the node keeps any copy it already holds. + ...(recoveryEnc ? { bundle_enc_recovery: recoveryEnc } : {}), + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + // ── Per-account blobs (playlists) ────────────────────────────────────── + + async storeUserBlob(kind, rev, blobEnc) { + const msg = await this._sendAndWait({ + type: 'user_blob_store', v: '0.1', + kind, rev, blob_enc: blobEnc, + }); + // A refused store is the size cap or the account quota, and the node says + // which. Thrown rather than swallowed: the caller has to be able to tell + // the reader that this playlist did not save. + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * One blob, or `{ rev: null, blob_enc: null }` when this account has never + * written that kind here — which is the ordinary state of a node the reader + * has just joined, and must not read as a failure. + */ + async fetchUserBlob(kind) { + const msg = await this._sendAndWait({ + type: 'user_blob_fetch', v: '0.1', kind, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return { rev: msg.rev ?? null, blob_enc: msg.blob_enc ?? null }; + } + + /** + * Which kinds this node holds, and at what revision — never a payload. + * + * What a client that has lost its local state needs: playlist ids are + * client-generated, so there is nothing to fetch by name until this says + * what the names are. + */ + async listUserBlobs() { + const msg = await this._sendAndWait({ + type: 'user_blob_list', v: '0.1', + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg.blobs || []; + } + + async deleteUserBlob(kind) { + const msg = await this._sendAndWait({ + type: 'user_blob_delete', v: '0.1', kind, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } +}); -- cgit v1.2.3