From fe30860c58e0f1b1efd457ff5eb5146d1e592da0 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 01:03:43 +0200 Subject: feat: passphrase change and account recovery (auth-confirm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:). - 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 Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc --- .../src/meshbay_hub/static/profile-page.js | 242 ++++++++++++++++++++- 1 file changed, 241 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/profile-page.js') 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 ce22116..10bcee7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -3,7 +3,10 @@ import { } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; -import { hubFetch } from './hub-client.js'; +import { + hubFetch, HUB, session, setAuth, + _storeBundleKey, _loadBundleKey, _storeRecoveryKey, +} from './hub-client.js'; export function ProfilePage({ user, onLogout }) { const [nodeKey, setNodeKey] = useState(''); @@ -47,6 +50,128 @@ export function ProfilePage({ user, onLogout }) { setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0); }, []); + // ── Passphrase change (docs/auth-confirm.md §3) ───────────────────────── + const [cpOpen, setCpOpen] = useState(false); + const [cpOld, setCpOld] = useState(''); + const [cpNew, setCpNew] = useState(''); + const [cpNew2, setCpNew2] = useState(''); + const [cpPhase, setCpPhase] = useState('form'); // form | confirm | working | done + const [cpEstimate, setCpEstimate] = useState(null); + const [cpProgress, setCpProgress] = useState(null); + const [cpResult, setCpResult] = useState(null); + const [cpError, setCpError] = useState(''); + + const cpReset = useCallback(() => { + setCpOpen(false); setCpPhase('form'); + setCpOld(''); setCpNew(''); setCpNew2(''); + setCpEstimate(null); setCpProgress(null); setCpResult(null); setCpError(''); + }, []); + + const _label = (g) => (g.owner_username ? `${g.name}@${g.owner_username}` : g.name); + + const cpBeginConfirm = useCallback(async (e) => { + e.preventDefault(); + setCpError(''); + if (cpNew.length < 12) { setCpError(t('settings.pw_too_short')); return; } + if (cpNew !== cpNew2) { setCpError(t('settings.pw_mismatch')); return; } + if (cpNew === cpOld) { setCpError(t('settings.pw_same')); return; } + try { + const mine = await hubFetch('/v1/groups/mine', { token: user.token }); + const groups = mine.groups || []; + setCpEstimate({ + reachable: groups.filter((g) => g.node_online).map(_label), + unreachable: groups.filter((g) => !g.node_online).map(_label), + }); + setCpPhase('confirm'); + } catch (err) { + setCpError(err.message); + } + }, [cpOld, cpNew, cpNew2, user.token]); + + const cpConfirm = useCallback(async () => { + setCpPhase('working'); + setCpError(''); + setCpProgress({ done: 0, total: 0 }); + try { + // Re-wrap every reachable node's identity bundle first — if this cannot + // run at all the account is left untouched. + const result = await window.MeshBayTransport.rewrapAllNodes({ + hubUrl: HUB, token: user.token, + username: user.username, userId: user.userId, + oldPassphrase: cpOld, newPassphrase: cpNew, + onProgress: setCpProgress, + }); + + const oldAuthKey = await window.MeshBayKeys.deriveAuthKey(cpOld, user.username); + const newAuthKey = await window.MeshBayKeys.deriveAuthKey(cpNew, user.username); + const resp = await hubFetch('/v1/users/password', { + method: 'POST', token: user.token, + body: { old_auth_key: oldAuthKey, new_auth_key: newAuthKey }, + }); + + // 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); + + setCpResult(result); + setCpPhase('done'); + } catch (err) { + const msg = /403|does not match/i.test(err.message) + ? t('settings.pw_wrong_current') : err.message; + setCpError(msg); + setCpPhase('confirm'); + } + }, [cpOld, cpNew, user]); + + // ── Recovery key backfill (docs/auth-confirm.md §4.3) ─────────────────── + // Enter the recovery key once per browser to add a recovery-wrapped copy of + // your identity to every group — covers groups joined before the key was + // loaded here. + const [rkOpen, setRkOpen] = useState(false); + const [rkInput, setRkInput] = useState(''); + const [rkPhase, setRkPhase] = useState('form'); // form | working | done + const [rkProgress, setRkProgress] = useState(null); + const [rkResult, setRkResult] = useState(null); + const [rkError, setRkError] = useState(''); + const rkLoaded = !!session.recoveryKey; + + const rkReset = useCallback(() => { + setRkOpen(false); setRkPhase('form'); setRkInput(''); + setRkProgress(null); setRkResult(null); setRkError(''); + }, []); + + const rkBackfill = useCallback(async (e) => { + e.preventDefault(); + const mnemonic = rkInput.trim(); + if (!mnemonic || !window.MeshBayKeys) return; + setRkError(''); + if (!session.bundleKey) session.bundleKey = await _loadBundleKey(); + if (!session.bundleKey) { setRkError(t('settings.recovery_need_relogin')); return; } + setRkPhase('working'); + setRkProgress(null); + try { + // Derives the key (and validates the mnemonic — a bad one throws here). + const key = await window.MeshBayKeys.deriveRecoveryKey(mnemonic, user.username); + session.recoveryKey = key; + await _storeRecoveryKey(key); + const r = await window.MeshBayTransport.rewrapAllNodes({ + hubUrl: HUB, token: user.token, + username: user.username, userId: user.userId, + bundleKey: session.bundleKey, // keep the current passphrase key + recoveryKey: mnemonic, + onProgress: setRkProgress, + }); + setRkResult(r); + setRkPhase('done'); + setRkInput(''); + } catch (err) { + setRkError(err.message); + setRkPhase('form'); + } + }, [rkInput, user]); + useEffect(() => { hubFetch('/v1/users/me', { token: user.token }) .then(data => { @@ -217,6 +342,121 @@ export function ProfilePage({ user, onLogout }) { +
+

${t('settings.passphrase')}

+

${t('settings.passphrase_hint')}

+ ${!cpOpen && html` + `} + + ${cpOpen && cpPhase === 'form' && html` +
+ setCpOld(e.target.value)} required /> + setCpNew(e.target.value)} required /> + setCpNew2(e.target.value)} required /> +
+ + +
+
`} + + ${cpOpen && cpPhase === 'confirm' && cpEstimate && html` +
+

${t('settings.passphrase_confirm_intro')}

+ ${cpEstimate.reachable.length > 0 && html` +

${t('settings.passphrase_reachable')}

+
    + ${cpEstimate.reachable.map(n => html`
  • ${n}
  • `)} +
`} + ${cpEstimate.unreachable.length > 0 && html` +

${t('settings.passphrase_unreachable')}

+
    + ${cpEstimate.unreachable.map(n => html`
  • ${n}
  • `)} +
`} +

${t('settings.passphrase_fallback_note')}

+ ${cpError && html`

${cpError}

`} +
+ + +
+
`} + + ${cpOpen && cpPhase === 'working' && html` +

+ ${t('settings.passphrase_working')} + ${cpProgress && cpProgress.total ? ` (${cpProgress.done}/${cpProgress.total})` : ''} +

`} + + ${cpOpen && cpPhase === 'done' && cpResult && html` +
+

${t('settings.passphrase_done')}

+ ${(cpResult.unreachable.length > 0 || cpResult.failed.length > 0) && html` +

+ ${t('settings.passphrase_needs_operator')} +

+
    + ${cpResult.unreachable.concat(cpResult.failed).map(g => + html`
  • ${g.name}${g.reason ? ` — ${g.reason}` : ''}
  • `)} +
`} + +
`} +
+ +
+

${t('settings.recovery')}

+

${t('settings.recovery_hint')}

+ ${rkLoaded && rkPhase !== 'done' && html` +

${t('settings.recovery_loaded')}

`} + + ${!rkOpen && html` + `} + + ${rkOpen && rkPhase === 'form' && html` +
+ + ${rkError && html`

${rkError}

`} +
+ + +
+
`} + + ${rkOpen && rkPhase === 'working' && html` +

+ ${t('settings.recovery_working')} + ${rkProgress && rkProgress.total ? ` (${rkProgress.done}/${rkProgress.total})` : ''} +

`} + + ${rkOpen && rkPhase === 'done' && rkResult && html` +
+

${t('settings.recovery_done')}

+ ${(rkResult.unreachable.length > 0 || rkResult.failed.length > 0) && html` +

${t('settings.recovery_partial')}

+
    + ${rkResult.unreachable.concat(rkResult.failed).map(g => + html`
  • ${g.name}${g.reason ? ` — ${g.reason}` : ''}
  • `)} +
`} + +
`} +
+

${t('settings.danger')}

${t('settings.delete_hint')}

-- cgit v1.2.3