aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 11:00:32 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 11:00:32 +0200
commit7c46b4e7dc2893974a37d6a701123a95803fcb95 (patch)
treea919618ce1c105e36c1fc85f7e492fbcb3265779 /packages/meshbay-client/src
parent68bfe56a19aeb4c16f8e185fc85d8eee61aef78f (diff)
downloadmeshbay-7c46b4e7dc2893974a37d6a701123a95803fcb95.tar.gz
feat(client): hybrid sign-in — passphrase once, then this device's key
D4. The passphrase stays the account's credential and its only recovery path; what changes is that it is not asked for on every launch. **The renderer never holds the device key.** It is generated, stored and used entirely in the main process, which signs `meshbay:user_auth:<username>:<ts>` on request. Same rule as the save dialog, for the same reason: the renderer is the part of this application that parses decrypted content from nodes, which is attacker-controlled input. And this key is *not* a per-node identity key — those are generated per node and never leave that relationship, so nothing here correlates a person across operators. First run asks which hub, with no default. A client that picks its own hub is a client that can be pointed at one, and the address is the whole of what this application trusts a hub for — the interface comes from the package. Verified against a hub running this code, not against the deployed one: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → `/v1/users/me` 200 → a stranger's key 401. The signature was also checked directly against the hub's own Python verifier before any of that. Inside the running application, over the debugging protocol: the bridge reaches the main process, the renderer calls the hub **through it** (200 — the CORS fix working end to end), and a call to a host that is not the configured hub is refused. **Not verified:** safeStorage persisting the key. This session has no secret service, and standing one up in xvfb did not succeed. The application behaves correctly there — it *refuses* rather than storing unprotected, and now says so in Settings, which is a real case rather than a hypothetical one since it is exactly what a headless or minimal desktop looks like. Worth remembering for next time: meshbay.org runs whatever was last deployed. It answered 405 on the Stage-C endpoints and reported MNP 0.2 while the tree had 0.3, so a local `uvicorn meshbay_hub.app:create_app --factory` on SQLite is what tests hub changes. Nothing was deployed to production for this. 799 tests pass; e2e.py passes end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src')
-rw-r--r--packages/meshbay-client/src/main.js72
-rw-r--r--packages/meshbay-client/src/preload.js10
2 files changed, 82 insertions, 0 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 867ce27..82dfec9 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -22,6 +22,7 @@
const { app, BrowserWindow, dialog, ipcMain, protocol, safeStorage, shell } =
require('electron');
+const crypto = require('node:crypto');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
@@ -200,6 +201,52 @@ function secretsBackend() {
return backend === 'basic_text' ? 'unprotected_fallback' : backend;
}
+// ── The device's hub key ────────────────────────────────────────────────────
+//
+// Ed25519, generated here on first sign-in, registered with the hub, and used
+// from then on instead of deriving a key from the passphrase every time. The
+// passphrase remains the account's credential and its only recovery path.
+//
+// **The renderer never holds it.** It parses decrypted content from nodes —
+// video, images, filenames — which is attacker-controlled input, so it asks for
+// a signature rather than being handed a key it could leak. This is the same
+// rule as the save dialog: the renderer asks, this process acts.
+//
+// Note what this key is *not*: it is not a per-node identity key. Those are
+// generated per node, pinned there, and never leave that relationship
+// (docs/per-node-identity-v1.md). Nothing here correlates a person across
+// operators, and nothing wraps a group key for it.
+
+const DEVICE_KEY = 'device_auth_ed25519';
+
+function deviceKey() {
+ const stored = readSecrets()[DEVICE_KEY];
+ if (!stored) return null;
+ return crypto.createPrivateKey({
+ key: Buffer.from(stored, 'base64'), format: 'der', type: 'pkcs8',
+ });
+}
+
+function ensureDeviceKey() {
+ const existing = deviceKey();
+ if (existing) return publicKeyB64(existing);
+ const { privateKey } = crypto.generateKeyPairSync('ed25519');
+ const all = readSecrets();
+ all[DEVICE_KEY] = privateKey.export({ format: 'der', type: 'pkcs8' })
+ .toString('base64');
+ writeSecrets(all);
+ return publicKeyB64(crypto.createPrivateKey({
+ key: Buffer.from(all[DEVICE_KEY], 'base64'), format: 'der', type: 'pkcs8' }));
+}
+
+function publicKeyB64(privateKey) {
+ // Raw 32 bytes, as the hub stores and as `pk_to_b64` produces: the DER
+ // SubjectPublicKeyInfo for Ed25519 is a fixed 12-byte prefix and the key.
+ const der = crypto.createPublicKey(privateKey)
+ .export({ format: 'der', type: 'spki' });
+ return der.subarray(der.length - 32).toString('base64');
+}
+
// ── Window ──────────────────────────────────────────────────────────────────
let mainWindow = null;
@@ -326,6 +373,31 @@ function registerBridge() {
};
});
+ ipcMain.handle('device:ensure', () => ensureDeviceKey());
+ ipcMain.handle('device:public', () => {
+ const key = deviceKey();
+ return key ? publicKeyB64(key) : null;
+ });
+ ipcMain.handle('device:sign', (_e, username) => {
+ const key = deviceKey();
+ if (!key) return null;
+ const timestamp = Math.floor(Date.now() / 1000);
+ // The same bytes `POST /v1/users/auth` verifies. The username is inside the
+ // signature, so one collected for a different account is not usable.
+ const message = Buffer.from(
+ `meshbay:user_auth:${String(username)}:${timestamp}`);
+ return {
+ timestamp,
+ signature: crypto.sign(null, message, key).toString('base64'),
+ };
+ });
+ ipcMain.handle('device:forget', () => {
+ const all = readSecrets();
+ delete all[DEVICE_KEY];
+ writeSecrets(all);
+ return true;
+ });
+
ipcMain.handle('secrets:backend', () => secretsBackend());
ipcMain.handle('secrets:get', (_e, name) => readSecrets()[String(name)] ?? null);
ipcMain.handle('secrets:set', (_e, name, value) => {
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index e3e240f..b649ae3 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -46,6 +46,16 @@ contextBridge.exposeInMainWorld('meshbay', {
// which CORS refuses and which is not a credential anyway.
fetch: (url, init) => ipcRenderer.invoke('hub:fetch', url, init),
+ // The device's hub key. Generated, held and used entirely in the main
+ // process: the interface asks for a signature and never sees a key, because
+ // it is the part of this application that parses hostile input.
+ device: {
+ ensure: () => ipcRenderer.invoke('device:ensure'),
+ publicKey: () => ipcRenderer.invoke('device:public'),
+ sign: (username) => ipcRenderer.invoke('device:sign', username),
+ forget: () => ipcRenderer.invoke('device:forget'),
+ },
+
secrets: {
get: (name) => ipcRenderer.invoke('secrets:get', name),
set: (name, value) => ipcRenderer.invoke('secrets:set', name, value),