/** * 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 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, without which the worker refuses // to register and downloads break with no error at all. `standard` gives it a // real origin, so IndexedDB survives an update instead of being keyed to // something that moves. 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) }, }); } 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; }); 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 };