aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/keyring.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 16:58:31 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 16:58:31 +0200
commit6d167392f6f8ede37e2794a68a3738f8ba03131d (patch)
tree9caacef15dd034c6425f4bb623e0cd50a28ec52a /packages/meshbay-client/src/keyring.js
parent8926f163dad9d32dc06c3a142658a4e11d9c12c1 (diff)
downloadmeshbay-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.js241
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 };