From 91297944791a36f30302ef8c86dd69ebeb177671 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 30 Sep 2026 15:06:14 +0200 Subject: 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 --- packages/meshbay-hub/src/meshbay_hub/api/users.py | 9 +- .../src/meshbay_hub/static/auth-page.js | 5 +- .../src/meshbay_hub/static/group-page.js | 16 +- .../src/meshbay_hub/static/hub-client.js | 7 +- .../src/meshbay_hub/static/keyderive.js | 262 +++++++++++---------- .../src/meshbay_hub/static/locales/de.js | 2 + .../src/meshbay_hub/static/locales/en.js | 2 + .../src/meshbay_hub/static/locales/es.js | 2 + .../src/meshbay_hub/static/locales/fr.js | 2 + .../src/meshbay_hub/static/locales/it.js | 2 + .../src/meshbay_hub/static/locales/ja.js | 2 + .../src/meshbay_hub/static/locales/nl.js | 2 + .../src/meshbay_hub/static/locales/pl.js | 2 + .../src/meshbay_hub/static/locales/pt-BR.js | 2 + .../src/meshbay_hub/static/locales/zh-CN.js | 2 + .../src/meshbay_hub/static/playlist-crypto.js | 16 +- .../src/meshbay_hub/static/playlists.js | 63 +++-- .../src/meshbay_hub/static/profile-page.js | 14 +- .../src/meshbay_hub/static/transport-rewrap.js | 41 ++-- .../src/meshbay_hub/static/transport.js | 28 ++- .../tests/harness/playlist_store_probe.py | 51 +++- .../meshbay-hub/tests/harness/playlist_ui_probe.py | 7 +- .../meshbay-hub/tests/test_bundle_kdf_parity.py | 102 ++++++++ packages/meshbay-hub/tests/test_bundle_key.py | 183 ++++++++++++++ packages/meshbay-hub/tests/test_bundle_pepper.py | 12 - packages/meshbay-hub/tests/test_playlist_key.py | 221 ----------------- packages/meshbay-hub/tests/test_playlist_store.py | 21 +- packages/meshbay-hub/tests/test_recovery_key.py | 7 +- packages/meshbay-hub/tests/test_rewrap_fanout.py | 61 +++-- packages/meshbay-hub/tests/test_spa_ordering.py | 17 ++ 30 files changed, 683 insertions(+), 480 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_bundle_key.py delete mode 100644 packages/meshbay-hub/tests/test_playlist_key.py (limited to 'packages') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 72ac68f..88e6d9e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -368,8 +368,9 @@ async def _login_failed(db: AsyncSession, username: str, ip: str, # # Half of what opens this account's keypair bundles on nodes; the passphrase is # the other half. Handed out only where the caller proved the passphrase or a -# device key — sign-in, device sign-in, a passphrase change — or already holds a -# session that did (`GET /me/bundle-pepper`). Never on a token refresh, which +# device key — sign-in, device sign-in — or already holds a session that did +# (`GET /me/bundle-pepper`, which a passphrase change uses: it re-seals every +# bundle before the hub is asked to accept the new passphrase). Never on a token refresh, which # proves only possession of a refresh token; never to a node token; never in a # token or a log line. @@ -1084,9 +1085,6 @@ async def change_password( )) db.add(IPLog(user_id=current_user.id, event="password_change", ip_address=client_ip(request))) - # The new passphrase makes a new bundle key, and the client does not keep - # the pepper; this call proved the old passphrase, so it carries it. - pepper = _bundle_pepper(current_user) await db.commit() return { @@ -1095,7 +1093,6 @@ async def change_password( "refresh_token": raw_rt, "token_type": "bearer", "expires_in": _ttl(), - **pepper, } 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; - - if (nodeRev > localRev) { + // 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 && !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, }; diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py index 9a54a0d..857cf1e 100755 --- a/packages/meshbay-hub/tests/harness/playlist_store_probe.py +++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py @@ -80,15 +80,15 @@ function fakeNode() { (async () => { const fail = (why) => parent.postMessage({ error: why, logs: LOGS.slice(0, 10) }, '*'); try { - // A real HKDF handle over fixed bytes, as `deriveBundleKeys` would produce - // — the point is that playlists.js gets its key the way it really does. + // A real master key over fixed bytes, the shape `deriveBundleSessionKey` + // produces — the point is that playlists.js gets its key the way it + // really does. const raw = new Uint8Array(32).fill(5); session.bundleKey = { - v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, - ['encrypt', 'decrypt']), - v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']), + v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']), + pepperVersion: 1, }; - const key = await derivePlaylistKey(session.bundleKey.v2hkdf); + const key = await derivePlaylistKey(session.bundleKey.v3); // ── local editing ────────────────────────────────────────────────────── const eveningId = await P.createPlaylist(USER, 'Soirée'); @@ -364,6 +364,39 @@ function fakeNode() { names: readBack ? Object.values(readBack.playlists).map((p) => p.name).sort() : [], }); + // ── a node sealed under the previous playlist key ───────────────────── + // + // The key changed, not the playlists: every row on such a node carries the + // revision the local copy has, so "push only when the node is behind" + // would leave all of it unreadable for ever. Built from what a clean sync + // stores, then every row resealed under another key, plus a body for a + // playlist this browser has never heard of. + const clean = fakeNode(); + await P.syncWith(clean, USER); + const oldKeyNode = fakeNode(); + for (const [kind, r] of clean.rows) { + oldKeyNode.rows.set(kind, { rev: r.rev, blob: await seal( + kind === MANIFEST_KIND ? { v: 1, rev: r.rev, playlists: {} } + : { id: kind, rev: r.rev, tracks: [] }, + kind, USER, junkKey) }); + } + oldKeyNode.rows.set(bodyKind('never-seen-here'), { rev: 3, blob: await seal( + { id: 'never-seen-here', rev: 3, tracks: [] }, bodyKind('never-seen-here'), USER, junkKey) }); + const rekeyResult = await P.syncWith(oldKeyNode, USER); + const readable = []; + for (const [kind, r] of oldKeyNode.rows) { + try { await open(r.blob, kind, USER, key); readable.push(kind); } catch { /* not */ } + } + steps.push({ + step: 'a node sealed under the previous key', + result: rekeyResult, + kinds: [...clean.rows.keys()].sort(), + readable: readable.sort(), + remaining: [...oldKeyNode.rows.keys()].sort(), + revsNotLowered: [...clean.rows].every(([k, r]) => + (oldKeyNode.rows.get(k) || { rev: -1 }).rev >= r.rev), + }); + // ── what a playlist costs, sealed ───────────────────────────────────── // // The cap below is in bytes, but the only number a reader can act on is a @@ -449,12 +482,12 @@ function fakeNode() { // playlist is not a broken sync. stored: bigNode.stored.map((e) => ({ kind: e.kind, bytes: e.bytes })) }); - // ── a session with no HKDF handle degrades rather than failing ───────── + // ── a session with no bundle key degrades rather than failing ────────── P.setPlaylistTransport(null); P.forgetPlaylistKey(); - session.bundleKey = { v2: session.bundleKey.v2 }; // pre-change session + session.bundleKey = { v2: 'a key from before the pepper' }; // pre-change session const r3 = await P.syncWith(fakeNode(), USER); - steps.push({ step: 'a session from before the HKDF handle', result: r3 }); + steps.push({ step: 'a session from before the pepper', result: r3 }); parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*'); } catch (err) { diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py index c705244..6e3be1e 100644 --- a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py +++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py @@ -173,12 +173,11 @@ const clickMenu = async (i) => { try { await initLocale(); - // A real HKDF handle, so the store derives its key the way it really does. + // A real master key, so the store derives its key the way it really does. const raw = new Uint8Array(32).fill(3); session.bundleKey = { - v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, - ['encrypt', 'decrypt']), - v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']), + v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']), + pepperVersion: 1, }; // Deleting a playlist asks, in the page (ask.js) — so the probe answers the // dialog the way a person would, by clicking its OK button. diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py index c62aace..a4bee43 100644 --- a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py +++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py @@ -129,3 +129,105 @@ def test_parameters_still_match_the_client(): assert f"ARGON2_TIME = {TIME_COST}" in source assert f"ARGON2_LANES = {LANES}" in source assert "meshbay:bundle:v2:" in source + + +# ── The whole chain: A, the pepper, M, the node key, the playlist key ───────── +# +# The real keyderive.js and playlist-crypto.js, over the real WebAssembly +# Argon2, against a reference written from the specification with nothing +# shared: argon2-cffi, `cryptography`'s HKDF and AES-GCM. Down to opening an +# MBK3 bundle, so the format and its associated data agree too. + +_CHAIN_HARNESS = r""" +const fs = require('fs'), path = require('path'), url = require('url'); +const webcrypto = require('crypto').webcrypto; +global.self = global; global.window = global; global.crypto = webcrypto; +global.Module = { wasmBinary: fs.readFileSync(process.argv[2]) }; +global.argon2 = require(process.argv[3]); +eval(fs.readFileSync(process.argv[4], 'utf8')); +const K = window.MeshBayKeys; +const fp = async (key) => Buffer.from(await webcrypto.subtle.encrypt( + { name: 'AES-GCM', iv: new Uint8Array(12) }, key, new Uint8Array(16))).toString('hex'); +(async () => { + const { derivePlaylistKey } = await import(url.pathToFileURL(process.argv[5]).href); + const out = []; + for (const v of JSON.parse(fs.readFileSync(process.argv[6], 'utf8'))) { + const sk = await K.deriveBundleSessionKey(v.password, v.username, v.user_id, v.pepper, 1); + const kNode = await K.nodeBundleKey(sk, v.node_pk); + const bundle = await K.encryptBundle( + Buffer.from('ed-private'), Buffer.from('x-private'), kNode, + { userId: v.user_id, nodePk: v.node_pk, pepperVersion: 1 }); + out.push({ node: await fp(kNode), playlists: await fp(await derivePlaylistKey(sk.v3)), + bundle }); + } + process.stdout.write(JSON.stringify(out)); +})().catch((e) => { console.error(e); process.exit(1); }); +""" + +CHAIN = [ + {"username": "alice", "password": "correct horse battery staple", + "user_id": "0b4f6f0e-5d7e-4e8a-9d2b-6a1c1b9e2f11", "node_pk": "Tm9kZUtleUE="}, + {"username": "utilisateur-é", "password": "üñïçø∂é ✓ 🔐", + "user_id": "7d1e0c2a-3b4c-4d5e-8f60-718293a4b5c6", "node_pk": "Tm9kZUtleUI="}, +] + + +def _hkdf(ikm: bytes, info: str) -> bytes: + from cryptography.hazmat.primitives.hashes import SHA256 + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + return HKDF(algorithm=SHA256(), length=32, salt=None, info=info.encode()).derive(ikm) + + +def _fp(key: bytes) -> str: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + return AESGCM(key).encrypt(bytes(12), bytes(16), None).hex() + + +@pytest.fixture(scope="module") +def chain(tmp_path_factory): + import base64 + d = tmp_path_factory.mktemp("chain") + vectors = [{**v, "pepper": base64.b64encode(bytes([i + 1]) * 32).decode()} + for i, v in enumerate(CHAIN)] + (d / "harness.cjs").write_text(_CHAIN_HARNESS, encoding="utf-8") + (d / "vectors.json").write_text(json.dumps(vectors), encoding="utf-8") + proc = subprocess.run( + ["node", str(d / "harness.cjs"), str(VENDOR / "argon2.wasm"), + str(VENDOR / "argon2.min.js"), str(STATIC / "keyderive.js"), + str(STATIC / "playlist-crypto.js"), str(d / "vectors.json")], + capture_output=True, text=True, encoding="utf-8", timeout=300) + if proc.returncode != 0: + pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}") + return vectors, json.loads(proc.stdout) + + +def _reference(v: dict) -> tuple[bytes, bytes]: + import base64 + a = bytes.fromhex(_python_hash(v["username"], v["password"])) + m = _hkdf(a + base64.b64decode(v["pepper"]), f"meshbay:bundle-master:v3|{v['user_id']}") + return (_hkdf(m, f"meshbay:bundle:v3|node|{v['node_pk']}"), + _hkdf(m, "meshbay:playlists:v2")) + + +def test_the_node_and_playlist_keys_match_across_languages(chain): + vectors, js = chain + for v, got in zip(vectors, js): + k_node, k_pl = _reference(v) + assert got["node"] == _fp(k_node), f"node key disagrees for {v['username']!r}" + assert got["playlists"] == _fp(k_pl), f"playlist key disagrees for {v['username']!r}" + + +def test_an_mbk3_bundle_opens_from_the_specification(chain): + """Magic, pepper version, nonce, AES-GCM with the account and node as + associated data — read back by code that shares nothing with the writer.""" + import base64 + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + vectors, js = chain + for v, got in zip(vectors, js): + raw = base64.b64decode(got["bundle"]) + assert raw[:4] == b"MBK3" and raw[4] == 1 + aad = f"meshbay:bundle:v3|{v['user_id']}|{v['node_pk']}".encode() + plain = json.loads(AESGCM(_reference(v)[0]).decrypt(raw[5:17], raw[17:], aad)) + assert base64.b64decode(plain["skEd"]) == b"ed-private" + assert base64.b64decode(plain["skX"]) == b"x-private" diff --git a/packages/meshbay-hub/tests/test_bundle_key.py b/packages/meshbay-hub/tests/test_bundle_key.py new file mode 100644 index 0000000..333f8f8 --- /dev/null +++ b/packages/meshbay-hub/tests/test_bundle_key.py @@ -0,0 +1,183 @@ +""" +The bundle key: one Argon2 run, the hub's pepper, one key per node. + +What a node stores — an identity bundle, a playlist blob — is sealed under keys +derived from `M = HKDF(A ‖ pepper, account id)`, where `A` is the passphrase's +Argon2id and the pepper is held by the hub (docs/MESHBAY_DESIGN.md §3.7). Each +of these is quiet when wrong: + + - **One Argon2id run per sign-in.** The budget is the ~650 ms already on that + path; a second run doubles it and nothing on screen says so. + - **The pepper and the account are in the key.** Without the pepper, the + operator holding a bundle can test passphrase guesses again. + - **One key per node, and a bundle bound to its node and account.** A leaked + node key, or a bundle copied elsewhere, opens nothing else. + - **The playlist key is the same on every device of one account**, and is not + any node's key. + - **An earlier format is refused, by name** — never opened, never guessed at. + +Node's WebCrypto is the real implementation here; only Argon2 is stubbed, and +stubbed precisely so the calls can be counted. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +KEYDERIVE = STATIC / "keyderive.js" +PLAYLIST_CRYPTO = STATIC / "playlist-crypto.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not KEYDERIVE.exists(), + reason="node or the SPA sources are not available") + +# keyderive.js assigns `window.MeshBayKeys` and reads `window.argon2`; node has +# neither, and a counted stub is the whole point. +PRELUDE = """ +globalThis.window = globalThis; +let argonCalls = 0; +globalThis.argon2 = { + ArgonType: { Argon2id: 2 }, + async hash(opts) { + argonCalls++; + // Deterministic, and a function of what was actually passed, so a changed + // salt domain or cost parameter shows up as different bytes rather than + // silently agreeing. + const seed = new TextEncoder().encode( + opts.pass + ':' + Array.from(opts.salt).join(',') + ':' + opts.time); + const digest = new Uint8Array( + await crypto.subtle.digest('SHA-256', seed)); + return { hash: digest }; + }, +}; +""" + + +def _run(tmp_path, body): + src = KEYDERIVE.read_text(encoding="utf-8") + script = tmp_path / "case.mjs" + helpers = ( + "const K = () => window.MeshBayKeys;\n" + "const PEPPER = btoa(String.fromCharCode(...new Uint8Array(32).fill(7)));\n" + "const OTHER_PEPPER = btoa(String.fromCharCode(...new Uint8Array(32).fill(9)));\n" + "// Same key <=> same bytes out of a fixed encryption.\n" + "const fp = async (key) => btoa(String.fromCharCode(...new Uint8Array(\n" + " await crypto.subtle.encrypt({ name: 'AES-GCM', iv: new Uint8Array(12) }, key,\n" + " new Uint8Array(16)))));\n" + f"const {{ derivePlaylistKey }} = await import('{PLAYLIST_CRYPTO.as_uri()}');\n" + ) + script.write_text(f"{PRELUDE}\n{src}\n{helpers}\n{body}\n", encoding="utf-8") + out = subprocess.run(["node", str(script)], + capture_output=True, text=True, encoding="utf-8", timeout=60) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_a_sign_in_runs_argon2_exactly_once(tmp_path): + out = _run(tmp_path, """ + argonCalls = 0; + const key = await K().deriveBundleSessionKey('passphrase', 'someone', 'uid-1', PEPPER, 1); + console.log(JSON.stringify({ + calls: argonCalls, alg: key.v3.algorithm.name, + extractable: key.v3.extractable, version: key.pepperVersion, + })); + """) + assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost" + assert out["alg"] == "HKDF" and out["extractable"] is False + assert out["version"] == 1 + + +def test_without_the_pepper_there_is_no_key(tmp_path): + out = _run(tmp_path, """ + let refused = false; + try { await K().deriveBundleSessionKey('p', 'someone', 'uid-1', null, 1); } + catch { refused = true; } + console.log(JSON.stringify({ refused })); + """) + assert out["refused"], "a key derived from the passphrase alone is what a node could attack" + + +def test_the_pepper_and_the_account_are_part_of_the_key(tmp_path): + out = _run(tmp_path, """ + const node = async (pepper, uid) => fp(await K().nodeBundleKey( + await K().deriveBundleSessionKey('same passphrase', 'someone', uid, pepper, 1), 'NODE')); + const base = await node(PEPPER, 'uid-1'); + console.log(JSON.stringify({ + again: base === await node(PEPPER, 'uid-1'), + other_pepper: base !== await node(OTHER_PEPPER, 'uid-1'), + other_account: base !== await node(PEPPER, 'uid-2'), + })); + """) + assert out == {"again": True, "other_pepper": True, "other_account": True} + + +def test_a_bundle_opens_on_its_node_for_its_account_only(tmp_path): + out = _run(tmp_path, """ + const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1); + const kA = await K().nodeBundleKey(sk, 'NODE-A'); + const kB = await K().nodeBundleKey(sk, 'NODE-B'); + const sealed = await K().encryptBundle(new Uint8Array([1]), new Uint8Array([2]), kA, + { userId: 'uid-1', nodePk: 'NODE-A', pepperVersion: 1 }); + const opens = async (key, meta) => { + try { await K().decryptBundle(sealed, key, meta); return true; } catch { return false; } + }; + console.log(JSON.stringify({ + format: K().bundleFormat(sealed), + magic: atob(sealed).slice(0, 4), version: atob(sealed).charCodeAt(4), + right: await opens(kA, { userId: 'uid-1', nodePk: 'NODE-A' }), + other_node_key: await opens(kB, { userId: 'uid-1', nodePk: 'NODE-B' }), + moved_to_other_node: await opens(kA, { userId: 'uid-1', nodePk: 'NODE-B' }), + served_for_other_account: await opens(kA, { userId: 'uid-2', nodePk: 'NODE-A' }), + })); + """) + assert out["format"] == "current" and out["magic"] == "MBK3" and out["version"] == 1 + assert out["right"] is True + assert out["other_node_key"] is False + assert out["moved_to_other_node"] is False + assert out["served_for_other_account"] is False + + +def test_an_earlier_format_is_refused_by_name(tmp_path): + """Sealed under the passphrase alone. Never opened, and the refusal says why + — the caller must not take it for an absent bundle and mint a new one.""" + out = _run(tmp_path, """ + const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1); + const k = await K().nodeBundleKey(sk, 'NODE'); + const results = []; + for (const old of [btoa('MBK2' + 'x'.repeat(40)), btoa('y'.repeat(44))]) { + let code = null; + try { await K().decryptBundle(old, k, { userId: 'uid-1', nodePk: 'NODE' }); } + catch (e) { code = e.code || null; } + results.push([K().bundleFormat(old), code]); + } + console.log(JSON.stringify(results)); + """) + assert out == [["retired", "bundle_format_retired"], ["retired", "bundle_format_retired"]] + + +def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path): + """The one key an account must hold everywhere: node keys differ per node, + and a playlist is read from any of them.""" + out = _run(tmp_path, """ + const pl = async (uid) => fp(await derivePlaylistKey((await K().deriveBundleSessionKey( + 'same passphrase', 'someone', uid, PEPPER, 1)).v3)); + const a = await pl('uid-1'); + console.log(JSON.stringify({ same: a === await pl('uid-1'), + other_account: a !== await pl('uid-2') })); + """) + assert out == {"same": True, "other_account": True} + + +def test_the_playlist_key_is_no_node_key(tmp_path): + out = _run(tmp_path, """ + const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1); + console.log(JSON.stringify({ + distinct: await fp(await derivePlaylistKey(sk.v3)) + !== await fp(await K().nodeBundleKey(sk, 'NODE')), + })); + """) + assert out["distinct"] diff --git a/packages/meshbay-hub/tests/test_bundle_pepper.py b/packages/meshbay-hub/tests/test_bundle_pepper.py index e7b126f..e99799f 100644 --- a/packages/meshbay-hub/tests/test_bundle_pepper.py +++ b/packages/meshbay-hub/tests/test_bundle_pepper.py @@ -104,18 +104,6 @@ async def test_an_open_session_may_ask_and_a_node_may_not(client): assert login["bundle_pepper"] not in r.text -@pytest.mark.asyncio -async def test_a_passphrase_change_carries_it(client): - """The new passphrase makes a new bundle key, and the client does not keep - the pepper to re-seal under it.""" - await _register(client, "pepper_chg") - login = await _login(client, "pepper_chg") - r = await client.post("/v1/users/password", headers=_bearer(login["access_token"]), - json={"old_auth_key": KEY, "new_auth_key": "n" * 44}) - assert r.status_code == 200, r.text - assert r.json()["bundle_pepper"] == login["bundle_pepper"] - - @pytest.mark.asyncio async def test_it_is_sealed_at_rest_and_bound_to_its_account(client, db_session): await _register(client, "pepper_rest") diff --git a/packages/meshbay-hub/tests/test_playlist_key.py b/packages/meshbay-hub/tests/test_playlist_key.py deleted file mode 100644 index 261cc32..0000000 --- a/packages/meshbay-hub/tests/test_playlist_key.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -The playlist key: one Argon2 run, two handles, one subkey. - -Identity keys are per node, so a blob encrypted under one is unreadable from -every other node — the precise opposite of what a playlist needs. The only -secret an account holds *everywhere* is the bundle key, so the playlist key is -derived from it with HKDF (docs/playlists.md §3.4). - -Three things have to hold, and getting any of them wrong is quiet: - - - **One Argon2id run per sign-in.** The budget is the ~650 ms already on that - path. A second call is mathematically pointless and doubles it, and nothing - on screen would say so. - - **The HKDF handle is a second import of the same bytes**, not a derivation - from the AES one — that is imported non-extractably with - `['encrypt','decrypt']`, from which nothing can be derived at all. - - **A purpose-separated subkey**, not the bundle key with a different AAD. - `groupbox.py` writes that rule down for chunk keys; it is the same rule. - -Node's WebCrypto is the real implementation here; only Argon2 is stubbed, and -stubbed precisely so the calls can be counted. -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -KEYDERIVE = STATIC / "keyderive.js" - -pytestmark = pytest.mark.skipif( - shutil.which("node") is None or not KEYDERIVE.exists(), - reason="node or the SPA sources are not available") - -# keyderive.js assigns `window.MeshBayKeys` and reads `window.argon2`; node has -# neither, and a counted stub is the whole point. -PRELUDE = """ -globalThis.window = globalThis; -let argonCalls = 0; -globalThis.argon2 = { - ArgonType: { Argon2id: 2 }, - async hash(opts) { - argonCalls++; - // Deterministic, and a function of what was actually passed, so a changed - // salt domain or cost parameter shows up as different bytes rather than - // silently agreeing. - const seed = new TextEncoder().encode( - opts.pass + ':' + Array.from(opts.salt).join(',') + ':' + opts.time); - const digest = new Uint8Array( - await crypto.subtle.digest('SHA-256', seed)); - return { hash: digest }; - }, -}; -""" - - -def _run(tmp_path, body): - src = KEYDERIVE.read_text(encoding="utf-8") - script = tmp_path / "case.mjs" - script.write_text(f"{PRELUDE}\n{src}\n{body}\n", encoding="utf-8") - out = subprocess.run(["node", str(script)], - capture_output=True, text=True, encoding="utf-8", timeout=60) - assert out.returncode == 0, out.stderr - return json.loads(out.stdout) - - -def test_a_sign_in_runs_argon2_exactly_once(tmp_path): - """The budget is the 650 ms already on the sign-in path. Two handles over - one run is the whole point of `deriveBundleKeys`.""" - out = _run(tmp_path, """ - argonCalls = 0; - const keys = await deriveBundleKeys('passphrase', 'someone'); - console.log(JSON.stringify({ - calls: argonCalls, - aes: keys.aes.algorithm.name, - hkdf: keys.hkdf.algorithm.name, - })); - """) - assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost" - assert out["aes"] == "AES-GCM" - assert out["hkdf"] == "HKDF" - - -def test_the_bundle_key_fields_a_session_carries_are_built_in_one_run(tmp_path): - """Both places that build a `session.bundleKey` go through this, so - `v2hkdf` cannot be the field one sign-in path forgot.""" - out = _run(tmp_path, """ - argonCalls = 0; - const fields = await window.MeshBayKeys.bundleKeyPairFields('p', 'someone'); - console.log(JSON.stringify({ - calls: argonCalls, - keys: Object.keys(fields).sort(), - v2: fields.v2.algorithm.name, - v2hkdf: fields.v2hkdf.algorithm.name, - })); - """) - assert out["calls"] == 1 - assert out["keys"] == ["v2", "v2hkdf"] - assert out["v2"] == "AES-GCM" and out["v2hkdf"] == "HKDF" - - -def test_the_aes_handle_is_unchanged_by_the_hkdf_one(tmp_path): - """`deriveEncryptionKey` still returns exactly what it always did — every - identity bundle already written is opened with it.""" - out = _run(tmp_path, """ - const legacy = await deriveEncryptionKey('p', 'someone'); - const paired = (await deriveBundleKeys('p', 'someone')).aes; - const data = new TextEncoder().encode('a keypair bundle'); - const iv = new Uint8Array(12); - const ct = await crypto.subtle.encrypt({name:'AES-GCM', iv}, legacy, data); - const back = await crypto.subtle.decrypt({name:'AES-GCM', iv}, paired, ct); - console.log(JSON.stringify({ - same: new TextDecoder().decode(back) === 'a keypair bundle', - extractable: legacy.extractable, - })); - """) - assert out["same"], "the paired AES handle is not the same key as before" - assert out["extractable"] is False - - -def test_the_playlist_key_is_a_subkey_and_not_the_bundle_key(tmp_path): - """Derived under its own `info`, so what opens a playlist opens nothing - else — and cannot be produced from the AES handle at all.""" - out = _run(tmp_path, """ - const { aes, hkdf } = await deriveBundleKeys('p', 'someone'); - const playlistKey = await crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new TextEncoder().encode('meshbay:playlists:v1') }, - hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - - const iv = new Uint8Array(12); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, playlistKey, new TextEncoder().encode('tracks')); - - // The bundle key must not open what the playlist key sealed. - let bundleOpens = true; - try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aes, ct); } - catch { bundleOpens = false; } - - // And nothing can be derived from the AES handle, which is why the HKDF - // one has to be a second import rather than a derivation. - let derivable = true; - try { - await crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new Uint8Array(0) }, - aes, { name: 'AES-GCM', length: 256 }, false, ['encrypt']); - } catch { derivable = false; } - - console.log(JSON.stringify({ bundleOpens, derivable })); - """) - assert out["bundleOpens"] is False, ( - "the playlist key is the bundle key — purpose separation is gone") - assert out["derivable"] is False, ( - "if the AES handle were derivable the second import would be needless; " - "it is not, which is exactly why deriveBundleKeys imports twice") - - -def test_a_different_info_gives_a_different_key(tmp_path): - """What makes it a *purpose*-separated subkey rather than a rename.""" - out = _run(tmp_path, """ - const { hkdf } = await deriveBundleKeys('p', 'someone'); - const mk = (info) => crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new TextEncoder().encode(info) }, - hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - const a = await mk('meshbay:playlists:v1'); - const b = await mk('meshbay:something-else:v1'); - const iv = new Uint8Array(12); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, a, new TextEncoder().encode('x')); - let opens = true; - try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, b, ct); } - catch { opens = false; } - console.log(JSON.stringify({ opens })); - """) - assert out["opens"] is False - - -def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path): - """The whole point, and the reason the nonce must be random rather than a - counter: two devices derive the *same* key, so a counter would repeat.""" - out = _run(tmp_path, """ - const mk = async () => { - const { hkdf } = await deriveBundleKeys('same passphrase', 'someone'); - return crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new TextEncoder().encode('meshbay:playlists:v1') }, - hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - }; - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, await mk(), new TextEncoder().encode('Evening')); - const back = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, await mk(), ct); - console.log(JSON.stringify({ text: new TextDecoder().decode(back) })); - """) - assert out["text"] == "Evening" - - -def test_a_different_account_derives_a_different_key(tmp_path): - """The salt is domain-separated per user; this is what that buys.""" - out = _run(tmp_path, """ - const mk = async (user) => { - const { hkdf } = await deriveBundleKeys('p', user); - return crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new TextEncoder().encode('meshbay:playlists:v1') }, - hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - }; - const iv = new Uint8Array(12); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, await mk('alice'), new TextEncoder().encode('x')); - let opens = true; - try { await crypto.subtle.decrypt({ name:'AES-GCM', iv }, await mk('bob'), ct); } - catch { opens = false; } - console.log(JSON.stringify({ opens })); - """) - assert out["opens"] is False diff --git a/packages/meshbay-hub/tests/test_playlist_store.py b/packages/meshbay-hub/tests/test_playlist_store.py index 701de41..3c1d109 100644 --- a/packages/meshbay-hub/tests/test_playlist_store.py +++ b/packages/meshbay-hub/tests/test_playlist_store.py @@ -272,16 +272,25 @@ def test_a_node_holding_something_unreadable_does_not_wedge_the_sync(steps): assert "Depuis le menu" in s["names"] -def test_a_session_from_before_the_hkdf_handle_degrades_rather_than_failing(steps): - """A bundle key loaded out of IndexedDB from before `deriveBundleKeys` - existed has no HKDF handle, and the passphrase is not in memory to - re-derive from. Playlists stay local until the next sign-in — reported, - rather than silently doing nothing.""" - r = steps["a session from before the HKDF handle"]["result"] +def test_a_session_from_before_the_pepper_degrades_rather_than_failing(steps): + """A bundle key from before the pepper opens nothing, and the passphrase + is not in memory to re-derive from. Playlists stay local until it is + entered again — reported, rather than silently doing nothing.""" + r = steps["a session from before the pepper"]["result"] assert r["ok"] is False and r["reason"] == "no_key" assert r["pushed"] == 0 +def test_a_node_sealed_under_the_previous_key_is_sealed_again(steps): + """After the playlist key changed, every row on a node has the revision the + local copy has. All of it is sealed again under the current key, no + revision goes down, and a body nothing here can name is dropped.""" + s = steps["a node sealed under the previous key"] + assert s["readable"] == s["kinds"], "a row is still sealed under the old key" + assert s["remaining"] == s["kinds"], "the unknown body was not dropped" + assert s["revsNotLowered"] is True + + def test_what_a_playlist_costs_sealed_is_measured_not_quoted(steps): """The ceiling the UI promises comes from here, not from the design doc. diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py index e43e6de..c574597 100644 --- a/packages/meshbay-hub/tests/test_recovery_key.py +++ b/packages/meshbay-hub/tests/test_recovery_key.py @@ -84,10 +84,11 @@ const fp = async (key) => hex(await webcrypto.subtle.encrypt( const kB = await K.deriveRecoveryKey(raw, 'acc-B'); const skEd = new Uint8Array([1, 2, 3]); const skX = new Uint8Array([4, 5, 6]); - const blob = await K.encryptBundleWithKey(skEd, skX, kA); + const sealedFor = { userId: 'acc-A', nodePk: 'node-key' }; + const blob = await K.encryptBundle(skEd, skX, kA, { ...sealedFor, pepperVersion: 0 }); let wrongRejected = false; - try { await K.decryptBundleWithKey(blob, kB); } catch { wrongRejected = true; } - const opened = await K.decryptBundleWithKey(blob, kA); + try { await K.decryptBundle(blob, kB, sealedFor); } catch { wrongRejected = true; } + const opened = await K.decryptBundle(blob, kA, sealedFor); out.recovery_wrap_isolates = wrongRejected && opened.skEd === btoa(String.fromCharCode(1, 2, 3)) && opened.skX === btoa(String.fromCharCode(4, 5, 6)); diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py index b278d0c..79a5bf5 100644 --- a/packages/meshbay-hub/tests/test_rewrap_fanout.py +++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py @@ -46,12 +46,13 @@ eval(process.argv[2].split(require('path').delimiter) .map((p) => fs.readFileSync(p, 'utf8')).join('\n')); const T = window.MeshBayTransport; -let deriveEncCalls = 0; +// Keys are opaque tags here; what is checked is which key sealed what, for +// which account on which node, under which pepper version. window.MeshBayKeys = { - deriveEncryptionKey: async (p) => { deriveEncCalls++; return { kind: 'enc', p }; }, - deriveEncryptionKeyV1: async (p) => ({ kind: 'encv1', p }), - deriveRecoveryKey: async (r) => ({ kind: 'rec', r }), - encryptBundleWithKey: async (_skEd, _skX, key) => 'wrapped:' + key.kind, + deriveRecoveryKey: async (r) => ({ kind: 'rec', r }), + nodeBundleKey: async (key, nodePk) => ({ kind: `${key.kind}@${nodePk}` }), + encryptBundle: async (_skEd, _skX, key, m) => + `wrapped:${key.kind}|${m.userId}|${m.nodePk}|v${m.pepperVersion}`, }; const b64 = (s) => Buffer.from(s).toString('base64'); @@ -65,8 +66,11 @@ const NODES = { const stored = []; const rewrapOnlySeen = []; -T.prototype.connect = async function (nodeId) { +const openedWith = []; +T.prototype.connect = async function (nodeId, _t, _g, _gek, _sk, bundleKey) { this._nodeId = nodeId; + this.nodePk = 'pk-' + nodeId; // what the handshake proves + openedWith.push(bundleKey && bundleKey.kind); rewrapOnlySeen.push(this._rewrapOnly === true); const s = NODES[nodeId] || {}; if (s.throws) throw new Error(s.throws); @@ -110,38 +114,47 @@ global.fetch = async (url) => { const names = (a) => a.map((x) => x.name).sort(); (async () => { + // Flow A — passphrase change: opened with the old key, sealed with the new. const A = await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid', - oldPassphrase: 'old', newPassphrase: 'new', + bundleKey: { kind: 'old', pepperVersion: 1 }, + newBundleKey: { kind: 'new', pepperVersion: 1 }, }); const storeA = stored.splice(0); + const openedA = openedWith.splice(0); + // Flow B — reset: the session key of the new passphrase opens nothing, the + // recovery copy does, and both copies are sealed again. const B = await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid', - newPassphrase: 'new', recoveryKey: 'A RECOVERY MNEMONIC', + bundleKey: { kind: 'cur', pepperVersion: 1 }, recoveryKey: 'A RECOVERY MNEMONIC', }); const storeB = stored.splice(0); - // Flow C — Profile backfill: keep the live passphrase key, just add the - // recovery copy. No passphrase strings, so deriveEncryptionKey is not called. - deriveEncCalls = 0; + // Flow C — Profile backfill: keep the live key, just add the recovery copy. const C = await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid', - bundleKey: { v2: { kind: 'bk' }, v1: { kind: 'bkv1' } }, + bundleKey: { kind: 'bk', pepperVersion: 1 }, recoveryKey: 'A RECOVERY MNEMONIC', }); const storeC = stored.splice(0); + let refusedWithoutKey = false; + try { + await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid' }); + } catch { refusedWithoutKey = true; } + process.stdout.write(JSON.stringify({ a_updated: names(A.updated), a_unreachable: names(A.unreachable), a_failed: names(A.failed), a_stored_nodes: storeA.map((s) => s.nodeId).sort(), a_recovery_always_null: storeA.every((s) => s.rec === null), - a_new_bundle_key_kind: A.newBundleKey && A.newBundleKey.v2 && A.newBundleKey.v2.kind, + a_stored: storeA.map((s) => s.enc), + a_opened_with: [...new Set(openedA)], b_stored: storeB.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })), c_stored: storeC.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })), - c_derive_enc_calls: deriveEncCalls, + refused_without_key: refusedWithoutKey, // Every transport the fan-out builds is flagged rewrap-only, so a stored // bundle it cannot open is reported, not silently replaced with a new one. all_rewrap_only: rewrapOnlySeen.length > 0 && rewrapOnlySeen.every(Boolean), @@ -189,24 +202,30 @@ def test_a_node_that_returns_no_identity_is_a_failure(result): assert "f@ann" in result["a_failed"] -def test_flow_a_writes_only_the_passphrase_copy(result): +def test_flow_a_opens_with_the_old_key_and_seals_with_the_new_for_that_node(result): assert result["a_recovery_always_null"] is True - assert result["a_new_bundle_key_kind"] == "enc" + assert result["a_opened_with"] == ["old"] + assert result["a_stored"] == ["wrapped:new@pk-n-ok|uid|pk-n-ok|v1"] def test_flow_b_writes_both_the_passphrase_and_the_recovery_copy(result): + # The recovery copy owes nothing to the pepper: version 0. assert result["b_stored"] == [ - {"node": "n-ok", "enc": "wrapped:enc", "rec": "wrapped:rec"}, + {"node": "n-ok", "enc": "wrapped:cur@pk-n-ok|uid|pk-n-ok|v1", + "rec": "wrapped:rec|uid|pk-n-ok|v0"}, ] def test_profile_backfill_keeps_the_live_key_and_adds_the_recovery_copy(result): - # bundleKey mode: the passphrase copy is re-wrapped with the same live key - # (kind "bk"), the recovery copy is added, and no passphrase is derived. assert result["c_stored"] == [ - {"node": "n-ok", "enc": "wrapped:bk", "rec": "wrapped:rec"}, + {"node": "n-ok", "enc": "wrapped:bk@pk-n-ok|uid|pk-n-ok|v1", + "rec": "wrapped:rec|uid|pk-n-ok|v0"}, ] - assert result["c_derive_enc_calls"] == 0 + + +def test_there_is_no_passphrase_path_left(result): + """Keys only: a caller that has not derived one with the pepper is refused.""" + assert result["refused_without_key"] is True def test_every_fanout_transport_is_rewrap_only(result): diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index fdedaf8..6f28aaa 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -91,6 +91,23 @@ def test_keys_are_recovered_before_the_join_is_attempted(): "browser that did not register has no key to sign the join with") +def test_a_bundle_is_opened_with_the_key_the_node_has_already_proved(): + """ + A bundle is sealed for one account on one node: its key derives from the + node's public key and its associated data names both. That key has to be + the one the challenge signature proved — recorded before the bundle is + fetched — or a bundle would be opened, or a new one sealed, for nothing. + """ + proved, user, sealed_for, fetch = _positions( + "this.nodePk = reply.node_pk", + "this._userId = userId", + "const sealedFor = { userId: this._userId, nodePk: this.nodePk }", + "type: 'keypair_bundle_fetch'", + ) + assert proved < sealed_for < fetch + assert user < sealed_for + + def test_the_ack_still_verifies_the_announced_node_key(): """ Taking node_pk from the challenge is only safe because the ack proves it and -- cgit v1.2.3