diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-01 13:06:32 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-01 13:06:32 +0200 |
| commit | e0905bd447f6214dc34e360554826ace45bde676 (patch) | |
| tree | 64c371d7675861f4af6ade210c46652bd610707a /packages/meshbay-hub | |
| parent | 0d9d91eeea9001ea3838272416e3d526b6a2a1fc (diff) | |
| download | meshbay-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')
5 files changed, 184 insertions, 6 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'); diff --git a/packages/meshbay-hub/tests/test_bundle_key.py b/packages/meshbay-hub/tests/test_bundle_key.py index 333f8f8..f6c99f8 100644 --- a/packages/meshbay-hub/tests/test_bundle_key.py +++ b/packages/meshbay-hub/tests/test_bundle_key.py @@ -156,7 +156,7 @@ def test_an_earlier_format_is_refused_by_name(tmp_path): } console.log(JSON.stringify(results)); """) - assert out == [["retired", "bundle_format_retired"], ["retired", "bundle_format_retired"]] + assert out == [["legacy", "bundle_format_retired"], ["retired", "bundle_format_retired"]] def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path): @@ -181,3 +181,49 @@ def test_the_playlist_key_is_no_node_key(tmp_path): })); """) assert out["distinct"] + + +def test_an_mbk2_bundle_is_opened_once_and_sealed_again_as_mbk3(tmp_path): + """ + TRANSITIONAL. Nodes still hold bundles sealed under the passphrase's Argon2 + key alone. The same Argon2 run that makes `M` makes that key, so the session + keeps it — decrypt only — and the identity is moved to MBK3 on the account's + next visit instead of the member being re-invited. + """ + out = _run(tmp_path, """ + argonCalls = 0; + const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1); + const calls = argonCalls; + // An MBK2 bundle as 0.16 wrote it: "MBK2" ‖ nonce ‖ AES-GCM(A), no AAD. + const a = await crypto.subtle.importKey('raw', await _bundleKeyBytes('p', 'someone'), + { name: 'AES-GCM' }, false, ['encrypt']); + const nonce = new Uint8Array(12).fill(3); + const plain = new TextEncoder().encode(JSON.stringify({ skEd: btoa('ED'), skX: btoa('XX') })); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, a, plain)); + const raw = new Uint8Array(4 + 12 + ct.length); + raw.set(new TextEncoder().encode('MBK2')); raw.set(nonce, 4); raw.set(ct, 16); + const mbk2 = btoa(String.fromCharCode(...raw)); + + const keys = await K().decryptLegacyBundle(mbk2, sk.legacy); + const resealed = await K().resealLegacyIdentity(keys, sk, null, { userId: 'uid-1', nodePk: 'NODE' }); + const back = await K().decryptBundle(resealed.bundleEnc, await K().nodeBundleKey(sk, 'NODE'), + { userId: 'uid-1', nodePk: 'NODE' }); + let otherPassphrase = 'opened'; + const sk2 = await K().deriveBundleSessionKey('another', 'someone', 'uid-1', PEPPER, 1); + try { await K().decryptLegacyBundle(mbk2, sk2.legacy); } catch { otherPassphrase = 'refused'; } + let sealsUnderLegacy = 'yes'; + try { await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, sk.legacy, plain); } + catch { sealsUnderLegacy = 'no'; } + console.log(JSON.stringify({ + calls, format: K().bundleFormat(mbk2), keys, newFormat: K().bundleFormat(resealed.bundleEnc), + back, otherPassphrase, sealsUnderLegacy, extractable: sk.legacy.extractable, + })); + """) + assert out["calls"] == 1, "keeping the legacy key must not cost a second Argon2 run" + assert out["format"] == "legacy" + assert out["keys"] == {"skEd": "RUQ=", "skX": "WFg="} + assert out["newFormat"] == "current" + assert out["back"] == out["keys"] + assert out["otherPassphrase"] == "refused" + assert out["sealsUnderLegacy"] == "no", "the legacy key opens; it never seals" + assert out["extractable"] is False diff --git a/packages/meshbay-hub/tests/test_desktop_keyring.py b/packages/meshbay-hub/tests/test_desktop_keyring.py index 963ee55..e55e6d0 100644 --- a/packages/meshbay-hub/tests/test_desktop_keyring.py +++ b/packages/meshbay-hub/tests/test_desktop_keyring.py @@ -119,10 +119,33 @@ const v = JSON.parse(fs.readFileSync(input, 'utf8')); await K.nodeBundleKey(sk, 'NODE-P'), { userId: v.userId, nodePk: 'NODE-P' }); out.sealed_here_opens_in_page = back.skX === pageId.skXB64; + // 3b. TRANSITIONAL: an MBK2 bundle, sealed under the Argon2 key alone as + // 0.16 wrote it, is opened with the legacy key kept beside M. + { + const nc = require('crypto'); + const salt = nc.createHash('sha256').update(`meshbay:bundle:v2:${v.user}`).digest().subarray(0, 16); + const a = await argon2(v.password, salt, + { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 }); + const ed = nc.generateKeyPairSync('ed25519').privateKey.export({ format: 'der', type: 'pkcs8' }); + const x = nc.generateKeyPairSync('x25519').privateKey.export({ format: 'der', type: 'pkcs8' }); + const nonce = nc.randomBytes(12); + const c = nc.createCipheriv('aes-256-gcm', Buffer.from(a), nonce); + const body = Buffer.concat([c.update(JSON.stringify({ skEd: ed.toString('base64'), + skX: x.toString('base64') })), c.final()]); + const mbk2 = Buffer.concat([Buffer.from('MBK2'), nonce, body, c.getAuthTag()]).toString('base64'); + out.legacy_open = ring.openBundle(v.userId, 'NODE-L', { bundleEnc: mbk2 }); + out.legacy_kept = ring.identity(v.userId, 'NODE-L'); + const keptLegacy = store.masters[v.userId].legacy; + delete store.masters[v.userId].legacy; + try { ring.openBundle(v.userId, 'NODE-M', { bundleEnc: mbk2 }); out.legacy_missing = 'opened'; } + catch (e) { out.legacy_missing = e.message; } + store.masters[v.userId].legacy = keptLegacy; + } + // 4. nothing but public keys come out of the keyring's answers. out.identity_answer = ring.identity(v.userId, v.node); out.retired = (() => { try { ring.openBundle(v.userId, 'NODE-R', - { bundleEnc: Buffer.from('MBK2' + 'x'.repeat(40)).toString('base64') }); } + { bundleEnc: Buffer.from('y'.repeat(44)).toString('base64') }); } catch (e) { return e.code; } })(); out.access_default = ring.browserAccess('someone-else'); ring.setBrowserAccess(v.userId, false); @@ -284,3 +307,12 @@ def test_the_application_never_asks_its_own_crypto_for_argon2(): source = (KEYRING.parent / name).read_text(encoding="utf-8") assert "crypto.argon2" not in source, name assert "wasmArgon2(" in (KEYRING.parent / "main.js").read_text(encoding="utf-8") + + +def test_an_mbk2_bundle_is_opened_with_the_kept_legacy_key(out): + """TRANSITIONAL. Kept unsealed, so the next settle replaces the node's copy + with MBK3 or withdraws it; without the legacy key the passphrase is asked + for, rather than the identity being given up.""" + assert set(out["legacy_open"]) == {"pkEdB64", "pkXB64"} + assert out["legacy_kept"]["sealedWith"] is None + assert out["legacy_missing"] == "no_legacy_key" |