aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-01 13:06:32 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-01 13:06:32 +0200
commite0905bd447f6214dc34e360554826ace45bde676 (patch)
tree64c371d7675861f4af6ade210c46652bd610707a /packages/meshbay-hub/src/meshbay_hub
parent0d9d91eeea9001ea3838272416e3d526b6a2a1fc (diff)
downloadmeshbay-e0905bd447f6214dc34e360554826ace45bde676.tar.gz
fix: an MBK2 bundle is opened once and stored again as MBK3
Transitional. The Argon2 run that makes M makes A, the key MBK2 bundles were sealed under; a session keeps it as a decrypt-only key (IndexedDB in a browser, the key storage in the desktop app). A client meeting an MBK2 bundle opens it — or its recovery copy — and stores the same identity as MBK3 once connected; the desktop app reseals or withdraws it as browser access says. A session without A asks for the passphrase once. Older formats stay refused by name. Replaces the unpin-and-reinvite step the 0.17 flag day required on every node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js51
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js53
3 files changed, 104 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index b1770a7..7bbcac5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -161,6 +161,12 @@ async function deriveBundleSessionKey(password, username, userId, pepperB64, pep
// HKDF keys are non-extractable by specification.
v3: await crypto.subtle.importKey('raw', m, 'HKDF', false, ['deriveKey', 'deriveBits']),
pepperVersion: pepperVersion || 1,
+ // TRANSITIONAL — the key MBK2 bundles were sealed under, which this same
+ // Argon2 run produces anyway. Kept for the session so a node still holding
+ // one has it opened and replaced by MBK3 on the account's next visit,
+ // rather than the member being re-invited. Decrypt only; nothing is sealed
+ // under it. Remove once no MBK2 bundle is left on any node.
+ legacy: await crypto.subtle.importKey('raw', a, { name: 'AES-GCM' }, false, ['decrypt']),
};
}
@@ -321,13 +327,52 @@ async function encryptBundle(skEdRaw, skXRaw, aesKey, { userId, nodePk, pepperVe
return btoa(String.fromCharCode(...out));
}
-/** 'current', or 'retired' for anything written before MBK3. */
+// TRANSITIONAL — the format before MBK3: "MBK2" ‖ nonce (12) ‖ AES-GCM under
+// the passphrase's Argon2 key alone, no associated data. Read once to be
+// replaced; never written.
+const LEGACY_MAGIC = 'MBK2';
+
+/**
+ * 'current'; 'legacy' for MBK2, opened once with the session's legacy key and
+ * replaced; 'retired' for anything older, which is not read at all.
+ */
function bundleFormat(bundleB64) {
try {
- return atob(bundleB64).startsWith(BUNDLE_MAGIC) ? 'current' : 'retired';
+ const head = atob(bundleB64).slice(0, 4);
+ if (head === BUNDLE_MAGIC) return 'current';
+ return head === LEGACY_MAGIC ? 'legacy' : 'retired';
} catch { return 'retired'; }
}
+/** TRANSITIONAL — open an MBK2 bundle (passphrase or recovery copy). */
+async function decryptLegacyBundle(bundleB64, aesKey) {
+ if (bundleFormat(bundleB64) !== 'legacy') throw new Error('not an MBK2 bundle');
+ const raw = _b64bytes(bundleB64);
+ const off = LEGACY_MAGIC.length;
+ const plain = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: raw.slice(off, off + 12) }, aesKey, raw.slice(off + 12));
+ return JSON.parse(new TextDecoder().decode(plain));
+}
+
+/**
+ * TRANSITIONAL — an identity read from an MBK2 bundle, sealed again as MBK3
+ * for the same node (and the recovery copy too, when a recovery key is in
+ * hand), for the caller to store in place of the old one.
+ */
+async function resealLegacyIdentity(keys, sessionKey, recoveryKey, { userId, nodePk }) {
+ const skEd = _b64bytes(keys.skEd);
+ const skX = _b64bytes(keys.skX);
+ const out = {
+ bundleEnc: await encryptBundle(skEd, skX, await nodeBundleKey(sessionKey, nodePk),
+ { userId, nodePk, pepperVersion: sessionKey.pepperVersion }),
+ };
+ if (recoveryKey) {
+ out.bundleEncRecovery = await encryptBundle(skEd, skX, recoveryKey,
+ { userId, nodePk, pepperVersion: 0 });
+ }
+ return out;
+}
+
// ── Registration ──────────────────────────────────────────────────────────────
/**
@@ -516,7 +561,7 @@ window.MeshBayKeys = {
// 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,
+ encryptBundle, decryptBundle, bundleFormat, decryptLegacyBundle, resealLegacyIdentity,
// Account recovery key (docs/MESHBAY_DESIGN.md §3.6).
generateRecoveryKey, deriveRecoveryKey,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
index 8bf884c..cdf86b0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
@@ -117,7 +117,9 @@ async function rewrapAllNodes(o) {
anyOk = true;
continue;
}
- if (tp.newNodeBundle) {
+ // 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
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 9b86921..f9e1370 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -684,6 +684,8 @@ class MeshBayTransport {
/** Set on a first join: the identity created for this node, still to be left with it. */
get newNodeBundle() { return this._newNodeBundle || null; }
+ /** TRANSITIONAL — the identity was read from an MBK2 bundle, not created. */
+ get upgradedLegacy() { return Boolean(this._upgradedLegacy); }
set newNodeBundle(v) { this._newNodeBundle = v; }
/** The recovery-wrapped copy of that same first-join identity, when a recovery key was in hand. */
@@ -765,6 +767,7 @@ class MeshBayTransport {
this._groupId = groupId || '';
this._newNodeBundle = null;
this._newNodeBundleRecovery = null;
+ this._upgradedLegacy = false;
this._joinError = null;
// Per connection, for the same reason the chat keys and the roster are
// dropped further down: the device the *previous* connection identified
@@ -1048,6 +1051,8 @@ class MeshBayTransport {
fresh = await this._settleNativeIdentity(kpResp);
} else if (this._nodeHasBundle && K.bundleFormat(kpResp.bundle_enc) === 'retired') {
throw _retiredBundleError();
+ } else if (this._nodeHasBundle && K.bundleFormat(kpResp.bundle_enc) === 'legacy') {
+ keys = await this._openLegacyBundle(kpResp, sealedFor);
} else if (this._nodeHasBundle) {
try {
keys = await K.decryptBundle(kpResp.bundle_enc,
@@ -1298,6 +1303,46 @@ class MeshBayTransport {
* hangs, the textbox is dead" report. Every exit below names itself.
*/
/**
+ * TRANSITIONAL — an MBK2 bundle, opened with the session's legacy key (or
+ * the recovery copy with the recovery key) and sealed again as MBK3, left
+ * for `settleNodeBundle` to store in its place once the connection is made.
+ *
+ * A session restored from before the legacy key was kept has none: the
+ * passphrase is asked for again (`no_keys`) rather than the identity being
+ * declared lost. A legacy key that does not open it — a bundle sealed under
+ * an older passphrase — is what a current bundle that does not open is: the
+ * caller goes on to a first join.
+ */
+ async _openLegacyBundle(kpResp, sealedFor) {
+ const K = window.MeshBayKeys;
+ let keys = null;
+ if (this._bundleKey.legacy) {
+ try { keys = await K.decryptLegacyBundle(kpResp.bundle_enc, this._bundleKey.legacy); }
+ catch { /* sealed under another passphrase */ }
+ }
+ if (!keys && this._recoveryKey && kpResp.bundle_enc_recovery
+ && K.bundleFormat(kpResp.bundle_enc_recovery) === 'legacy') {
+ try {
+ keys = await K.decryptLegacyBundle(kpResp.bundle_enc_recovery, this._recoveryKey);
+ this._recoveredFromRecovery = true;
+ } catch { /* not this recovery key */ }
+ }
+ if (!keys) {
+ if (!this._bundleKey.legacy && !this._recoveryKey) {
+ const err = new Error('Your passphrase is needed once to update how this node keeps your identity');
+ err.reason = 'no_keys';
+ throw err;
+ }
+ return null;
+ }
+ const sealed = await K.resealLegacyIdentity(keys, this._bundleKey, this._recoveryKey, sealedFor);
+ this._newNodeBundle = sealed.bundleEnc;
+ this._newNodeBundleRecovery = sealed.bundleEncRecovery || null;
+ this._upgradedLegacy = true;
+ return keys;
+ }
+
+ /**
* This node's identity when the desktop application holds the keys.
*
* Kept by the application once it has it, so a bundle left on the node —
@@ -1316,8 +1361,16 @@ class MeshBayTransport {
throw _retiredBundleError();
}
try {
+ // An MBK2 bundle too (TRANSITIONAL): the application opens it with
+ // the legacy key it kept from the passphrase, and `settleNodeBundle`
+ // then replaces or withdraws it as browser access says.
pub = await P.openBundle(uid, pk, { bundleEnc: kpResp.bundle_enc });
} catch (e) {
+ if (String(e && e.message).includes('no_legacy_key')) {
+ const err = new Error('Your passphrase is needed once to update how this node keeps your identity');
+ err.reason = 'no_keys';
+ throw err;
+ }
// Sealed under a passphrase no longer in use: as in a browser, a
// passphrase change must report it, and a first join replaces it.
if (this._rewrapOnly) throw new Error('could not open the stored identity');