diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 11:00:32 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 11:00:32 +0200 |
| commit | 7c46b4e7dc2893974a37d6a701123a95803fcb95 (patch) | |
| tree | a919618ce1c105e36c1fc85f7e492fbcb3265779 /packages/meshbay-client/src/main.js | |
| parent | 68bfe56a19aeb4c16f8e185fc85d8eee61aef78f (diff) | |
| download | meshbay-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/main.js')
| -rw-r--r-- | packages/meshbay-client/src/main.js | 72 |
1 files changed, 72 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) => { |