/** * MeshBay desktop client — the Electron main process. * * The reason this exists, stated once so nobody has to rediscover it: **the * interface ships inside this package and loads from disk.** A shell that * points a WebView at the hub's /app/ is a browser with a different icon and * fixes nothing — the hub could still send whatever code it liked, which is * finding T3. The hub is used for its API and for nothing else. * * What that does and does not buy is worth being honest about: it does not make * the hub untrusted. A build downloaded from meshbay.org and signed with a key * its operator holds relocates the trust rather than removing it. What changes * is **detectability** — an attack has to ship as an artifact that can be * hashed and compared, instead of being one HTTP response aimed at one person. * That value is realised by reproducible builds and published hashes (18.7), * not by the packaging format. * * See docs/desktop-client-v1.md §2 and §3. */ 'use strict'; const { app, BrowserWindow, dialog, ipcMain, protocol, safeStorage, shell } = require('electron'); const crypto = require('node:crypto'); const fs = require('node:fs'); const fsp = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); const UI_DIR = path.join(__dirname, '..', 'ui'); const SCHEME = 'app'; // The policy, sent as a header on every response. // // Not a tag: `frame-ancestors` is ignored there — Chromium says so in // the console — and a policy with a directive that silently does nothing is // worse than one without it. A header is authoritative for every directive, // and this handler is the only thing that serves the interface, so there is one // source rather than two that can drift. // // `'wasm-unsafe-eval'` is required and must stay: the bundle key is Argon2id in // WebAssembly, and a policy without it does not degrade anything — it locks // every user out of their keys. // // The hub is reachable under connect-src, for its API and its signaling socket. // It is deliberately absent from script-src: nothing it returns is executed, // which is the whole reason this application exists (T3). const CSP = [ "default-src 'none'", "script-src 'self' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "media-src 'self' blob:", "font-src 'self'", "connect-src 'self' https: wss:", "worker-src 'self'", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'none'", ].join('; '); // ── The scheme the interface is served from ───────────────────────────────── // // Not file://. Service workers, ES modules and IndexedDB all misbehave there, // and the streamed-download path needs a *controlled* page — a worker that is // merely active is not enough, which this codebase has already learned once. // // `secure` is what makes it a secure context, and without it the whole of // `crypto.subtle` is undefined — measured on Electron 42 / Chromium 148, where // a page on a non-secure scheme failed every algorithm including AES-GCM. // `standard` gives it a real origin, so IndexedDB survives an update instead of // being keyed to something that moves. // // What it does NOT buy: service workers. Chromium refuses to register one on a // custom scheme whatever its privileges ("The URL protocol of the current // origin is not supported"). So `sw.js` never runs here, and the streamed // download it exists for is replaced by the native save dialog below — which is // the better path anyway. It stays in the package because the same files serve // the browser, where it is one of only three ways to write a large file. protocol.registerSchemesAsPrivileged([{ scheme: SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, // Range requests, for playing a film corsEnabled: true, }, }]); /** * Serve the packaged interface, and refuse to leave it. * * Every path is resolved and checked against the UI directory before anything * is read: the renderer is the least trusted part of this process, and a * traversal here would hand it the user's filesystem. */ function registerUiProtocol() { protocol.handle(SCHEME, async (request) => { const url = new URL(request.url); const rel = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'index.html'; const target = path.resolve(UI_DIR, rel); const root = path.resolve(UI_DIR); if (target !== root && !target.startsWith(root + path.sep)) { return new Response('Not found', { status: 404 }); } try { const body = await fsp.readFile(target); return new Response(body, { headers: { 'Content-Type': contentType(target), 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', }, }); } catch { return new Response('Not found', { status: 404 }); } }); } function contentType(file) { const ext = path.extname(file).toLowerCase(); return { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', }[ext] || 'application/octet-stream'; } // ── Configuration ─────────────────────────────────────────────────────────── function configPath() { return path.join(app.getPath('userData'), 'config.json'); } function readConfig() { try { return JSON.parse(fs.readFileSync(configPath(), 'utf8')); } catch { // No default hub. A client that picks its own is a client that can be // pointed at one, so the first run asks and the answer is remembered. return { hubBase: '', window: null }; } } function writeConfig(next) { const dir = path.dirname(configPath()); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 }); } let config = readConfig(); // ── Secrets ───────────────────────────────────────────────────────────────── // // The OS keychain, through safeStorage. What it protects and what it does not // is reported rather than assumed: on Linux, safeStorage falls back to a fixed // key when no keyring is running — a headless session, a minimal desktop — and // it does so silently. Someone who believes the OS is holding their keys should // be told when it is not. function secretsFile() { return path.join(app.getPath('userData'), 'secrets.bin'); } function readSecrets() { try { const raw = fs.readFileSync(secretsFile()); if (!safeStorage.isEncryptionAvailable()) return {}; return JSON.parse(safeStorage.decryptString(raw)); } catch { return {}; } } function writeSecrets(all) { if (!safeStorage.isEncryptionAvailable()) { throw new Error('No OS key storage is available on this system'); } fs.mkdirSync(path.dirname(secretsFile()), { recursive: true }); fs.writeFileSync(secretsFile(), safeStorage.encryptString(JSON.stringify(all)), { mode: 0o600 }); } function secretsBackend() { if (!safeStorage.isEncryptionAvailable()) return 'unavailable'; if (process.platform !== 'linux') return process.platform === 'darwin' ? 'keychain' : 'dpapi'; const backend = safeStorage.getSelectedStorageBackend ? safeStorage.getSelectedStorageBackend() : 'unknown'; // "basic_text" is Electron's fixed-key fallback: encrypted on disk, but by a // key that is not a secret. Named plainly so the interface can say so. return backend === 'basic_text' ? 'unprotected_fallback' : backend; } // ── The device's hub key ──────────────────────────────────────────────────── // // Ed25519, generated here on first sign-in, registered with the hub, and used // from then on instead of deriving a key from the passphrase every time. The // passphrase remains the account's credential and its only recovery path. // // **The renderer never holds it.** It parses decrypted content from nodes — // video, images, filenames — which is attacker-controlled input, so it asks for // a signature rather than being handed a key it could leak. This is the same // rule as the save dialog: the renderer asks, this process acts. // // Note what this key is *not*: it is not a per-node identity key. Those are // generated per node, pinned there, and never leave that relationship // (docs/per-node-identity-v1.md). Nothing here correlates a person across // operators, and nothing wraps a group key for it. const DEVICE_KEY = 'device_auth_ed25519'; function deviceKey() { const stored = readSecrets()[DEVICE_KEY]; if (!stored) return null; return crypto.createPrivateKey({ key: Buffer.from(stored, 'base64'), format: 'der', type: 'pkcs8', }); } function ensureDeviceKey() { const existing = deviceKey(); if (existing) return publicKeyB64(existing); const { privateKey } = crypto.generateKeyPairSync('ed25519'); const all = readSecrets(); all[DEVICE_KEY] = privateKey.export({ format: 'der', type: 'pkcs8' }) .toString('base64'); writeSecrets(all); return publicKeyB64(crypto.createPrivateKey({ key: Buffer.from(all[DEVICE_KEY], 'base64'), format: 'der', type: 'pkcs8' })); } function publicKeyB64(privateKey) { // Raw 32 bytes, as the hub stores and as `pk_to_b64` produces: the DER // SubjectPublicKeyInfo for Ed25519 is a fixed 12-byte prefix and the key. const der = crypto.createPublicKey(privateKey) .export({ format: 'der', type: 'spki' }); return der.subarray(der.length - 32).toString('base64'); } // Everything the interface is allowed to ask Chromium for. Watching a film // full-screen is the whole list. const GRANTED_PERMISSIONS = new Set(['fullscreen']); // No hub call is still going to be answered after this. The hub's longest is // signaling a WebRTC offer, which gives up at fifteen seconds of its own. const HUB_FETCH_TIMEOUT_MS = 30000; // ── Window ────────────────────────────────────────────────────────────────── let mainWindow = null; function createWindow() { const bounds = (config.window && config.window.width) ? config.window : { width: 1200, height: 800, }; const win = new BrowserWindow({ ...bounds, minWidth: 320, show: false, webPreferences: { // The three that matter. `sandbox` keeps the Chromium renderer sandbox — // the strongest one available, and the reason Electron is not the // trade-off the earlier design recorded against "native". Without // `contextIsolation` the preload's objects are reachable and mutable from // page script, which would make the bridge below decorative. sandbox: true, contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, 'preload.js'), // The hub address, handed over as a process argument rather than fetched. // `platform.hubBase()` runs while the module graph is loading — before // anything can await — so it has to be synchronous, and synchronous IPC // would block the renderer on every call for a value that never changes // within a run. Changing it restarts the window. additionalArguments: [`--meshbay-hub=${config.hubBase || ''}`], // The page is loaded over app:// and talks to the hub over https. Neither // needs to reach the local filesystem. webSecurity: true, }, }); win.once('ready-to-show', () => win.show()); // The hub must never become the document origin. Anything that would navigate // away from the packaged interface is refused, and an external link opens in // the user's own browser rather than inside a window holding their keys. const isOurs = (target) => { try { return new URL(target).protocol === `${SCHEME}:`; } catch { return false; } }; win.webContents.on('will-navigate', (event, target) => { if (!isOurs(target)) event.preventDefault(); }); win.webContents.setWindowOpenHandler(({ url }) => { if (/^https?:$/.test(new URL(url).protocol)) shell.openExternal(url); return { action: 'deny' }; }); // Deny by default, with one exception, and the exception is the point of the // comment. A blanket `callback(false)` here is what stopped a film going // fullscreen: Chromium's own video controls ask for `fullscreen`, and a // **denial does not reject** — `requestFullscreen()` returns a promise that // never settles, so the button simply does nothing and there is no error // anywhere to find. Measured, not deduced: the probe reported `NEVER SETTLED` // and the main process logged `PERMISSION ASKED: fullscreen`. // // So: enumerate what is granted rather than what is refused. A camera, a // microphone, a location, notifications and MIDI are all still refused, and // anything Chromium adds later arrives refused rather than quietly allowed. win.webContents.session.setPermissionRequestHandler( (_wc, permission, callback) => callback(GRANTED_PERMISSIONS.has(permission))); // `Permissions.query` takes the other handler; same answer, or the two can // disagree about what the page is allowed to do. win.webContents.session.setPermissionCheckHandler( (_wc, permission) => GRANTED_PERMISSIONS.has(permission)); win.on('close', () => { if (!win.isMinimized() && !win.isFullScreen()) { config = { ...config, window: win.getBounds() }; writeConfig(config); } }); // `win`, not `mainWindow`. Changing the hub closes one window and opens // another, and `closed` arrives *after* the replacement has been assigned — // so a handler reading the module variable nulls out the new window, and the // new window's `ready-to-show` then crashes on it. Every handler here belongs // to the window it was created with, and only the current one may clear the // reference. win.on('closed', () => { if (mainWindow === win) mainWindow = null; }); mainWindow = win; win.loadURL(`${SCHEME}://meshbay/index.html`); return win; } // ── Bridge ────────────────────────────────────────────────────────────────── // // Everything the interface may ask of this process, enumerated. A handler that // takes a path from the renderer and acts on it is the shape to avoid: the // renderer parses decrypted content from nodes, which is attacker-controlled // input, so it is treated as hostile even though it is our own code. function registerBridge() { ipcMain.handle('hub:set', async (_e, base) => { const url = String(base || '').trim().replace(/\/+$/, ''); if (url && !/^https:\/\//.test(url) && !/^http:\/\/(localhost|127\.)/.test(url)) { // http is allowed only to a loopback address, for someone running a hub // on their own machine. Anywhere else it would put the session token on // the wire in clear. throw new Error('The hub address must be https'); } // Ask the hub whether it is one, before writing the address down. // // Without this the first-run screen accepts anything shaped like a URL and // the application is then broken with no way back — there was no way to // change the hub once it was set, so a typo meant editing a JSON file by // hand. `https` typed at an `http` hub is the obvious case and it fails // with a TLS error that says nothing to anyone. let version; try { const probe = await fetch(`${url}/v1/hub/version`, { signal: AbortSignal.timeout(10000) }); if (!probe.ok) throw new Error(`answered ${probe.status}`); version = await probe.json(); if (!version || !version.hub) throw new Error('did not answer as a hub'); } catch (e) { throw new Error(describeUnreachable(url, e)); } config = { ...config, hubBase: url }; writeConfig(config); // The renderer reads the address from a process argument, so the window has // to be rebuilt for a change to take. Reloading in place would leave the // interface talking to the old hub with no sign of it. // Build the replacement first, then close the old one: the new window is // what `mainWindow` points at, so the outgoing window's `closed` handler // finds a reference that is no longer its own and leaves it alone. const outgoing = mainWindow; createWindow(); if (outgoing && !outgoing.isDestroyed()) outgoing.close(); return url; }); // Every call to the hub leaves from here, not from the renderer. // // Not a preference. The page's origin is `app://meshbay`, and a browser fetch // from it is refused by CORS — the hub has no CORS middleware at all, which is // a posture worth keeping: its API is reachable from no web origin whatever. // Widening it for `app://meshbay` would be worse than it looks, because that // origin is not a credential — any Electron application on any machine can // claim the same scheme and host. // // So the renderer asks and this process goes, exactly as it does for saving a // file. Node's fetch has no origin and no CORS, the hub stays closed to the // web, and there is one place where network egress happens. ipcMain.handle('hub:fetch', async (_e, url, init) => { const target = new URL(String(url)); const base = config.hubBase ? new URL(config.hubBase) : null; // The renderer may only reach the hub it is signed in to. A path it // controls must not become a request to somewhere else. if (!base || target.origin !== base.origin) { throw new Error('Refused: not this hub'); } let response; try { response = await fetch(target, { method: (init && init.method) || 'GET', headers: (init && init.headers) || {}, body: (init && init.body) || undefined, // Node's fetch waits as long as the OS lets it, which for a host that // accepts a connection and then says nothing is minutes. The hub's own // longest call is signaling, which gives up at fifteen seconds, so // anything past this is not an answer that is still coming. signal: AbortSignal.timeout(HUB_FETCH_TIMEOUT_MS), }); } catch (e) { if (e && (e.name === 'TimeoutError' || e.name === 'AbortError')) { throw new Error( `${target.origin} accepted the connection but did not answer within ` + `${Math.round(HUB_FETCH_TIMEOUT_MS / 1000)}s.`); } // Node's fetch says "fetch failed" for everything from a refused // connection to a TLS mismatch, which tells a person nothing at all. throw new Error(describeUnreachable(target.origin, e)); } return { status: response.status, ok: response.ok, headers: Object.fromEntries(response.headers), body: await response.text(), }; }); ipcMain.handle('hub:probe', async (_e, url) => { const target = String(url || config.hubBase || '').replace(/\/+$/, ''); if (!target) return null; try { const r = await fetch(`${target}/v1/hub/version`, { signal: AbortSignal.timeout(10000) }); return r.ok ? await r.json() : null; } catch { return null; } }); ipcMain.handle('device:ensure', () => ensureDeviceKey()); ipcMain.handle('device:public', () => { const key = deviceKey(); return key ? publicKeyB64(key) : null; }); ipcMain.handle('device:sign', (_e, username) => { const key = deviceKey(); if (!key) return null; const timestamp = Math.floor(Date.now() / 1000); // The same bytes `POST /v1/users/auth` verifies. The username is inside the // signature, so one collected for a different account is not usable. const message = Buffer.from( `meshbay:user_auth:${String(username)}:${timestamp}`); return { timestamp, signature: crypto.sign(null, message, key).toString('base64'), }; }); ipcMain.handle('device:forget', () => { const all = readSecrets(); delete all[DEVICE_KEY]; writeSecrets(all); return true; }); ipcMain.handle('secrets:backend', () => secretsBackend()); ipcMain.handle('secrets:get', (_e, name) => readSecrets()[String(name)] ?? null); ipcMain.handle('secrets:set', (_e, name, value) => { const all = readSecrets(); all[String(name)] = String(value); writeSecrets(all); return true; }); ipcMain.handle('secrets:clear', (_e, name) => { const all = readSecrets(); delete all[String(name)]; writeSecrets(all); return true; }); // Downloads are written to disk as they arrive — never collected in memory // and handed over at the end. // // That is what the browser does through the File System Access API or a // service worker, and **this application has neither**: `showDirectoryPicker` // is absent, and Chromium refuses to register a worker on a custom scheme. So // the chain fell through to its floor, which accumulates the whole file in // the page and hands Chromium a blob — a gigabyte of RAM for a gigabyte of // film, and a Save As dialog at the *end*, which is how it was noticed. // // The renderer still never names a path. It asks; the user chooses once; the // main process holds the handle and the renderer refers to it by an opaque id. const sinks = new Map(); const completedPaths = new Map(); let sinkId = 0; /** `name`, or the first "name (n).ext" that is not taken — never an overwrite. */ function freeName(dir, filename) { if (!fs.existsSync(path.join(dir, filename))) return filename; const ext = path.extname(filename); const stem = path.basename(filename, ext); for (let n = 2; n < 1000; n++) { const candidate = `${stem} (${n})${ext}`; if (!fs.existsSync(path.join(dir, candidate))) return candidate; } throw new Error(`No free name for ${filename}`); } ipcMain.handle('folder:choose', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); if (result.canceled || !result.filePaths.length) return null; config = { ...config, downloadDir: result.filePaths[0] }; writeConfig(config); return config.downloadDir; }); // Where downloads land when nobody has chosen anywhere. A browser does not // make you pick a folder before it will save a file, and neither should this // — "save automatically" that opens a dialog is not automatic. function defaultDownloadDir() { try { return app.getPath('downloads'); } catch { return os.homedir(); } } function chosenDownloadDir() { const dir = config.downloadDir; // A folder that has been removed or unmounted is not a folder any more, // and saying so beats failing on the first chunk of a download. if (!dir) return null; try { return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; } } ipcMain.handle('folder:get', () => { const chosen = chosenDownloadDir(); // `name` is what the settings row already renders, for the browser's // directory handle as much as for this. `isDefault` is how it knows not to // offer "forget" for a folder nobody chose. return { name: chosen || defaultDownloadDir(), isDefault: !chosen }; }); ipcMain.handle('folder:forget', () => { config = { ...config, downloadDir: '' }; writeConfig(config); return true; }); ipcMain.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]; return { path: chosen, name: path.basename(chosen) }; }); ipcMain.handle('save:begin', async (_e, suggestedName, opts) => { const wanted = path.basename(String(suggestedName || 'download')); const chosen = chosenDownloadDir(); let target = null; // "Save automatically" means exactly that: no dialog. Into the chosen // folder if there is one, otherwise the system's Downloads folder — the // first version required a folder to have been picked first, so the very // first automatic download opened a dialog, which is the one thing the // setting says it will not do. // // The one case that still asks: a folder *was* chosen and has since gone. // Redirecting those files somewhere else without saying so is worse than a // dialog — someone who picked an external drive wants to know it is not // there, not to find the film in their home directory a week later. if (opts && opts.auto && !(config.downloadDir && !chosen)) { const dir = chosen || defaultDownloadDir(); try { fs.mkdirSync(dir, { recursive: true }); target = path.join(dir, freeName(dir, wanted)); } catch { target = null; } } if (!target) { const dir = chosen || defaultDownloadDir(); const result = await dialog.showSaveDialog(mainWindow, { defaultPath: path.join(dir, wanted), }); if (result.canceled || !result.filePath) return null; target = result.filePath; } const id = String(++sinkId); sinks.set(id, { stream: fs.createWriteStream(target), path: target }); return { id, name: path.basename(target), path: target }; }); ipcMain.handle('save:write', async (_e, id, chunk) => { const sink = sinks.get(String(id)); if (!sink) throw new Error('No such download'); // Awaiting the callback is what applies backpressure: without it the // renderer would outrun the disk and queue the file in memory anyway, // which is the thing this exists to avoid. await new Promise((resolve, reject) => sink.stream.write(Buffer.from(chunk), (err) => (err ? reject(err) : resolve()))); return true; }); ipcMain.handle('save:end', async (_e, id) => { const sink = sinks.get(String(id)); if (!sink) return false; sinks.delete(String(id)); completedPaths.set(String(id), sink.path); await new Promise((resolve) => sink.stream.end(resolve)); return true; }); ipcMain.handle('save:open', async (_e, id) => { const p = completedPaths.get(String(id)); if (!p) return false; await shell.openPath(p); return true; }); ipcMain.handle('save:abort', async (_e, id) => { const sink = sinks.get(String(id)); if (!sink) return false; sinks.delete(String(id)); await new Promise((resolve) => sink.stream.close(resolve)); // A cancelled download leaves a truncated file, which is worse than none: // it looks like a complete one to whoever opens it next. try { fs.unlinkSync(sink.path); } catch { /* already gone */ } return true; }); } /** * Why the hub could not be reached, in words somebody can act on. * * `TypeError: fetch failed` is what Node says for a refused connection, a DNS * failure and a TLS mismatch alike. The most common mistake by far is `https` * typed at a hub speaking plain `http`, so that one is named outright. */ function describeUnreachable(url, error) { const cause = (error && error.cause) || {}; const code = cause.code || ''; const detail = cause.message || error.message || String(error); if (/^https:/.test(url) && (code === 'ECONNRESET' || /wrong version|SSL|TLS|EPROTO/i.test(detail))) { return `${url} does not speak https. If this hub is on your own machine, ` + `it is probably http — try http:// instead.`; } if (code === 'ECONNREFUSED') { return `Nothing is listening at ${url}. Is the hub running?`; } if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { return `${url} could not be found. Check the address.`; } if (error && error.name === 'TimeoutError') { return `${url} did not answer in time.`; } return `Could not reach ${url}: ${detail}`; } // ── Lifecycle ─────────────────────────────────────────────────────────────── // One instance. Two would fight over the config file and the secrets blob, and // the second would look like the first had lost its state. if (!app.requestSingleInstanceLock()) { app.quit(); } else { app.on('second-instance', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); app.whenReady().then(() => { registerUiProtocol(); registerBridge(); createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); } module.exports = { contentType, secretsBackend, CSP };