aboutsummaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--docs/MESHBAY_DESIGN.md47
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md12
-rw-r--r--packages/meshbay-client/src/keyring.js34
-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
-rw-r--r--packages/meshbay-hub/tests/test_bundle_key.py48
-rw-r--r--packages/meshbay-hub/tests/test_desktop_keyring.py34
8 files changed, 252 insertions, 31 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index e721cb2..c443955 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -651,15 +651,27 @@ which were a second oracle when their key came from the passphrase alone.
A bundle copied to another node, or served for another account, does not open.
The recovery copy (§3.6) has the same format under the recovery key, with pepper
-version 0. **Nothing else is read**: a bundle in an earlier format — sealed under
-the passphrase alone — is refused by name, never opened and never replaced by a
-new identity behind the member's back, which would leave the node pinning a key
-nobody holds. The client says so, and the way out is the operator's: `member
-unpin` (which drops the bundle) and a fresh code. That was the flag day of 0.17.0
-(§5.6).
+version 0. **Nothing else is written.** One earlier format is still read, once,
+to be replaced (*transitional*): `MBK2` — `"MBK2" ‖ nonce ‖ AES-GCM(A, {skEd, skX})`,
+no associated data — sealed under the passphrase's Argon2 key alone. The Argon2
+run that makes `M` makes `A` anyway, so a session keeps `A` too, as a
+decrypt-only key (in IndexedDB beside `M`; in the desktop application beside `M`
+in its key storage, until sign-out). A client that meets an `MBK2` bundle opens
+it with that key — or its recovery copy with the recovery key — and stores the
+same identity as `MBK3` once the connection is made; the desktop application
+stores it, or withdraws it, as the account's browser access says. Keeping `A`
+for a session adds nothing a node does not already hold: the `MBK2` bundle on its
+disk is the same oracle, until it is replaced. A session that has no `A` — one
+opened before it was kept, a device sign-in — asks for the passphrase once. A
+legacy key that does not open it means a bundle sealed under an older passphrase,
+treated like a current bundle that does not open. Anything older than `MBK2` is
+refused by name, never opened and never replaced by a new identity behind the
+member's back, which would leave the node pinning a key nobody holds; the way
+out is the operator's, `member unpin` and a fresh code. The `MBK2` reader goes
+once no node holds one.
**What a session keeps is `M`**, non-extractable, in IndexedDB until sign-out.
-Not the pepper and not `A`. So the hub is asked for the pepper once per sign-in,
+Not the pepper, and `A` only as the transitional decrypt-only key above. So the hub is asked for the pepper once per sign-in,
inside the sign-in response, and never again while the session lasts: reloads,
reconnections and new nodes derive nothing.
@@ -1411,17 +1423,16 @@ reachable on every node, which is exactly what "no compatibility switch" forbids
So the floor moved to 4.0, `client.minimum` moved to the release that carries the
new client, and the hub, node and SPA deploy together.
-**0.17.0 is a flag day with no protocol change at all**, and it follows the same
-rule. The keypair bundle is opaque to the node, so MNP did not move; what changed
-is what a client writes into it and reads out of it (`MBK3`, §3.7). A client that
-still read the older format would keep it reachable, and an older client would
-still write it — the passphrase-only seal that the pepper exists to replace. So
-the client refuses the old format by name, and `client.minimum` moved to 0.17.0
-so that no desktop client can go on writing it; the browser takes the new client
-from the hub on reload. What it costs is stated rather than migrated: each
-identity sealed in the old format is re-created on its node after `member unpin`
-and a fresh code, and a playlist the node alone held is lost — any copy a browser
-or the application still has is sealed again over it.
+**0.17.0 is a flag day**, and it follows the same rule. What a client writes into
+the keypair bundle is `MBK3` (§3.7); an older client would go on writing the
+passphrase-only seal the pepper exists to replace, so `client.minimum` is 0.17.0
+and the browser takes the new client from the hub on reload. An `MBK2` bundle is
+read once and replaced (§3.7), so no identity has to be re-created; a playlist the
+node alone held under the old key is lost — any copy a browser or the application
+still has is sealed again over it. And retiring a device is signed under a
+transcript of its own (`meshbay:device_revoke:v1`, §3.3), so a 0.17 client and a
+0.16 node, or the reverse, cannot retire devices with each other: hub, nodes and
+clients move together.
**Every *requirement* is true of every peer the client can reach.** The floor moves
with each MAJOR, so `check_version` refuses at the handshake any peer that cannot
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index 676b96f..e7fce90 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -685,11 +685,13 @@ the node that proved it, for the account that stored it.
(`keyderive.js` in a browser, `keyring.js` in the desktop application's main
process), and optionally a second copy under the account recovery key. The node
stores bytes and serves them back to the same `user_id`.
-* **A client reads `MBK3` and nothing else.** A bundle in an earlier format was
- sealed under the passphrase alone; the client refuses it by name
- (`bundle_format_retired`) and does not mint a replacement identity, which would
- leave the node pinning a key nobody holds. `member unpin` drops the bundle, and
- the next join is a first contact.
+* **A client writes `MBK3` and nothing else.** It reads one earlier format once,
+ to replace it (*transitional*): `MBK2`, `"MBK2" ‖ nonce (12) ‖ AES-GCM` under the
+ passphrase's Argon2 key, no associated data. The identity in it is stored again
+ as `MBK3` with `keypair_bundle_store` once the session is up. Anything older is
+ refused by name (`bundle_format_retired`), and the client does not mint a
+ replacement identity, which would leave the node pinning a key nobody holds;
+ `member unpin` drops the bundle, and the next join is a first contact.
* `keypair_bundle_store` is accepted **after** authentication (it is not in the
pre-proof list); the fetch is what happens before.
* A `store` omitting `bundle_enc_recovery` leaves any existing recovery copy in place.
diff --git a/packages/meshbay-client/src/keyring.js b/packages/meshbay-client/src/keyring.js
index 4fb6eac..ec37fd4 100644
--- a/packages/meshbay-client/src/keyring.js
+++ b/packages/meshbay-client/src/keyring.js
@@ -24,6 +24,8 @@ const { transcriptFor } = require('./transcripts.js');
// keyderive.js: the same numbers, or no bundle opens across the two.
const ARGON2 = { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 };
const MAGIC = Buffer.from('MBK3');
+// TRANSITIONAL — the format before MBK3, read once to be replaced (keyderive.js).
+const LEGACY_MAGIC = Buffer.from('MBK2');
const X25519_SPKI = Buffer.from('302a300506032b656e032100', 'hex');
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
@@ -63,6 +65,17 @@ function seal(identity, key, userId, nodePk, pepperVersion) {
return b64(Buffer.concat([MAGIC, Buffer.from([pepperVersion & 0xff]), nonce, ct]));
}
+/** TRANSITIONAL — MBK2: "MBK2" ‖ nonce ‖ AES-GCM under the Argon2 key, no AAD. */
+function openLegacy(bundleB64, key) {
+ const raw = unb64(bundleB64);
+ const nonce = raw.subarray(4, 16);
+ const body = raw.subarray(16, raw.length - 16);
+ const d = crypto.createDecipheriv('aes-256-gcm', key, nonce);
+ d.setAuthTag(raw.subarray(raw.length - 16));
+ const plain = JSON.parse(Buffer.concat([d.update(body), d.final()]).toString());
+ return { ed: plain.skEd, x: plain.skX };
+}
+
function open(bundleB64, key, userId, nodePk) {
const raw = unb64(bundleB64);
if (!raw.subarray(0, 4).equals(MAGIC)) {
@@ -142,7 +155,11 @@ function createKeyring({ load, save, argon2 }) {
const v = pepperVersion || 1;
if (p) { pending.set(userId, { m, v }); return true; }
const s = state();
- s.masters[userId] = { m: b64(m), v };
+ // `legacy` (TRANSITIONAL): the Argon2 key itself, which MBK2 bundles
+ // were sealed under — kept beside `M`, in the same OS-protected store
+ // and for as long, so a node still holding one has it opened and
+ // replaced on the next connection. Remove once no MBK2 bundle is left.
+ s.masters[userId] = { m: b64(m), v, legacy: b64(a) };
save(s);
return true;
},
@@ -150,7 +167,10 @@ function createKeyring({ load, save, argon2 }) {
const p = pending.get(userId);
if (!p) return false;
const s = state();
- s.masters[userId] = { m: b64(p.m), v: p.v };
+ // The legacy key stays the old passphrase's: MBK2 bundles were sealed
+ // under that one, never under the new.
+ const legacy = (s.masters[userId] || {}).legacy;
+ s.masters[userId] = { m: b64(p.m), v: p.v, ...(legacy ? { legacy } : {}) };
save(s);
pending.delete(userId);
return true;
@@ -177,6 +197,16 @@ function createKeyring({ load, save, argon2 }) {
* was entered (a reset on a machine that had never held this identity).
*/
openBundle(userId, nodePk, { bundleEnc, recoveryEnc, recoveryMnemonic, username }) {
+ if (unb64(bundleEnc).subarray(0, 4).equals(LEGACY_MAGIC)) {
+ // TRANSITIONAL. Kept unsealed (`sealedWith: null`), so the next
+ // settle replaces the node's copy with MBK3, or withdraws it when the
+ // account has no browser access.
+ const legacy = (state().masters[userId] || {}).legacy;
+ if (!legacy) throw new Error('no_legacy_key');
+ const id = openLegacy(bundleEnc, unb64(legacy));
+ keep(userId, nodePk, { ...id, sealedWith: null });
+ return publicOf(id);
+ }
const { m } = master(userId);
let id;
try {
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"