diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 11:28:31 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 12:07:49 +0200 |
| commit | b7bf11c077bdd165400cf8d80c5cd1ad4248d854 (patch) | |
| tree | e80590cd7b798e21a4ac663fd1e653a66e5f3b34 | |
| parent | 6348698322e5bfcb80d0ef0dcd833ba4e6179640 (diff) | |
| download | meshbay-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>
| -rw-r--r-- | CLAUDE.md | 2 | ||||
| -rw-r--r-- | docs/transfers-v1.md | 2 | ||||
| -rw-r--r-- | packages/meshbay-client/scripts/index.html | 9 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 9 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js | 451 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js | 309 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-codec.js | 224 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js | 342 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-media.js | 284 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-pins.js | 86 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js | 152 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js | 120 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-upload.js | 228 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 2163 |
14 files changed, 2234 insertions, 2147 deletions
@@ -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 @@ <script src="./keyderive.js"></script> <script src="./crypto.js"></script> <script src="./transport.js"></script> + <script src="./transport-codec.js"></script> + <script src="./transport-roster.js"></script> + <script src="./transport-pins.js"></script> + <script src="./transport-chat.js"></script> + <script src="./transport-media.js"></script> + <script src="./transport-admin.js"></script> + <script src="./transport-upload.js"></script> + <script src="./transport-devices.js"></script> + <script src="./transport-rewrap.js"></script> <script type="module" src="./app.js"></script> </body> </html> 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 = """\ <script src="/a/{v}/keyderive.js"></script> <script src="/a/{v}/crypto.js"></script> <script src="/a/{v}/transport.js"></script> + <script src="/a/{v}/transport-codec.js"></script> + <script src="/a/{v}/transport-roster.js"></script> + <script src="/a/{v}/transport-pins.js"></script> + <script src="/a/{v}/transport-chat.js"></script> + <script src="/a/{v}/transport-media.js"></script> + <script src="/a/{v}/transport-admin.js"></script> + <script src="/a/{v}/transport-upload.js"></script> + <script src="/a/{v}/transport-devices.js"></script> + <script src="/a/{v}/transport-rewrap.js"></script> <script type="module" src="/a/{v}/app.js"></script> </body> </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:<group>`, 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 <audio> + * element cannot decode at all (WMA, Musepack) into AAC/M4A. Returns + * `{ hash, size, mime }` — the *cache* hash to pull through the normal + * file_req/chunk path (fetchChunk/pipelinedDownload), not the file's own + * id, the same indirection already used for a TMDB poster or a + * MusicBrainz cover. Cached node-side after the first call, but ffmpeg + * still has to run at least once and a transcode slot can be busy, so + * this gets a longer timeout than the metadata lookups above. + */ + async requestAudioTranscode(fileId) { + const msg = await this._sendAndWait( + { type: 'audio_transcode_req', v: '0.9', file_id: fileId }, 120000); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * One embedded subtitle track, extracted node-side to WebVTT. Returns + * `{ hash, size, mime, track }` — the *cache* hash to pull through the + * ordinary file_req/chunk path, the same indirection as an audio transcode + * or a TMDB poster, and cached node-side under the file's own id so a film + * is extracted once rather than once per viewing. + * + * `track` is the ordinal the node published in `stream_init.subtitle_tracks` + * and is passed back untouched: it counts every subtitle stream in the + * container, including the bitmap ones that are never listed, so it is not + * a position in the list this client received. + */ + async requestSubtitle(fileId, track) { + // Generous because the node's own bound is, and for the same reason: the + // extraction demuxes the whole container, measured at 9.8 s per GB on an + // external disk — 71 s for a 7.3 GB film, and more for a 4K one. The node + // always answers, with a refusal if its own budget runs out, so this is a + // backstop against a peer that has gone silent rather than a deadline for + // the work. It is paid once per film: every later viewing is cached. + const msg = await this._sendAndWait( + { type: 'subtitle_req', v: '0.9', file_id: fileId, track }, 900000); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Whether MusicBrainz lookups run for this group at all — per-group from + * the start (docs/MESHBAY_DESIGN.md §9.8). Signed like setTmdbEnabled. + */ + async setMusicbrainzEnabled(enabled, signFn) { + const msg = await this._sendAndWait({ + type: 'musicbrainz_enabled', v: '0.8', enabled: Boolean(enabled), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + // Python's f"{bool}" is "True"/"False", not JS's lowercase — must + // match apps/music.py _do_musicbrainz_enabled byte-for-byte. + const subject = enabled ? 'True' : 'False'; + return this._authorizeAdminOp(msg, 'musicbrainz_enabled', subject, signFn); + } + return msg; + } + + /** + * Ask for a video stream, and say how much we can take. + * + * `credits` bounds what is in flight. Without it the node pushes the whole + * film as fast as ffmpeg produces it and the browser holds all of it while + * MediaSource consumes a segment at a time — which is fine for a clip and + * fatal for anything worth streaming. + */ + requestStream(fileId, credits = STREAM_CREDITS, start = 0, audioTrack = null) { + // `start` is a seek: the node retires whatever this session was streaming + // and spawns ffmpeg again from there. Omitted or zero is the film's + // beginning, which is what an 0.1 node understands. + // + // `audioTrack` is omitted entirely unless the caller has one, and the + // caller only has one because a `stream_init` listed the tracks. A node + // too old to enumerate them is therefore never sent a field it would + // ignore — which matters more here than it looks: ignoring it would not + // degrade the stream, it would serve a different language without saying + // so. + const req = { type: 'stream_req', v: '0.1', file_id: fileId, credits, start }; + if (Number.isInteger(audioTrack) && audioTrack >= 0) { + req.audio_track = audioTrack; + } + console.log('[stream] sending stream_req start:', start, 'credits:', credits, + 'audio_track:', req.audio_track ?? '-'); + this._send(req); + } + + /** Room for `n` more segments. */ + grantStreamCredit(n = 1) { + if (!this._connected) return; + console.log('[stream] grant credit:', n); + this._send({ type: 'stream_more', v: '0.1', n }); + } + + /** + * Tell the node what the player sees. + * + * A hang on a phone is unreadable from here: there is no console to open and + * the node's own log shows a stream it is feeding perfectly well. This puts + * the two halves in one file. The node only logs it. + */ + sendStreamDiag(diag) { + if (!this._connected) return; + try { this._send({ type: 'client_diag', v: '0.1', ...diag }); } catch { /* gone */ } + } + + /** + * Nobody is watching any more. + * + * Closing the viewer used to say nothing to the node, which went on + * transcoding and holding one of its two slots until the credit timeout — so + * the next video answered "server busy". + */ + stopStream() { + if (!this._connected) return; + try { this._send({ type: 'stream_stop', v: '0.1' }); } catch { /* gone */ } + } +}); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-pins.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-pins.js new file mode 100644 index 0000000..c0699dc --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-pins.js @@ -0,0 +1,86 @@ +// Which node this browser has met under which key (trust on first use), and +// the version range a node declares at the handshake. + +// ── Node identity pinning (11.5.8) ─────────────────────────────────────────── + +const NODE_PIN_PREFIX = 'mb_nodepin_'; + +/** + * The node's half of the version range, from `handshake_challenge`. + * + * Mirrors meshbay_common/handshake.py::check_version(). A node that declares no + * range at all is a node that predates negotiation — that is every 0.x node, and + * none of them can serve a sealed index or a sealed ack — so it is refused here + * rather than left to fail later as a message that will not open. + */ +function _checkNodeVersion(reply) { + const parse = (v) => { + const m = /^(\d+)\.(\d+)$/.exec(String(v || '')); + return m ? [Number(m[1]), Number(m[2])] : null; + }; + const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]); + const fail = (reason, message) => { + const e = new Error(message); + e.reason = reason; + throw e; + }; + + const theirs = parse(reply.v); + if (!theirs) { + fail('node_version_unreadable', + 'The node did not declare a readable protocol version.'); + } + // No declared minimum means "only what I speak" — the correct reading of a + // node from before this field existed. + const theirMin = parse(reply.v_min) || theirs; + if (cmp(theirs, parse(MNP_V_MIN)) < 0) { + fail('node_too_old', + 'This node is running an older MeshBay than this page needs. ' + + 'Its operator has to update it.'); + } + if (cmp(theirMin, parse(MNP_V)) > 0) { + fail('client_too_old', + 'This page is older than the node it is talking to. ' + + 'Reload to pick up the current version.'); + } +} + +function _checkNodePin(nodeId, nodePk) { + if (!nodeId || !nodePk) return; + const key = NODE_PIN_PREFIX + nodeId; + + let pinned = null; + try { pinned = localStorage.getItem(key); } catch { return; } + + if (pinned === null) { + try { localStorage.setItem(key, nodePk); } catch {} + return; + } + if (pinned !== nodePk) { + throw new Error( + 'This node\'s identity key has changed. That is expected only if its ' + + 'operator reinstalled the node — otherwise someone may be impersonating ' + + 'it. Verify with the operator out of band, then clear the pin in ' + + 'Settings to accept the new key.'); + } +} + +/** Forget a pinned node identity — the deliberate escape hatch for a legitimate rotation. */ +function clearNodePin(nodeId) { + try { + if (nodeId) localStorage.removeItem(NODE_PIN_PREFIX + nodeId); + else { + for (const k of Object.keys(localStorage)) + if (k.startsWith(NODE_PIN_PREFIX)) localStorage.removeItem(k); + } + } catch {} +} + +function pinnedNodeCount() { + try { + return Object.keys(localStorage).filter(k => k.startsWith(NODE_PIN_PREFIX)).length; + } catch { return 0; } +} + +MeshBayTransport.clearNodePin = clearNodePin; +MeshBayTransport.pinnedNodeCount = pinnedNodeCount; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js new file mode 100644 index 0000000..6ae51d1 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js @@ -0,0 +1,152 @@ +// A passphrase change, carried to every node that holds this account's +// identity bundle. + +// ── Passphrase change: re-wrap every reachable identity bundle ─────────────── +// +// docs/MESHBAY_DESIGN.md §3.6. The passphrase-derived bundle_key encrypts this +// account's per-node identity on every node it has joined. Changing the +// passphrase changes that key, so each bundle must be read with the old key and +// written back with the new one — on the node, while both keys are in hand. +// +// The reachable set is the online nodes of the account's current groups. A node +// that is offline, or belongs to a group left since, cannot be reached here and +// is reported so the caller can tell the user to ask that group's operator to +// unpin them and issue a fresh code (§3.4). + +function _acHubFetch(hubUrl, path, init) { + const p = typeof window !== 'undefined' && window.MeshBayPlatform; + const url = (hubUrl || '') + path; + return (p && p.apiFetch) ? p.apiFetch(url, init) : fetch(url, init); +} + +async function _acHubGet(hubUrl, token, path) { + const r = await _acHubFetch(hubUrl, path, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!r.ok) throw new Error(`${path} → ${r.status}`); + return r.json(); +} + +function _acWithTimeout(promise, ms, label) { + let timer; + return Promise.race([ + promise.finally(() => clearTimeout(timer)), + new Promise((_, rej) => { + timer = setTimeout(() => rej(new Error(`${label} timed out`)), ms); + }), + ]); +} + +/** + * @param {object} o + * @param {string} o.hubUrl same base the SPA uses for the hub + * @param {string} o.token a fresh access token + * @param {string} o.username + * @param {string} o.userId + * @param {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy + * @param {string} o.newPassphrase + * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B, + * docs/MESHBAY_DESIGN.md §3.6). + * When given, the recovery-wrapped copy is read where the + * passphrase copy cannot be, and a fresh one is written back. + * @param {(p:{done:number,total:number})=>void} [o.onProgress] + * @returns {Promise<{updated:Array,unreachable:Array,failed:Array,newBundleKey:object}>} + */ +async function rewrapAllNodes(o) { + const K = window.MeshBayKeys; + if (!K || !K.deriveEncryptionKey) { + throw new Error('key module unavailable'); + } + let oldKey, newKey; + if (o.bundleKey) { + // "Keep the current passphrase key, just add / refresh the recovery copy" + // — the Profile backfill (docs/MESHBAY_DESIGN.md §3.6). `o.bundleKey` is the + // live {v2,v1} session key, so no passphrase is needed. + oldKey = newKey = o.bundleKey; + } else { + // Flow B has no old passphrase; connect will fail the passphrase decrypt and + // fall back to the recovery copy, so a placeholder key is fine for `oldKey`. + const oldPass = o.oldPassphrase || o.newPassphrase; + oldKey = { + v2: await K.deriveEncryptionKey(oldPass, o.username), + v1: await K.deriveEncryptionKeyV1(oldPass, o.username), + }; + newKey = { + v2: await K.deriveEncryptionKey(o.newPassphrase, o.username), + v1: await K.deriveEncryptionKeyV1(o.newPassphrase, o.username), + }; + } + const recoveryKey = o.recoveryKey + ? await K.deriveRecoveryKey(o.recoveryKey, o.username) + : null; + + const mine = await _acHubGet(o.hubUrl, o.token, '/v1/groups/mine'); + const groups = mine.groups || (Array.isArray(mine) ? mine : []); + const updated = [], unreachable = [], failed = []; + + for (const g of groups) { + const label = g.owner_username ? `${g.name}@${g.owner_username}` : g.name; + let nodes = []; + try { + const nd = await _acHubGet(o.hubUrl, o.token, `/v1/groups/${g.id}/nodes`); + nodes = nd.nodes || []; + } catch (e) { + failed.push({ groupId: g.id, name: label, reason: e.message }); + if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); + continue; + } + if (nodes.length === 0) { + unreachable.push({ groupId: g.id, name: label, reason: 'node offline' }); + if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); + continue; + } + + let anyOk = false, lastErr = null; + for (const n of nodes) { + const tp = new MeshBayTransport(o.hubUrl, o.token); + // Recover the *existing* identity or report this node — never mint a new + // one just because the stored bundle would not open. + tp._rewrapOnly = true; + try { + await _acWithTimeout( + tp.connect(n.node_id, o.token, g.id, null, null, oldKey, + o.username, o.userId, null, recoveryKey), + 30000, 'connect'); + if (tp.newNodeBundle) { + // No identity existed on this node — connect just minted one under + // the old key. Don't persist it: the next time this group is opened + // the normal flow creates one under the current key, and storing it + // here could also walk back a deliberate bundle withdrawal. Nothing + // is stranded, so this node needs no fix. + anyOk = true; + continue; + } + const sk = tp.sessionKeys; + if (!sk) { lastErr = new Error('identity not recovered'); continue; } + const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0)); + const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0)); + const reEnc = await K.encryptBundleWithKey(skEd, skX, newKey.v2); + // In Flow B, refresh the recovery copy too (same R) so the node's + // passphrase copy and recovery copy stay in step. + const reRecovery = recoveryKey + ? await K.encryptBundleWithKey(skEd, skX, recoveryKey) + : null; + await tp.storeKeypairBundle(reEnc, reRecovery); + anyOk = true; + } catch (e) { + lastErr = e; + } finally { + try { tp.close(); } catch { /* already gone */ } + } + } + + if (anyOk) updated.push({ groupId: g.id, name: label }); + else failed.push({ groupId: g.id, name: label, + reason: (lastErr && lastErr.message) || 'unreachable' }); + if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); + } + + return { updated, unreachable, failed, newBundleKey: newKey }; +} + +MeshBayTransport.rewrapAllNodes = rewrapAllNodes; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js new file mode 100644 index 0000000..ca0769d --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js @@ -0,0 +1,120 @@ +// What a node says about who is in a group, checked rather than trusted: the +// roster's signatures, and the keys this browser pinned for each account. + +/** + * Walk each account's devices outwards from the one nobody countersigned. + * + * A device is *verified* when a chain of real signatures reaches it from that + * account's root — the device an operator code admitted, which by definition + * has no countersignature and is the trust-on-first-use anchor. Anything the + * node lists but cannot evidence stays out of `verified`, so a substituted key + * is not laundered into the set merely by being mentioned. + * + * Devices pinned before the evidence was kept (2026-09-07) carry no signature + * and are treated exactly like a root: honest about what they are, rather than + * quietly accepted as verified. + */ +async function _verifyRoster(payload, nodePk) { + const C = window.MeshBayCrypto; + const byAccount = new Map(); + const devices = payload.devices || []; + + const per = new Map(); + for (const d of devices) { + if (!per.has(d.user_id)) per.set(d.user_id, []); + per.get(d.user_id).push(d); + } + + for (const [userId, list] of per) { + // Roots first: no countersigner, or one whose evidence was never stored. + const verified = []; + const chain = new Map(); + const pending = []; + for (const d of list) { + // A root is a device that names **no** countersigner: an operator code + // admitted it, and there is nothing to verify. + // + // Naming one and carrying no proof is *not* a root, and treating it as + // one was a hole this file's tests caught: a node that writes the roster + // can put any key it likes in an account's row, and if "no signature" + // meant "root" it would have been laundered straight into `verified`. + // Such a device is unevidenced — which is also the honest reading of one + // pinned before the evidence was kept. + if (!d.added_by_pk) verified.push(d.pk_ed25519); + else pending.push(d); + } + // Then repeatedly admit anything countersigned by something already in. + let progress = true; + while (progress && pending.length) { + progress = false; + for (let i = pending.length - 1; i >= 0; i--) { + const d = pending[i]; + if (!verified.includes(d.added_by_pk)) continue; + let ok = false; + try { + const transcript = C.deviceAddTranscript( + payload.node_pk || nodePk, userId, d.pk_ed25519, d.pk_x25519, + C.b64decode(d.add_nonce), d.add_ts); + ok = await C.verifyNodeSignature(d.added_by_pk, d.add_sig, transcript); + } catch { ok = false; } + if (ok) { + verified.push(d.pk_ed25519); + chain.set(d.pk_ed25519, d.added_by_pk); + pending.splice(i, 1); + progress = true; + } + } + } + byAccount.set(userId, { + username: (list[0] || {}).username || '', + all: list.map(d => d.pk_ed25519), + verified, + chain, + // Listed by the node and not reachable by any chain of signatures. + unevidenced: pending.map(d => d.pk_ed25519), + }); + } + return { byAccount }; +} + +// Which device keys this browser has accepted for each account, per node. +// localStorage rather than a runtime capability: it is a per-viewer +// convenience whose loss costs one "first sight" and never a wrong answer — +// forgetting a pin makes the next key read as `first`, not as verified. +const _PIN_NS = 'meshbay_account_pins'; + +function _pinKey(nodePk, userId) { + return `${_PIN_NS}:${nodePk || ''}:${userId}`; +} + +async function _readPinnedAccount(nodePk, userId) { + try { + const raw = localStorage.getItem(_pinKey(nodePk, userId)); + return raw ? JSON.parse(raw) : null; + } catch { return null; } +} + +async function _writePinnedAccount(nodePk, userId, keys) { + try { + localStorage.setItem(_pinKey(nodePk, userId), JSON.stringify(keys)); + } catch { /* private window, or storage refused — one more "first sight" */ } +} + +/** + * A wire payload as text. + * + * A plaintext message arrives as a string from the node; msgpack `bin` arrives + * as a Uint8Array. Both have to render. + * + * This function was deleted once, with an unrelated helper that sat next to it, + * and nothing complained: its only caller is inside `_openChatMessage`, whose + * rejection the chat panel swallows in a `.catch()` that just marks the page + * unloaded. The visible result was a conversation that rendered completely + * empty, with no error in the console and the node answering perfectly — found + * by `chat_send_probe.py`, not by reading this file. + */ +function _asText(payload) { + if (payload instanceof Uint8Array) return new TextDecoder().decode(payload); + if (payload == null) return ''; + return String(payload); +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-upload.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-upload.js new file mode 100644 index 0000000..9f4b531 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-upload.js @@ -0,0 +1,228 @@ +// Uploads: a whole file, sealed chunk by chunk with several in flight, and the +// folders it lands in. +// +// Methods of MeshBayTransport, copied onto its prototype by extendTransport +// (transport.js, which the shell loads first). + +extendTransport(class { + /** + * Push a whole file, several chunks in flight at once. + * + * One chunk per round trip is 48 KB of throughput per RTT no matter how much + * bandwidth there is: 4.8 MB/s on a 10 ms path, 480 KB/s on a 100 ms one, and + * the sender is idle for almost all of it — which also keeps SCTP's congestion + * window shut, so the transport never gets a chance to speed up either. A + * window of chunks makes the rate depend on bandwidth rather than distance. + * + * Order is not at risk: a DataChannel is ordered and reliable by default, and + * the node refuses any chunk that is not the one it expects next. + * + * The node decides where this lands (uploads/) and under what name — it finds a + * free one rather than replacing anything. The ack says which, and that is what + * this returns. + * + * `dir` names the folder to upload into, as a virtual path + * (`Media/Films/1999`) — where the sender is actually looking. The node + * resolves it against the group's own roots, which refuses `..`, absolute + * segments and anything escaping its root; it is a place among the group's + * folders, never a path on the operator's filesystem. + * + * `root` is the older, coarser form: the root's name and nothing below it. + * Kept because a node that predates `dir` reads it, and because Chat has no + * folder on screen to name. Omitting both leaves the node to pick, which it + * only does for a client old enough to have had one destination. + */ + async uploadFile(file, { chunkSize, onProgress, signal, root, dir, + tr = '' } = {}) { + // The same file twice at once would confuse the node, which keys its own + // upload state by folder and name — and would race for the same + // destination. The guard uses the same key: by name alone, a dropped folder + // holding a `cover.jpg` in two albums failed the second one for nothing. + const inFlightKey = `${dir || ''}/${file.name}`; + if (this._inFlightUploads.has(inFlightKey)) { + throw new Error(`${file.name} is already being uploaded`); + } + if (!this._gekRaw) throw new Error('This group has no key on this device'); + const C = window.MeshBayCrypto; + const groupId = (this._connectArgs && this._connectArgs.groupId) || ''; + this._inFlightUploads.add(inFlightKey); + const uploadId = _hex(crypto.getRandomValues(new Uint8Array(16))); + const size = chunkSize || UPLOAD_CHUNK_SIZE; + const total = Math.max(1, Math.ceil(file.size / size)); + let acked = 0; + let stored = null; + let failure = null; + + const acks = []; + const wake = () => { + acked += 1; + if (onProgress) onProgress(Math.min(file.size, acked * size), file.size); + const waiter = acks.shift(); + if (waiter) waiter(); + }; + // "Where am I?" — resolved by the node's answer to the probe chunk below, + // or by anything that says this node cannot answer it. + let settleProbe = null; + const probed = new Promise((r) => { settleProbe = r; }); + const answerProbe = (from) => { + if (!settleProbe) return false; + const done = settleProbe; + settleProbe = null; + done(from); + return true; + }; + this._uploaders.set(uploadId, (msg) => { + if (msg.type === 'error') { + // A node that predates the probe refuses its index. That is not a + // failure — it is the answer "start from the beginning", which is what + // this client did before there was anything to ask. + if (answerProbe(0)) return; + failure = new Error(msg.detail || 'Upload refused'); + wake(); + return; + } + // The ack is sealed too — `stored_as` and the folder it landed in name + // the operator's content. Opening it is what makes the result usable, so + // a failure here fails the upload rather than being swallowed: a chat + // attachment that cannot learn its stored name would point at nothing. + C.openGroup(this._gekRaw, 'upload', 'file_upload_ack', groupId, msg) + .then((plain) => { + const payload = msgpack_decode(plain); + if (payload.stored_as) stored = payload; + // Only the probe's answer carries this, so the two are told apart + // without trusting the index the node echoed back in clear. + if (typeof payload.resume_from === 'number') return answerProbe(payload.resume_from); + return false; + }) + .catch((e) => { + failure = new Error( + `The node's upload reply did not open under the group key (${e.message})`); + return false; + }) + // A probe's answer is not a chunk: waking here would credit the + // progress bar with a chunk that was never sent. + .then((wasProbe) => { if (!wasProbe) wake(); }); + }); + + const nextAck = () => new Promise(r => acks.push(r)); + + try { + // Ask before sending anything. An upload interrupted at 99% used to start + // again from zero, because the node kept its position on the connection + // that was lost — see `uploads.py`. The question goes inside the seal, as + // a chunk with no bytes, because naming the file on a clear message is + // exactly what sealing this path was for. + // Sealed first, spread second — the same shape as the chunk loop below, + // and not only for symmetry: `test_the_upload_itself_is_sealed` reads + // this call and fails if a filename appears in it, which is how it can + // tell a field outside the seal from one inside it. + const probeSealed = await C.sealGroup( + this._gekRaw, 'upload', 'file_upload', groupId, + msgpack_encode({ filename: file.name, data: new Uint8Array(0), + dir: dir || '', root: root || '' })); + this._send({ + type: 'file_upload', + v: '0.1', + upload_id: uploadId, + chunk_index: UPLOAD_PROBE_INDEX, + total_chunks: total, + ...(tr ? { tr } : {}), + ...probeSealed, + }); + // Bounded: a node that answers neither the probe nor its refusal must not + // leave an upload waiting for ever. Starting over is always safe. + let from = await Promise.race([ + probed, + new Promise((r) => setTimeout(() => { answerProbe(0); r(0); }, + UPLOAD_PROBE_TIMEOUT_MS)), + ]); + // Defensive: a node reporting a position at or past the end would have + // renamed the file and dropped its state, so this cannot happen — and if + // it does, sending everything again is the answer that cannot corrupt. + if (!(from > 0) || from >= total) from = 0; + if (from > 0) { + acked = from; + if (onProgress) onProgress(Math.min(file.size, from * size), file.size); + } + + for (let i = from; i < total; i++) { + if (signal && signal.aborted) throw _aborted(); + // Between two chunks, never inside one — the node refuses a chunk that + // is not the one it expects, so a position is the only thing worth + // remembering. Nothing is recorded here beyond that: the node holds the + // real position, and the probe above is what asks for it on the way + // back in, which makes resuming correct even across a reconnect. + if (signal && signal.paused) { + signal.resumeFrom = i; + const paused = new Error('Paused'); + paused.name = 'PausedError'; + throw paused; + } + // Backpressure: without it the whole file lands in the browser's send + // buffer in seconds and the progress bar becomes a work of fiction. + while (this._channel && this._channel.bufferedAmount > UPLOAD_BUFFER_HIGH) { + if (signal && signal.aborted) throw _aborted(); + await new Promise(r => setTimeout(r, 20)); + } + while (i - acked >= UPLOAD_WINDOW) { + await nextAck(); + if (failure) throw failure; + } + if (failure) throw failure; + + const buf = new Uint8Array( + await file.slice(i * size, (i + 1) * size).arrayBuffer()); + // The name, the destination and the bytes go inside the seal together. + // Mirrors `file_upload_wire` in meshbay_common/protocol.py; only the + // fields the node routes on stay outside it. + const sealed = await C.sealGroup( + this._gekRaw, 'upload', 'file_upload', groupId, + msgpack_encode({ filename: file.name, data: buf, + dir: dir || '', root: root || '' })); + this._send({ + type: 'file_upload', + v: '0.1', + upload_id: uploadId, + chunk_index: i, + total_chunks: total, + ...(tr ? { tr } : {}), + ...sealed, + }); + } + while (acked < total) { + await nextAck(); + if (failure) throw failure; + } + } finally { + this._uploaders.delete(uploadId); + this._inFlightUploads.delete(inFlightKey); + } + return stored || {}; + } + + /** Create a directory under the current one. Any member may. */ + async createDirectory(dir, name) { + const msg = await this._sendAndWait({ + type: 'dir_create', v: '0.1', dir: dir || '', name, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } +}); + +// 48 KB is what fits comfortably in one SCTP message across stacks; the window is +// what makes the rate independent of the round trip. 32 × 48 KB = 1.5 MB in +// flight, which saturates any path up to roughly 100 Mb/s at 100 ms. +const UPLOAD_CHUNK_SIZE = 48 * 1024; +const UPLOAD_WINDOW = 32; +// "Where am I?", asked as an ordinary sealed upload chunk with no bytes rather +// than on a clear message. Mirrors UPLOAD_PROBE_INDEX in +// meshbay_common/protocol.py; the node writes nothing and answers with +// `resume_from`, and one that predates it refuses the index, which reads as +// "start from the beginning". +const UPLOAD_PROBE_INDEX = -1; +// How long to wait for that answer before assuming there is none. A node that +// answers neither the probe nor its refusal must not leave an upload waiting +// for ever, and starting over is always safe. +const UPLOAD_PROBE_TIMEOUT_MS = 5000; +const UPLOAD_BUFFER_HIGH = 1024 * 1024; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 817f182..202db94 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -14,6 +14,10 @@ * const index = await transport.fetchIndex(); * const chunk = await transport.fetchChunk(fileId, 0); * transport.close(); + * + * This file is the core — connection, reconnection, leases, dispatch. The rest + * of MeshBayTransport's methods, and the free functions they use, are in the + * transport-*.js scripts the shell loads after it (see "Parts" at the end). */ async function _pkFromSk(skPkcs8B64) { @@ -35,23 +39,6 @@ async function _pkEdFromSk(skPkcs8B64) { return pad ? b64 + '='.repeat(4 - pad) : b64; } -// 48 KB is what fits comfortably in one SCTP message across stacks; the window is -// what makes the rate independent of the round trip. 32 × 48 KB = 1.5 MB in -// flight, which saturates any path up to roughly 100 Mb/s at 100 ms. -const UPLOAD_CHUNK_SIZE = 48 * 1024; -const UPLOAD_WINDOW = 32; -// "Where am I?", asked as an ordinary sealed upload chunk with no bytes rather -// than on a clear message. Mirrors UPLOAD_PROBE_INDEX in -// meshbay_common/protocol.py; the node writes nothing and answers with -// `resume_from`, and one that predates it refuses the index, which reads as -// "start from the beginning". -const UPLOAD_PROBE_INDEX = -1; -// How long to wait for that answer before assuming there is none. A node that -// answers neither the probe nor its refusal must not leave an upload waiting -// for ever, and starting over is always safe. -const UPLOAD_PROBE_TIMEOUT_MS = 5000; -const UPLOAD_BUFFER_HIGH = 1024 * 1024; - // Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a // slow link and small enough that nothing accumulates. // How long to collect ICE candidates before sending the offer anyway. Long @@ -119,7 +106,7 @@ function _aborted() { } // Every request type that goes through the two-step admin_challenge / -// admin_response flow (_authorizeAdminOp below) — one entry per +// admin_response flow (_authorizeAdminOp, transport-admin.js) — one entry per // `_authorizeAdminOp(msg, expectedOp, ...)` call site. Found live: enabling // the Music app and then saving its root folder in the same Settings visit // (the new merged Directories section makes this a natural, fast @@ -1340,60 +1327,6 @@ class MeshBayTransport { } /** - * 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; - } - - /** * The full index. Resolves with the sealed payload already opened — * `_applyIndexMessage` does that before it hands the message to whoever is * waiting, so both the reply to this call and the node's own unsolicited @@ -1452,383 +1385,6 @@ class MeshBayTransport { } /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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 <audio> - * element cannot decode at all (WMA, Musepack) into AAC/M4A. Returns - * `{ hash, size, mime }` — the *cache* hash to pull through the normal - * file_req/chunk path (fetchChunk/pipelinedDownload), not the file's own - * id, the same indirection already used for a TMDB poster or a - * MusicBrainz cover. Cached node-side after the first call, but ffmpeg - * still has to run at least once and a transcode slot can be busy, so - * this gets a longer timeout than the metadata lookups above. - */ - async requestAudioTranscode(fileId) { - const msg = await this._sendAndWait( - { type: 'audio_transcode_req', v: '0.9', file_id: fileId }, 120000); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - /** - * One embedded subtitle track, extracted node-side to WebVTT. Returns - * `{ hash, size, mime, track }` — the *cache* hash to pull through the - * ordinary file_req/chunk path, the same indirection as an audio transcode - * or a TMDB poster, and cached node-side under the file's own id so a film - * is extracted once rather than once per viewing. - * - * `track` is the ordinal the node published in `stream_init.subtitle_tracks` - * and is passed back untouched: it counts every subtitle stream in the - * container, including the bitmap ones that are never listed, so it is not - * a position in the list this client received. - */ - async requestSubtitle(fileId, track) { - // Generous because the node's own bound is, and for the same reason: the - // extraction demuxes the whole container, measured at 9.8 s per GB on an - // external disk — 71 s for a 7.3 GB film, and more for a 4K one. The node - // always answers, with a refusal if its own budget runs out, so this is a - // backstop against a peer that has gone silent rather than a deadline for - // the work. It is paid once per film: every later viewing is cached. - const msg = await this._sendAndWait( - { type: 'subtitle_req', v: '0.9', file_id: fileId, track }, 900000); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - /** - * Whether MusicBrainz lookups run for this group at all — per-group from - * the start (docs/MESHBAY_DESIGN.md §9.8). Signed like setTmdbEnabled. - */ - async setMusicbrainzEnabled(enabled, signFn) { - const msg = await this._sendAndWait({ - type: 'musicbrainz_enabled', v: '0.8', enabled: Boolean(enabled), - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - // Python's f"{bool}" is "True"/"False", not JS's lowercase — must - // match apps/music.py _do_musicbrainz_enabled byte-for-byte. - const subject = enabled ? 'True' : 'False'; - return this._authorizeAdminOp(msg, 'musicbrainz_enabled', subject, 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); - } - - /** * Liveness on this already-open channel. Resolves with the round trip in ms, * rejects on timeout — a DataChannel whose peer vanished without closing * still reads as connected, and nothing else here notices until a real @@ -1842,1093 +1398,12 @@ class MeshBayTransport { return Math.round(performance.now() - started); } - /** - * 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); - } - - /** - * 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'; - } - - /** - * 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' }; - } - } - - /** - * 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 for a video stream, and say how much we can take. - * - * `credits` bounds what is in flight. Without it the node pushes the whole - * film as fast as ffmpeg produces it and the browser holds all of it while - * MediaSource consumes a segment at a time — which is fine for a clip and - * fatal for anything worth streaming. - */ - requestStream(fileId, credits = STREAM_CREDITS, start = 0, audioTrack = null) { - // `start` is a seek: the node retires whatever this session was streaming - // and spawns ffmpeg again from there. Omitted or zero is the film's - // beginning, which is what an 0.1 node understands. - // - // `audioTrack` is omitted entirely unless the caller has one, and the - // caller only has one because a `stream_init` listed the tracks. A node - // too old to enumerate them is therefore never sent a field it would - // ignore — which matters more here than it looks: ignoring it would not - // degrade the stream, it would serve a different language without saying - // so. - const req = { type: 'stream_req', v: '0.1', file_id: fileId, credits, start }; - if (Number.isInteger(audioTrack) && audioTrack >= 0) { - req.audio_track = audioTrack; - } - console.log('[stream] sending stream_req start:', start, 'credits:', credits, - 'audio_track:', req.audio_track ?? '-'); - this._send(req); - } - - /** Room for `n` more segments. */ - grantStreamCredit(n = 1) { - if (!this._connected) return; - console.log('[stream] grant credit:', n); - this._send({ type: 'stream_more', v: '0.1', n }); - } - - /** - * Tell the node what the player sees. - * - * A hang on a phone is unreadable from here: there is no console to open and - * the node's own log shows a stream it is feeding perfectly well. This puts - * the two halves in one file. The node only logs it. - */ - sendStreamDiag(diag) { - if (!this._connected) return; - try { this._send({ type: 'client_diag', v: '0.1', ...diag }); } catch { /* gone */ } - } - - /** - * Nobody is watching any more. - * - * Closing the viewer used to say nothing to the node, which went on - * transcoding and holding one of its two slots until the credit timeout — so - * the next video answered "server busy". - */ - stopStream() { - if (!this._connected) return; - try { this._send({ type: 'stream_stop', v: '0.1' }); } catch { /* gone */ } - } - - /** - * Push a whole file, several chunks in flight at once. - * - * One chunk per round trip is 48 KB of throughput per RTT no matter how much - * bandwidth there is: 4.8 MB/s on a 10 ms path, 480 KB/s on a 100 ms one, and - * the sender is idle for almost all of it — which also keeps SCTP's congestion - * window shut, so the transport never gets a chance to speed up either. A - * window of chunks makes the rate depend on bandwidth rather than distance. - * - * Order is not at risk: a DataChannel is ordered and reliable by default, and - * the node refuses any chunk that is not the one it expects next. - * - * The node decides where this lands (uploads/) and under what name — it finds a - * free one rather than replacing anything. The ack says which, and that is what - * this returns. - * - * `dir` names the folder to upload into, as a virtual path - * (`Media/Films/1999`) — where the sender is actually looking. The node - * resolves it against the group's own roots, which refuses `..`, absolute - * segments and anything escaping its root; it is a place among the group's - * folders, never a path on the operator's filesystem. - * - * `root` is the older, coarser form: the root's name and nothing below it. - * Kept because a node that predates `dir` reads it, and because Chat has no - * folder on screen to name. Omitting both leaves the node to pick, which it - * only does for a client old enough to have had one destination. - */ - async uploadFile(file, { chunkSize, onProgress, signal, root, dir, - tr = '' } = {}) { - // The same file twice at once would confuse the node, which keys its own - // upload state by folder and name — and would race for the same - // destination. The guard uses the same key: by name alone, a dropped folder - // holding a `cover.jpg` in two albums failed the second one for nothing. - const inFlightKey = `${dir || ''}/${file.name}`; - if (this._inFlightUploads.has(inFlightKey)) { - throw new Error(`${file.name} is already being uploaded`); - } - if (!this._gekRaw) throw new Error('This group has no key on this device'); - const C = window.MeshBayCrypto; - const groupId = (this._connectArgs && this._connectArgs.groupId) || ''; - this._inFlightUploads.add(inFlightKey); - const uploadId = _hex(crypto.getRandomValues(new Uint8Array(16))); - const size = chunkSize || UPLOAD_CHUNK_SIZE; - const total = Math.max(1, Math.ceil(file.size / size)); - let acked = 0; - let stored = null; - let failure = null; - - const acks = []; - const wake = () => { - acked += 1; - if (onProgress) onProgress(Math.min(file.size, acked * size), file.size); - const waiter = acks.shift(); - if (waiter) waiter(); - }; - // "Where am I?" — resolved by the node's answer to the probe chunk below, - // or by anything that says this node cannot answer it. - let settleProbe = null; - const probed = new Promise((r) => { settleProbe = r; }); - const answerProbe = (from) => { - if (!settleProbe) return false; - const done = settleProbe; - settleProbe = null; - done(from); - return true; - }; - this._uploaders.set(uploadId, (msg) => { - if (msg.type === 'error') { - // A node that predates the probe refuses its index. That is not a - // failure — it is the answer "start from the beginning", which is what - // this client did before there was anything to ask. - if (answerProbe(0)) return; - failure = new Error(msg.detail || 'Upload refused'); - wake(); - return; - } - // The ack is sealed too — `stored_as` and the folder it landed in name - // the operator's content. Opening it is what makes the result usable, so - // a failure here fails the upload rather than being swallowed: a chat - // attachment that cannot learn its stored name would point at nothing. - C.openGroup(this._gekRaw, 'upload', 'file_upload_ack', groupId, msg) - .then((plain) => { - const payload = msgpack_decode(plain); - if (payload.stored_as) stored = payload; - // Only the probe's answer carries this, so the two are told apart - // without trusting the index the node echoed back in clear. - if (typeof payload.resume_from === 'number') return answerProbe(payload.resume_from); - return false; - }) - .catch((e) => { - failure = new Error( - `The node's upload reply did not open under the group key (${e.message})`); - return false; - }) - // A probe's answer is not a chunk: waking here would credit the - // progress bar with a chunk that was never sent. - .then((wasProbe) => { if (!wasProbe) wake(); }); - }); - - const nextAck = () => new Promise(r => acks.push(r)); - - try { - // Ask before sending anything. An upload interrupted at 99% used to start - // again from zero, because the node kept its position on the connection - // that was lost — see `uploads.py`. The question goes inside the seal, as - // a chunk with no bytes, because naming the file on a clear message is - // exactly what sealing this path was for. - // Sealed first, spread second — the same shape as the chunk loop below, - // and not only for symmetry: `test_the_upload_itself_is_sealed` reads - // this call and fails if a filename appears in it, which is how it can - // tell a field outside the seal from one inside it. - const probeSealed = await C.sealGroup( - this._gekRaw, 'upload', 'file_upload', groupId, - msgpack_encode({ filename: file.name, data: new Uint8Array(0), - dir: dir || '', root: root || '' })); - this._send({ - type: 'file_upload', - v: '0.1', - upload_id: uploadId, - chunk_index: UPLOAD_PROBE_INDEX, - total_chunks: total, - ...(tr ? { tr } : {}), - ...probeSealed, - }); - // Bounded: a node that answers neither the probe nor its refusal must not - // leave an upload waiting for ever. Starting over is always safe. - let from = await Promise.race([ - probed, - new Promise((r) => setTimeout(() => { answerProbe(0); r(0); }, - UPLOAD_PROBE_TIMEOUT_MS)), - ]); - // Defensive: a node reporting a position at or past the end would have - // renamed the file and dropped its state, so this cannot happen — and if - // it does, sending everything again is the answer that cannot corrupt. - if (!(from > 0) || from >= total) from = 0; - if (from > 0) { - acked = from; - if (onProgress) onProgress(Math.min(file.size, from * size), file.size); - } - - for (let i = from; i < total; i++) { - if (signal && signal.aborted) throw _aborted(); - // Between two chunks, never inside one — the node refuses a chunk that - // is not the one it expects, so a position is the only thing worth - // remembering. Nothing is recorded here beyond that: the node holds the - // real position, and the probe above is what asks for it on the way - // back in, which makes resuming correct even across a reconnect. - if (signal && signal.paused) { - signal.resumeFrom = i; - const paused = new Error('Paused'); - paused.name = 'PausedError'; - throw paused; - } - // Backpressure: without it the whole file lands in the browser's send - // buffer in seconds and the progress bar becomes a work of fiction. - while (this._channel && this._channel.bufferedAmount > UPLOAD_BUFFER_HIGH) { - if (signal && signal.aborted) throw _aborted(); - await new Promise(r => setTimeout(r, 20)); - } - while (i - acked >= UPLOAD_WINDOW) { - await nextAck(); - if (failure) throw failure; - } - if (failure) throw failure; - - const buf = new Uint8Array( - await file.slice(i * size, (i + 1) * size).arrayBuffer()); - // The name, the destination and the bytes go inside the seal together. - // Mirrors `file_upload_wire` in meshbay_common/protocol.py; only the - // fields the node routes on stay outside it. - const sealed = await C.sealGroup( - this._gekRaw, 'upload', 'file_upload', groupId, - msgpack_encode({ filename: file.name, data: buf, - dir: dir || '', root: root || '' })); - this._send({ - type: 'file_upload', - v: '0.1', - upload_id: uploadId, - chunk_index: i, - total_chunks: total, - ...(tr ? { tr } : {}), - ...sealed, - }); - } - while (acked < total) { - await nextAck(); - if (failure) throw failure; - } - } finally { - this._uploaders.delete(uploadId); - this._inFlightUploads.delete(inFlightKey); - } - return stored || {}; - } - - /** Create a directory under the current one. Any member may. */ - async createDirectory(dir, name) { - const msg = await this._sendAndWait({ - type: 'dir_create', v: '0.1', dir: dir || '', name, - }); - 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:<group>`, 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; - } - - /** - * 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 ───────────────────────────────────────────────────── // // Identity keys are per node, so a browser and a desktop client are two keys // on one account here. A new one is admitted by a key this node already // pinned — never by the hub, which holds no user keys and so cannot // countersign anything. See docs/MESHBAY_DESIGN.md §3.3. - /** - * 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) ────────────────────────────────────── // // MNP 3.1, docs/playlists.md §8.1. The node stores bytes it cannot read and // hands them back; `user_id` is never sent, because the node takes it from @@ -2940,54 +1415,6 @@ class MeshBayTransport { // bottom of _handleMessage — that branch exists for nodes too old to stamp // `req_id`, and no node old enough to skip it knows these messages at all. - 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; - } - get gekRaw() { return this._gekRaw; } /** @@ -3736,346 +2163,6 @@ class MeshBayTransport { } /** - * Walk each account's devices outwards from the one nobody countersigned. - * - * A device is *verified* when a chain of real signatures reaches it from that - * account's root — the device an operator code admitted, which by definition - * has no countersignature and is the trust-on-first-use anchor. Anything the - * node lists but cannot evidence stays out of `verified`, so a substituted key - * is not laundered into the set merely by being mentioned. - * - * Devices pinned before the evidence was kept (2026-09-07) carry no signature - * and are treated exactly like a root: honest about what they are, rather than - * quietly accepted as verified. - */ -async function _verifyRoster(payload, nodePk) { - const C = window.MeshBayCrypto; - const byAccount = new Map(); - const devices = payload.devices || []; - - const per = new Map(); - for (const d of devices) { - if (!per.has(d.user_id)) per.set(d.user_id, []); - per.get(d.user_id).push(d); - } - - for (const [userId, list] of per) { - // Roots first: no countersigner, or one whose evidence was never stored. - const verified = []; - const chain = new Map(); - const pending = []; - for (const d of list) { - // A root is a device that names **no** countersigner: an operator code - // admitted it, and there is nothing to verify. - // - // Naming one and carrying no proof is *not* a root, and treating it as - // one was a hole this file's tests caught: a node that writes the roster - // can put any key it likes in an account's row, and if "no signature" - // meant "root" it would have been laundered straight into `verified`. - // Such a device is unevidenced — which is also the honest reading of one - // pinned before the evidence was kept. - if (!d.added_by_pk) verified.push(d.pk_ed25519); - else pending.push(d); - } - // Then repeatedly admit anything countersigned by something already in. - let progress = true; - while (progress && pending.length) { - progress = false; - for (let i = pending.length - 1; i >= 0; i--) { - const d = pending[i]; - if (!verified.includes(d.added_by_pk)) continue; - let ok = false; - try { - const transcript = C.deviceAddTranscript( - payload.node_pk || nodePk, userId, d.pk_ed25519, d.pk_x25519, - C.b64decode(d.add_nonce), d.add_ts); - ok = await C.verifyNodeSignature(d.added_by_pk, d.add_sig, transcript); - } catch { ok = false; } - if (ok) { - verified.push(d.pk_ed25519); - chain.set(d.pk_ed25519, d.added_by_pk); - pending.splice(i, 1); - progress = true; - } - } - } - byAccount.set(userId, { - username: (list[0] || {}).username || '', - all: list.map(d => d.pk_ed25519), - verified, - chain, - // Listed by the node and not reachable by any chain of signatures. - unevidenced: pending.map(d => d.pk_ed25519), - }); - } - return { byAccount }; -} - -// Which device keys this browser has accepted for each account, per node. -// localStorage rather than a runtime capability: it is a per-viewer -// convenience whose loss costs one "first sight" and never a wrong answer — -// forgetting a pin makes the next key read as `first`, not as verified. -const _PIN_NS = 'meshbay_account_pins'; - -function _pinKey(nodePk, userId) { - return `${_PIN_NS}:${nodePk || ''}:${userId}`; -} - -async function _readPinnedAccount(nodePk, userId) { - try { - const raw = localStorage.getItem(_pinKey(nodePk, userId)); - return raw ? JSON.parse(raw) : null; - } catch { return null; } -} - -async function _writePinnedAccount(nodePk, userId, keys) { - try { - localStorage.setItem(_pinKey(nodePk, userId), JSON.stringify(keys)); - } catch { /* private window, or storage refused — one more "first sight" */ } -} - -/** - * A wire payload as text. - * - * A plaintext message arrives as a string from the node; msgpack `bin` arrives - * as a Uint8Array. Both have to render. - * - * This function was deleted once, with an unrelated helper that sat next to it, - * and nothing complained: its only caller is inside `_openChatMessage`, whose - * rejection the chat panel swallows in a `.catch()` that just marks the page - * unloaded. The visible result was a conversation that rendered completely - * empty, with no error in the console and the node answering perfectly — found - * by `chat_send_probe.py`, not by reading this file. - */ -function _asText(payload) { - if (payload instanceof Uint8Array) return new TextDecoder().decode(payload); - if (payload == null) return ''; - return String(payload); -} - -// ── 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(''); -} - -/** * Why a code from an invitation link must not go to this node, or null. * * `link_other_node` is the caller's cue to try the next node the hub listed, @@ -4131,237 +2218,23 @@ function _extractDtlsFingerprint(sdp) { return bytes; } -// ── Node identity pinning (11.5.8) ─────────────────────────────────────────── - -const NODE_PIN_PREFIX = 'mb_nodepin_'; - -/** - * The node's half of the version range, from `handshake_challenge`. - * - * Mirrors meshbay_common/handshake.py::check_version(). A node that declares no - * range at all is a node that predates negotiation — that is every 0.x node, and - * none of them can serve a sealed index or a sealed ack — so it is refused here - * rather than left to fail later as a message that will not open. - */ -function _checkNodeVersion(reply) { - const parse = (v) => { - const m = /^(\d+)\.(\d+)$/.exec(String(v || '')); - return m ? [Number(m[1]), Number(m[2])] : null; - }; - const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]); - const fail = (reason, message) => { - const e = new Error(message); - e.reason = reason; - throw e; - }; - - const theirs = parse(reply.v); - if (!theirs) { - fail('node_version_unreadable', - 'The node did not declare a readable protocol version.'); - } - // No declared minimum means "only what I speak" — the correct reading of a - // node from before this field existed. - const theirMin = parse(reply.v_min) || theirs; - if (cmp(theirs, parse(MNP_V_MIN)) < 0) { - fail('node_too_old', - 'This node is running an older MeshBay than this page needs. ' - + 'Its operator has to update it.'); - } - if (cmp(theirMin, parse(MNP_V)) > 0) { - fail('client_too_old', - 'This page is older than the node it is talking to. ' - + 'Reload to pick up the current version.'); - } -} - -function _checkNodePin(nodeId, nodePk) { - if (!nodeId || !nodePk) return; - const key = NODE_PIN_PREFIX + nodeId; - - let pinned = null; - try { pinned = localStorage.getItem(key); } catch { return; } - - if (pinned === null) { - try { localStorage.setItem(key, nodePk); } catch {} - return; - } - if (pinned !== nodePk) { - throw new Error( - 'This node\'s identity key has changed. That is expected only if its ' + - 'operator reinstalled the node — otherwise someone may be impersonating ' + - 'it. Verify with the operator out of band, then clear the pin in ' + - 'Settings to accept the new key.'); - } -} - -/** Forget a pinned node identity — the deliberate escape hatch for a legitimate rotation. */ -function clearNodePin(nodeId) { - try { - if (nodeId) localStorage.removeItem(NODE_PIN_PREFIX + nodeId); - else { - for (const k of Object.keys(localStorage)) - if (k.startsWith(NODE_PIN_PREFIX)) localStorage.removeItem(k); - } - } catch {} -} - -function pinnedNodeCount() { - try { - return Object.keys(localStorage).filter(k => k.startsWith(NODE_PIN_PREFIX)).length; - } catch { return 0; } -} - -// ── Passphrase change: re-wrap every reachable identity bundle ─────────────── -// -// docs/MESHBAY_DESIGN.md §3.6. The passphrase-derived bundle_key encrypts this -// account's per-node identity on every node it has joined. Changing the -// passphrase changes that key, so each bundle must be read with the old key and -// written back with the new one — on the node, while both keys are in hand. +// ── Parts ─────────────────────────────────────────────────────────────────── // -// The reachable set is the online nodes of the account's current groups. A node -// that is offline, or belongs to a group left since, cannot be reached here and -// is reported so the caller can tell the user to ask that group's operator to -// unpin them and issue a fresh code (§3.4). - -function _acHubFetch(hubUrl, path, init) { - const p = typeof window !== 'undefined' && window.MeshBayPlatform; - const url = (hubUrl || '') + path; - return (p && p.apiFetch) ? p.apiFetch(url, init) : fetch(url, init); -} - -async function _acHubGet(hubUrl, token, path) { - const r = await _acHubFetch(hubUrl, path, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!r.ok) throw new Error(`${path} → ${r.status}`); - return r.json(); -} - -function _acWithTimeout(promise, ms, label) { - let timer; - return Promise.race([ - promise.finally(() => clearTimeout(timer)), - new Promise((_, rej) => { - timer = setTimeout(() => rej(new Error(`${label} timed out`)), ms); - }), - ]); -} - -/** - * @param {object} o - * @param {string} o.hubUrl same base the SPA uses for the hub - * @param {string} o.token a fresh access token - * @param {string} o.username - * @param {string} o.userId - * @param {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy - * @param {string} o.newPassphrase - * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B, - * docs/MESHBAY_DESIGN.md §3.6). - * When given, the recovery-wrapped copy is read where the - * passphrase copy cannot be, and a fresh one is written back. - * @param {(p:{done:number,total:number})=>void} [o.onProgress] - * @returns {Promise<{updated:Array,unreachable:Array,failed:Array,newBundleKey:object}>} - */ -async function rewrapAllNodes(o) { - const K = window.MeshBayKeys; - if (!K || !K.deriveEncryptionKey) { - throw new Error('key module unavailable'); - } - let oldKey, newKey; - if (o.bundleKey) { - // "Keep the current passphrase key, just add / refresh the recovery copy" - // — the Profile backfill (docs/MESHBAY_DESIGN.md §3.6). `o.bundleKey` is the - // live {v2,v1} session key, so no passphrase is needed. - oldKey = newKey = o.bundleKey; - } else { - // Flow B has no old passphrase; connect will fail the passphrase decrypt and - // fall back to the recovery copy, so a placeholder key is fine for `oldKey`. - const oldPass = o.oldPassphrase || o.newPassphrase; - oldKey = { - v2: await K.deriveEncryptionKey(oldPass, o.username), - v1: await K.deriveEncryptionKeyV1(oldPass, o.username), - }; - newKey = { - v2: await K.deriveEncryptionKey(o.newPassphrase, o.username), - v1: await K.deriveEncryptionKeyV1(o.newPassphrase, o.username), - }; - } - const recoveryKey = o.recoveryKey - ? await K.deriveRecoveryKey(o.recoveryKey, o.username) - : null; - - const mine = await _acHubGet(o.hubUrl, o.token, '/v1/groups/mine'); - const groups = mine.groups || (Array.isArray(mine) ? mine : []); - const updated = [], unreachable = [], failed = []; - - for (const g of groups) { - const label = g.owner_username ? `${g.name}@${g.owner_username}` : g.name; - let nodes = []; - try { - const nd = await _acHubGet(o.hubUrl, o.token, `/v1/groups/${g.id}/nodes`); - nodes = nd.nodes || []; - } catch (e) { - failed.push({ groupId: g.id, name: label, reason: e.message }); - if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); - continue; - } - if (nodes.length === 0) { - unreachable.push({ groupId: g.id, name: label, reason: 'node offline' }); - if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); - continue; +// MeshBayTransport's methods are written across several classic scripts, loaded +// after this one in the shell's order (api/webapp.py, and the desktop client's +// scripts/index.html): each declares its share in a class of its own, verbatim, +// and hands it here to be copied onto the prototype. A name defined twice is a +// mistake in the split, not an override, and fails loudly at load. +function extendTransport(part) { + for (const name of Object.getOwnPropertyNames(part.prototype)) { + if (name === 'constructor') continue; + if (Object.prototype.hasOwnProperty.call(MeshBayTransport.prototype, name)) { + throw new Error(`MeshBayTransport.${name} is defined twice`); } - - let anyOk = false, lastErr = null; - for (const n of nodes) { - const tp = new MeshBayTransport(o.hubUrl, o.token); - // Recover the *existing* identity or report this node — never mint a new - // one just because the stored bundle would not open. - tp._rewrapOnly = true; - try { - await _acWithTimeout( - tp.connect(n.node_id, o.token, g.id, null, null, oldKey, - o.username, o.userId, null, recoveryKey), - 30000, 'connect'); - if (tp.newNodeBundle) { - // No identity existed on this node — connect just minted one under - // the old key. Don't persist it: the next time this group is opened - // the normal flow creates one under the current key, and storing it - // here could also walk back a deliberate bundle withdrawal. Nothing - // is stranded, so this node needs no fix. - anyOk = true; - continue; - } - const sk = tp.sessionKeys; - if (!sk) { lastErr = new Error('identity not recovered'); continue; } - const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0)); - const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0)); - const reEnc = await K.encryptBundleWithKey(skEd, skX, newKey.v2); - // In Flow B, refresh the recovery copy too (same R) so the node's - // passphrase copy and recovery copy stay in step. - const reRecovery = recoveryKey - ? await K.encryptBundleWithKey(skEd, skX, recoveryKey) - : null; - await tp.storeKeypairBundle(reEnc, reRecovery); - anyOk = true; - } catch (e) { - lastErr = e; - } finally { - try { tp.close(); } catch { /* already gone */ } - } - } - - if (anyOk) updated.push({ groupId: g.id, name: label }); - else failed.push({ groupId: g.id, name: label, - reason: (lastErr && lastErr.message) || 'unreachable' }); - if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); + Object.defineProperty(MeshBayTransport.prototype, name, + Object.getOwnPropertyDescriptor(part.prototype, name)); } - - return { updated, unreachable, failed, newBundleKey: newKey }; } // Export -MeshBayTransport.clearNodePin = clearNodePin; -MeshBayTransport.pinnedNodeCount = pinnedNodeCount; -MeshBayTransport.rewrapAllNodes = rewrapAllNodes; window.MeshBayTransport = MeshBayTransport; |