diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 15:06:14 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 15:06:14 +0200 |
| commit | 91297944791a36f30302ef8c86dd69ebeb177671 (patch) | |
| tree | 568188114baf438059458f1bc87903f4894cec90 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | a55d40b74bda77dff6ec565abdd551607fc665d6 (diff) | |
| download | meshbay-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')
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, }; |