summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/main.js
blob: 3fc52ff8617323d9fcd3d093899ffbbc063f0e15 (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
/**
 * 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 };