aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
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
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')
-rw-r--r--packages/meshbay-client/src/argon2-wasm.js66
-rw-r--r--packages/meshbay-client/src/keyring.js241
-rw-r--r--packages/meshbay-client/src/main.js74
-rw-r--r--packages/meshbay-client/src/preload.js26
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: {