diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 229 |
1 files changed, 211 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 286bc9d..f7ae256 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -296,13 +296,19 @@ class MeshBayTransport { get newNodeBundle() { return this._newNodeBundle || null; } set newNodeBundle(v) { this._newNodeBundle = v; } + /** The recovery-wrapped copy of that same first-join identity, when a recovery key was in hand. */ + get newNodeBundleRecovery() { return this._newNodeBundleRecovery || null; } + set newNodeBundleRecovery(v) { this._newNodeBundleRecovery = v; } + async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, - userId, joinCode) { + userId, joinCode, recoveryKey) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() // itself settles on below) after the WebRTC connection is declared // "failed" — see the pc.onconnectionstatechange handler further down. - this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode }; + this._connectArgs = { + nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey, + }; this._lastToken = jwtToken; // The constructor sets this once from whatever token the caller had at // the time — and the signaling POST below reads *this*, not `jwtToken`. @@ -316,9 +322,11 @@ class MeshBayTransport { this._gekRaw = gekRaw || null; this._sessionKeys = sessionKeys || null; this._bundleKey = bundleKey || null; + this._recoveryKey = recoveryKey || null; this._username = username || null; this._userId = userId || null; this._newNodeBundle = null; + this._newNodeBundleRecovery = null; this._joinError = null; this._pc = new RTCPeerConnection({ iceServers: [ @@ -548,20 +556,58 @@ class MeshBayTransport { const kpResp = await this._sendAndWait({ type: 'keypair_bundle_fetch', v: '0.1', }); + let keys = null; + let openErr = null; if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) { - const keys = await window.MeshBayKeys.decryptBundleWithKey( - kpResp.bundle_enc, this._bundleKey); + try { + keys = await window.MeshBayKeys.decryptBundleWithKey( + kpResp.bundle_enc, this._bundleKey); + } catch (e) { + openErr = e; + // The passphrase key did not open the bundle. If we hold a recovery + // key and the node kept a recovery copy, try that — Flow B + // (docs/auth-confirm.md §4.5): recovering an identity after a lost + // passphrase, before re-wrapping it under the new one. + if (this._recoveryKey && kpResp.bundle_enc_recovery) { + try { + keys = await window.MeshBayKeys.decryptBundleWithKey( + kpResp.bundle_enc_recovery, this._recoveryKey); + this._recoveredFromRecovery = true; + } catch { /* recovery copy did not open either */ } + } + } + } + + if (!keys && this._rewrapOnly) { + // A passphrase-change / backfill run must recover the *existing* + // identity or report the node — never mint a new one. These strings + // are shown on the reset / backfill screens. + throw new Error( + !kpResp.found ? 'no identity on this node' + : this._recoveryKey + ? (kpResp.bundle_enc_recovery + ? "recovery key does not open this node's bundle" + : 'no recovery copy on this node') + : (openErr && openErr.message) || 'could not open the stored identity'); + } + + if (keys) { const pkXB64 = await _pkFromSk(keys.skX); this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; } else { - // This node has never seen us. Generate the identity we will use here - // and nowhere else; it is stored on this node once the join succeeds, - // which is what lets another browser become the same person here. - const id = await window.MeshBayKeys.generateNodeIdentity(this._bundleKey); + // Either the node has never seen us, or it holds a stale bundle we + // cannot open (wrapped under a passphrase we no longer use, with no + // usable recovery copy — e.g. an unpin that left the old bundle + // behind). Mint a fresh identity and let the join path take over; a + // successful join overwrites whatever was stored. A recovery-wrapped + // copy is left too when a recovery key is in hand (§4.3). + const id = await window.MeshBayKeys.generateNodeIdentity( + this._bundleKey, this._recoveryKey); this._sessionKeys = { skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64, }; this._newNodeBundle = id.bundleEnc; + this._newNodeBundleRecovery = id.bundleEncRecovery || null; fresh = true; } } @@ -598,15 +644,11 @@ class MeshBayTransport { } if (!gekRaw && !this._sessionKeys) { - // No identity keys in this browser and none recoverable from the node: - // the keypair bundle is created where you register and only reaches a - // node after a first successful connection, so a brand-new member opening - // a second browser has nothing to sign or unwrap with. Say that, rather - // than blaming the GEK — a code prompt here would be useless, since a - // code proves who you are and we have no key to bind to. - const err = new Error( - 'This browser does not hold your keys. Open the group once from the ' - + 'browser where you registered — after that this one can recover them.'); + // No key in this browser to sign or unwrap with — `bundleKey` was null. + // The caller (group-page.js) shows a passphrase prompt on this reason + // and retries; a code prompt would be useless, since a code proves who + // you are and there is no key to bind it to. + const err = new Error('Your passphrase is needed to unlock your keys in this browser.'); err.reason = 'no_keys'; throw err; } @@ -1777,11 +1819,14 @@ class MeshBayTransport { return msg; } - async storeKeypairBundle(bundleEnc) { + async storeKeypairBundle(bundleEnc, recoveryEnc) { const msg = await this._sendAndWait({ type: 'keypair_bundle_store', v: '0.1', bundle_enc: bundleEnc, + // MNP 0.14, optional: the recovery-wrapped copy. Omitted for a plain + // re-backup; the node keeps any copy it already holds. + ...(recoveryEnc ? { bundle_enc_recovery: recoveryEnc } : {}), }); if (msg.type === 'error') throw new Error(msg.detail); return msg; @@ -2560,7 +2605,155 @@ function pinnedNodeCount() { } catch { return 0; } } +// ── Passphrase change: re-wrap every reachable identity bundle ─────────────── +// +// docs/auth-confirm.md §3.2. 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 {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy + * @param {string} o.newPassphrase + * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B, docs/auth-confirm.md §4.5). + * 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.deriveEncryptionKey) { + throw new Error('key module unavailable'); + } + let oldKey, newKey; + if (o.bundleKey) { + // "Keep the current passphrase key, just add / refresh the recovery copy" + // — the Profile backfill (docs/auth-confirm.md §4.3). `o.bundleKey` is the + // live {v2,v1} session key, so no passphrase is needed. + oldKey = newKey = o.bundleKey; + } else { + // Flow B has no old passphrase; connect will fail the passphrase decrypt and + // fall back to the recovery copy, so a placeholder key is fine for `oldKey`. + const oldPass = o.oldPassphrase || o.newPassphrase; + oldKey = { + v2: await K.deriveEncryptionKey(oldPass, o.username), + v1: await K.deriveEncryptionKeyV1(oldPass, o.username), + }; + newKey = { + v2: await K.deriveEncryptionKey(o.newPassphrase, o.username), + v1: await K.deriveEncryptionKeyV1(o.newPassphrase, o.username), + }; + } + 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), + 30000, 'connect'); + if (tp.newNodeBundle) { + // 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.sessionKeys; + 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)); + const reEnc = await K.encryptBundleWithKey(skEd, skX, newKey.v2); + // 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.encryptBundleWithKey(skEd, skX, recoveryKey) + : 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 }; +} + // Export MeshBayTransport.clearNodePin = clearNodePin; MeshBayTransport.pinnedNodeCount = pinnedNodeCount; +MeshBayTransport.rewrapAllNodes = rewrapAllNodes; window.MeshBayTransport = MeshBayTransport; |