diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 16:58:31 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 16:58:31 +0200 |
| commit | 6d167392f6f8ede37e2794a68a3738f8ba03131d (patch) | |
| tree | 9caacef15dd034c6425f4bb623e0cd50a28ec52a /packages/meshbay-client/src/keyring.js | |
| parent | 8926f163dad9d32dc06c3a142658a4e11d9c12c1 (diff) | |
| download | meshbay-6d167392f6f8ede37e2794a68a3738f8ba03131d.tar.gz | |
feat(client): the desktop application keeps M and every node identity in its main process
keyring.js derives, opens, mints, seals, signs and agrees there; the page gets
public keys and a handle. Argon2 comes from the page's own WebAssembly build
(Electron's crypto has none). Without OS key storage the page keeps its keys as
a browser does. A node's bundle is settled after connecting, re-sealed when the
key changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src/keyring.js')
| -rw-r--r-- | packages/meshbay-client/src/keyring.js | 241 |
1 files changed, 241 insertions, 0 deletions
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 }; |