aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/profile-page.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/profile-page.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/profile-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js242
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>`}