diff options
Diffstat (limited to 'packages/meshbay-client/src')
| -rw-r--r-- | packages/meshbay-client/src/cast-chromecast.js | 71 | ||||
| -rw-r--r-- | packages/meshbay-client/src/keyring.js | 34 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 66 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 3 | ||||
| -rw-r--r-- | packages/meshbay-client/src/transcripts.js | 16 |
5 files changed, 123 insertions, 67 deletions
diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js index 1355cdd..7fc8079 100644 --- a/packages/meshbay-client/src/cast-chromecast.js +++ b/packages/meshbay-client/src/cast-chromecast.js @@ -78,19 +78,25 @@ class CastChromecast { constructor() { this._bonjour = null; this._browser = null; + this._scanTimer = null; this._devices = new Map(); this._client = null; this._player = null; this._connectedDevice = null; } - async discover() { + /** + * Start a scan and return at once; `devices()` reads what it has found so far. + * + * The scan runs its full length because a receiver coming back from a reset + * can take several seconds to answer, but most answer within two, and a + * picker that waits the full length to show any of them is a picker that is + * slow every single time. So the page polls and lists each one as it lands. + * A scan started over an unfinished one replaces it, timer included. + */ + startScan() { this._devices.clear(); - - if (this._browser) { - this._browser.stop(); - this._browser = null; - } + this._stopScan(); if (!this._bonjour) { this._bonjour = new Bonjour(); @@ -98,30 +104,39 @@ class CastChromecast { debug('[cast-chromecast] scanning for devices...'); - return new Promise((resolve) => { - this._browser = this._bonjour.find({ type: 'googlecast' }, (service) => { - const id = service.txt?.id || service.name; - const name = service.txt?.fn || service.name; - const host = service.addresses?.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) - || (service.referer && service.referer.address); - const port = service.port || 8009; + this._browser = this._bonjour.find({ type: 'googlecast' }, (service) => { + const id = service.txt?.id || service.name; + const name = service.txt?.fn || service.name; + const host = service.addresses?.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) + || (service.referer && service.referer.address); + const port = service.port || 8009; - if (host && id) { - this._devices.set(id, { id, name, host, port }); - debug(`[cast-chromecast] discovered: "${name}" at ${host}:${port}`); - } - }); - - setTimeout(() => { - if (this._browser) { - this._browser.stop(); - this._browser = null; - } - const devices = Array.from(this._devices.values()); - debug(`[cast-chromecast] scan complete: ${devices.length} device(s)`); - resolve(devices); - }, SCAN_DURATION_MS); + if (host && id) { + this._devices.set(id, { id, name, host, port }); + debug(`[cast-chromecast] discovered: "${name}" at ${host}:${port}`); + } }); + + this._scanTimer = setTimeout(() => { + this._stopScan(); + debug(`[cast-chromecast] scan complete: ${this._devices.size} device(s)`); + }, SCAN_DURATION_MS); + } + + devices() { + return { + devices: Array.from(this._devices.values()), + scanning: this._browser !== null, + }; + } + + _stopScan() { + clearTimeout(this._scanTimer); + this._scanTimer = null; + if (this._browser) { + this._browser.stop(); + this._browser = null; + } } async connect(deviceId, mediaUrl, subtitle) { diff --git a/packages/meshbay-client/src/keyring.js b/packages/meshbay-client/src/keyring.js index 4fb6eac..ec37fd4 100644 --- a/packages/meshbay-client/src/keyring.js +++ b/packages/meshbay-client/src/keyring.js @@ -24,6 +24,8 @@ const { transcriptFor } = require('./transcripts.js'); // keyderive.js: the same numbers, or no bundle opens across the two. const ARGON2 = { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 }; const MAGIC = Buffer.from('MBK3'); +// TRANSITIONAL — the format before MBK3, read once to be replaced (keyderive.js). +const LEGACY_MAGIC = Buffer.from('MBK2'); const X25519_SPKI = Buffer.from('302a300506032b656e032100', 'hex'); const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; @@ -63,6 +65,17 @@ function seal(identity, key, userId, nodePk, pepperVersion) { return b64(Buffer.concat([MAGIC, Buffer.from([pepperVersion & 0xff]), nonce, ct])); } +/** TRANSITIONAL — MBK2: "MBK2" ‖ nonce ‖ AES-GCM under the Argon2 key, no AAD. */ +function openLegacy(bundleB64, key) { + const raw = unb64(bundleB64); + const nonce = raw.subarray(4, 16); + const body = raw.subarray(16, raw.length - 16); + const d = crypto.createDecipheriv('aes-256-gcm', key, nonce); + d.setAuthTag(raw.subarray(raw.length - 16)); + const plain = JSON.parse(Buffer.concat([d.update(body), d.final()]).toString()); + return { ed: plain.skEd, x: plain.skX }; +} + function open(bundleB64, key, userId, nodePk) { const raw = unb64(bundleB64); if (!raw.subarray(0, 4).equals(MAGIC)) { @@ -142,7 +155,11 @@ function createKeyring({ load, save, argon2 }) { const v = pepperVersion || 1; if (p) { pending.set(userId, { m, v }); return true; } const s = state(); - s.masters[userId] = { m: b64(m), v }; + // `legacy` (TRANSITIONAL): the Argon2 key itself, which MBK2 bundles + // were sealed under — kept beside `M`, in the same OS-protected store + // and for as long, so a node still holding one has it opened and + // replaced on the next connection. Remove once no MBK2 bundle is left. + s.masters[userId] = { m: b64(m), v, legacy: b64(a) }; save(s); return true; }, @@ -150,7 +167,10 @@ function createKeyring({ load, save, argon2 }) { const p = pending.get(userId); if (!p) return false; const s = state(); - s.masters[userId] = { m: b64(p.m), v: p.v }; + // The legacy key stays the old passphrase's: MBK2 bundles were sealed + // under that one, never under the new. + const legacy = (s.masters[userId] || {}).legacy; + s.masters[userId] = { m: b64(p.m), v: p.v, ...(legacy ? { legacy } : {}) }; save(s); pending.delete(userId); return true; @@ -177,6 +197,16 @@ function createKeyring({ load, save, argon2 }) { * was entered (a reset on a machine that had never held this identity). */ openBundle(userId, nodePk, { bundleEnc, recoveryEnc, recoveryMnemonic, username }) { + if (unb64(bundleEnc).subarray(0, 4).equals(LEGACY_MAGIC)) { + // TRANSITIONAL. Kept unsealed (`sealedWith: null`), so the next + // settle replaces the node's copy with MBK3, or withdraws it when the + // account has no browser access. + const legacy = (state().masters[userId] || {}).legacy; + if (!legacy) throw new Error('no_legacy_key'); + const id = openLegacy(bundleEnc, unb64(legacy)); + keep(userId, nodePk, { ...id, sealedWith: null }); + return publicOf(id); + } const { m } = master(userId); let id; try { diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 309d5f6..0ad7e71 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -1144,24 +1144,33 @@ function registerBridge() { return true; }); - // A folder chosen here is one the person pointed at in a dialog this process - // drew, which is the consent that sharing it needs: the node is given it - // without asking again. A folder the page names that was not chosen here is - // confirmed natively before the node hears of it (`node:op`). - const pickedFolders = new Set(); - handle('root:choose', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); if (result.canceled || !result.filePaths.length) return null; const chosen = result.filePaths[0]; - pickedFolders.add(path.resolve(chosen)); return { path: chosen, name: path.basename(chosen) }; }); + // The Mark-of-the-Web, as a browser leaves on every download: the file came + // from somebody else's machine, and Windows decides what that means — + // SmartScreen for a program, Protected View for a document. This application + // writes its files itself, so nothing else marks them. NTFS only; elsewhere + // there is no such stream, and nothing is lost by not having one. + function markFromInternet(file) { + if (process.platform !== 'win32') return; + try { + fs.writeFileSync(`${file}:Zone.Identifier`, '[ZoneTransfer]\r\nZoneId=3\r\n'); + } catch { /* FAT, exFAT, a network share: no alternate data streams */ } + } + handle('save:begin', async (_e, suggestedName, opts) => { - const wanted = path.basename(String(suggestedName || 'download')); + // Bidirectional controls replaced here as well as in the page + // (portable-name.js): "invoice\u202efdp.exe" would be saved, and listed by + // the file manager, as "invoiceexe.pdf". + const wanted = path.basename(String(suggestedName || 'download')) + .replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '_'); const chosen = chosenDownloadDir(); let target = null; @@ -1231,6 +1240,7 @@ function registerBridge() { console.error('[MeshBay] could not finalise download:', err.message); return false; } + markFromInternet(sink.path); completedPaths.set(String(id), sink.path); return true; }); @@ -2168,51 +2178,34 @@ function registerBridge() { const group = (a) => `/api/groups/${anId(a.groupId, 'the group')}`; const root = (a) => `${group(a)}/roots/${encodeURIComponent(aText(a.rootName, 'the folder name'))}`; - // Sharing a folder the person did not choose in this process's dialog. - async function confirmFolder(folder) { - if (!pickedFolders.has(path.resolve(folder))) { - await confirmOrRefuse('native.folder_confirm', { path: folder }); - } - } - const NODE_OPS = { status: () => ['GET', '/api/status'], groups: () => ['GET', '/api/groups'], indexStatus: () => ['GET', '/api/index-status'], groupIndexStatus: (a) => ['GET', `${group(a)}/index-status`], reload: () => ['POST', '/api/reload'], - attachGroup: async (a) => { + attachGroup: (a) => { const body = { name: aText(a.name, 'the group name'), shared_dir: aText(a.path, 'the folder', 4096), - writable: a.writable !== false }; - await confirmOrRefuse('native.attach_confirm', - { name: body.name, path: body.shared_dir }); + writable: a.writable !== false, + // The person's choice on the creation form; the node never + // takes it from the hub. + join_policy: a.joinPolicy === 'open' ? 'open' : 'invite' }; return ['POST', '/api/groups/attach', body]; }, detachGroup: (a) => ['POST', '/api/groups/detach', { name: aText(a.name, 'the group name') }], - addRoot: async (a) => { + addRoot: (a) => { const body = { path: aText(a.path, 'the folder', 4096) }; if (a.name) body.name = aText(a.name, 'the folder name'); if (a.writable !== undefined) body.writable = Boolean(a.writable); if (a.removable !== undefined) body.removable = Boolean(a.removable); - const target = `${group(a)}/roots`; - await confirmFolder(body.path); - return ['POST', target, body]; + return ['POST', `${group(a)}/roots`, body]; }, updateRoot: (a) => ['PATCH', root(a), anObject(a.updates)], ejectRoot: (a) => ['PUT', `${root(a)}/eject`], plugRoot: (a) => ['PUT', `${root(a)}/plug`], removeRoot: (a) => ['DELETE', root(a)], - // Creating a missing key replaces nothing -- the node keeps an existing one - // -- so only a rotation is asked about. - initGek: async (a) => { - const target = group(a); - if (a.rotate) { - await confirmOrRefuse('node.gek_rotate_confirm'); - return ['POST', `${target}/gek?rotate=true`]; - } - return ['POST', `${target}/gek`]; - }, + initGek: (a) => ['POST', `${group(a)}/gek${a.rotate ? '?rotate=true' : ''}`], pairOperator: () => ['POST', '/api/operator/pair'], roster: (a) => ['GET', a.groupId ? `/api/roster?group_id=${anId(a.groupId, 'the group')}` : '/api/roster'], @@ -2320,10 +2313,13 @@ function registerBridge() { // ── Chromecast discovery + control ────────────────────────────────────── - handle('cast:discover', async () => { - return castChromecast.discover(); + handle('cast:scan', async () => { + castChromecast.startScan(); + return true; }); + handle('cast:devices', async () => castChromecast.devices()); + handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { return castChromecast.connect(deviceId, mediaUrl, subtitle === undefined ? castRelay.subtitle : subtitle); diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index e9c34d2..665ec1f 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -175,7 +175,8 @@ contextBridge.exposeInMainWorld('meshbay', { subtitle: (sub) => ipcRenderer.invoke('cast:subtitle', sub), finish: () => ipcRenderer.invoke('cast:finish'), status: () => ipcRenderer.invoke('cast:status'), - discover: () => ipcRenderer.invoke('cast:discover'), + scan: () => ipcRenderer.invoke('cast:scan'), + devices: () => ipcRenderer.invoke('cast:devices'), chromecastConnect: (opts) => ipcRenderer.invoke('cast:chromecast:connect', opts), chromecastReload: (opts) => ipcRenderer.invoke('cast:chromecast:reload', opts), chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'), diff --git a/packages/meshbay-client/src/transcripts.js b/packages/meshbay-client/src/transcripts.js index 0b6d0c0..8b0f6ef 100644 --- a/packages/meshbay-client/src/transcripts.js +++ b/packages/meshbay-client/src/transcripts.js @@ -30,6 +30,20 @@ function lenPrefixed(prefix, parts) { return Buffer.concat(chunks); } +// The signed operations this application asks a node to perform +// (meshbay_common/adminop.py). A list, not a pattern: what widens a node's +// sharing — `root_add`, `root_update`, `group_attach`, gone from MNP 6.0 — is +// never signed here, so a node older than that cannot be driven into it by a +// script in the page either. +const ADMIN_OPS = new Set([ + 'file_delete', 'dir_delete', 'invite_create', 'invite_link_create', + 'invite_cancel', 'member_revoke', 'apps_enabled', 'set_scan_settings', + 'tmdb_config', 'tmdb_enabled', 'tmdb_override', 'tmdb_rematch', + 'musicbrainz_enabled', 'root_remove', 'root_eject', 'root_plug', + 'app_directories', 'chat_directory', 'chat_link_preview', 'search_listed', + 'chat_epoch', +]); + // ── Field checks ────────────────────────────────────────────────────────── // // Shapes, not trust: what is checked here is that a field is what its name @@ -143,7 +157,7 @@ function transcriptFor(kind, f, ctx) { } case 'admin': { const op = String(fields.op ?? ''); - if (!/^[a-z_]{1,32}$/.test(op)) refuse('not an operation'); + if (!ADMIN_OPS.has(op)) refuse('not an operation'); return lenPrefixed(PREFIX.admin, [ enc(op), enc(sameNode()), enc(groupId(fields.groupId)), enc(text(fields.subject, 'the subject', 16384)), |