aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/keyderive.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js300
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,
};