aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 11:28:31 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 12:07:49 +0200
commitb7bf11c077bdd165400cf8d80c5cd1ad4248d854 (patch)
treee80590cd7b798e21a4ac663fd1e653a66e5f3b34 /packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
parent6348698322e5bfcb80d0ef0dcd833ba4e6179640 (diff)
downloadmeshbay-b7bf11c077bdd165400cf8d80c5cd1ad4248d854.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js342
1 files changed, 342 insertions, 0 deletions
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;
+ }
+});