/** * 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 fs = require('node:fs'); const fsp = require('node:fs/promises'); 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; } // ── Window ────────────────────────────────────────────────────────────────── let mainWindow = null; function createWindow() { const bounds = (config.window && config.window.width) ? config.window : { width: 1200, height: 800, }; mainWindow = 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, }, }); mainWindow.once('ready-to-show', () => mainWindow.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; } }; mainWindow.webContents.on('will-navigate', (event, target) => { if (!isOurs(target)) event.preventDefault(); }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (/^https?:$/.test(new URL(url).protocol)) shell.openExternal(url); return { action: 'deny' }; }); // Nothing in this application needs a camera, a microphone or a location. mainWindow.webContents.session.setPermissionRequestHandler( (_wc, _permission, callback) => callback(false)); mainWindow.on('close', () => { if (!mainWindow.isMinimized() && !mainWindow.isFullScreen()) { config = { ...config, window: mainWindow.getBounds() }; writeConfig(config); } }); mainWindow.on('closed', () => { mainWindow = null; }); mainWindow.loadURL(`${SCHEME}://meshbay/index.html`); } // ── 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', (_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'); } 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. if (mainWindow) { mainWindow.close(); createWindow(); } 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'); } const response = await fetch(target, { method: (init && init.method) || 'GET', headers: (init && init.headers) || {}, body: (init && init.body) || undefined, }); return { status: response.status, ok: response.ok, headers: Object.fromEntries(response.headers), body: await response.text(), }; }); 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; }); // The renderer never names a path. It asks for a dialog; the user chooses; // the main process holds the handle and the renderer only ever refers to it // by an opaque id. const sinks = new Map(); let sinkId = 0; ipcMain.handle('save:begin', async (_e, suggestedName) => { const result = await dialog.showSaveDialog(mainWindow, { defaultPath: path.basename(String(suggestedName || 'download')), }); if (result.canceled || !result.filePath) return null; const id = String(++sinkId); sinks.set(id, fs.createWriteStream(result.filePath)); return { id, name: path.basename(result.filePath) }; }); ipcMain.handle('save:write', async (_e, id, chunk) => { const sink = sinks.get(String(id)); if (!sink) throw new Error('No such download'); await new Promise((resolve, reject) => sink.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)); await new Promise((resolve) => sink.end(resolve)); return true; }); } // ── 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 };