diff options
Diffstat (limited to 'packages/meshbay-client/src/main.js')
| -rw-r--r-- | packages/meshbay-client/src/main.js | 331 |
1 files changed, 331 insertions, 0 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js new file mode 100644 index 0000000..3fc52ff --- /dev/null +++ b/packages/meshbay-client/src/main.js @@ -0,0 +1,331 @@ +/** + * 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 }; |