/** * MeshBay Browser Key Management — keyderive.js * * Two things come from the passphrase, kept apart by their salts: * - `auth_key` (PBKDF2), the hub credential — the passphrase never leaves; * - `A` (Argon2id), which with the hub-held pepper gives the session's bundle * key `M`, from which each node's bundle key and the playlist key derive. * * Identity keys are per node: generated on a first join, sealed for that node * and that account (`MBK3`), and left with that node. The hub stores no user * key. See docs/MESHBAY_DESIGN.md §3.1, §3.7. */ const PBKDF2_ITERATIONS = 600000; // OWASP 2023 recommendation for PBKDF2-SHA512 /** * Where the hub is, and how to reach it — resolved when a call is made, not * when this file loads. * * This used to be `const HUB = ''`, "same origin", which is true of a page the * hub served and false of one loaded from a package: there the origin is * `app://meshbay`, so `/v1/users/register` resolved against it and the * application's own protocol handler answered 404. Registration and sign-in — * the first two things anybody does — failed with "Not found". * * This is a classic script, loaded before the module graph, so it cannot import * the adapter. It reads the global the adapter publishes, at call time: by then * `platform.js` has run, and in a browser both of these are exactly what they * were before. */ function hubBase() { const p = typeof window !== 'undefined' && window.MeshBayPlatform; return p ? p.hubBase() : ''; } function hubCall(path, init) { const p = typeof window !== 'undefined' && window.MeshBayPlatform; return p && p.apiFetch ? p.apiFetch(hubBase() + path, init) : fetch(hubBase() + path, init); } // ── Auth key derivation (password split) ────────────────────────────────────── /** * Derive an auth key from password + username using PBKDF2-SHA512. * This key is sent to the hub for authentication — the raw password never leaves the browser. * Uses a different salt domain than the bundle key's Argon2 run, so the two * derived values are cryptographically independent. */ async function deriveAuthKey(password, username) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey( 'raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']); const salt = await crypto.subtle.digest( 'SHA-256', enc.encode(`meshbay:auth:v1:${username}`)); const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', hash: 'SHA-512', salt, iterations: PBKDF2_ITERATIONS }, km, 256); return btoa(String.fromCharCode(...new Uint8Array(bits))); } // ── Key generation ──────────────────────────────────────────────────────────── /** * Generate random Ed25519 + X25519 keypairs using WebCrypto. * Returns raw bytes for both (not CryptoKey objects, for easier serialisation). */ async function generateKeypairs() { // Ed25519 (signing) const edKey = await crypto.subtle.generateKey( { name: 'Ed25519' }, true, ['sign', 'verify']); const skEdRaw = await crypto.subtle.exportKey('pkcs8', edKey.privateKey); const pkEdRaw = await crypto.subtle.exportKey('spki', edKey.publicKey); // X25519 (key agreement) const xKey = await crypto.subtle.generateKey( { name: 'X25519' }, true, ['deriveBits']); const skXRaw = await crypto.subtle.exportKey('pkcs8', xKey.privateKey); const pkXRaw = await crypto.subtle.exportKey('spki', xKey.publicKey); return { skEdRaw, pkEdRaw, skXRaw, pkXRaw }; } // ── Password → AES key ──────────────────────────────────────────────────────── // Argon2id parameters for the keypair bundle. // // This is the one KDF in the browser that guards something an adversary can take // away and attack at leisure: the bundle is stored on every node whose group its // owner joins (finding C4). PBKDF2 was the wrong tool — it is compute-only, which // is exactly what a GPU is good at, so 600k iterations bought far less than the // wall-clock time suggested. // // 128 MB / t=3 / p=1 measured at ~640 ms through this WASM build on a desktop. // Memory is the lever, not time: each guess must hold 128 MB, so a 24 GB card // fits ~187 in parallel and its bandwidth caps it near 2k guesses/s, against no // ceiling at all for PBKDF2. 256 MB would double that again at ~1.3 s, which is // too much to ask of a phone for something paid at every sign-in. const ARGON2_MEM_KIB = 131072; // 128 MB const ARGON2_TIME = 3; const ARGON2_LANES = 1; // What a bundle is written as. Nothing else is read: a bundle in an earlier // format was sealed under the passphrase alone, which is exactly what an // operator holding it could attack offline, and there is no migration window — // such an identity is re-created on that node after the operator unpins it. const BUNDLE_MAGIC = 'MBK3'; function _argon2() { const a = (typeof window !== 'undefined' && window.argon2) || globalThis.argon2; if (!a) throw new Error('Argon2 unavailable — vendor/argon2.min.js did not load'); return a; } /** * `A`, the passphrase's half of the bundle key: Argon2id with a deterministic, * per-account salt. Deterministic so it can be derived once at sign-in and the * passphrase dropped; unique per account, so no shared precomputation. */ async function _bundleKeyBytes(password, username) { const enc = new TextEncoder(); const salt = new Uint8Array(await crypto.subtle.digest( 'SHA-256', enc.encode(`meshbay:bundle:v2:${username}`))).slice(0, 16); const out = await _argon2().hash({ pass: password, salt, time: ARGON2_TIME, mem: ARGON2_MEM_KIB, parallelism: ARGON2_LANES, hashLen: 32, type: _argon2().ArgonType.Argon2id, }); return out.hash; } const _b64bytes = (b64) => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); const _hkdf = (info) => ({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode(info), }); /** * The session's bundle key: `M = HKDF(A ‖ pepper, "…master:v3|" + user id)`. * * The pepper is held by the hub and handed only to a session that proved the * passphrase or a device key (docs/MESHBAY_DESIGN.md §3.7). Without it a bundle * cannot be opened however good the guess, so the operator of a node holding * one has nothing to test offline. `M` is what a session keeps — imported as a * non-extractable HKDF key, in IndexedDB until sign-out — and every key that * opens something is derived from it: one per node, one for playlists. The * pepper itself and `A` are not kept. * * One Argon2 run: the ~650 ms on the sign-in path is the whole budget. */ async function deriveBundleSessionKey(password, username, userId, pepperB64, pepperVersion) { if (!userId || !pepperB64) throw new Error('the hub did not provide the bundle pepper'); const a = new Uint8Array(await _bundleKeyBytes(password, username)); const pepper = _b64bytes(pepperB64); const ikm = new Uint8Array(a.length + pepper.length); ikm.set(a); ikm.set(pepper, a.length); const base = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']); const m = await crypto.subtle.deriveBits( _hkdf(`meshbay:bundle-master:v3|${userId}`), base, 256); return { // HKDF keys are non-extractable by specification. v3: await crypto.subtle.importKey('raw', m, 'HKDF', false, ['deriveKey', 'deriveBits']), pepperVersion: pepperVersion || 1, }; } /** * The session's bundle key wherever it is kept. In a browser, derived here. In * the desktop application, derived and kept by its main process, and what * comes back is a handle — `{ native, userId, pepperVersion }` — that opens * nothing by itself: the transport asks the application for what it needs. * `pending`: a passphrase change, held aside until the hub accepts it. */ async function sessionBundleKey(password, username, userId, pepperB64, pepperVersion, { pending = false } = {}) { const P = typeof window !== 'undefined' && window.MeshBayPlatform; if (P && P.nativeKeys && await P.nativeKeys()) { if (!userId || !pepperB64) throw new Error('the hub did not provide the bundle pepper'); await P.keys.deriveSession({ password, username, userId, pepperB64, pepperVersion, pending }); return { native: true, userId, pepperVersion: pepperVersion || 1, pending }; } return deriveBundleSessionKey(password, username, userId, pepperB64, pepperVersion); } /** * The key that seals this account's identity on ONE node. A leaked one opens * that node's bundle and no other. */ async function nodeBundleKey(sessionKey, nodePkB64) { if (!sessionKey || !sessionKey.v3) throw new Error('no bundle key in this session'); if (!nodePkB64) throw new Error("the node's key is not known yet"); return crypto.subtle.deriveKey( _hkdf(`meshbay:bundle:v3|node|${nodePkB64}`), sessionKey.v3, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } /** * GET the pepper for a session that is already open — a stored session from * before the pepper existed, a passphrase change. Sign-in carries it already. */ async function fetchBundlePepper(token) { const resp = await hubCall('/v1/users/me/bundle-pepper', { headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) throw new Error(`bundle pepper: ${resp.status}`); const data = await resp.json(); return { pepper: data.bundle_pepper, version: data.bundle_pepper_version }; } /** The account id a token of ours names — what the master key is bound to. */ function _subOf(token) { try { const part = String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); return JSON.parse(atob(part)).sub || null; } catch { return null; } } // ── Account recovery key ───────────────────────────────────────────────────── // // docs/MESHBAY_DESIGN.md §3.6. A full-entropy secret the user keeps outside the // passphrase — in their password manager, or (step 3) e-mailed to them. It // wraps a *second* copy of every per-node identity bundle, so a forgotten // passphrase does not strand the account's group identities. // // 256 bits of real entropy, so the derivation is HKDF, not Argon2: there is // nothing to brute-force and no reason to make the legitimate path slow. The // username domain-separates it, exactly as for the bundle key. const _B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // RFC 4648, no padding /** 32 random bytes, shown to the human as 13 groups of 4 Base32 chars. */ function generateRecoveryKey() { const R = crypto.getRandomValues(new Uint8Array(32)); return { rawB64: btoa(String.fromCharCode(...R)), mnemonic: _toMnemonic(R), }; } function _toMnemonic(bytes) { let bits = 0, value = 0, out = ''; for (const b of bytes) { value = (value << 8) | b; bits += 8; while (bits >= 5) { out += _B32[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += _B32[(value << (5 - bits)) & 31]; return out.replace(/(.{4})(?=.)/g, '$1 '); } function _fromMnemonic(mnemonic) { const clean = String(mnemonic).replace(/[^A-Za-z2-7]/g, '').toUpperCase(); let bits = 0, value = 0; const out = []; for (const ch of clean) { const idx = _B32.indexOf(ch); if (idx < 0) throw new Error('invalid recovery key'); value = (value << 5) | idx; 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 new Uint8Array(out.slice(0, 32)); } /** * Derive the AES-GCM key that wraps the recovery copy of a bundle. * `R` is the raw Uint8Array(32) or its Base32 mnemonic string. */ async function deriveRecoveryKey(R, username) { const raw = (typeof R === 'string') ? _fromMnemonic(R) : new Uint8Array(R); const km = await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode(`meshbay:recovery:v1:${username}`), }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } // ── Bundle encryption ───────────────────────────────────────────────────────── // Binds a bundle to its account and its node: a bundle copied to another node, // or served for another account, does not open. const _bundleAad = (userId, nodePkB64) => new TextEncoder().encode(`meshbay:bundle:v3|${userId}|${nodePkB64}`); /** * Seal a keypair bundle for one node: * base64( "MBK3" ‖ pepper version (1 byte) ‖ nonce (12) ‖ AES-GCM(plaintext, aad) ). * `pepperVersion` is 0 for a recovery copy, which is sealed under the recovery * key and owes nothing to the pepper. Plaintext: JSON { skEd, skX } (pkcs8, b64). */ async function encryptBundle(skEdRaw, skXRaw, aesKey, { userId, nodePk, pepperVersion }) { if (!userId || !nodePk) throw new Error('a bundle is sealed for one account on one node'); const nonce = crypto.getRandomValues(new Uint8Array(12)); const data = new TextEncoder().encode(JSON.stringify({ skEd: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))), skX: btoa(String.fromCharCode(...new Uint8Array(skXRaw))), })); const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce, additionalData: _bundleAad(userId, nodePk) }, aesKey, data); const magic = new TextEncoder().encode(BUNDLE_MAGIC); const out = new Uint8Array(magic.length + 1 + nonce.length + ct.byteLength); out.set(magic); out[magic.length] = pepperVersion & 0xff; out.set(nonce, magic.length + 1); out.set(new Uint8Array(ct), magic.length + 1 + nonce.length); return btoa(String.fromCharCode(...out)); } /** 'current', or 'retired' for anything written before MBK3. */ function bundleFormat(bundleB64) { try { return atob(bundleB64).startsWith(BUNDLE_MAGIC) ? 'current' : 'retired'; } catch { return 'retired'; } } // ── Registration ────────────────────────────────────────────────────────────── /** * Full registration flow: * 1. Generate random keypairs * 2. Encrypt bundle with password * 3. POST to hub (public keys only — no keypair bundle) * 4. Store encrypted bundle locally for backup to node on first connect * * Returns the raw private keys for immediate use after registration. */ async function registerUser(username, email, password, recoveryMnemonic, captchaToken) { // No keypair here any more. Identity keys are per node: one is generated the // first time this account joins a given node, encrypted under the passphrase, // and left with that node. So an operator who cracks what sits on their own // disk holds a key that is worthless anywhere else — and on their own node, // one that unlocks nothing they did not already have. // // It also means the hub stores no user key to publish, which is what H3 read. const authKey = await deriveAuthKey(password, username); const payload = { username, email, auth_key: authKey }; // The recovery mnemonic, when the user opted to have it e-mailed: the hub // appends it to the verification e-mail and stores it nowhere // (docs/MESHBAY_DESIGN.md §3.6). Omitted when they chose to save it themselves. if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic; // reCAPTCHA response, when the hub has a captcha configured. The widget lives // in RegisterPage (auth-page.js); this function just forwards its token. A // hub with no captcha configured sends nothing and the server does not check. if (captchaToken) payload.captcha_token = captchaToken; const resp = await hubCall('/v1/users/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`); let userId = null; try { userId = (await resp.json()).user_id || null; } catch { /* no body */ } return { registered: true, userId }; } /** * A fresh identity for one node, sealed for that node only. * * Returns { skEdB64, skXB64, pkXB64, bundleEnc, bundleEncRecovery? } — the * bundle goes to that node and nowhere else, and is what any other browser * fetches to become the same person there. When `recoveryKey` is supplied a * second copy sealed under it rides along, so a forgotten passphrase does not * strand this identity (docs/MESHBAY_DESIGN.md §3.6). */ async function generateNodeIdentity(sessionKey, recoveryKey, { userId, nodePk }) { const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, { name: 'X25519' }, true, []); const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); const out = { skEdB64: b64(skEdRaw), skXB64: b64(skXRaw), pkXB64: b64(pkXBytes), bundleEnc: await encryptBundle(skEdRaw, skXRaw, await nodeBundleKey(sessionKey, nodePk), { userId, nodePk, pepperVersion: sessionKey.pepperVersion }), }; if (recoveryKey) { out.bundleEncRecovery = await encryptBundle(skEdRaw, skXRaw, recoveryKey, { userId, nodePk, pepperVersion: 0 }); } return out; } /** * Open a bundle fetched from a node. A bundle in a retired format is refused * with `code = 'bundle_format_retired'` — never opened, and never quietly * replaced by a new identity: the caller says so. */ async function decryptBundle(bundleB64, aesKey, { userId, nodePk }) { if (bundleFormat(bundleB64) !== 'current') { const err = new Error('bundle_format_retired'); err.code = 'bundle_format_retired'; throw err; } const raw = _b64bytes(bundleB64); const off = BUNDLE_MAGIC.length + 1; const plain = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: raw.slice(off, off + 12), additionalData: _bundleAad(userId, nodePk) }, aesKey, raw.slice(off + 12)); return JSON.parse(new TextDecoder().decode(plain)); } /** * Login and recover private keys. * * If localStorage has a keypair bundle (new registration, not yet pushed to node), * decrypts it and returns the keys + encrypted bundle for push to node. * Otherwise returns bundleKey so the caller can fetch from node during handshake. */ async function loginAndRecover(username, password) { const authKey = await deriveAuthKey(password, username); const resp = await hubCall('/v1/users/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, auth_key: authKey }), }); if (!resp.ok) { // The hub's `detail`, not the raw body: the sign-in page matches on it // (`email_verification_required`, `account_locked`), and a message wrapped // as "Login failed: {json}" matched nothing, so neither was ever shown. const body = await resp.text(); let detail = body; // `error` is the per-IP rate limiter's field (slowapi), `detail` everyone else's. try { const j = JSON.parse(body); detail = j.detail || j.error || body; } catch { /* not JSON */ } const err = new Error(String(detail)); err.status = resp.status; err.retryAfter = Number(resp.headers && resp.headers.get ? resp.headers.get('Retry-After') : 0) || 0; throw err; } const data = await resp.json(); const result = { accessToken: data.access_token, refreshToken: data.refresh_token, // The pepper rides on the sign-in response, so this costs no extra call; // it is folded into the key here and not kept. bundleKey: await sessionBundleKey( password, username, _subOf(data.access_token), data.bundle_pepper, data.bundle_pepper_version), }; // Nothing else to recover at sign-in. Identity keys belong to a node, so they // are fetched from the node being connected to (or generated there on a first // join) — see transport.js. All that is needed here is the key that opens them. return result; } // regenerateKeys() removed. Rotating an identity is now per node: the operator // runs `meshbay-node member unpin ` and issues a fresh code. A hub call // that silently changed what every node believed about someone was the wrong // shape for this. async function signBytes(skEdPkcs8B64, message) { const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0)); const sk = await crypto.subtle.importKey( 'pkcs8', skRaw, { name: 'Ed25519' }, false, ['sign']); const sig = await crypto.subtle.sign('Ed25519', sk, message); return btoa(String.fromCharCode(...new Uint8Array(sig))); } window.MeshBayKeys = { registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes, deriveAuthKey, // The bundle key (docs/MESHBAY_DESIGN.md §3.1, §3.7): one session key per // sign-in, one derived key per node, one format. deriveBundleSessionKey, sessionBundleKey, nodeBundleKey, fetchBundlePepper, encryptBundle, decryptBundle, bundleFormat, // Account recovery key (docs/MESHBAY_DESIGN.md §3.6). generateRecoveryKey, deriveRecoveryKey, };