aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 15:06:14 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 15:06:14 +0200
commit91297944791a36f30302ef8c86dd69ebeb177671 (patch)
tree568188114baf438059458f1bc87903f4894cec90 /packages/meshbay-hub/src/meshbay_hub/static
parenta55d40b74bda77dff6ec565abdd551607fc665d6 (diff)
downloadmeshbay-91297944791a36f30302ef8c86dd69ebeb177671.tar.gz
feat: bundles sealed per node under the passphrase and the hub's pepper
The session key is M = HKDF(Argon2(passphrase) || pepper, account id); each node's bundle key and the playlist key derive from it. Bundles are MBK3, bound to account and node; MBK1/MBK2 are refused by name, never replaced silently. Playlists move to key v2 and are re-sealed over unreadable node copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js262
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlists.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js41
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js28
19 files changed, 273 insertions, 197 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 09c4acf..8e67e20 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -683,9 +683,12 @@ export function ResetPasswordPage({ onLogin }) {
await _storeRecoveryKey(session.recoveryKey);
setPhase('working');
const auth = loadAuth() || {};
+ // The sign-in above derived the key for the new passphrase; the bundles
+ // are still sealed under the old one, so connect opens the recovery copy
+ // and they are sealed again under this key.
const r = await window.MeshBayTransport.rewrapAllNodes({
hubUrl: HUB, token: auth.token, username: name, userId: auth.userId,
- newPassphrase: password, recoveryKey: mnemonic,
+ bundleKey: session.bundleKey, recoveryKey: mnemonic,
onProgress: setProgress,
});
setResult(r);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index b6f3d37..b18c811 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -245,12 +245,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setError('');
try {
// Same derivation as sign-in — the token is already ours, only the key
- // that opens node bundles is missing here. Persisted so this browser is
- // set up from now on.
- session.bundleKey = {
- ...(await window.MeshBayKeys.bundleKeyPairFields(pass, username)),
- v1: await window.MeshBayKeys.deriveEncryptionKeyV1(pass, username),
- };
+ // that opens node bundles is missing here, and the pepper that goes into
+ // it is asked for with that token. Persisted so this browser is set up
+ // from now on.
+ const { pepper, version } = await window.MeshBayKeys.fetchBundlePepper(token);
+ session.bundleKey = await window.MeshBayKeys.deriveBundleSessionKey(
+ pass, username, userId, pepper, version);
await _storeBundleKey(session.bundleKey);
setPassInput('');
setNeedsPass(false);
@@ -260,7 +260,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
} finally {
setPassBusy(false);
}
- }, [passInput, username]);
+ }, [passInput, username, userId, token]);
// Everything one handshake ack tells this page, applied in one place.
//
@@ -650,6 +650,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// The node has no bundle for us and this browser derived no key to make
// one — the passphrase form below is the way in, not a support request.
if (err.reason === 'no_keys') setNeedsPass(true);
+ // An identity sealed before the pepper: only the operator can clear it.
+ if (err.reason === 'bundle_format_retired') err.message = t('group.bundle_format_retired');
// A key this node has never pinned, for an account it knows. The way in
// is a device already trusted here, not an operator — which is the
// whole point of device linking: a second browser or a native client
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index ba91f00..3081ad8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -159,7 +159,12 @@ async function _loadKey(slot) {
// only groups joined in the unbroken session that generated it. Cleared with
// everything else on sign-out.
const _storeBundleKey = (key) => _storeKey('bk', key);
-const _loadBundleKey = () => _loadKey('bk');
+// A key stored before the pepper (`{v2, v1, …}`) opens nothing any more: it is
+// no key at all, and the group page asks for the passphrase again.
+const _loadBundleKey = async () => {
+ const k = await _loadKey('bk');
+ return k && k.v3 ? k : null;
+};
const _storeRecoveryKey = (key) => _storeKey('rk', key);
const _loadRecoveryKey = () => _loadKey('rk');
async function _clearKeyDB() {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 33b1cf2..8f820ec 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -1,23 +1,14 @@
/**
* MeshBay Browser Key Management — keyderive.js
*
- * Web registration flow (avoids algorithm mismatch with Python Argon2id):
+ * Two things come from the passphrase, kept apart by their salts:
+ * - `auth_key` (PBKDF2), the hub credential — the passphrase never leaves;
+ * - `A` (Argon2id), which with the hub-held pepper gives the session's bundle
+ * key `M`, from which each node's bundle key and the playlist key derive.
*
- * REGISTRATION:
- * 1. Browser generates RANDOM Ed25519 + X25519 keypairs via WebCrypto
- * 2. Bundle (sk_ed || sk_x) is encrypted with AES-256-GCM
- * using a key derived from password via PBKDF2-SHA512
- * 3. Encrypted bundle + public keys sent to hub for storage
- *
- * LOGIN (new device):
- * 1. Hub returns the encrypted bundle
- * 2. Browser decrypts it locally with the password
- * 3. Private keys loaded into memory (never leave the browser)
- *
- * Password change: re-encrypt bundle with new password-derived key.
- *
- * Keys never leave the browser in cleartext.
- * Hub stores: public keys + encrypted bundle (cannot read private keys).
+ * Identity keys are per node: generated on a first join, sealed for that node
+ * and that account (`MBK3`), and left with that node. The hub stores no user
+ * key. See docs/MESHBAY_DESIGN.md §3.1, §3.7.
*/
const PBKDF2_ITERATIONS = 600000; // OWASP 2023 recommendation for PBKDF2-SHA512
@@ -52,7 +43,7 @@ function hubCall(path, init) {
/**
* Derive an auth key from password + username using PBKDF2-SHA512.
* This key is sent to the hub for authentication — the raw password never leaves the browser.
- * Uses a different salt domain than deriveEncryptionKey (bundle key), so the two
+ * Uses a different salt domain than the bundle key's Argon2 run, so the two
* derived values are cryptographically independent.
*/
async function deriveAuthKey(password, username) {
@@ -108,9 +99,11 @@ const ARGON2_MEM_KIB = 131072; // 128 MB
const ARGON2_TIME = 3;
const ARGON2_LANES = 1;
-// Bundles written before this carry no marker and are read with the old KDF.
-// They are re-encrypted the first time their owner signs in (see upgradeBundle).
-const BUNDLE_V2_MAGIC = 'MBK2';
+// What a bundle is written as. Nothing else is read: a bundle in an earlier
+// format was sealed under the passphrase alone, which is exactly what an
+// operator holding it could attack offline, and there is no migration window —
+// such an identity is re-created on that node after the operator unpins it.
+const BUNDLE_MAGIC = 'MBK3';
function _argon2() {
const a = (typeof window !== 'undefined' && window.argon2) || globalThis.argon2;
@@ -118,29 +111,10 @@ function _argon2() {
return a;
}
-/** Legacy: PBKDF2-SHA512. Kept to read bundles written before the change. */
-async function deriveEncryptionKeyV1(password, username) {
- const enc = new TextEncoder();
- const km = await crypto.subtle.importKey(
- 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
- const salt = await crypto.subtle.digest(
- 'SHA-256', enc.encode(`meshbay:bundle:v1:${username}`));
- return crypto.subtle.deriveKey(
- { name: 'PBKDF2', hash: 'SHA-512', salt, iterations: PBKDF2_ITERATIONS },
- km,
- { name: 'AES-GCM', length: 256 },
- false,
- ['encrypt', 'decrypt'],
- );
-}
-
/**
- * Derive the bundle key with Argon2id.
- *
- * The salt stays deterministic and domain-separated per user, as before: it is
- * what lets the key be derived once at sign-in and kept, instead of holding the
- * passphrase in memory to re-derive it whenever a bundle turns up. It is unique
- * per account, so it does what a salt is for — no shared precomputation.
+ * `A`, the passphrase's half of the bundle key: Argon2id with a deterministic,
+ * per-account salt. Deterministic so it can be derived once at sign-in and the
+ * passphrase dropped; unique per account, so no shared precomputation.
*/
async function _bundleKeyBytes(password, username) {
const enc = new TextEncoder();
@@ -154,41 +128,75 @@ async function _bundleKeyBytes(password, username) {
return out.hash;
}
-async function deriveEncryptionKey(password, username) {
- return crypto.subtle.importKey(
- 'raw', await _bundleKeyBytes(password, username),
- { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
-}
+const _b64bytes = (b64) => Uint8Array.from(atob(b64), c => c.charCodeAt(0));
+const _hkdf = (info) => ({
+ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
+ info: new TextEncoder().encode(info),
+});
/**
- * The bundle key as **two handles over one Argon2 run**.
- *
- * `aes` is what has always been returned: the key that opens a node's identity
- * bundle. `hkdf` is the same 32 bytes imported a second time as an HKDF key,
- * from which purpose-separated subkeys can be derived — playlists are the
- * first (docs/playlists.md §3.4).
+ * The session's bundle key: `M = HKDF(A ‖ pepper, "…master:v3|" + user id)`.
*
- * It has to be a second import of the same bytes, and not a derivation from
- * `aes`: that one is imported non-extractably with `['encrypt','decrypt']`, so
- * nothing can be derived from it at all. And it has to be one Argon2 run: a
- * second call would put another ~650 ms on the sign-in path for a key that is
- * mathematically identical.
+ * The pepper is held by the hub and handed only to a session that proved the
+ * passphrase or a device key (docs/MESHBAY_DESIGN.md §3.7). Without it a bundle
+ * cannot be opened however good the guess, so the operator of a node holding
+ * one has nothing to test offline. `M` is what a session keeps — imported as a
+ * non-extractable HKDF key, in IndexedDB until sign-out — and every key that
+ * opens something is derived from it: one per node, one for playlists. The
+ * pepper itself and `A` are not kept.
*
- * A subkey rather than the bundle key reused with a different AAD, for the
- * reason `groupbox.py` already writes down for chunk keys — purpose separation
- * is what stops one use's mistake becoming every use's.
+ * One Argon2 run: the ~650 ms on the sign-in path is the whole budget.
*/
-async function deriveBundleKeys(password, username) {
- const raw = await _bundleKeyBytes(password, username);
+async function deriveBundleSessionKey(password, username, userId, pepperB64, pepperVersion) {
+ if (!userId || !pepperB64) throw new Error('the hub did not provide the bundle pepper');
+ const a = new Uint8Array(await _bundleKeyBytes(password, username));
+ const pepper = _b64bytes(pepperB64);
+ const ikm = new Uint8Array(a.length + pepper.length);
+ ikm.set(a);
+ ikm.set(pepper, a.length);
+ const base = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
+ const m = await crypto.subtle.deriveBits(
+ _hkdf(`meshbay:bundle-master:v3|${userId}`), base, 256);
return {
- aes: await crypto.subtle.importKey(
- 'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']),
- // HKDF keys are non-extractable by specification; `false` is the only
- // value this accepts.
- hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']),
+ // HKDF keys are non-extractable by specification.
+ v3: await crypto.subtle.importKey('raw', m, 'HKDF', false, ['deriveKey', 'deriveBits']),
+ pepperVersion: pepperVersion || 1,
};
}
+/**
+ * The key that seals this account's identity on ONE node. A leaked one opens
+ * that node's bundle and no other.
+ */
+async function nodeBundleKey(sessionKey, nodePkB64) {
+ if (!sessionKey || !sessionKey.v3) throw new Error('no bundle key in this session');
+ if (!nodePkB64) throw new Error("the node's key is not known yet");
+ return crypto.subtle.deriveKey(
+ _hkdf(`meshbay:bundle:v3|node|${nodePkB64}`), sessionKey.v3,
+ { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+}
+
+/**
+ * GET the pepper for a session that is already open — a stored session from
+ * before the pepper existed, a passphrase change. Sign-in carries it already.
+ */
+async function fetchBundlePepper(token) {
+ const resp = await hubCall('/v1/users/me/bundle-pepper', {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!resp.ok) throw new Error(`bundle pepper: ${resp.status}`);
+ const data = await resp.json();
+ return { pepper: data.bundle_pepper, version: data.bundle_pepper_version };
+}
+
+/** The account id a token of ours names — what the master key is bound to. */
+function _subOf(token) {
+ try {
+ const part = String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
+ return JSON.parse(atob(part)).sub || null;
+ } catch { return null; }
+}
+
// ── Account recovery key ─────────────────────────────────────────────────────
//
// docs/MESHBAY_DESIGN.md §3.6. A full-entropy secret the user keeps outside the
@@ -255,32 +263,40 @@ async function deriveRecoveryKey(R, username) {
// ── Bundle encryption ─────────────────────────────────────────────────────────
+// Binds a bundle to its account and its node: a bundle copied to another node,
+// or served for another account, does not open.
+const _bundleAad = (userId, nodePkB64) =>
+ new TextEncoder().encode(`meshbay:bundle:v3|${userId}|${nodePkB64}`);
+
/**
- * Encrypt the keypair bundle with a bundle key derived at sign-in. Always
- * writes v2. Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) }
+ * Seal a keypair bundle for one node:
+ * base64( "MBK3" ‖ pepper version (1 byte) ‖ nonce (12) ‖ AES-GCM(plaintext, aad) ).
+ * `pepperVersion` is 0 for a recovery copy, which is sealed under the recovery
+ * key and owes nothing to the pepper. Plaintext: JSON { skEd, skX } (pkcs8, b64).
*/
-async function encryptBundleWithKey(skEdRaw, skXRaw, aesKey) {
- const nonce = crypto.getRandomValues(new Uint8Array(12));
- const data = new TextEncoder().encode(JSON.stringify({
+async function encryptBundle(skEdRaw, skXRaw, aesKey, { userId, nodePk, pepperVersion }) {
+ if (!userId || !nodePk) throw new Error('a bundle is sealed for one account on one node');
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
+ const data = new TextEncoder().encode(JSON.stringify({
skEd: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))),
skX: btoa(String.fromCharCode(...new Uint8Array(skXRaw))),
}));
- const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, data);
- // base64( "MBK2" || nonce || ciphertext ). The marker is what tells a reader
- // which KDF produced the key, so old bundles stay readable and new ones are
- // never fed to the old derivation.
- const magic = new TextEncoder().encode(BUNDLE_V2_MAGIC);
- const out = new Uint8Array(magic.length + nonce.length + ct.byteLength);
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: _bundleAad(userId, nodePk) }, aesKey, data);
+ const magic = new TextEncoder().encode(BUNDLE_MAGIC);
+ const out = new Uint8Array(magic.length + 1 + nonce.length + ct.byteLength);
out.set(magic);
- out.set(nonce, magic.length);
- out.set(new Uint8Array(ct), magic.length + nonce.length);
+ out[magic.length] = pepperVersion & 0xff;
+ out.set(nonce, magic.length + 1);
+ out.set(new Uint8Array(ct), magic.length + 1 + nonce.length);
return btoa(String.fromCharCode(...out));
}
-function bundleVersion(bundleB64) {
+/** 'current', or 'retired' for anything written before MBK3. */
+function bundleFormat(bundleB64) {
try {
- return atob(bundleB64).startsWith(BUNDLE_V2_MAGIC) ? 2 : 1;
- } catch { return 1; }
+ return atob(bundleB64).startsWith(BUNDLE_MAGIC) ? 'current' : 'retired';
+ } catch { return 'retired'; }
}
// ── Registration ──────────────────────────────────────────────────────────────
@@ -325,15 +341,15 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha
}
/**
- * A fresh identity for one node, encrypted under the passphrase-derived key.
+ * A fresh identity for one node, sealed for that node only.
*
* Returns { skEdB64, skXB64, pkXB64, bundleEnc, bundleEncRecovery? } — the
* bundle goes to that node and nowhere else, and is what any other browser
* fetches to become the same person there. When `recoveryKey` is supplied a
- * second copy wrapped under it rides along, so a forgotten passphrase does not
+ * second copy sealed under it rides along, so a forgotten passphrase does not
* strand this identity (docs/MESHBAY_DESIGN.md §3.6).
*/
-async function generateNodeIdentity(bundleKey, recoveryKey) {
+async function generateNodeIdentity(sessionKey, recoveryKey, { userId, nodePk }) {
const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, { name: 'X25519' }, true, []);
@@ -342,31 +358,33 @@ async function generateNodeIdentity(bundleKey, recoveryKey) {
skEdB64: b64(skEdRaw),
skXB64: b64(skXRaw),
pkXB64: b64(pkXBytes),
- bundleEnc: await encryptBundleWithKey(skEdRaw, skXRaw, bundleKey.v2 || bundleKey),
+ bundleEnc: await encryptBundle(skEdRaw, skXRaw, await nodeBundleKey(sessionKey, nodePk),
+ { userId, nodePk, pepperVersion: sessionKey.pepperVersion }),
};
if (recoveryKey) {
- out.bundleEncRecovery = await encryptBundleWithKey(skEdRaw, skXRaw, recoveryKey);
+ out.bundleEncRecovery = await encryptBundle(skEdRaw, skXRaw, recoveryKey,
+ { userId, nodePk, pepperVersion: 0 });
}
return out;
}
/**
- * Decrypt a keypair bundle using a pre-derived AES-256 CryptoKey.
- * Used when the bundle is fetched from the node (bundleKey was derived at login).
+ * Open a bundle fetched from a node. A bundle in a retired format is refused
+ * with `code = 'bundle_format_retired'` — never opened, and never quietly
+ * replaced by a new identity: the caller says so.
*/
-async function decryptBundleWithKey(bundleB64, aesKeyOrPair) {
- const v2 = bundleVersion(bundleB64) === 2;
- // Callers derive both keys at sign-in and pass the pair, because which one a
- // bundle needs is only known once it has been read — and the passphrase is
- // deliberately not kept around to derive the other one later.
- const key = (aesKeyOrPair && aesKeyOrPair.v2)
- ? (v2 ? aesKeyOrPair.v2 : aesKeyOrPair.v1)
- : aesKeyOrPair;
- const raw = Uint8Array.from(atob(bundleB64), c => c.charCodeAt(0));
- const off = v2 ? BUNDLE_V2_MAGIC.length : 0;
- const nonce = raw.slice(off, off + 12);
- const ct = raw.slice(off + 12);
- const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
+async function decryptBundle(bundleB64, aesKey, { userId, nodePk }) {
+ if (bundleFormat(bundleB64) !== 'current') {
+ const err = new Error('bundle_format_retired');
+ err.code = 'bundle_format_retired';
+ throw err;
+ }
+ const raw = _b64bytes(bundleB64);
+ const off = BUNDLE_MAGIC.length + 1;
+ const plain = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: raw.slice(off, off + 12),
+ additionalData: _bundleAad(userId, nodePk) },
+ aesKey, raw.slice(off + 12));
return JSON.parse(new TextDecoder().decode(plain));
}
@@ -408,12 +426,11 @@ async function loginAndRecover(username, password) {
const result = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
- // Both, so a bundle written before the KDF changed can still be opened —
- // and re-written with the new one on the next backup.
- bundleKey: {
- ...(await _bundleKeyPairFields(password, username)),
- v1: await deriveEncryptionKeyV1(password, username),
- },
+ // The pepper rides on the sign-in response, so this costs no extra call;
+ // it is folded into the key here and not kept.
+ bundleKey: await deriveBundleSessionKey(
+ password, username, _subOf(data.access_token),
+ data.bundle_pepper, data.bundle_pepper_version),
};
// Nothing else to recover at sign-in. Identity keys belong to a node, so they
@@ -435,28 +452,13 @@ async function signBytes(skEdPkcs8B64, message) {
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
-/**
- * `{ v2, v2hkdf }` — the two fields every `session.bundleKey` carries for the
- * current KDF. One helper because there are two places that build that object
- * and they must not drift: a `v2hkdf` missing from one of them is a playlist
- * store that silently does nothing on whichever sign-in path skipped it.
- */
-async function _bundleKeyPairFields(password, username) {
- const { aes, hkdf } = await deriveBundleKeys(password, username);
- return { v2: aes, v2hkdf: hkdf };
-}
-
window.MeshBayKeys = {
registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes,
- deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
- // Exposed for the passphrase change (docs/MESHBAY_DESIGN.md §3.6): re-wrapping a
- // node's identity bundle needs the old key (a {v2,v1} pair, since an old
- // bundle may be v1) to read it and the new v2 key to write it back.
- deriveEncryptionKey, deriveEncryptionKeyV1,
- // One Argon2 run, an AES handle and an HKDF handle. Whatever builds a
- // `session.bundleKey` uses this, so `v2hkdf` is never the field one sign-in
- // path forgot (docs/playlists.md §3.4).
- deriveBundleKeys, bundleKeyPairFields: _bundleKeyPairFields,
+ deriveAuthKey,
+ // 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, nodeBundleKey, fetchBundlePepper,
+ encryptBundle, decryptBundle, bundleFormat,
// Account recovery key (docs/MESHBAY_DESIGN.md §3.6).
generateRecoveryKey, deriveRecoveryKey,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index e2363eb..3190f9d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1241,6 +1241,8 @@ export default {
'hosts.refuse': "Ablehnen",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Dieser Node speichert Ihre Identität in einem Format, das diese Version nicht mehr liest. Bitten Sie den Betreiber, „meshbay-node member unpin" für Ihr Konto auszuführen und Ihnen einen neuen Einladungscode zu senden.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Die Gruppe „{name}" auf diesem Computer hosten und den Ordner {path} mit ihren Mitgliedern teilen?',
'native.folder_confirm': 'Den Ordner {path} mit den Mitgliedern einer auf diesem Computer gehosteten Gruppe teilen? Er wurde nicht in der Ordnerauswahl gewählt.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 2fdda50..ea31e81 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1222,6 +1222,8 @@ export default {
'hosts.refuse': "Refuse",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'This node holds your identity in a format this version no longer reads. Ask its operator to run “meshbay-node member unpin” for your account and send you a new invitation code.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Host the group "{name}" on this computer and share the folder {path} with its members?',
'native.folder_confirm': 'Share the folder {path} with the members of a group hosted on this computer? It was not chosen in the folder picker.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 498cba3..2621632 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1235,6 +1235,8 @@ export default {
'hosts.refuse': "Rechazar",
'hosts.online': "en línea",
+ 'group.bundle_format_retired': 'Este node guarda su identidad en un formato que esta versión ya no lee. Pida a su operador que ejecute «meshbay-node member unpin» para su cuenta y le envíe un nuevo código de invitación.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': '¿Alojar el grupo «{name}» en este ordenador y compartir la carpeta {path} con sus miembros?',
'native.folder_confirm': '¿Compartir la carpeta {path} con los miembros de un grupo alojado en este ordenador? No se eligió en el selector de carpetas.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c62ed98..bdb89bd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1250,6 +1250,8 @@ export default {
'hosts.refuse': "Refuser",
'hosts.online': "en ligne",
+ 'group.bundle_format_retired': 'Ce node détient votre identité dans un format que cette version ne lit plus. Demandez à son opérateur d\'exécuter « meshbay-node member unpin » pour votre compte et de vous envoyer un nouveau code d\'invitation.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Héberger le groupe « {name} » sur cet ordinateur et partager le dossier {path} avec ses membres ?',
'native.folder_confirm': 'Partager le dossier {path} avec les membres d\'un groupe hébergé sur cet ordinateur ? Il n\'a pas été choisi dans le sélecteur de dossier.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 9f1d9f6..f87df9a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1249,6 +1249,8 @@ export default {
'hosts.refuse': "Rifiuta",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Questo node conserva la tua identità in un formato che questa versione non legge più. Chiedi al suo operatore di eseguire «meshbay-node member unpin» per il tuo account e di inviarti un nuovo codice di invito.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Ospitare il gruppo «{name}» su questo computer e condividere la cartella {path} con i suoi membri?',
'native.folder_confirm': 'Condividere la cartella {path} con i membri di un gruppo ospitato su questo computer? Non è stata scelta nel selettore di cartelle.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index a4bb5ca..3ca369f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1233,6 +1233,8 @@ export default {
'hosts.refuse': "拒否",
'hosts.online': "オンライン",
+ 'group.bundle_format_retired': 'この node は、このバージョンでは読めなくなった形式であなたの ID を保持しています。運用者に、あなたのアカウントに対して「meshbay-node member unpin」を実行し、新しい招待コードを送るよう依頼してください。',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'このコンピューターでグループ「{name}」をホストし、フォルダー {path} をメンバーと共有しますか?',
'native.folder_confirm': 'このコンピューターでホストしているグループのメンバーとフォルダー {path} を共有しますか?このフォルダーはフォルダー選択画面で選ばれたものではありません。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 2fdf236..a733fde 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1251,6 +1251,8 @@ export default {
'hosts.refuse': "Weigeren",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Deze node bewaart je identiteit in een formaat dat deze versie niet meer leest. Vraag de beheerder om "meshbay-node member unpin" voor je account uit te voeren en je een nieuwe uitnodigingscode te sturen.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'De groep "{name}" op deze computer hosten en de map {path} met de leden delen?',
'native.folder_confirm': 'De map {path} delen met de leden van een groep die op deze computer wordt gehost? Hij is niet gekozen in de mapkiezer.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 0530b79..2537bc1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1277,6 +1277,8 @@ export default {
'hosts.refuse': "Odrzuć",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Ten node przechowuje Twoją tożsamość w formacie, którego ta wersja już nie odczytuje. Poproś jego operatora o uruchomienie „meshbay-node member unpin" dla Twojego konta i przesłanie nowego kodu zaproszenia.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Hostować grupę „{name}" na tym komputerze i udostępnić jej członkom folder {path}?',
'native.folder_confirm': 'Udostępnić folder {path} członkom grupy hostowanej na tym komputerze? Nie został wybrany w oknie wyboru folderu.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index d2be432..73d3e21 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1236,6 +1236,8 @@ export default {
'hosts.refuse': "Recusar",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Este node guarda sua identidade em um formato que esta versão não lê mais. Peça ao operador que execute "meshbay-node member unpin" para sua conta e envie um novo código de convite.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Hospedar o grupo "{name}" neste computador e compartilhar a pasta {path} com os membros?',
'native.folder_confirm': 'Compartilhar a pasta {path} com os membros de um grupo hospedado neste computador? Ela não foi escolhida no seletor de pastas.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index c994780..f0440b8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1222,6 +1222,8 @@ export default {
'hosts.refuse': "拒绝",
'hosts.online': "在线",
+ 'group.bundle_format_retired': '此 node 以本版本不再读取的格式保存您的身份。请其运营者为您的账户运行“meshbay-node member unpin”,并向您发送新的邀请码。',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': '在这台电脑上托管群组“{name}”,并与其成员共享文件夹 {path}?',
'native.folder_confirm': '与这台电脑上托管的群组成员共享文件夹 {path}?该文件夹不是在文件夹选择器中选择的。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
index 0f41d1e..6323b96 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
@@ -47,21 +47,23 @@ const PAD_TO = 4096;
const NONCE_BYTES = 12;
/**
- * The playlist key, from the HKDF handle over the bundle key.
+ * The playlist key, from the session's bundle master key `M`
+ * (keyderive.js `deriveBundleSessionKey`).
*
- * A purpose-separated subkey rather than the bundle key reused with a different
+ * A purpose-separated subkey rather than a bundle key reused with a different
* AAD — the rule `groupbox.py` writes down for chunk keys, for the same reason.
- * v2 only: playlists are new, so there is no legacy blob and no v1 branch to
- * take by mistake.
+ * `v2`: the v1 key came from the passphrase alone, so a blob sealed under it
+ * was a second offline oracle for the passphrase on every node. Such a blob no
+ * longer opens, and the local copy is sealed again over it (playlists.js).
*/
-async function derivePlaylistKey(hkdfHandle) {
+async function derivePlaylistKey(masterKey) {
return crypto.subtle.deriveKey(
{
name: 'HKDF', hash: 'SHA-256',
salt: new Uint8Array(0),
- info: new TextEncoder().encode('meshbay:playlists:v1'),
+ info: new TextEncoder().encode('meshbay:playlists:v2'),
},
- hkdfHandle, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+ masterKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
index bac9b3d..eec94e8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
@@ -92,14 +92,14 @@ let _key = null;
let _keyFor = null;
/**
- * The playlist key, derived once per sign-in from the HKDF handle that rides
- * alongside the bundle key (`keyderive.js`'s deriveBundleKeys).
+ * The playlist key, derived once per sign-in from the session's bundle master
+ * key (`keyderive.js`'s deriveBundleSessionKey).
*
- * `v2hkdf` is absent when this browser's session predates it — a stored bundle
- * key from before the change, loaded out of IndexedDB. There is nothing to do
- * about that here and nothing to fall back to: the passphrase is not in memory
- * to re-derive from. Playlists stay local until the next sign-in, which is a
- * degradation rather than a failure and is reported as one.
+ * Absent when this browser holds no such key — a session stored before the
+ * pepper, whose key `_loadBundleKey` no longer returns. There is nothing to
+ * fall back to: the passphrase is not in memory to re-derive from. Playlists
+ * stay local until the passphrase is entered again, which is a degradation
+ * rather than a failure and is reported as one.
*/
async function playlistKey(userId) {
if (_key && _keyFor === userId) return _key;
@@ -108,8 +108,8 @@ async function playlistKey(userId) {
bundleKey = await _loadBundleKey();
if (bundleKey) session.bundleKey = bundleKey;
}
- if (!bundleKey || !bundleKey.v2hkdf) return null;
- _key = await derivePlaylistKey(bundleKey.v2hkdf);
+ if (!bundleKey || !bundleKey.v3) return null;
+ _key = await derivePlaylistKey(bundleKey.v3);
_keyFor = userId;
return _key;
}
@@ -574,8 +574,8 @@ async function syncWith(transport, userId) {
}
const key = await playlistKey(userId);
if (!key) {
- // No HKDF handle: a session from before it existed. Nothing to fall back
- // to, and silently doing nothing would be the worse answer.
+ // No bundle key in this browser (a session from before the pepper). Nothing
+ // to fall back to, and silently doing nothing would be the worse answer.
result.reason = 'no_key';
return result;
}
@@ -597,6 +597,7 @@ async function syncWith(transport, userId) {
}
let theirs = null;
+ let unreadableManifest = false;
if (have.has(MANIFEST_KIND)) {
let row = null;
try {
@@ -620,6 +621,7 @@ async function syncWith(transport, userId) {
console.warn('[MeshBay] playlist manifest on this node will not open:',
err.message, '— overwriting it with the local copy');
result.unreadable = true;
+ unreadableManifest = true;
theirs = null;
}
}
@@ -647,8 +649,15 @@ async function syncWith(transport, userId) {
const kind = bodyKind(p.id);
const nodeRev = have.has(kind) ? (have.get(kind) || 0) : -1;
const localRev = local.rev || 0;
+ // A body that will not open is not a newer copy of anything, and the local
+ // copy goes over it now — at a revision no lower than the node's, so every
+ // device still sees the node as current. Waiting for the next write left
+ // it unreadable indefinitely whenever the revisions happened to be equal,
+ // which after the playlist key changed is every body on every node. A
+ // manifest that would not open says the same of every body behind it.
+ let overwrite = unreadableManifest && have.has(kind);
- if (nodeRev > localRev) {
+ if (nodeRev > localRev && !overwrite) {
try {
const row = await transport.fetchUserBlob(kind);
if (row && row.blob_enc) {
@@ -660,12 +669,17 @@ async function syncWith(transport, userId) {
}
}
} catch {
- // Same reasoning as the manifest above, and already the right shape:
- // one body that will not open must not stop the rest, and the local
- // copy is pushed over it on the next write to that playlist.
+ // Same reasoning as the manifest above: one body that will not open
+ // must not stop the rest.
result.unreadable = true;
+ overwrite = true;
}
- } else if (localRev > nodeRev && localRev > 0) {
+ }
+ if ((overwrite || localRev > nodeRev) && localRev > 0) {
+ const pushRev = Math.max(localRev, nodeRev);
+ // The local copy takes the revision it is pushed under, or the next sync
+ // would see the node ahead and fetch it back every time.
+ if (pushRev > localRev) await _saveBody(st, { ...local, rev: pushRev });
// Was: one `catch {}` covering both of the cases below. A node that went
// away mid-sweep and a playlist that can never be sent are not the same
// event, and swallowing the second is the silent loss this whole design
@@ -673,7 +687,7 @@ async function syncWith(transport, userId) {
// stopped leaving the browser, and nothing anywhere says so.
let sealed;
try {
- sealed = await seal(local, kind, userId, key);
+ sealed = await seal({ ...local, rev: pushRev }, kind, userId, key);
} catch {
result.failed.push(p.name);
continue;
@@ -683,7 +697,7 @@ async function syncWith(transport, userId) {
continue;
}
try {
- await transport.storeUserBlob(kind, localRev, sealed);
+ await transport.storeUserBlob(kind, pushRev, sealed);
result.pushed += 1;
} catch {
// A node that went away mid-sweep: the next sync pushes this, because
@@ -704,6 +718,17 @@ async function syncWith(transport, userId) {
try { await transport.deleteUserBlob(kind); } catch { /* next time round */ }
}
+ // Behind a manifest that would not open, a body this browser has no playlist
+ // for is unreadable and unknown: nothing can merge it, and it only fills the
+ // account's quota on this node.
+ if (unreadableManifest) {
+ const known = new Set(Object.keys(merged.playlists).map(bodyKind));
+ for (const kind of have.keys()) {
+ if (kind === MANIFEST_KIND || known.has(kind)) continue;
+ try { await transport.deleteUserBlob(kind); } catch { /* next time round */ }
+ }
+ }
+
// The manifest goes last, so a node never advertises a body it has not been
// given: a reader on a third device would fetch a watermark, ask for the
// body behind it and be told there is none.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index d3d06d7..7a309a9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -141,10 +141,18 @@ export function ProfilePage({ user, onLogout }) {
try {
// Re-wrap every reachable node's identity bundle first — if this cannot
// run at all the account is left untouched.
+ // Both keys from the passphrases, with the pepper this open session asks
+ // for: the old one opens the bundles as they are, the new one seals them.
+ const K = window.MeshBayKeys;
+ const { pepper, version } = await K.fetchBundlePepper(user.token);
+ const oldKey = await K.deriveBundleSessionKey(
+ cpOld, user.username, user.userId, pepper, version);
+ const newKey = await K.deriveBundleSessionKey(
+ cpNew, user.username, user.userId, pepper, version);
const result = await window.MeshBayTransport.rewrapAllNodes({
hubUrl: HUB, token: user.token,
username: user.username, userId: user.userId,
- oldPassphrase: cpOld, newPassphrase: cpNew,
+ bundleKey: oldKey, newBundleKey: newKey,
onProgress: setCpProgress,
});
@@ -158,8 +166,8 @@ export function ProfilePage({ user, onLogout }) {
// Keep this tab signed in with the fresh pair, and move the session's
// bundle key forward so the next node connection opens the new bundles.
setAuth({ ...user, token: resp.access_token, refreshToken: resp.refresh_token });
- session.bundleKey = result.newBundleKey;
- _storeBundleKey(result.newBundleKey);
+ session.bundleKey = newKey;
+ _storeBundleKey(newKey);
setCpResult(result);
setCpPhase('done');
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 18ad9d4..a9229dc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
@@ -43,8 +43,11 @@ function _acWithTimeout(promise, ms, label) {
* @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 {object} o.bundleKey the session key that opens the bundles as they are
+ * (keyderive.js `deriveBundleSessionKey`). In Flow B it
+ * opens nothing and connect falls back to the recovery copy.
+ * @param {object} [o.newBundleKey] the key to seal them under; defaults to `bundleKey`
+ * (the Profile backfill: same key, a recovery copy added)
* @param {string} [o.recoveryKey] the recovery mnemonic (Flow B,
* docs/MESHBAY_DESIGN.md §3.6).
* When given, the recovery-wrapped copy is read where the
@@ -54,28 +57,14 @@ function _acWithTimeout(promise, ms, label) {
*/
async function rewrapAllNodes(o) {
const K = window.MeshBayKeys;
- if (!K || !K.deriveEncryptionKey) {
+ if (!K || !K.encryptBundle) {
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/MESHBAY_DESIGN.md §3.6). `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),
- };
- }
+ if (!o.bundleKey) throw new Error('no bundle key in this session');
+ // Keys, never passphrases: each caller has derived them already, with the
+ // pepper only the hub holds (docs/MESHBAY_DESIGN.md §3.7).
+ const oldKey = o.bundleKey;
+ const newKey = o.newBundleKey || o.bundleKey;
const recoveryKey = o.recoveryKey
? await K.deriveRecoveryKey(o.recoveryKey, o.username)
: null;
@@ -125,11 +114,15 @@ async function rewrapAllNodes(o) {
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);
+ // Sealed for this account on the node just connected to — the key
+ // that node proved during the handshake.
+ const sealedFor = { userId: o.userId, nodePk: tp.nodePk };
+ const reEnc = await K.encryptBundle(skEd, skX, await K.nodeBundleKey(newKey, tp.nodePk),
+ { ...sealedFor, pepperVersion: newKey.pepperVersion });
// 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)
+ ? await K.encryptBundle(skEd, skX, recoveryKey, { ...sealedFor, pepperVersion: 0 })
: null;
await tp.storeKeypairBundle(reEnc, reRecovery);
anyOk = true;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 04c2d23..b504a28 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -966,15 +966,31 @@ class MeshBayTransport {
// that opens nothing anywhere else.
let fresh = false;
if (!this._sessionKeys && this._bundleKey && window.MeshBayKeys) {
+ const K = window.MeshBayKeys;
+ // A bundle is sealed for this account on this node — the key the node
+ // just proved above, and no other.
+ const sealedFor = { userId: this._userId, nodePk: this.nodePk };
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
+ && K.bundleFormat(kpResp.bundle_enc) === 'retired') {
+ // Sealed under the passphrase alone, before the pepper. Not opened,
+ // and not replaced by a new identity behind the member's back: that
+ // would leave the node pinning a key nobody holds. The operator
+ // unpins them (which drops this bundle) and sends a new code.
+ const err = new Error('This node holds your identity in a format this version '
+ + 'no longer reads. Ask its operator to run "meshbay-node member unpin" '
+ + 'for your account and send you a new invitation code.');
+ err.reason = 'bundle_format_retired';
+ throw err;
+ }
if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) {
try {
- keys = await window.MeshBayKeys.decryptBundleWithKey(
- kpResp.bundle_enc, this._bundleKey);
+ keys = await K.decryptBundle(kpResp.bundle_enc,
+ await K.nodeBundleKey(this._bundleKey, this.nodePk), sealedFor);
} catch (e) {
openErr = e;
// The passphrase key did not open the bundle. If we hold a recovery
@@ -983,8 +999,8 @@ class MeshBayTransport {
// 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);
+ keys = await K.decryptBundle(
+ kpResp.bundle_enc_recovery, this._recoveryKey, sealedFor);
this._recoveredFromRecovery = true;
} catch { /* recovery copy did not open either */ }
}
@@ -1014,8 +1030,8 @@ class MeshBayTransport {
// 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);
+ const id = await K.generateNodeIdentity(
+ this._bundleKey, this._recoveryKey, sealedFor);
this._sessionKeys = {
skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64,
};