aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/main.js
blob: 867ce27218e80b5328bdec2fb37e82bb390ca05a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
/**
 * 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 <meta> 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 };