diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 01:03:43 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 01:03:43 +0200 |
| commit | fe30860c58e0f1b1efd457ff5eb5146d1e592da0 (patch) | |
| tree | 99a3e96994738c4e96f969a365679475dc4cf5cd /packages/meshbay-hub/src/meshbay_hub/static/profile-page.js | |
| parent | 51d2d734c228f1e46670962480258abfe586d6c4 (diff) | |
| download | meshbay-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/profile-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/profile-page.js | 242 |
1 files changed, 241 insertions, 1 deletions
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 => { @@ -218,6 +343,121 @@ export function ProfilePage({ user, onLogout }) { </div> <div class="settings-section"> + <h3 class="settings-heading">${t('settings.passphrase')}</h3> + <p class="settings-hint">${t('settings.passphrase_hint')}</p> + ${!cpOpen && html` + <button class="admin-btn" onClick=${() => setCpOpen(true)}> + ${t('settings.passphrase_change')} + </button>`} + + ${cpOpen && cpPhase === 'form' && html` + <form onSubmit=${cpBeginConfirm} style="display:flex;flex-direction:column;gap:8px;max-width:340px"> + <input type="password" autocomplete="current-password" + placeholder=${t('settings.passphrase_current')} + value=${cpOld} onInput=${e => setCpOld(e.target.value)} required /> + <input type="password" autocomplete="new-password" + placeholder=${t('settings.passphrase_new')} + value=${cpNew} onInput=${e => setCpNew(e.target.value)} required /> + <input type="password" autocomplete="new-password" + placeholder=${t('settings.passphrase_new_repeat')} + value=${cpNew2} onInput=${e => setCpNew2(e.target.value)} required /> + <div style="display:flex;gap:8px"> + <button class="admin-btn" type="submit">${t('settings.continue')}</button> + <button class="btn-secondary" type="button" onClick=${cpReset}> + ${t('settings.cancel')} + </button> + </div> + </form>`} + + ${cpOpen && cpPhase === 'confirm' && cpEstimate && html` + <div style="max-width:420px"> + <p class="settings-hint">${t('settings.passphrase_confirm_intro')}</p> + ${cpEstimate.reachable.length > 0 && html` + <p style="margin:8px 0 2px"><strong>${t('settings.passphrase_reachable')}</strong></p> + <ul style="margin:0 0 8px 18px"> + ${cpEstimate.reachable.map(n => html`<li>${n}</li>`)} + </ul>`} + ${cpEstimate.unreachable.length > 0 && html` + <p style="margin:8px 0 2px"><strong>${t('settings.passphrase_unreachable')}</strong></p> + <ul style="margin:0 0 8px 18px"> + ${cpEstimate.unreachable.map(n => html`<li>${n}</li>`)} + </ul>`} + <p class="settings-hint">${t('settings.passphrase_fallback_note')}</p> + ${cpError && html`<p class="error-msg">${cpError}</p>`} + <div style="display:flex;gap:8px;margin-top:8px"> + <button class="btn-danger" onClick=${cpConfirm}> + ${t('settings.passphrase_confirm_btn')} + </button> + <button class="btn-secondary" onClick=${cpReset}>${t('settings.cancel')}</button> + </div> + </div>`} + + ${cpOpen && cpPhase === 'working' && html` + <p class="settings-hint"> + ${t('settings.passphrase_working')} + ${cpProgress && cpProgress.total ? ` (${cpProgress.done}/${cpProgress.total})` : ''} + </p>`} + + ${cpOpen && cpPhase === 'done' && cpResult && html` + <div style="max-width:420px"> + <p style="color:var(--success)">${t('settings.passphrase_done')}</p> + ${(cpResult.unreachable.length > 0 || cpResult.failed.length > 0) && html` + <p class="settings-hint" style="margin-top:8px"> + ${t('settings.passphrase_needs_operator')} + </p> + <ul style="margin:0 0 8px 18px"> + ${cpResult.unreachable.concat(cpResult.failed).map(g => + html`<li>${g.name}${g.reason ? ` — ${g.reason}` : ''}</li>`)} + </ul>`} + <button class="admin-btn" onClick=${cpReset}>${t('settings.done')}</button> + </div>`} + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.recovery')}</h3> + <p class="settings-hint">${t('settings.recovery_hint')}</p> + ${rkLoaded && rkPhase !== 'done' && html` + <p class="settings-hint" style="color:var(--success)">${t('settings.recovery_loaded')}</p>`} + + ${!rkOpen && html` + <button class="admin-btn" onClick=${() => setRkOpen(true)}> + ${t('settings.recovery_open')} + </button>`} + + ${rkOpen && rkPhase === 'form' && html` + <form onSubmit=${rkBackfill} style="display:flex;flex-direction:column;gap:8px;max-width:360px"> + <textarea placeholder=${t('settings.recovery_input_ph')} + value=${rkInput} onInput=${e => setRkInput(e.target.value)} rows="2" + style="font-family:monospace;font-size:0.9em;letter-spacing:0.08em;resize:vertical"></textarea> + ${rkError && html`<p class="error-msg">${rkError}</p>`} + <div style="display:flex;gap:8px"> + <button class="admin-btn" type="submit">${t('settings.recovery_submit')}</button> + <button class="btn-secondary" type="button" onClick=${rkReset}> + ${t('settings.cancel')} + </button> + </div> + </form>`} + + ${rkOpen && rkPhase === 'working' && html` + <p class="settings-hint"> + ${t('settings.recovery_working')} + ${rkProgress && rkProgress.total ? ` (${rkProgress.done}/${rkProgress.total})` : ''} + </p>`} + + ${rkOpen && rkPhase === 'done' && rkResult && html` + <div style="max-width:420px"> + <p style="color:var(--success)">${t('settings.recovery_done')}</p> + ${(rkResult.unreachable.length > 0 || rkResult.failed.length > 0) && html` + <p class="settings-hint" style="margin-top:8px">${t('settings.recovery_partial')}</p> + <ul style="margin:0 0 8px 18px"> + ${rkResult.unreachable.concat(rkResult.failed).map(g => + html`<li>${g.name}${g.reason ? ` — ${g.reason}` : ''}</li>`)} + </ul>`} + <button class="admin-btn" onClick=${rkReset}>${t('settings.done')}</button> + </div>`} + </div> + + <div class="settings-section"> <h3 class="settings-heading">${t('settings.danger')}</h3> <p class="settings-hint">${t('settings.delete_hint')}</p> ${delError && html`<p class="error-msg">${delError}</p>`} |