aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js451
1 files changed, 451 insertions, 0 deletions
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;
+ }
+});