/** * 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'); const { transcriptFor } = require('./transcripts.js'); // 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'); // TRANSITIONAL — the format before MBK3, read once to be replaced (keyderive.js). const LEGACY_MAGIC = Buffer.from('MBK2'); 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])); } /** TRANSITIONAL — MBK2: "MBK2" ‖ nonce ‖ AES-GCM under the Argon2 key, no AAD. */ function openLegacy(bundleB64, key) { const raw = unb64(bundleB64); const nonce = raw.subarray(4, 16); const body = raw.subarray(16, raw.length - 16); const d = crypto.createDecipheriv('aes-256-gcm', key, nonce); 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 }; } 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))), }); // A bundle is a copy of an identity for a browser to open with the // passphrase. With browser access off none may exist, whatever the page asks: // the page is where hostile content is parsed, and one bundle left on a node // is all a passphrase-only sign-in on the web needs. const accessOn = (userId) => state().access[userId] !== false; const needAccess = (userId) => { if (!accessOn(userId)) throw new Error('Refused: browser access is off for this account'); }; 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(); // `legacy` (TRANSITIONAL): the Argon2 key itself, which MBK2 bundles // were sealed under — kept beside `M`, in the same OS-protected store // and for as long, so a node still holding one has it opened and // replaced on the next connection. Remove once no MBK2 bundle is left. s.masters[userId] = { m: b64(m), v, legacy: b64(a) }; save(s); return true; }, commitPending(userId) { const p = pending.get(userId); if (!p) return false; const s = state(); // The legacy key stays the old passphrase's: MBK2 bundles were sealed // under that one, never under the new. const legacy = (s.masters[userId] || {}).legacy; s.masters[userId] = { m: b64(p.m), v: p.v, ...(legacy ? { legacy } : {}) }; 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 }) { if (unb64(bundleEnc).subarray(0, 4).equals(LEGACY_MAGIC)) { // TRANSITIONAL. Kept unsealed (`sealedWith: null`), so the next // settle replaces the node's copy with MBK3, or withdraws it when the // account has no browser access. const legacy = (state().masters[userId] || {}).legacy; if (!legacy) throw new Error('no_legacy_key'); const id = openLegacy(bundleEnc, unb64(legacy)); keep(userId, nodePk, { ...id, sealedWith: null }); return publicOf(id); } 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 } = {}) { needAccess(userId); 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) { needAccess(userId); 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 what `kind` names, built from `fields` (transcripts.js). */ signAs(userId, nodePk, kind, fields) { const id = stored(userId, nodePk); const transcript = transcriptFor(kind, fields, { userId, nodePk, ...publicOf(id) }); return b64(crypto.sign(null, transcript, privateFrom(id.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: accessOn, setBrowserAccess(userId, on) { const s = state(); s.access[userId] = Boolean(on); save(s); return Boolean(on); }, }; } module.exports = { createKeyring };