aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js229
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;