// A passphrase change, carried to every node that holds this account's // identity bundle. // ── Passphrase change: re-wrap every reachable identity bundle ─────────────── // // docs/MESHBAY_DESIGN.md §3.6. The passphrase-derived bundle_key encrypts this // account's per-node identity on every node it has joined. Changing the // passphrase changes that key, so each bundle must be read with the old key and // written back with the new one — on the node, while both keys are in hand. // // The reachable set is the online nodes of the account's current groups. A node // that is offline, or belongs to a group left since, cannot be reached here and // is reported so the caller can tell the user to ask that group's operator to // unpin them and issue a fresh code (§3.4). function _acHubFetch(hubUrl, path, init) { const p = typeof window !== 'undefined' && window.MeshBayPlatform; const url = (hubUrl || '') + path; return (p && p.apiFetch) ? p.apiFetch(url, init) : fetch(url, init); } async function _acHubGet(hubUrl, token, path) { const r = await _acHubFetch(hubUrl, path, { headers: { Authorization: `Bearer ${token}` }, }); if (!r.ok) throw new Error(`${path} → ${r.status}`); return r.json(); } function _acWithTimeout(promise, ms, label) { let timer; return Promise.race([ promise.finally(() => clearTimeout(timer)), new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`${label} timed out`)), ms); }), ]); } /** * @param {object} o * @param {string} o.hubUrl same base the SPA uses for the hub * @param {string} o.token a fresh access token * @param {string} o.username * @param {string} o.userId * @param {object} o.bundleKey the session key that opens the bundles as they are * (keyderive.js `deriveBundleSessionKey`). In Flow B it * opens nothing and connect falls back to the recovery copy. * @param {object} [o.newBundleKey] the key to seal them under; defaults to `bundleKey` * (the Profile backfill: same key, a recovery copy added) * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B, * docs/MESHBAY_DESIGN.md §3.6). * When given, the recovery-wrapped copy is read where the * passphrase copy cannot be, and a fresh one is written back. * @param {(p:{done:number,total:number})=>void} [o.onProgress] * @returns {Promise<{updated:Array,unreachable:Array,failed:Array,newBundleKey:object}>} */ async function rewrapAllNodes(o) { const K = window.MeshBayKeys; if (!K || !K.encryptBundle) { throw new Error('key module unavailable'); } if (!o.bundleKey) throw new Error('no bundle key in this session'); // Keys, never passphrases: each caller has derived them already, with the // pepper only the hub holds (docs/MESHBAY_DESIGN.md §3.7). const oldKey = o.bundleKey; const newKey = o.newBundleKey || o.bundleKey; const recoveryKey = o.recoveryKey ? await K.deriveRecoveryKey(o.recoveryKey, o.username) : null; const mine = await _acHubGet(o.hubUrl, o.token, '/v1/groups/mine'); const groups = mine.groups || (Array.isArray(mine) ? mine : []); const updated = [], unreachable = [], failed = []; for (const g of groups) { const label = g.owner_username ? `${g.name}@${g.owner_username}` : g.name; let nodes = []; try { const nd = await _acHubGet(o.hubUrl, o.token, `/v1/groups/${g.id}/nodes`); nodes = nd.nodes || []; } catch (e) { failed.push({ groupId: g.id, name: label, reason: e.message }); if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); continue; } if (nodes.length === 0) { unreachable.push({ groupId: g.id, name: label, reason: 'node offline' }); if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); continue; } let anyOk = false, lastErr = null; for (const n of nodes) { const tp = new MeshBayTransport(o.hubUrl, o.token); // Recover the *existing* identity or report this node — never mint a new // one just because the stored bundle would not open. tp._rewrapOnly = true; try { await _acWithTimeout( tp.connect(n.node_id, o.token, g.id, null, null, oldKey, o.username, o.userId, null, recoveryKey, undefined, n.pk_node), 30000, 'connect'); if (tp.identity && tp.identity.native) { // The desktop application holds this identity: it seals, and only // for an account with browser access — without it, nothing of the // identity is on the node to re-seal. const P = window.MeshBayPlatform.keys; if (await P.browserAccess(o.userId)) { const sealed = await P.sealBundle(o.userId, tp.nodePk, { pending: Boolean(o.newBundleKey && o.newBundleKey.pending) }); const rec = o.recoveryKey ? await P.sealRecovery(o.userId, tp.nodePk, o.recoveryKey, o.username) : null; await tp.storeKeypairBundle(sealed.bundle, rec); await P.markSealed(o.userId, tp.nodePk, sealed.fingerprint); } anyOk = true; continue; } // An identity read from an MBK2 bundle (TRANSITIONAL) is an existing // one, and is re-sealed below like any other. if (tp.newNodeBundle && !tp.upgradedLegacy) { // No identity existed on this node — connect just minted one under // the old key. Don't persist it: the next time this group is opened // the normal flow creates one under the current key, and storing it // here could also walk back a deliberate bundle withdrawal. Nothing // is stranded, so this node needs no fix. anyOk = true; continue; } const sk = tp.identity && tp.identity.raw; if (!sk) { lastErr = new Error('identity not recovered'); continue; } const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0)); const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0)); // Sealed for this account on the node just connected to — the key // that node proved during the handshake. const sealedFor = { userId: o.userId, nodePk: tp.nodePk }; const reEnc = await K.encryptBundle(skEd, skX, await K.nodeBundleKey(newKey, tp.nodePk), { ...sealedFor, pepperVersion: newKey.pepperVersion }); // In Flow B, refresh the recovery copy too (same R) so the node's // passphrase copy and recovery copy stay in step. const reRecovery = recoveryKey ? await K.encryptBundle(skEd, skX, recoveryKey, { ...sealedFor, pepperVersion: 0 }) : null; await tp.storeKeypairBundle(reEnc, reRecovery); anyOk = true; } catch (e) { lastErr = e; } finally { try { tp.close(); } catch { /* already gone */ } } } if (anyOk) updated.push({ groupId: g.id, name: label }); else failed.push({ groupId: g.id, name: label, reason: (lastErr && lastErr.message) || 'unreachable' }); if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); } return { updated, unreachable, failed, newBundleKey: newKey }; } MeshBayTransport.rewrapAllNodes = rewrapAllNodes;