aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 01:03:43 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 01:03:43 +0200
commitfe30860c58e0f1b1efd457ff5eb5146d1e592da0 (patch)
tree99a3e96994738c4e96f969a365679475dc4cf5cd /packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
parent51d2d734c228f1e46670962480258abfe586d6c4 (diff)
downloadmeshbay-fe30860c58e0f1b1efd457ff5eb5146d1e592da0.tar.gz
feat: passphrase change and account recovery (auth-confirm)
The passphrase derives two independent client-side values: auth_key (the hub verifier) and bundle_key (AES-GCM key for the per-node identity bundles, which live on nodes and never on the hub). Changing or recovering a passphrase is therefore two operations — swap the hub verifier, and re-wrap every reachable node's identity bundle. Flow A — change a known passphrase (Profile page) - POST /v1/users/password re-proves the current passphrase, swaps pw_hash/salt/version, revokes every refresh token and returns a fresh pair so the tab that made the change stays signed in. - MeshBayTransport.rewrapAllNodes: for every group's online node, connect with the old key, read the identity off the handshake, store it back under the new key. Returns updated / unreachable / failed so the UI can point at the operator-unpin fallback for the gaps. Always-shown confirmation dialog listing reachable and unreachable groups. Recovery key - keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>). - Every per-node identity gets a second copy wrapped under the recovery key: keypair_bundles.bundle_enc_recovery (node-only column, added in _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs), carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive. - session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded on connect, so a group joined in any later session still leaves a recovery copy. - Shown once at registration; optionally folded into the verification e-mail as a pass-through the hub never stores or logs, with an opt-out. - Profile -> Recovery key re-loads R and backfills every reachable node via rewrapAllNodes in bundleKey mode (no passphrase re-entry). Flow B — recover a lost passphrase (#/reset, linked from sign-in) - POST /v1/users/password/reset-request {username, email}: both must be the pair on file, checked against the blind email_hash (never decrypted). A mismatch — wrong e-mail, unknown username, non-active account — takes the identical no-op path (no code, no mail, same 200), so it reveals nothing and cannot be used to spray reset mail from a username alone. 5/min, 1-hour single-use code. - POST /v1/users/password/reset {username, code, new_auth_key}: same expiry / attempts / single-use checks as e-mail verification; revokes every session and deletes every registered device key so a stored one cannot sign back in past the reset. - ResetPasswordPage: request code -> code + optional recovery key + new passphrase -> reset + sign-in -> fan-out. connect() falls back to the recovery-wrapped copy when the passphrase key cannot open bundle_enc. Without a recovery key: sign-in is restored and each group needs the operator-unpin fallback. Supporting fixes (found in live testing) - member unpin now also deletes the keypair bundle; connect() mints a fresh identity when handed a bundle it cannot open (unless _rewrapOnly, set by rewrapAllNodes), so a rejoin completes instead of dead-ending before the invite-code prompt. - A browser with no bundle key gets a passphrase prompt on the group page instead of a "go back to the browser you registered on" message. - RegisterPage / LoginPage / ResetPasswordPage trim the username so every key derivation matches the hub's stored form. Docs: docs/auth-confirm.md. Locale keys across all ten catalogues. Tests: test_password_change, test_password_reset, test_recovery_email, test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492 passed; node suite 741 passed (the lone test_packaging_units failure is a pre-existing RPM-spec flake, reproducible on main). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/keyderive.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js96
1 files changed, 89 insertions, 7 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index ce38d35..0aaa6a5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -155,6 +155,70 @@ async function deriveEncryptionKey(password, username) {
'raw', out.hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
+// ── Account recovery key ─────────────────────────────────────────────────────
+//
+// docs/auth-confirm.md §4.3. A full-entropy secret the user keeps outside the
+// passphrase — in their password manager, or (step 3) e-mailed to them. It
+// wraps a *second* copy of every per-node identity bundle, so a forgotten
+// passphrase does not strand the account's group identities.
+//
+// 256 bits of real entropy, so the derivation is HKDF, not Argon2: there is
+// nothing to brute-force and no reason to make the legitimate path slow. The
+// username domain-separates it, exactly as for the bundle key.
+
+const _B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // RFC 4648, no padding
+
+/** 32 random bytes, shown to the human as 13 groups of 4 Base32 chars. */
+function generateRecoveryKey() {
+ const R = crypto.getRandomValues(new Uint8Array(32));
+ return {
+ rawB64: btoa(String.fromCharCode(...R)),
+ mnemonic: _toMnemonic(R),
+ };
+}
+
+function _toMnemonic(bytes) {
+ let bits = 0, value = 0, out = '';
+ for (const b of bytes) {
+ value = (value << 8) | b;
+ bits += 8;
+ while (bits >= 5) { out += _B32[(value >>> (bits - 5)) & 31]; bits -= 5; }
+ }
+ if (bits > 0) out += _B32[(value << (5 - bits)) & 31];
+ return out.replace(/(.{4})(?=.)/g, '$1 ');
+}
+
+function _fromMnemonic(mnemonic) {
+ const clean = String(mnemonic).replace(/[^A-Za-z2-7]/g, '').toUpperCase();
+ let bits = 0, value = 0;
+ const out = [];
+ for (const ch of clean) {
+ const idx = _B32.indexOf(ch);
+ if (idx < 0) throw new Error('invalid recovery key');
+ value = (value << 5) | idx;
+ bits += 5;
+ if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
+ }
+ if (out.length < 32) throw new Error('recovery key too short');
+ return new Uint8Array(out.slice(0, 32));
+}
+
+/**
+ * Derive the AES-GCM key that wraps the recovery copy of a bundle.
+ * `R` is the raw Uint8Array(32) or its Base32 mnemonic string.
+ */
+async function deriveRecoveryKey(R, username) {
+ const raw = (typeof R === 'string') ? _fromMnemonic(R) : new Uint8Array(R);
+ const km = await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']);
+ return crypto.subtle.deriveKey(
+ {
+ name: 'HKDF', hash: 'SHA-256',
+ salt: new Uint8Array(0),
+ info: new TextEncoder().encode(`meshbay:recovery:v1:${username}`),
+ },
+ km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+}
+
// ── Bundle encryption ─────────────────────────────────────────────────────────
/**
@@ -212,7 +276,7 @@ async function decryptBundle(bundleB64, password, username) {
*
* Returns the raw private keys for immediate use after registration.
*/
-async function registerUser(username, email, password) {
+async function registerUser(username, email, password, recoveryMnemonic) {
// No keypair here any more. Identity keys are per node: one is generated the
// first time this account joins a given node, encrypted under the passphrase,
// and left with that node. So an operator who cracks what sits on their own
@@ -222,10 +286,16 @@ async function registerUser(username, email, password) {
// It also means the hub stores no user key to publish, which is what H3 read.
const authKey = await deriveAuthKey(password, username);
+ const payload = { username, email, auth_key: authKey };
+ // The recovery mnemonic, when the user opted to have it e-mailed: the hub
+ // appends it to the verification e-mail and stores it nowhere
+ // (docs/auth-confirm.md §4.4). Omitted when they chose to save it themselves.
+ if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic;
+
const resp = await hubCall('/v1/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, email, auth_key: authKey }),
+ body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`);
@@ -235,21 +305,27 @@ async function registerUser(username, email, password) {
/**
* A fresh identity for one node, encrypted under the passphrase-derived key.
*
- * Returns { skEdB64, skXB64, pkXB64, bundleEnc } — the bundle goes to that node
- * and nowhere else, and is what any other browser fetches to become the same
- * person there.
+ * 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
+ * strand this identity (docs/auth-confirm.md §4.3).
*/
-async function generateNodeIdentity(bundleKey) {
+async function generateNodeIdentity(bundleKey, recoveryKey) {
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, []);
const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto));
- return {
+ const out = {
skEdB64: b64(skEdRaw),
skXB64: b64(skXRaw),
pkXB64: b64(pkXBytes),
bundleEnc: await encryptBundleWithKey(skEdRaw, skXRaw, bundleKey.v2 || bundleKey),
};
+ if (recoveryKey) {
+ out.bundleEncRecovery = await encryptBundleWithKey(skEdRaw, skXRaw, recoveryKey);
+ }
+ return out;
}
/**
@@ -324,4 +400,10 @@ async function signBytes(skEdPkcs8B64, message) {
window.MeshBayKeys = {
registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes,
deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
+ // Exposed for the passphrase change (docs/auth-confirm.md §3): 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,
+ // Account recovery key (docs/auth-confirm.md §4.3).
+ generateRecoveryKey, deriveRecoveryKey,
};