// The operator's side: signed operations (the two-step challenge), the node's // settings and roots, members and invitations. // // Methods of MeshBayTransport, copied onto its prototype by extendTransport // (transport.js, which the shell loads first). extendTransport(class { /** * Pair this browser with the node using a one-time code (M3, and the same * substitution as H3). * * The node has no way to know which key belongs to its operator unless someone * tells it locally — asking the hub would let the hub name itself node * administrator. The code comes from `meshbay-node operator pair`, over SSH, and * the hub never sees it. */ async pairOperator(userId, code) { if (!this._connected) throw new Error('Not connected to the node'); if (!userId) throw new Error('Missing user id'); if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) { throw new Error('Identity keys unavailable in this browser — sign in again'); } if (!this._nonceNode || !this.nodePk) { throw new Error('Handshake incomplete — reconnect and retry'); } const C = window.MeshBayCrypto; // Both public keys are derived from OUR OWN secret keys, never read back from // the hub: signing a public key the directory handed us would reintroduce the // substitution this whole mechanism exists to close. const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); const ts = Math.floor(Date.now() / 1000); // group_id is empty: operator authority is node-wide, not per group. const transcript = C.joinTranscript( this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts); const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript); const resp = await this._sendAndWait({ type: 'join_request', v: '0.1', group_id: '', pk_ed25519: pkEdB64, pk_x25519: pkXB64, code: code || '', ts, sig, }); if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused'); if (resp.type !== 'join_result' || !resp.ok) { const reason = resp.reason || 'unknown'; const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`); err.reason = reason; throw err; } this.memberRole = 'operator'; return resp; } async setAppDirectories(appKey, directories, signFn) { const clean = [...new Set( (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean), )].sort(); const msg = await this._sendAndWait({ type: 'app_directories', v: '1.1', app: appKey, directories: clean, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp( msg, 'app_directories', `${appKey}:${clean.join(',')}`, signFn); } return msg; } /** * Whether this group's files appear in members' cross-group Search. * Presentation only — opening the group lists everything regardless. */ async setSearchListed(listed, signFn) { const msg = await this._sendAndWait({ type: 'search_listed', v: '3.0', listed: Boolean(listed), }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp( msg, 'search_listed', listed ? 'on' : 'off', signFn); } return msg; } /** * Authorize a privileged node operation with the user's Ed25519 identity key. * * The client rebuilds the signed transcript from the challenge fields and refuses * to sign unless the operation and subject match what the user actually asked for. * Previously the node sent 32 opaque random bytes and the client signed them * blind, which let any peer obtain a signature over content of its choosing * (finding H5). */ async _authorizeAdminOp(challenge, expectedOp, expectedSubject, signFn) { if (challenge.op !== expectedOp || challenge.subject !== expectedSubject) { throw new Error( `Refusing to sign: node asked to authorize "${challenge.op}" on ` + `"${challenge.subject}", but the requested action was "${expectedOp}" ` + `on "${expectedSubject}"`); } if (!signFn) throw new Error('Admin challenge received but no signing key available'); const transcript = window.MeshBayCrypto.adminTranscript( challenge.op, challenge.node_pk, challenge.group_id, challenge.subject, challenge.nonce, challenge.ts); const signature = await signFn(transcript); console.log('[MeshBay] _authorizeAdminOp: signed', challenge.op, 'op_id=', challenge.op_id, '— sending admin_response'); const ack = await this._sendAndWait({ type: 'admin_response', v: '0.1', op_id: challenge.op_id, signature, // Not read by the node (_do_admin_response only looks at op_id and // signature) — carried so _sendAndWait can key this reply by op, the // same way the admin_challenge that preceded it was keyed. Without // it, two admin_response replies in flight together (e.g. one op's // app_directories_ack arriving while another's apps_enabled_ack is still // pending) are matched by nothing more than arrival order. op: challenge.op, }); console.log('[MeshBay] _authorizeAdminOp:', challenge.op, 'admin_response reply =', ack); if (ack.type === 'error') throw new Error(ack.detail); return ack; } async deleteFile(fileId, signFn) { const msg = await this._sendAndWait({ type: 'file_delete', v: '0.1', file_id: fileId, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'file_delete', fileId, signFn); } return msg; } /** * Remove an empty directory. Operator only, and the node checks that — this * signs with the identity it pinned for us, exactly like deleting a file. */ async deleteDirectory(dir, signFn) { const msg = await this._sendAndWait({ type: 'dir_delete', v: '0.1', dir, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { // `dir`, not msg.subject: comparing the node's answer against itself is // no check at all, and the point of this one is that we know what we // asked for without being told. return this._authorizeAdminOp(msg, 'dir_delete', dir, signFn); } return msg; } /** * Stop this node serving the group key to someone. Operator only. * * Only the node can do this: its roster decides who it serves. Removing them * on the hub is the other half, and neither implies the other. */ /** * Turn a group "application" (Chat, Files, ...) on or off for everyone. * * Takes the whole set in one signed message rather than one op per app, so * ticking several boxes in Settings costs one signature. `apps` is sorted * and joined the same way on the node before it is shown for signing — * `_authorizeAdminOp` below checks the two match. */ async setAppsEnabled(apps, signFn) { // Files cannot be turned off — MNP permits root exploration regardless of // this list, so hiding the tab only ever misled — and the node adds it if // it is missing. That normalisation has to happen *here too*: the subject // below is rebuilt from what this client sent, and compared byte for byte // against what the node put in the challenge. A list arriving here without // `files` would produce two different strings and `_authorizeAdminOp` // would refuse to sign an op the operator did ask for. It is reachable // only from a caller that builds the list from something other than the // node's own answer, which is exactly the kind of caller a later phase // adds. (`apps.js` marks it `alwaysEnabled`; this file is a classic // script and cannot import it.) const full = apps.includes('files') ? [...apps] : ['files', ...apps]; const msg = await this._sendAndWait({ type: 'apps_enabled', v: '0.1', apps: full, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp( msg, 'apps_enabled', [...full].sort().join(','), signFn); } return msg; } /** * How often the node's reconciliation backstop runs, and how long it * waits after a file's last write before hashing it (indexer.py * DirectoryIndexer). Whole seconds only: the node builds the signing * subject with Python's `%g` (drops a trailing ".0"), and the simplest * way to always match it byte-for-byte from JS is to never send a * fractional value in the first place. */ async setScanSettings(reconcileIntervalSecs, debounceSecs, signFn) { const reconcile = Math.round(reconcileIntervalSecs); const debounce = Math.round(debounceSecs); const msg = await this._sendAndWait({ type: 'set_scan_settings', v: '0.1', reconcile_interval_secs: reconcile, debounce_secs: debounce, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp( msg, 'set_scan_settings', `${reconcile},${debounce}`, signFn); } return msg; } async revokeMember(userId, signFn) { const msg = await this._sendAndWait({ type: 'member_revoke', v: '0.1', user_id: userId, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'member_revoke', userId, signFn); } return msg; } // ── Node management (D5) ─────────────────────────────────────────────── async fetchNodeStatus() { const msg = await this._sendAndWait({ type: 'node_status', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } async updateNodeSettings(settings) { const msg = await this._sendAndWait({ type: 'node_settings_set', v: '0.1', settings, }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) { const msg = await this._sendAndWait({ type: 'root_add', v: '1.1', group_id: groupId, path, name: name || '', kind: kind || 'generic', writable: !!writable, removable: !!removable, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'root_add', path, signFn); } return msg; } async removeRoot(groupId, rootName, signFn) { const msg = await this._sendAndWait({ type: 'root_remove', v: '0.1', group_id: groupId, root_name: rootName, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'root_remove', rootName, signFn); } return msg; } async updateRoot(groupId, rootName, { writable, removable } = {}, signFn) { const updates = []; if (writable !== undefined) updates.push(`rw=${writable ? 'on' : 'off'}`); if (removable !== undefined) updates.push(`rem=${removable ? 'on' : 'off'}`); const subject = updates.length ? `${rootName}:${updates.join(',')}` : rootName; const msg = await this._sendAndWait({ type: 'root_update', v: '1.1', group_id: groupId, root_name: rootName, ...(writable !== undefined && { writable }), ...(removable !== undefined && { removable }), }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'root_update', subject, signFn); } return msg; } async ejectRoot(groupId, rootName, signFn) { const msg = await this._sendAndWait({ type: 'root_eject', v: '1.1', group_id: groupId, root_name: rootName, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'root_eject', rootName, signFn); } return msg; } async plugRoot(groupId, rootName, signFn) { const msg = await this._sendAndWait({ type: 'root_plug', v: '1.1', group_id: groupId, root_name: rootName, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'root_plug', rootName, signFn); } return msg; } async unpinMember(userId, signFn) { const msg = await this._sendAndWait({ type: 'member_unpin', v: '0.1', user_id: userId, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'member_unpin', userId, signFn); } return msg; } async rotateGek(groupId, signFn) { const msg = await this._sendAndWait({ type: 'gek_rotate', v: '0.1', group_id: groupId, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'gek_rotate', groupId, signFn); } return msg; } async fetchRoster(groupId) { const msg = await this._sendAndWait({ type: 'roster_read', v: '0.1', group_id: groupId || '', }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } async fetchDenylist() { const msg = await this._sendAndWait({ type: 'denylist_read', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } async clearDenylist(subject) { const msg = await this._sendAndWait({ type: 'denylist_clear', v: '0.1', subject: subject || '', }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } async attachGroup(name, sharedDir, uploadDir, signFn) { const msg = await this._sendAndWait({ type: 'group_attach', v: '0.1', name, shared_dir: sharedDir, upload_dir: uploadDir || '', }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'group_attach', name, signFn); } return msg; } async detachGroup(name, signFn) { const msg = await this._sendAndWait({ type: 'group_detach', v: '0.1', name, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'group_detach', name, signFn); } return msg; } async reloadConfig() { const msg = await this._sendAndWait({ type: 'node_reload', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } /** * Ask the node for a one-time pairing code admitting `userId` to this group. * * This replaces wrapping the group key in the browser. We no longer fetch the * invitee's public key from the hub, so the hub can no longer answer with its own * and be handed the group key (H3). The node wraps the key later, itself, for a * key the invitee proves possession of. * * Returns {code, expires_at} — the code is displayed once and passed to the * invitee out of band. */ async createInvite(userId, groupId, username, signFn) { const msg = await this._sendAndWait({ type: 'invite_create', v: '0.1', user_id: userId, group_id: groupId, username: username || '', }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'invite_create', userId, signFn); } return msg; } /** * A code bound to no account, for an invitation link (MNP 3.4). Signed like * any invitation, and the subject the operator signs is the outcome: a link * into this group, `link:`, and nothing else. */ async createLinkInvite(groupId, signFn) { const msg = await this._sendAndWait({ type: 'invite_link_create', v: '0.1', group_id: groupId, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'invite_link_create', `link:${groupId}`, signFn); } return msg; } /** Take back an unredeemed invitation link, by the handle it was issued with. */ async cancelLinkInvite(inviteId, signFn) { const msg = await this._sendAndWait({ type: 'invite_cancel', v: '0.1', invite_id: inviteId, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'invite_cancel', inviteId, signFn); } return msg; } });