diff options
Diffstat (limited to 'packages/meshbay-client/src')
| -rw-r--r-- | packages/meshbay-client/src/main.js | 72 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 10 |
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), |