diff options
Diffstat (limited to 'packages/meshbay-client')
| -rw-r--r-- | packages/meshbay-client/src/argon2-wasm.js | 66 | ||||
| -rw-r--r-- | packages/meshbay-client/src/keyring.js | 241 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 74 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 26 |
4 files changed, 407 insertions, 0 deletions
diff --git a/packages/meshbay-client/src/argon2-wasm.js b/packages/meshbay-client/src/argon2-wasm.js new file mode 100644 index 0000000..c68e994 --- /dev/null +++ b/packages/meshbay-client/src/argon2-wasm.js @@ -0,0 +1,66 @@ +/** + * Argon2id for the main process, from the page's own WebAssembly build. + * + * Electron's Node is built on BoringSSL, which has no Argon2: `crypto.argon2` + * exists there and refuses (`ERR_CRYPTO_ARGON2_NOT_SUPPORTED`) — found by + * signing in to the real application, since a plain Node has it. The vendored + * build the page already runs is the one implementation both sides can share, + * which also makes their agreement a matter of construction. + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +let ready = null; + +/** + * The emscripten loader reads its options from a global `Module` (through + * `self`) when it is required, and again while the WebAssembly instantiates, + * which is asynchronous. Both globals are set for that time only — until the + * first derivation has run — so nothing else in this process sees them after. + */ +function load(vendorDir) { + if (ready) return ready; + const saved = {}; + for (const name of ['self', 'Module']) { + saved[name] = Object.prototype.hasOwnProperty.call(globalThis, name) + ? { value: globalThis[name] } : null; + } + const restore = () => { + for (const [name, was] of Object.entries(saved)) { + if (was) globalThis[name] = was.value; else delete globalThis[name]; + } + }; + globalThis.Module = { wasmBinary: fs.readFileSync(path.join(vendorDir, 'argon2.wasm')) }; + globalThis.self = globalThis; + ready = (async () => { + try { + const lib = require(path.join(vendorDir, 'argon2.min.js')); + // One derivation at the lowest cost: the instance exists once it returns. + await lib.hash({ pass: 'x', salt: new Uint8Array(8), time: 1, mem: 8, + hashLen: 16, type: lib.ArgonType.Argon2id }); + return lib; + } finally { + restore(); + } + })(); + ready.catch(() => { ready = null; }); + return ready; +} + +/** `(password, salt, params) => Promise<Buffer>` over the vendored build. */ +function wasmArgon2(vendorDir) { + return async (password, salt, { memory, passes, parallelism, tagLength }) => { + const a = await load(vendorDir); + const out = await a.hash({ + pass: String(password), salt: new Uint8Array(salt), + time: passes, mem: memory, parallelism, hashLen: tagLength, + type: a.ArgonType.Argon2id, + }); + return Buffer.from(out.hash); + }; +} + +module.exports = { wasmArgon2 }; diff --git a/packages/meshbay-client/src/keyring.js b/packages/meshbay-client/src/keyring.js new file mode 100644 index 0000000..1df2860 --- /dev/null +++ b/packages/meshbay-client/src/keyring.js @@ -0,0 +1,241 @@ +/** + * The account's keys in the desktop application: the bundle master key `M` and + * the identity on every node, held here and never handed to the page. + * + * The page parses content from nodes, which is attacker-controlled input + * (docs/MESHBAY_DESIGN.md §8.2), so it asks this process to sign and to agree + * on an X25519 secret, and is told public keys. The derivation and the bundle + * format are the page's own (keyderive.js) byte for byte — a bundle this seals + * opens in a browser and the reverse — and a test holds the two together. + * + * One key does leave: the playlist key. It opens nothing but the playlists the + * page displays anyway, and sealing them in the page keeps that code in one + * place. + * + * Storage is injected (`load`/`save` of one JSON object): the main process + * keeps it in the OS key storage beside the device key. + */ + +'use strict'; + +const crypto = require('node:crypto'); + +// keyderive.js: the same numbers, or no bundle opens across the two. +const ARGON2 = { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 }; +const MAGIC = Buffer.from('MBK3'); +const X25519_SPKI = Buffer.from('302a300506032b656e032100', 'hex'); +const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +const b64 = (buf) => Buffer.from(buf).toString('base64'); +const unb64 = (s) => Buffer.from(String(s || ''), 'base64'); +const hkdf = (ikm, info) => Buffer.from( + crypto.hkdfSync('sha256', ikm, Buffer.alloc(0), Buffer.from(info), 32)); + + +const rawPublic = (keyObject) => { + const der = crypto.createPublicKey(keyObject).export({ format: 'der', type: 'spki' }); + return der.subarray(der.length - 32); +}; +const privateFrom = (pkcs8B64) => + crypto.createPrivateKey({ key: unb64(pkcs8B64), format: 'der', type: 'pkcs8' }); + +function fromMnemonic(mnemonic) { + const clean = String(mnemonic).replace(/[^A-Za-z2-7]/g, '').toUpperCase(); + let bits = 0; let value = 0; const out = []; + for (const ch of clean) { + value = (value << 5) | B32.indexOf(ch); + bits += 5; + if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; } + } + if (out.length < 32) throw new Error('recovery key too short'); + return Buffer.from(out.slice(0, 32)); +} + +const aad = (userId, nodePk) => Buffer.from(`meshbay:bundle:v3|${userId}|${nodePk}`); + +function seal(identity, key, userId, nodePk, pepperVersion) { + const nonce = crypto.randomBytes(12); + const c = crypto.createCipheriv('aes-256-gcm', key, nonce); + c.setAAD(aad(userId, nodePk)); + const ct = Buffer.concat([ + c.update(JSON.stringify({ skEd: identity.ed, skX: identity.x })), c.final(), c.getAuthTag()]); + return b64(Buffer.concat([MAGIC, Buffer.from([pepperVersion & 0xff]), nonce, ct])); +} + +function open(bundleB64, key, userId, nodePk) { + const raw = unb64(bundleB64); + if (!raw.subarray(0, 4).equals(MAGIC)) { + const err = new Error('bundle_format_retired'); + err.code = 'bundle_format_retired'; + throw err; + } + const nonce = raw.subarray(5, 17); + const body = raw.subarray(17, raw.length - 16); + const d = crypto.createDecipheriv('aes-256-gcm', key, nonce); + d.setAAD(aad(userId, nodePk)); + d.setAuthTag(raw.subarray(raw.length - 16)); + const plain = JSON.parse(Buffer.concat([d.update(body), d.final()]).toString()); + return { ed: plain.skEd, x: plain.skX }; +} + +/** + * `argon2(password, salt, params)` is injected: Electron's own crypto has no + * Argon2 (argon2-wasm.js), and the test runs what the application runs. + */ +function createKeyring({ load, save, argon2 }) { + if (typeof argon2 !== 'function') throw new Error('keyring: no Argon2 implementation'); + // In memory: the key of a passphrase change not yet accepted by the hub. + const pending = new Map(); + + const state = () => { + const s = load() || {}; + s.masters = s.masters || {}; + s.identities = s.identities || {}; + s.access = s.access || {}; + return s; + }; + const master = (userId, { usePending = false } = {}) => { + if (usePending && pending.has(userId)) return pending.get(userId); + const m = state().masters[userId]; + if (!m) throw new Error('no bundle key in this session'); + return { m: unb64(m.m), v: m.v }; + }; + const fingerprint = (m) => crypto.createHash('sha256').update(m).digest('hex').slice(0, 16); + const stored = (userId, nodePk) => { + const id = (state().identities[userId] || {})[nodePk]; + if (!id) throw new Error('no identity for this node'); + return id; + }; + const publicOf = (id) => ({ + pkEdB64: b64(rawPublic(privateFrom(id.ed))), + pkXB64: b64(rawPublic(privateFrom(id.x))), + }); + const keep = (userId, nodePk, id) => { + const s = state(); + s.identities[userId] = s.identities[userId] || {}; + s.identities[userId][nodePk] = { ...(s.identities[userId][nodePk] || {}), ...id }; + save(s); + }; + + return { + hasSession: (userId) => Boolean(state().masters[userId]), + + /** + * `M` from the passphrase and the pepper — one Argon2 run, as in the page. + * `pending`: a passphrase change, kept aside until the hub accepts it. + */ + async deriveSession({ password, username, userId, pepperB64, pepperVersion, pending: p }) { + if (!userId || !pepperB64) throw new Error('the hub did not provide the bundle pepper'); + const salt = crypto.createHash('sha256') + .update(`meshbay:bundle:v2:${username}`).digest().subarray(0, 16); + const a = await argon2(password, salt, ARGON2); + const m = hkdf(Buffer.concat([a, unb64(pepperB64)]), `meshbay:bundle-master:v3|${userId}`); + const v = pepperVersion || 1; + if (p) { pending.set(userId, { m, v }); return true; } + const s = state(); + s.masters[userId] = { m: b64(m), v }; + save(s); + return true; + }, + commitPending(userId) { + const p = pending.get(userId); + if (!p) return false; + const s = state(); + s.masters[userId] = { m: b64(p.m), v: p.v }; + save(s); + pending.delete(userId); + return true; + }, + dropPending: (userId) => pending.delete(userId), + + /** Sign-out: `M` goes. The identities stay — they are this device's. */ + forgetSession(userId) { + const s = state(); + delete s.masters[userId]; + save(s); + pending.delete(userId); + return true; + }, + + identity(userId, nodePk) { + const id = (state().identities[userId] || {})[nodePk]; + return id ? { ...publicOf(id), sealedWith: id.sealedWith || null } : null; + }, + + /** + * Take an identity out of a node's bundle and keep it here. The recovery + * copy is tried when the passphrase copy does not open and a recovery key + * was entered (a reset on a machine that had never held this identity). + */ + openBundle(userId, nodePk, { bundleEnc, recoveryEnc, recoveryMnemonic, username }) { + const { m } = master(userId); + let id; + try { + id = open(bundleEnc, hkdf(m, `meshbay:bundle:v3|node|${nodePk}`), userId, nodePk); + } catch (err) { + if (err.code === 'bundle_format_retired' || !recoveryEnc || !recoveryMnemonic) throw err; + const rk = hkdf(fromMnemonic(recoveryMnemonic), `meshbay:recovery:v1:${username}`); + id = open(recoveryEnc, rk, userId, nodePk); + } + keep(userId, nodePk, { ...id, sealedWith: null }); + return publicOf(id); + }, + + mint(userId, nodePk) { + const ed = crypto.generateKeyPairSync('ed25519'); + const x = crypto.generateKeyPairSync('x25519'); + const id = { + ed: b64(ed.privateKey.export({ format: 'der', type: 'pkcs8' })), + x: b64(x.privateKey.export({ format: 'der', type: 'pkcs8' })), + sealedWith: null, + }; + keep(userId, nodePk, id); + return publicOf(id); + }, + + /** The identity sealed for its node, under `M` (or the pending one). */ + sealBundle(userId, nodePk, { pending: usePending = false } = {}) { + const { m, v } = master(userId, { usePending }); + const bundle = seal(stored(userId, nodePk), hkdf(m, `meshbay:bundle:v3|node|${nodePk}`), + userId, nodePk, v); + return { bundle, fingerprint: fingerprint(m) }; + }, + /** The recovery copy: sealed under the recovery key, owing nothing to `M`. */ + sealRecovery(userId, nodePk, mnemonic, username) { + const rk = hkdf(fromMnemonic(mnemonic), `meshbay:recovery:v1:${username}`); + return seal(stored(userId, nodePk), rk, userId, nodePk, 0); + }, + /** After the node took it: what the next connection compares against. */ + markSealed(userId, nodePk, fp) { + keep(userId, nodePk, { sealedWith: fp || null }); + return true; + }, + currentFingerprint: (userId) => fingerprint(master(userId).m), + + sign(userId, nodePk, bytesB64) { + return b64(crypto.sign(null, unb64(bytesB64), privateFrom(stored(userId, nodePk).ed))); + }, + shared(userId, nodePk, peerPkB64) { + const publicKey = crypto.createPublicKey({ + key: Buffer.concat([X25519_SPKI, unb64(peerPkB64)]), format: 'der', type: 'spki' }); + return b64(crypto.diffieHellman({ + privateKey: privateFrom(stored(userId, nodePk).x), publicKey })); + }, + + playlistKey: (userId) => b64(hkdf(master(userId).m, 'meshbay:playlists:v2')), + + // ── browser access ───────────────────────────────────────────────────── + // Whether this account leaves bundles on nodes for a browser to open. An + // account created here says no until the person says yes (natively, in + // main.js); any other account keeps what it always had. + browserAccess: (userId) => state().access[userId] !== false, + setBrowserAccess(userId, on) { + const s = state(); + s.access[userId] = Boolean(on); + save(s); + return Boolean(on); + }, + }; +} + +module.exports = { createKeyring }; diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 5e1120b..9a08e96 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -31,6 +31,8 @@ const os = require('node:os'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); const vm = require('node:vm'); +const { createKeyring } = require('./keyring.js'); +const { wasmArgon2 } = require('./argon2-wasm.js'); // Linux window managers/desktop shells (GNOME's dash included) group and // icon-match a running window by its WM_CLASS, resolved against an installed @@ -482,6 +484,8 @@ function secretsBackend() { // operators, and nothing wraps a group key for it. const DEVICE_KEY = 'device_auth_ed25519'; +// The account's bundle master key and node identities (keyring.js). +const KEYRING_SLOT = 'keyring_v1'; function deviceKey() { const stored = readSecrets()[DEVICE_KEY]; @@ -979,6 +983,76 @@ function registerBridge() { // the OS protects the store. handle('secrets:backend', () => secretsBackend()); + // ── The account's keys (keyring.js) ───────────────────────────────────── + // + // `M` and every node identity stay in this process; the page is answered + // with public keys, signatures and agreements. Only where the OS can protect + // what is stored: an identity kept here and lost at the next start would + // leave a node pinning a key nobody holds, so without key storage the page + // keeps its keys the way a browser does. + const keysAvailable = () => secretsBackend() !== 'unavailable'; + const keyring = createKeyring({ + argon2: wasmArgon2(path.join(UI_DIR, 'vendor')), + load: () => { try { return JSON.parse(readSecrets()[KEYRING_SLOT] || '{}'); } catch { return {}; } }, + save: (state) => { + const all = readSecrets(); + all[KEYRING_SLOT] = JSON.stringify(state); + writeSecrets(all); + }, + }); + const uid = (v) => { + const s = String(v || ''); + if (!/^[0-9a-f-]{36}$/i.test(s)) throw new Error('Refused: not an account id'); + return s; + }; + const npk = (v) => { + const s = String(v || ''); + if (!/^[A-Za-z0-9+/=]{1,100}$/.test(s)) throw new Error("Refused: not a node's key"); + return s; + }; + const needKeys = () => { if (!keysAvailable()) throw new Error('No OS key storage'); }; + + handle('keys:available', () => keysAvailable()); + handle('keys:derive-session', async (_e, o) => { + needKeys(); + const a = o || {}; + return keyring.deriveSession({ + password: String(a.password || ''), username: String(a.username || ''), + userId: uid(a.userId), pepperB64: String(a.pepperB64 || ''), + pepperVersion: Number(a.pepperVersion) || 1, pending: Boolean(a.pending), + }); + }); + handle('keys:commit-pending', (_e, u) => keyring.commitPending(uid(u))); + handle('keys:drop-pending', (_e, u) => keyring.dropPending(uid(u))); + handle('keys:has-session', (_e, u) => keysAvailable() && keyring.hasSession(uid(u))); + handle('keys:forget-session', (_e, u) => keyring.forgetSession(uid(u))); + handle('keys:identity', (_e, u, n) => keyring.identity(uid(u), npk(n))); + handle('keys:open-bundle', (_e, u, n, o) => keyring.openBundle(uid(u), npk(n), { + bundleEnc: String((o && o.bundleEnc) || ''), + })); + handle('keys:mint', (_e, u, n) => { needKeys(); return keyring.mint(uid(u), npk(n)); }); + handle('keys:seal-bundle', (_e, u, n, o) => + keyring.sealBundle(uid(u), npk(n), { pending: Boolean(o && o.pending) })); + handle('keys:seal-recovery', (_e, u, n, mnemonic, username) => + keyring.sealRecovery(uid(u), npk(n), String(mnemonic || ''), String(username || ''))); + handle('keys:mark-sealed', (_e, u, n, fp) => keyring.markSealed(uid(u), npk(n), String(fp || ''))); + handle('keys:fingerprint', (_e, u) => keyring.currentFingerprint(uid(u))); + handle('keys:sign', (_e, u, n, bytes) => keyring.sign(uid(u), npk(n), String(bytes || ''))); + handle('keys:shared', (_e, u, n, peer) => keyring.shared(uid(u), npk(n), String(peer || ''))); + handle('keys:playlist-key', (_e, u) => keyring.playlistKey(uid(u))); + handle('keys:browser-access', (_e, u) => keyring.browserAccess(uid(u))); + // Turning it on puts this account's identities on every node, sealed for a + // browser to open with the passphrase: the person decides that here, in a + // dialog the page cannot answer. Turning it off only narrows. + handle('keys:set-browser-access', async (_e, u, on) => { + const id = uid(u); + if (on && !keyring.browserAccess(id)) await confirmOrRefuse('native.browser_access_confirm'); + return keyring.setBrowserAccess(id, Boolean(on)); + }); + // An account created on this device starts without browser access. Only + // ever narrows, so the page may say it. + handle('keys:created-here', (_e, u) => keyring.setBrowserAccess(uid(u), false)); + // Which catalogue native confirmations are worded from. A language code and // nothing else: an unknown one leaves the current language in place. handle('ui:locale', (_e, code) => { diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 632718b..469bc48 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -80,6 +80,32 @@ contextBridge.exposeInMainWorld('meshbay', { forget: () => ipcRenderer.invoke('device:forget'), }, + // The account's bundle key and its identity on every node (keyring.js). + // Held in the main process: the interface is told public keys and handed + // signatures and agreements, never a private key or the key that opens + // bundles. Bytes cross as base64. + keys: { + available: () => ipcRenderer.invoke('keys:available'), + deriveSession: (o) => ipcRenderer.invoke('keys:derive-session', o), + commitPending: (u) => ipcRenderer.invoke('keys:commit-pending', u), + dropPending: (u) => ipcRenderer.invoke('keys:drop-pending', u), + hasSession: (u) => ipcRenderer.invoke('keys:has-session', u), + forgetSession: (u) => ipcRenderer.invoke('keys:forget-session', u), + identity: (u, n) => ipcRenderer.invoke('keys:identity', u, n), + openBundle: (u, n, o) => ipcRenderer.invoke('keys:open-bundle', u, n, o), + mint: (u, n) => ipcRenderer.invoke('keys:mint', u, n), + sealBundle: (u, n, o) => ipcRenderer.invoke('keys:seal-bundle', u, n, o), + sealRecovery: (u, n, m, name) => ipcRenderer.invoke('keys:seal-recovery', u, n, m, name), + markSealed: (u, n, fp) => ipcRenderer.invoke('keys:mark-sealed', u, n, fp), + fingerprint: (u) => ipcRenderer.invoke('keys:fingerprint', u), + sign: (u, n, bytes) => ipcRenderer.invoke('keys:sign', u, n, bytes), + shared: (u, n, peer) => ipcRenderer.invoke('keys:shared', u, n, peer), + playlistKey: (u) => ipcRenderer.invoke('keys:playlist-key', u), + browserAccess: (u) => ipcRenderer.invoke('keys:browser-access', u), + setBrowserAccess: (u, on) => ipcRenderer.invoke('keys:set-browser-access', u, on), + createdHere: (u) => ipcRenderer.invoke('keys:created-here', u), + }, + // Whether the OS protects what the main process stores. The store itself is // not reachable from here: it holds the device key above. secrets: { |