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 --- CLAUDE.md | 2 +- docs/transfers-v1.md | 2 +- packages/meshbay-client/scripts/index.html | 9 + packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 9 + .../src/meshbay_hub/static/transport-admin.js | 451 ++++ .../src/meshbay_hub/static/transport-chat.js | 309 +++ .../src/meshbay_hub/static/transport-codec.js | 224 ++ .../src/meshbay_hub/static/transport-devices.js | 342 +++ .../src/meshbay_hub/static/transport-media.js | 284 +++ .../src/meshbay_hub/static/transport-pins.js | 86 + .../src/meshbay_hub/static/transport-rewrap.js | 152 ++ .../src/meshbay_hub/static/transport-roster.js | 120 + .../src/meshbay_hub/static/transport-upload.js | 228 ++ .../src/meshbay_hub/static/transport.js | 2371 +------------------- 14 files changed, 2338 insertions(+), 2251 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-codec.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-media.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-pins.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-upload.js diff --git a/CLAUDE.md b/CLAUDE.md index 9d9c8e8..c6a27af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -923,7 +923,7 @@ here are kept only where they are a rule about *editing* the code. | Application registry | `apps.js` | §9.4 — one entry per application, its component loaded on first use through `lazy.js` | | Applications | `chat-app.js`, `files-app.js`, `video-app.js`, `music-app.js`, `photos-app.js` (+ `*-app-settings.js`) | §9.5–§9.9 | | Shared settings widgets | `settings-ui.js`, `folder-tree.js` | **A pane must not import `group-settings.js`** — that is an import cycle, and it fails as a component that silently does not render | -| Transport, handshake, device hello, roster verify | `transport.js` | §5.2, §3.3 | +| Transport, handshake, device hello, roster verify | `transport.js` (core: connection, reconnect, leases, dispatch) + `transport-*.js` (chat, media, admin, upload, devices, codec, roster, pins, rewrap — classic scripts loaded after it, methods added by `extendTransport`) | §5.2, §3.3 | | Crypto | `crypto.js`, `keyderive.js` | §4 | | Hub session, token renewal, IndexedDB (keys, playlists) | `hub-client.js` | §3.1. **No group index is stored**: the `group_indexes` store had no reader for weeks and is emptied on start-up (`purgeGroupIndexCache`) | | Where the hub is | `platform.js` — `hubBase()` | **the only file allowed to decide this** (§8.3) | diff --git a/docs/transfers-v1.md b/docs/transfers-v1.md index e9caadb..a07d7d6 100644 --- a/docs/transfers-v1.md +++ b/docs/transfers-v1.md @@ -238,7 +238,7 @@ function asked: |---|---|---|---| | Thumbnails, TMDB posters, cover art, cached audio transcodes | `MediaThumb` (`video-app.js:204`), and the Music/Photos grids through it | a **media-cache id**, not an index entry — `_try_serve_thumbnail`, `webrtc/files.py` | **Never leased, never counted, never queued.** One chunk each, out of a bounded cache the node built itself | | Looking at one file — a photo opened full size, a PDF, an image, a text file | `PhotoViewer` (`photos-app.js:171`), the Files preview modal (`files-app.js:610`) | a real index entry, fetched whole | **Not leased.** Bounded by §3.4.1 below, which no real viewer ever reaches | -| Downloading, and uploading | `downloadEntry`, `downloadDirectory` (`file-utils.js`), `uploadFile` (`transport.js:2192`) | a real index entry | **Leased.** These are exactly the three call sites that go through `transfers.start()` — the three that produce a row in the transfers widget | +| Downloading, and uploading | `downloadEntry`, `downloadDirectory` (`file-utils.js`), `uploadFile` (`transport-upload.js`) | a real index entry | **Leased.** These are exactly the three call sites that go through `transfers.start()` — the three that produce a row in the transfers widget | The last column is the whole rule, and it is worth stating as a sentence someone can check by reading: **a transfer is something the transfers widget diff --git a/packages/meshbay-client/scripts/index.html b/packages/meshbay-client/scripts/index.html index ad7af0c..0d34797 100644 --- a/packages/meshbay-client/scripts/index.html +++ b/packages/meshbay-client/scripts/index.html @@ -30,6 +30,15 @@ + + + + + + + + + diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 054d04a..626c639 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -192,6 +192,15 @@ _HTML = """\ + + + + + + + + + diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js new file mode 100644 index 0000000..03a0f33 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js @@ -0,0 +1,451 @@ +// The operator's side: signed operations (the two-step challenge), the node's +// settings and roots, members and invitations. +// +// Methods of MeshBayTransport, copied onto its prototype by extendTransport +// (transport.js, which the shell loads first). + +extendTransport(class { + /** + * Pair this browser with the node using a one-time code (M3, and the same + * substitution as H3). + * + * The node has no way to know which key belongs to its operator unless someone + * tells it locally — asking the hub would let the hub name itself node + * administrator. The code comes from `meshbay-node operator pair`, over SSH, and + * the hub never sees it. + */ + async pairOperator(userId, code) { + if (!this._connected) throw new Error('Not connected to the node'); + if (!userId) throw new Error('Missing user id'); + 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; + // Both public keys are derived from OUR OWN secret keys, never read back from + // the hub: signing a public key the directory handed us would reintroduce the + // substitution this whole mechanism exists to close. + const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); + const ts = Math.floor(Date.now() / 1000); + + // group_id is empty: operator authority is node-wide, not per group. + const transcript = C.joinTranscript( + this.nodePk, '', 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: '', + pk_ed25519: pkEdB64, + pk_x25519: pkXB64, + code: code || '', + ts, + sig, + }); + + if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused'); + if (resp.type !== 'join_result' || !resp.ok) { + const reason = resp.reason || 'unknown'; + const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`); + err.reason = reason; + throw err; + } + this.memberRole = 'operator'; + return resp; + } + + async setAppDirectories(appKey, directories, signFn) { + const clean = [...new Set( + (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean), + )].sort(); + const msg = await this._sendAndWait({ + type: 'app_directories', v: '1.1', app: appKey, directories: clean, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'app_directories', `${appKey}:${clean.join(',')}`, signFn); + } + return msg; + } + + /** + * Whether this group's files appear in members' cross-group Search. + * Presentation only — opening the group lists everything regardless. + */ + async setSearchListed(listed, signFn) { + const msg = await this._sendAndWait({ + type: 'search_listed', v: '3.0', listed: Boolean(listed), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'search_listed', listed ? 'on' : 'off', signFn); + } + return msg; + } + + /** + * Authorize a privileged node operation with the user's Ed25519 identity key. + * + * The client rebuilds the signed transcript from the challenge fields and refuses + * to sign unless the operation and subject match what the user actually asked for. + * Previously the node sent 32 opaque random bytes and the client signed them + * blind, which let any peer obtain a signature over content of its choosing + * (finding H5). + */ + async _authorizeAdminOp(challenge, expectedOp, expectedSubject, signFn) { + if (challenge.op !== expectedOp || challenge.subject !== expectedSubject) { + throw new Error( + `Refusing to sign: node asked to authorize "${challenge.op}" on ` + + `"${challenge.subject}", but the requested action was "${expectedOp}" ` + + `on "${expectedSubject}"`); + } + if (!signFn) throw new Error('Admin challenge received but no signing key available'); + + const transcript = window.MeshBayCrypto.adminTranscript( + challenge.op, challenge.node_pk, challenge.group_id, + challenge.subject, challenge.nonce, challenge.ts); + + const signature = await signFn(transcript); + console.log('[MeshBay] _authorizeAdminOp: signed', challenge.op, 'op_id=', challenge.op_id, + '— sending admin_response'); + const ack = await this._sendAndWait({ + type: 'admin_response', + v: '0.1', + op_id: challenge.op_id, + signature, + // Not read by the node (_do_admin_response only looks at op_id and + // signature) — carried so _sendAndWait can key this reply by op, the + // same way the admin_challenge that preceded it was keyed. Without + // it, two admin_response replies in flight together (e.g. one op's + // app_directories_ack arriving while another's apps_enabled_ack is still + // pending) are matched by nothing more than arrival order. + op: challenge.op, + }); + console.log('[MeshBay] _authorizeAdminOp:', challenge.op, 'admin_response reply =', ack); + if (ack.type === 'error') throw new Error(ack.detail); + return ack; + } + + async deleteFile(fileId, signFn) { + const msg = await this._sendAndWait({ + type: 'file_delete', + v: '0.1', + file_id: fileId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'file_delete', fileId, signFn); + } + return msg; + } + + /** + * Remove an empty directory. Operator only, and the node checks that — this + * signs with the identity it pinned for us, exactly like deleting a file. + */ + async deleteDirectory(dir, signFn) { + const msg = await this._sendAndWait({ + type: 'dir_delete', + v: '0.1', + dir, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + // `dir`, not msg.subject: comparing the node's answer against itself is + // no check at all, and the point of this one is that we know what we + // asked for without being told. + return this._authorizeAdminOp(msg, 'dir_delete', dir, signFn); + } + return msg; + } + + /** + * Stop this node serving the group key to someone. Operator only. + * + * Only the node can do this: its roster decides who it serves. Removing them + * on the hub is the other half, and neither implies the other. + */ + /** + * Turn a group "application" (Chat, Files, ...) on or off for everyone. + * + * Takes the whole set in one signed message rather than one op per app, so + * ticking several boxes in Settings costs one signature. `apps` is sorted + * and joined the same way on the node before it is shown for signing — + * `_authorizeAdminOp` below checks the two match. + */ + async setAppsEnabled(apps, signFn) { + // Files cannot be turned off — MNP permits root exploration regardless of + // this list, so hiding the tab only ever misled — and the node adds it if + // it is missing. That normalisation has to happen *here too*: the subject + // below is rebuilt from what this client sent, and compared byte for byte + // against what the node put in the challenge. A list arriving here without + // `files` would produce two different strings and `_authorizeAdminOp` + // would refuse to sign an op the operator did ask for. It is reachable + // only from a caller that builds the list from something other than the + // node's own answer, which is exactly the kind of caller a later phase + // adds. (`apps.js` marks it `alwaysEnabled`; this file is a classic + // script and cannot import it.) + const full = apps.includes('files') ? [...apps] : ['files', ...apps]; + const msg = await this._sendAndWait({ + type: 'apps_enabled', v: '0.1', apps: full, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'apps_enabled', [...full].sort().join(','), signFn); + } + return msg; + } + + /** + * How often the node's reconciliation backstop runs, and how long it + * waits after a file's last write before hashing it (indexer.py + * DirectoryIndexer). Whole seconds only: the node builds the signing + * subject with Python's `%g` (drops a trailing ".0"), and the simplest + * way to always match it byte-for-byte from JS is to never send a + * fractional value in the first place. + */ + async setScanSettings(reconcileIntervalSecs, debounceSecs, signFn) { + const reconcile = Math.round(reconcileIntervalSecs); + const debounce = Math.round(debounceSecs); + const msg = await this._sendAndWait({ + type: 'set_scan_settings', v: '0.1', + reconcile_interval_secs: reconcile, debounce_secs: debounce, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'set_scan_settings', `${reconcile},${debounce}`, signFn); + } + return msg; + } + + async revokeMember(userId, signFn) { + const msg = await this._sendAndWait({ + type: 'member_revoke', v: '0.1', user_id: userId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'member_revoke', userId, signFn); + } + return msg; + } + + // ── Node management (D5) ─────────────────────────────────────────────── + + async fetchNodeStatus() { + const msg = await this._sendAndWait({ type: 'node_status', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async updateNodeSettings(settings) { + const msg = await this._sendAndWait({ + type: 'node_settings_set', v: '0.1', settings, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) { + const msg = await this._sendAndWait({ + type: 'root_add', v: '1.1', + group_id: groupId, path, + name: name || '', kind: kind || 'generic', + writable: !!writable, removable: !!removable, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'root_add', path, signFn); + } + return msg; + } + + async removeRoot(groupId, rootName, signFn) { + const msg = await this._sendAndWait({ + type: 'root_remove', v: '0.1', + group_id: groupId, root_name: rootName, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'root_remove', rootName, signFn); + } + return msg; + } + + async updateRoot(groupId, rootName, { writable, removable } = {}, signFn) { + const updates = []; + if (writable !== undefined) updates.push(`rw=${writable ? 'on' : 'off'}`); + if (removable !== undefined) updates.push(`rem=${removable ? 'on' : 'off'}`); + const subject = updates.length ? `${rootName}:${updates.join(',')}` : rootName; + const msg = await this._sendAndWait({ + type: 'root_update', v: '1.1', + group_id: groupId, root_name: rootName, + ...(writable !== undefined && { writable }), + ...(removable !== undefined && { removable }), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'root_update', subject, signFn); + } + return msg; + } + + async ejectRoot(groupId, rootName, signFn) { + const msg = await this._sendAndWait({ + type: 'root_eject', v: '1.1', + group_id: groupId, root_name: rootName, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'root_eject', rootName, signFn); + } + return msg; + } + + async plugRoot(groupId, rootName, signFn) { + const msg = await this._sendAndWait({ + type: 'root_plug', v: '1.1', + group_id: groupId, root_name: rootName, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'root_plug', rootName, signFn); + } + return msg; + } + + async unpinMember(userId, signFn) { + const msg = await this._sendAndWait({ + type: 'member_unpin', v: '0.1', user_id: userId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'member_unpin', userId, signFn); + } + return msg; + } + + async rotateGek(groupId, signFn) { + const msg = await this._sendAndWait({ + type: 'gek_rotate', v: '0.1', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'gek_rotate', groupId, signFn); + } + return msg; + } + + async fetchRoster(groupId) { + const msg = await this._sendAndWait({ + type: 'roster_read', v: '0.1', group_id: groupId || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async fetchDenylist() { + const msg = await this._sendAndWait({ type: 'denylist_read', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async clearDenylist(subject) { + const msg = await this._sendAndWait({ + type: 'denylist_clear', v: '0.1', subject: subject || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async attachGroup(name, sharedDir, uploadDir, signFn) { + const msg = await this._sendAndWait({ + type: 'group_attach', v: '0.1', + name, shared_dir: sharedDir, upload_dir: uploadDir || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'group_attach', name, signFn); + } + return msg; + } + + async detachGroup(name, signFn) { + const msg = await this._sendAndWait({ + type: 'group_detach', v: '0.1', name, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'group_detach', name, signFn); + } + return msg; + } + + async reloadConfig() { + const msg = await this._sendAndWait({ type: 'node_reload', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Ask the node for a one-time pairing code admitting `userId` to this group. + * + * This replaces wrapping the group key in the browser. We no longer fetch the + * invitee's public key from the hub, so the hub can no longer answer with its own + * and be handed the group key (H3). The node wraps the key later, itself, for a + * key the invitee proves possession of. + * + * Returns {code, expires_at} — the code is displayed once and passed to the + * invitee out of band. + */ + async createInvite(userId, groupId, username, signFn) { + const msg = await this._sendAndWait({ + type: 'invite_create', + v: '0.1', + user_id: userId, + group_id: groupId, + username: username || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'invite_create', userId, signFn); + } + return msg; + } + + /** + * A code bound to no account, for an invitation link (MNP 3.4). Signed like + * any invitation, and the subject the operator signs is the outcome: a link + * into this group, `link:`, and nothing else. + */ + async createLinkInvite(groupId, signFn) { + const msg = await this._sendAndWait({ + type: 'invite_link_create', v: '0.1', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'invite_link_create', `link:${groupId}`, signFn); + } + return msg; + } + + /** Take back an unredeemed invitation link, by the handle it was issued with. */ + async cancelLinkInvite(inviteId, signFn) { + const msg = await this._sendAndWait({ + type: 'invite_cancel', v: '0.1', invite_id: inviteId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'invite_cancel', inviteId, signFn); + } + return msg; + } +}); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js new file mode 100644 index 0000000..d6d733f --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js @@ -0,0 +1,309 @@ +// The group chat: history, sending, link previews, and the epoch keys that +// seal it. +// +// Methods of MeshBayTransport, copied onto its prototype by extendTransport +// (transport.js, which the shell loads first). + +extendTransport(class { + /** + * Unfurl a URL pasted in chat. The node fetches it (the browser cannot — + * CSP and CORS — and would leak every reader's IP), parses an OpenGraph + * card, and caches any image in its thumb store; `image_thumb_hash` then + * rides the normal file_req path like a poster. `ok: false` means "no + * preview" (blocked, unreachable, not HTML) — the caller just shows the + * bare link. Keyed by url: a message with several links fires one each. + */ + async fetchLinkPreview(url) { + const msg = await this._sendAndWait({ type: 'link_preview_req', v: '0.6', url }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Where chat attachments are written. + * + * Its own message rather than `setAppDirectories('chat', ...)`: this one is + * a destination, and the node refuses a read-only root for it. A caller + * reaching for the generic form would get a refusal it has no reason to + * expect, so the difference is in the name. + */ + async setChatDirectory(path, signFn) { + const clean = (path || '').replace(/^\/+|\/+$/g, ''); + const msg = await this._sendAndWait({ + type: 'chat_directory', v: '1.1', path: clean, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'chat_directory', clean, signFn); + } + return msg; + } + + /** Whether the node unfurls links members post in this group's chat. */ + async setChatLinkPreview(enabled, signFn) { + const msg = await this._sendAndWait({ + type: 'chat_link_preview', v: '1.1', enabled: Boolean(enabled), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'chat_link_preview', enabled ? 'on' : 'off', signFn); + } + return msg; + } + + /** + * Open a new chat epoch by hand. Operator only, and signed. + * + * Not a switch — there is nothing to turn on. The removals that matter open + * an epoch by themselves; this is the operator saying "move the key anyway", + * the same instruction as `rotateGek` and signed for the same reason. + */ + async rotateChatEpoch(signFn) { + // This connection's own group, not a parameter. Every settings pane takes + // the same props by design (`test_app_settings_plugin.py`), so reaching for + // a `groupId` here would make the loop that renders them conditional — and + // the transport already knows which group it is connected to. + const groupId = this._groupId || ''; + const msg = await this._sendAndWait({ + type: 'chat_epoch', v: '2.0', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'chat_epoch', groupId, signFn); + } + return msg; + } + + /** + * A page of chat history, newest first by default. + * + * `before` is a message id, not a timestamp: it pages backwards from the + * newest, which is the direction a conversation is read. Asking without it + * used to mean `since: 0`, which paged *forwards* from the very first message + * — so a busy group opened on its oldest page and never showed the recent + * exchange. + * + * Returns { messages, hasMore } — hasMore says whether anything older exists, + * so the "load older" control knows when to stop offering. + */ + async fetchChatHistory({ before = null, limit = 100 } = {}) { + const msg = await this._sendAndWait({ + type: 'chat_hist', + v: '0.2', + before: before, + limit: limit, + }); + if (msg.type === 'error') throw new Error(msg.detail); + const rows = msg.messages || []; + const messages = []; + for (const row of rows) messages.push(await this._openChatMessage(row)); + return { messages, hasMore: !!msg.has_more }; + } + + /** + * Turn one stored or relayed chat message into what the panel renders. + * + * **The one place that decides how a message is read.** Live messages and + * history arrive by different routes and used to be shaped at each of them; + * with a `format` column and more than one way to read a payload, two copies + * of that decision is two places to get it wrong, and the disagreement would + * show up only in history. + * + * `payload` is bytes on the wire now — the node stopped decoding it as UTF-8, + * which mangled anything that was not text. A message that cannot be read + * comes back marked rather than thrown away: a gap in a conversation the + * reader can see is honest, and silently dropping messages is not. + */ + async _openChatMessage(row) { + const base = { + id: row.id, + sender_id: row.sender_id, + sender_name: row.sender_name || '', + timestamp: row.timestamp, + thread_id: row.thread_id, + }; + const format = row.format || 0; + if (format === 0) { + return { ...base, payload: _asText(row.payload) }; + } + if (format !== 1) { + return { ...base, payload: '', unreadable: 'format' }; + } + return this._openSealedChat(base, row); + } + + /** + * Send one message, sealed under this group's current chat epoch key and + * signed with this device's key. + * + * There is no plaintext path. MNP 2.0 has no unencrypted chat and the node + * refuses one, so a fallback here could only ever produce a refusal the user + * cannot act on — and a client that quietly posted in clear into a group + * whose members believe their chat is encrypted is the downgrade the whole + * design is about not having. + * + * `sender_name` goes **inside** the envelope. On the wire it is a field any + * peer can set to anything, and the node caches it to render history — so + * display-name spoofing is free while chat is plaintext. Sealed and signed, + * it is as authenticated as the message it names. + * + * Refuses rather than falls back. A client that cannot seal must not quietly + * post in clear into a group whose members believe their chat is encrypted; + * the node refuses it too, and the two refusals agreeing is the point. + */ + async sendChat(text, iteration, threadId, senderName) { + const keys = await this.chatKeys(); + const epoch = this.chatEpoch || keys.current; + const epochKey = keys.byEpoch.get(epoch); + if (!epochKey) throw new Error('No chat key for this group — reconnect'); + if (!this.devicePk || !this._sessionKeys || !this._sessionKeys.skEdB64) { + throw new Error('This device is not identified to the node — reconnect'); + } + + const C = window.MeshBayCrypto; + const gid = this._groupId || ''; + const plaintext = msgpack_encode({ + text: String(text), + thread_id: threadId || null, + sender_name: senderName || '', + sent_at: Math.floor(Date.now() / 1000), + }); + const { nonce, ct } = await C.sealChat( + epochKey, gid, epoch, this.devicePk, plaintext); + const device = C.b64decode(this.devicePk); + const sig = C.b64decode(await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, + C.chatSigningTranscript(gid, epoch, device, nonce, ct))); + + const msg = await this._sendAndWait({ + type: 'chat_msg', + v: '2.0', + format: 1, + epoch, + ct, + nonce, + device, + sig, + thread_id: threadId || null, + // Deliberately absent: the display name is inside the envelope now. + sender_name: null, + }); + // Every other request in this file refuses an `error` reply; this one + // returned it as though the node had accepted the message. It never + // mattered while a refusal reached the wrong caller anyway — now that a + // reply finds the request that made it, a message the node rejected would + // otherwise appear in the conversation as sent. It rejects more of them + // than it used to: a stale epoch, an envelope the node dislikes, or a + // device claim that is not this connection's all come back as `error`. + if (msg.type === 'error') throw new Error(msg.detail || 'chat send refused'); + return msg; + } + + /** + * This group's chat epoch moved. + * + * An epoch opens when somebody is removed, and a client that kept sealing + * under the retired key would be writing messages the group can still read + * but that the removed member could read too. Dropping the cached keys is + * what makes the next send fetch the new one. + */ + _applyChatEpoch(msg) { + if (msg.epoch) this.chatEpoch = msg.epoch; + this._chatKeys = null; + this._chatKeysInFlight = null; + if (this._onChatEpoch) this._onChatEpoch(this.chatEpoch); + } + + /** + * 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 + * this morning read a conversation from last year, and a member who joined + * yesterday read the history the group already had. The node decides which + * epochs a member is entitled to; this asks for what it is given. + */ + async chatKeys() { + if (this._chatKeys) return this._chatKeys; + if (this._chatKeysInFlight) return this._chatKeysInFlight; + + this._chatKeysInFlight = (async () => { + const resp = await this._sendAndWait({ + type: 'chat_keys_req', v: '2.0', group_id: this._groupId || '', + }); + if (resp.type === 'error') throw new Error(resp.detail); + // Sealed under a group-derived subkey. A payload that does not open is + // not "no keys" — it is a peer we cannot talk to, and treating it as an + // empty set would present an encrypted group as one with no history. + const payload = msgpack_decode(await window.MeshBayCrypto.openGroup( + this._gekRaw, 'chat_keys', 'chat_keys_resp', this._groupId || '', resp)); + const byEpoch = new Map(); + for (const e of payload.epochs || []) byEpoch.set(e.epoch, e.key); + this._chatKeys = { byEpoch, current: payload.current || 0 }; + return this._chatKeys; + })(); + try { + return await this._chatKeysInFlight; + } finally { + this._chatKeysInFlight = null; + } + } + + /** + * Open one sealed message, or mark it unreadable and say why. + * + * Authorship is established **before** decryption: the signature is over the + * ciphertext, so a message that does not verify is never rendered as having + * been written by the account it claims — which is the whole point of signing + * rather than trusting the node's `sender_id`. + * + * An unreadable message is kept and marked, never dropped. A gap the reader + * can see is honest; a conversation quietly missing messages is not. + */ + async _openSealedChat(base, row) { + const C = window.MeshBayCrypto; + const gid = this._groupId || ''; + const epoch = row.epoch || 0; + const device = row.device; + const nonce = row.nonce; + const ct = row.ct; + if (!device || !nonce || !ct || !row.sig) { + return { ...base, payload: '', unreadable: 'envelope' }; + } + + if (!await C.verifyChatSignature(device, gid, epoch, nonce, ct, row.sig)) { + return { ...base, payload: '', unreadable: 'signature' }; + } + + let keys; + try { + keys = await this.chatKeys(); + } catch { + return { ...base, payload: '', unreadable: 'keys' }; + } + const epochKey = keys.byEpoch.get(epoch); + if (!epochKey) return { ...base, payload: '', unreadable: 'epoch' }; + + const deviceB64 = C.b64encode(device); + 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 || ''), + sender_name: plain.sender_name || base.sender_name, + thread_id: plain.thread_id ?? base.thread_id, + device: deviceB64, + verified: true, + trust, + }; + } catch { + return { ...base, payload: '', unreadable: 'decrypt' }; + } + } +}); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-codec.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-codec.js new file mode 100644 index 0000000..eba0461 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-codec.js @@ -0,0 +1,224 @@ +// The wire codec: the subset of msgpack MNP uses (maps, strings, integers, +// binary, arrays, null), and hex for the ids the transport mints. + +// ── Minimal msgpack encode/decode ──────────────────────────────────────────── +// Covers the subset used by MNP: maps, strings, integers, binary, arrays, null. + +function msgpack_encode(obj) { + const parts = []; + _encodeValue(obj, parts); + const total = parts.reduce((s, p) => s + p.length, 0); + const result = new Uint8Array(total); + let off = 0; + for (const p of parts) { result.set(p, off); off += p.length; } + return result; +} + +function _encodeValue(val, parts) { + if (val === null || val === undefined) { + parts.push(new Uint8Array([0xc0])); + } else if (typeof val === 'boolean') { + parts.push(new Uint8Array([val ? 0xc3 : 0xc2])); + } else if (typeof val === 'number') { + if (Number.isInteger(val)) { + if (val >= 0 && val <= 127) { + parts.push(new Uint8Array([val])); + } else if (val >= 0 && val <= 0xff) { + parts.push(new Uint8Array([0xcc, val])); + } else if (val >= 0 && val <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xcd; + new DataView(b.buffer).setUint16(1, val, false); + parts.push(b); + } else if (val >= 0 && val <= 0xffffffff) { + const b = new Uint8Array(5); b[0] = 0xce; + new DataView(b.buffer).setUint32(1, val, false); + parts.push(b); + } else if (val >= 0 && val <= Number.MAX_SAFE_INTEGER) { + // Same split as the 0xcf decoder case above, in reverse — without + // this, a value over 0xffffffff fell to the plain int32 branch + // below and silently wrapped to a wrong, unrelated number instead + // of failing loudly. + const b = new Uint8Array(9); b[0] = 0xcf; + const dv = new DataView(b.buffer); + dv.setUint32(1, Math.floor(val / 4294967296), false); + dv.setUint32(5, val % 4294967296, false); + parts.push(b); + } else if (val >= -32 && val < 0) { + parts.push(new Uint8Array([val & 0xff])); + } else if (val >= -128 && val < 0) { + const b = new Uint8Array(2); b[0] = 0xd0; b[1] = val & 0xff; + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xd2; + new DataView(b.buffer).setInt32(1, val, false); + parts.push(b); + } + } else { + const b = new Uint8Array(9); b[0] = 0xcb; + new DataView(b.buffer).setFloat64(1, val, false); + parts.push(b); + } + } else if (typeof val === 'string') { + const encoded = new TextEncoder().encode(val); + if (encoded.length <= 31) { + parts.push(new Uint8Array([0xa0 | encoded.length])); + } else if (encoded.length <= 0xff) { + parts.push(new Uint8Array([0xd9, encoded.length])); + } else if (encoded.length <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xda; + new DataView(b.buffer).setUint16(1, encoded.length, false); + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xdb; + new DataView(b.buffer).setUint32(1, encoded.length, false); + parts.push(b); + } + parts.push(encoded); + } else if (val instanceof Uint8Array) { + if (val.length <= 0xff) { + parts.push(new Uint8Array([0xc4, val.length])); + } else if (val.length <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xc5; + new DataView(b.buffer).setUint16(1, val.length, false); + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xc6; + new DataView(b.buffer).setUint32(1, val.length, false); + parts.push(b); + } + parts.push(val); + } else if (Array.isArray(val)) { + if (val.length <= 15) { + parts.push(new Uint8Array([0x90 | val.length])); + } else if (val.length <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xdc; + new DataView(b.buffer).setUint16(1, val.length, false); + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xdd; + new DataView(b.buffer).setUint32(1, val.length, false); + parts.push(b); + } + for (const item of val) _encodeValue(item, parts); + } else if (typeof val === 'object') { + const keys = Object.keys(val); + if (keys.length <= 15) { + parts.push(new Uint8Array([0x80 | keys.length])); + } else if (keys.length <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xde; + new DataView(b.buffer).setUint16(1, keys.length, false); + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xdf; + new DataView(b.buffer).setUint32(1, keys.length, false); + parts.push(b); + } + for (const k of keys) { + _encodeValue(k, parts); + _encodeValue(val[k], parts); + } + } +} + +function msgpack_decode(buf) { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const [val] = _decodeValue(buf, view, 0); + return val; +} + +function _decodeValue(buf, view, offset) { + const byte = buf[offset]; + + if (byte <= 0x7f) return [byte, offset + 1]; + if ((byte & 0xe0) === 0xe0) return [byte - 256, offset + 1]; + if ((byte & 0xa0) === 0xa0) { + const len = byte & 0x1f; + return [new TextDecoder().decode(buf.slice(offset + 1, offset + 1 + len)), offset + 1 + len]; + } + if ((byte & 0xf0) === 0x90) { + const len = byte & 0x0f; + return _decodeArray(buf, view, offset + 1, len); + } + if ((byte & 0xf0) === 0x80) { + const len = byte & 0x0f; + return _decodeMap(buf, view, offset + 1, len); + } + + switch (byte) { + case 0xc0: return [null, offset + 1]; + case 0xc2: return [false, offset + 1]; + case 0xc3: return [true, offset + 1]; + case 0xc4: { const len = buf[offset + 1]; return [buf.slice(offset + 2, offset + 2 + len), offset + 2 + len]; } + case 0xc5: { const len = view.getUint16(offset + 1, false); return [buf.slice(offset + 3, offset + 3 + len), offset + 3 + len]; } + case 0xc6: { const len = view.getUint32(offset + 1, false); return [buf.slice(offset + 5, offset + 5 + len), offset + 5 + len]; } + case 0xcc: return [buf[offset + 1], offset + 2]; + case 0xcd: return [view.getUint16(offset + 1, false), offset + 3]; + case 0xce: return [view.getUint32(offset + 1, false), offset + 5]; + // uint64/int64 — never emitted by this file's own encoder (a JS number + // above 0xffffffff falls to float64 there), but the node's real msgpack + // library sends a plain uint64 for any Python int over ~4.3 billion, and + // a raw byte count crosses that easily (found live: IndexProgress. + // scanned_bytes/total_bytes in the handshake ack, indexer.py, for a + // group whose total library size exceeds ~4 GB). Split into two 32-bit + // halves rather than DataView's getBigUint64/getBigInt64 — a BigInt + // would silently poison every arithmetic use of these fields elsewhere + // (percentage math, comparisons) — and every real byte count fits in a + // plain JS number well under Number.MAX_SAFE_INTEGER (2^53). + case 0xcf: { + const hi = view.getUint32(offset + 1, false); + const lo = view.getUint32(offset + 5, false); + return [hi * 4294967296 + lo, offset + 9]; + } + case 0xd3: { + const hi = view.getInt32(offset + 1, false); + const lo = view.getUint32(offset + 5, false); + return [hi * 4294967296 + lo, offset + 9]; + } + case 0xcb: return [view.getFloat64(offset + 1, false), offset + 9]; + case 0xd0: return [view.getInt8(offset + 1), offset + 2]; + case 0xd1: return [view.getInt16(offset + 1, false), offset + 3]; + case 0xd2: return [view.getInt32(offset + 1, false), offset + 5]; + case 0xd9: { + const len = buf[offset + 1]; + return [new TextDecoder().decode(buf.slice(offset + 2, offset + 2 + len)), offset + 2 + len]; + } + case 0xda: { + const len = view.getUint16(offset + 1, false); + return [new TextDecoder().decode(buf.slice(offset + 3, offset + 3 + len)), offset + 3 + len]; + } + case 0xdb: { + const len = view.getUint32(offset + 1, false); + return [new TextDecoder().decode(buf.slice(offset + 5, offset + 5 + len)), offset + 5 + len]; + } + case 0xdc: { const len = view.getUint16(offset + 1, false); return _decodeArray(buf, view, offset + 3, len); } + case 0xdd: { const len = view.getUint32(offset + 1, false); return _decodeArray(buf, view, offset + 5, len); } + case 0xde: { const len = view.getUint16(offset + 1, false); return _decodeMap(buf, view, offset + 3, len); } + case 0xdf: { const len = view.getUint32(offset + 1, false); return _decodeMap(buf, view, offset + 5, len); } + default: throw new Error(`Unknown msgpack type: 0x${byte.toString(16)}`); + } +} + +function _decodeArray(buf, view, offset, count) { + const arr = []; + for (let i = 0; i < count; i++) { + const [val, newOff] = _decodeValue(buf, view, offset); + arr.push(val); + offset = newOff; + } + return [arr, offset]; +} + +function _decodeMap(buf, view, offset, count) { + const obj = {}; + for (let i = 0; i < count; i++) { + const [key, off1] = _decodeValue(buf, view, offset); + const [val, off2] = _decodeValue(buf, view, off1); + obj[key] = val; + offset = off2; + } + return [obj, offset]; +} + +function _hex(bytes) { + return [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); +} 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; + } +}); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js new file mode 100644 index 0000000..f94eb40 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js @@ -0,0 +1,284 @@ +// What the media apps ask the node for: TMDB and MusicBrainz metadata, audio +// transcoding, subtitles, and the video stream. +// +// Methods of MeshBayTransport, copied onto its prototype by extendTransport +// (transport.js, which the shell loads first). + +extendTransport(class { + /** + * TMDB metadata for one file (Videos app, docs/MESHBAY_DESIGN.md §9.7). + * Keyed by the entry's own `id` (its content hash) — never a path: a + * path names the *folder* a file is in (indexer.py's `_virtual_dir`), so + * two files sharing a folder (any multi-episode season) would resolve to + * whichever entry the node's index happened to return first (found live + * via the Music app's identical bug, 2026-08-25 — see apps/video_meta.py's + * `_do_media_meta_request`). + * `confidence: 0` (no tmdb_id, no fields) means no confident match — + * the caller falls back to a thumbnail-only card (§4.1), not an error. + */ + async fetchMediaMeta(fileId) { + const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.6', file_id: fileId }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * One season's own overview/air_date/poster (docs/MESHBAY_DESIGN.md §9.7's + * per-season view) — a show's own tmdb_meta is one static field that does + * not necessarily describe every season alike, found live: a 3-season + * show whose overview read as season-3-specific for every season. + * Keyed like media_meta_req: a season-tab bar can fire a request per tab + * before the previous one lands, and matching by arrival order would hand + * one season's data to a different season's tab whenever two responses + * reordered. + */ + async fetchSeasonMeta(tmdbId, season) { + const msg = await this._sendAndWait({ + type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Raw TMDB search candidates for an operator correcting a wrong automatic + * match — unlike fetchMediaMeta, this never collapses to one best guess: + * a human picks from several, so several is the point. Read-only, not an + * admin op: it looks nothing up in this node's own state and changes + * nothing, so it needs no signature (mirrors why media_meta_req isn't + * signed either). + */ + async searchTmdb(mediaType, query) { + const msg = await this._sendAndWait({ + type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Correct a wrong automatic TMDB match. Signed like + * setTmdbConfig: it replaces what every member sees for a show/movie, + * node-wide (media_cache is shared, not per-viewer) — an unsigned + * override would let any member vandalize another show's metadata. + * Applies to every file sharing the representative one's display_title, + * not just the file the operator happened to be looking at (apps/ + * video_meta.py's _admin_exec_tmdb_override). Keyed by `fileId`, not a path + * — same reasoning as fetchMediaMeta above. + */ + async overrideTmdbMatch(fileId, tmdbId, mediaType, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_override', v: '0.7', file_id: fileId, tmdb_id: tmdbId, media_type: mediaType, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + const subject = `file_id=${fileId},tmdb_id=${tmdbId},media_type=${mediaType}`; + return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn); + } + return msg; + } + + /** + * Drop one file's cached TMDB match so it re-resolves with the node's + * current matcher (V13) — the one-click alternative to the full + * search-and-pick flow. Signed for the same reason as overrideTmdbMatch. + */ + async rematchTmdbMatch(fileId, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_rematch', v: '0.7', file_id: fileId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'tmdb_rematch', `file_id=${fileId}`, signFn); + } + return msg; + } + + /** + * Set/clear a custom TMDB API token, and/or set the language TMDB is + * queried in (e.g. "fr-FR") — one for the whole node, since both are one + * operator's shared credential/cache, not a per-group concern (see + * setTmdbEnabled below for the per-group on/off switch). Signed like + * setAppsEnabled/updateRoot — an unsigned change would let any + * member alter outbound third-party network traffic the operator never + * agreed to (docs/MESHBAY_DESIGN.md §9.7, §6.5). `token: ''` explicitly clears + * a previously-set custom token; omit it (undefined/null), like + * `language`, to leave whatever is stored unchanged. + */ + async setTmdbConfig(token, language, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_config', v: '0.7', + token: token === undefined ? null : token, + language: language === undefined ? null : language, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + // Must match the node's subject byte-for-byte (apps/video_meta.py + // _do_tmdb_config) — the token itself is never part of the subject + // (it would end up in the audit log in plaintext), only whether one + // was supplied. The language is not a secret, so it appears as-is. + const subject = `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`; + return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn); + } + return msg; + } + + /** + * Whether TMDB lookups run for this group at all — per-group (2026-08-24, + * used to be node-wide): a real media-library group and a test/demo group + * on the same node need not share the decision to spend TMDB quota and + * make outbound requests. Signed like the rest — it decides whether + * this group's members' Videos tab ever makes outbound TMDB traffic. + */ + async setTmdbEnabled(enabled, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_enabled', v: '0.7', enabled: Boolean(enabled), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + // Must match the node's subject byte-for-byte (apps/video_meta.py + // _do_tmdb_enabled): Python's f"{bool}" is "True"/"False", not JS's + // lowercase. + const subject = enabled ? 'True' : 'False'; + return this._authorizeAdminOp(msg, 'tmdb_enabled', subject, signFn); + } + return msg; + } + + /** + * MusicBrainz metadata for one track (Music app, docs/MESHBAY_DESIGN.md §9.8) + * — same shape as fetchMediaMeta, minus a season/episode concept: + * album-level (release), resolved from the track's own artist/album + * fields already in the index. Keyed by the track's own `id` (content + * hash), not a path — a path names the *folder* a track is in, and an + * album is one folder with many tracks in it; three unrelated albums + * shared one folder's track's cover before this fix (found live, + * 2026-08-25). `confidence: 0` means no confident match (or MusicBrainz + * off for this group, or nothing configured) — the caller falls back to + * the embedded/no cover it already had, not an error. + */ + async fetchMusicMeta(fileId) { + const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.9', file_id: fileId }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Server-side transcode of a Music-app file the browser's own