diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/keyderive.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/keyderive.js | 300 |
1 files changed, 153 insertions, 147 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index 879f56f..9f28b5c 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,93 @@ 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**. + * The session's bundle key: `M = HKDF(A ‖ pepper, "…master:v3|" + user id)`. * - * `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 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. * - * 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. - * - * 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 session's bundle key wherever it is kept. In a browser, derived here. In + * the desktop application, derived and kept by its main process, and what + * comes back is a handle — `{ native, userId, pepperVersion }` — that opens + * nothing by itself: the transport asks the application for what it needs. + * `pending`: a passphrase change, held aside until the hub accepts it. + */ +async function sessionBundleKey(password, username, userId, pepperB64, pepperVersion, + { pending = false } = {}) { + const P = typeof window !== 'undefined' && window.MeshBayPlatform; + if (P && P.nativeKeys && await P.nativeKeys()) { + if (!userId || !pepperB64) throw new Error('the hub did not provide the bundle pepper'); + await P.keys.deriveSession({ password, username, userId, pepperB64, pepperVersion, pending }); + return { native: true, userId, pepperVersion: pepperVersion || 1, pending }; + } + return deriveBundleSessionKey(password, username, userId, pepperB64, pepperVersion); +} + +/** + * 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,48 +281,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 the password-derived AES key. - * 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 encryptBundle(skEdRaw, skXRaw, password, username) { - const aesKey = await deriveEncryptionKey(password, username); - return encryptBundleWithKey(skEdRaw, skXRaw, aesKey); -} - -/** Same, when the key was already derived at sign-in. Always writes v2. */ -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; } -} - -/** - * Decrypt a keypair bundle. Throws if password is wrong. - */ -async function decryptBundle(bundleB64, password, username) { - const key = bundleVersion(bundleB64) === 2 - ? await deriveEncryptionKey(password, username) - : await deriveEncryptionKeyV1(password, username); - return decryptBundleWithKey(bundleB64, key); + return atob(bundleB64).startsWith(BUNDLE_MAGIC) ? 'current' : 'retired'; + } catch { return 'retired'; } } // ── Registration ────────────────────────────────────────────────────────────── @@ -337,19 +355,21 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha }); if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`); - return { registered: true }; + let userId = null; + try { userId = (await resp.json()).user_id || null; } catch { /* no body */ } + return { registered: true, userId }; } /** - * 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, []); @@ -358,31 +378,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)); } @@ -424,12 +446,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 sessionBundleKey( + 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 @@ -451,28 +472,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, sessionBundleKey, nodeBundleKey, fetchBundlePepper, + encryptBundle, decryptBundle, bundleFormat, // Account recovery key (docs/MESHBAY_DESIGN.md §3.6). generateRecoveryKey, deriveRecoveryKey, }; |