summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/auth-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/auth-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/auth-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js268
1 files changed, 260 insertions, 8 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 860f890..7c92d52 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -2,7 +2,9 @@ import {
html, useState,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
-import { hubFetch, navigate } from './hub-client.js';
+import {
+ hubFetch, navigate, session, HUB, loadAuth, _storeRecoveryKey,
+} from './hub-client.js';
import * as platform from './platform.js';
const PASSWORD_MIN_BITS = 60;
@@ -72,12 +74,15 @@ export function LoginPage({ onLogin }) {
const onSubmit = async (e) => {
e.preventDefault();
- if (!username || !password) return;
+ const name = username.trim();
+ if (!name || !password) return;
setError('');
setPendingVerif(false);
setLoading(true);
try {
- await onLogin(username, password);
+ // Trimmed to match the hub's stored username and every client-side key
+ // derivation (auth_key, bundle_key, recovery_key all fold the username in).
+ await onLogin(name, password);
navigate('/');
} catch (err) {
if (err.message === 'email_verification_required') {
@@ -114,6 +119,9 @@ export function LoginPage({ onLogin }) {
<div class="login-footer">
${t('login.no_account')} <a href="#/register">${t('login.register_link')}</a>
</div>
+ <div class="login-footer">
+ <a href="#/reset">${t('login.forgot')}</a>
+ </div>
</div>
</div>
`;
@@ -125,14 +133,19 @@ export function RegisterPage() {
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
- const [phase, setPhase] = useState('form'); // form | verify | done
+ const [phase, setPhase] = useState('form'); // form | recovery | verify | done
const [loading, setLoading] = useState(false);
const [code, setCode] = useState('');
const [verifying, setVerifying] = useState(false);
const [resent, setResent] = useState(false);
+ const [recoveryMnemonic, setRecoveryMnemonic] = useState('');
+ const [recoverySaved, setRecoverySaved] = useState(false);
+ const [recoveryCopied, setRecoveryCopied] = useState(false);
+ const [emailRecovery, setEmailRecovery] = useState(true);
const onSubmit = async (e) => {
e.preventDefault();
+ const name = username.trim();
if (password !== confirm) { setError(t('register.err_mismatch')); return; }
if (password.length < PASSWORD_MIN_LEN) {
setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return;
@@ -144,14 +157,30 @@ export function RegisterPage() {
setLoading(true);
try {
if (window.MeshBayKeys) {
- await window.MeshBayKeys.registerUser(username, email, password);
+ // The account recovery key (docs/auth-confirm.md §4.3/§4.4): generated
+ // here, shown once on the next screen. When the user leaves "e-mail it"
+ // checked, the mnemonic goes in the register body so the hub appends it
+ // to the verification e-mail (and stores it nowhere); otherwise it is
+ // screen-only. The derived key is kept in `session.recoveryKey` and
+ // persisted, so groups joined later — this session or a future one —
+ // still leave a recovery-wrapped copy on their node.
+ const rk = window.MeshBayKeys.generateRecoveryKey();
+ // `name` (trimmed), not the raw field: the hub stores the trimmed
+ // username and every key derivation must fold in the same string.
+ await window.MeshBayKeys.registerUser(
+ name, email, password, emailRecovery ? rk.mnemonic : null);
+ setRecoveryMnemonic(rk.mnemonic);
+ session.recoveryKey =
+ await window.MeshBayKeys.deriveRecoveryKey(rk.mnemonic, name);
+ await _storeRecoveryKey(session.recoveryKey);
+ setPhase('recovery');
} else {
await hubFetch('/v1/users/register', {
method: 'POST',
- body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
+ body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
});
+ setPhase('verify');
}
- setPhase('verify');
} catch (err) {
setError(err.message);
} finally {
@@ -183,7 +212,10 @@ export function RegisterPage() {
try {
await hubFetch('/v1/users/register', {
method: 'POST',
- body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
+ body: {
+ username: username.trim(), email, password,
+ pk_user_ed25519: '', pk_user_x25519: '',
+ },
});
setResent(true);
} catch (err) {
@@ -205,6 +237,48 @@ export function RegisterPage() {
`;
}
+ if (phase === 'recovery') {
+ const copyRecovery = async () => {
+ try {
+ await navigator.clipboard.writeText(recoveryMnemonic);
+ setRecoveryCopied(true);
+ setTimeout(() => setRecoveryCopied(false), 2000);
+ } catch { /* clipboard blocked — the text is on screen to copy by hand */ }
+ };
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('register.recovery_title')}</h2>
+ <p style="margin-bottom:12px; color:var(--text-secondary)">
+ ${t('register.recovery_intro')}
+ </p>
+ <code style="display:block; padding:12px; border:1px solid var(--border);
+ border-radius:6px; font-size:1.05em; letter-spacing:0.12em;
+ line-height:1.9; word-spacing:0.3em; text-align:center;
+ user-select:all; background:var(--bg-secondary, transparent)">
+ ${recoveryMnemonic}
+ </code>
+ <button class="btn-secondary" style="margin-top:8px" onClick=${copyRecovery}>
+ ${recoveryCopied ? t('register.recovery_copied') : t('register.recovery_copy')}
+ </button>
+ <p style="margin-top:12px; color:var(--text-secondary)">
+ ${emailRecovery ? t('register.recovery_emailed') : t('register.recovery_not_emailed')}
+ </p>
+ <p class="error-msg" style="margin-top:8px">${t('register.recovery_warning')}</p>
+ <label style="display:flex; gap:8px; align-items:flex-start; margin-top:12px">
+ <input type="checkbox" checked=${recoverySaved}
+ onChange=${e => setRecoverySaved(e.target.checked)} />
+ <span>${t('register.recovery_saved')}</span>
+ </label>
+ <button style="margin-top:12px" disabled=${!recoverySaved}
+ onClick=${() => setPhase('verify')}>
+ ${t('register.recovery_continue')}
+ </button>
+ </div>
+ </div>
+ `;
+ }
+
if (phase === 'verify') {
return html`
<div class="page-center">
@@ -263,6 +337,12 @@ export function RegisterPage() {
<input type="password" placeholder="${t('register.confirm')}" value=${confirm}
onInput=${e => setConfirm(e.target.value)}
autocomplete="new-password" required />
+ <label style="display:flex; gap:8px; align-items:flex-start; margin:4px 0 2px;
+ font-size:0.88em; color:var(--text-secondary)">
+ <input type="checkbox" checked=${emailRecovery}
+ onChange=${e => setEmailRecovery(e.target.checked)} />
+ <span>${t('register.recovery_email_opt')}</span>
+ </label>
${error && html`<div class="error-msg">${error}</div>`}
<button type="submit" disabled=${loading}>
${loading ? t('register.loading') : t('register.submit')}
@@ -275,3 +355,175 @@ export function RegisterPage() {
</div>
`;
}
+
+
+// ── Passphrase reset — Flow B (docs/auth-confirm.md §4) ─────────────────────
+//
+// An e-mail code restores hub login. A recovery key, if the user still has one,
+// restores the per-node identities in the same step: the fan-out reads each
+// node's recovery-wrapped bundle and re-wraps it under the new passphrase.
+// Without a recovery key, sign-in comes back and the groups do not.
+export function ResetPasswordPage({ onLogin }) {
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [phase, setPhase] = useState('request'); // request | form | working | done | norecovery
+ const [code, setCode] = useState('');
+ const [recovery, setRecovery] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [progress, setProgress] = useState(null);
+ const [result, setResult] = useState(null);
+
+ const requestCode = async (e) => {
+ e.preventDefault();
+ if (!username.trim() || !email.trim()) return;
+ setError('');
+ setBusy(true);
+ try {
+ await hubFetch('/v1/users/password/reset-request', {
+ method: 'POST',
+ body: { username: username.trim(), email: email.trim() },
+ });
+ setPhase('form');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const doReset = async (e) => {
+ e.preventDefault();
+ if (!window.MeshBayKeys) { setError(t('reset.err_unsupported')); return; }
+ if (password.length < PASSWORD_MIN_LEN) {
+ setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return;
+ }
+ if (passwordBits(password) < PASSWORD_MIN_BITS) {
+ setError(t('register.err_too_weak')); return;
+ }
+ if (password !== confirm) { setError(t('register.err_mismatch')); return; }
+ setError('');
+ setBusy(true);
+ let signedIn = false;
+ try {
+ const name = username.trim();
+ const newAuthKey = await window.MeshBayKeys.deriveAuthKey(password, name);
+ // Before this point a failure means the code or passphrase is wrong and
+ // the account is untouched — go back to the form.
+ await hubFetch('/v1/users/password/reset', {
+ method: 'POST',
+ body: { username: name, code: code.trim(), new_auth_key: newAuthKey },
+ });
+ await onLogin(name, password); // sets session.bundleKey
+ signedIn = true;
+
+ const mnemonic = recovery.trim();
+ if (!mnemonic) { setPhase('norecovery'); return; }
+
+ session.recoveryKey =
+ await window.MeshBayKeys.deriveRecoveryKey(mnemonic, name);
+ await _storeRecoveryKey(session.recoveryKey);
+ setPhase('working');
+ const auth = loadAuth() || {};
+ const r = await window.MeshBayTransport.rewrapAllNodes({
+ hubUrl: HUB, token: auth.token, username: name, userId: auth.userId,
+ newPassphrase: password, recoveryKey: mnemonic,
+ onProgress: setProgress,
+ });
+ setResult(r);
+ setPhase('done');
+ } catch (err) {
+ setError(err.message);
+ // After sign-in the reset already happened and the code is spent — do not
+ // send the user back to re-enter it. Land on the done screen with the
+ // error shown; their groups may need the operator fallback.
+ setPhase(signedIn ? 'done' : 'form');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (phase === 'working') {
+ return html`
+ <div class="page-center"><div class="card login-card">
+ <h2>${t('reset.title')}</h2>
+ <p class="settings-hint">
+ ${t('reset.working')}
+ ${progress && progress.total ? ` (${progress.done}/${progress.total})` : ''}
+ </p>
+ </div></div>
+ `;
+ }
+
+ if (phase === 'done' || phase === 'norecovery') {
+ const stragglers = phase === 'done' && result
+ ? result.unreachable.concat(result.failed) : [];
+ return html`
+ <div class="page-center"><div class="card login-card">
+ <h2>${t('reset.title')}</h2>
+ <p style="color:var(--success)">${t('reset.signin_restored')}</p>
+ ${error && html`<div class="error-msg" style="margin-top:8px">${error}</div>`}
+ ${phase === 'norecovery' && html`
+ <p class="settings-hint" style="margin-top:8px">${t('reset.no_recovery')}</p>`}
+ ${phase === 'done' && !error && stragglers.length === 0 && html`
+ <p class="settings-hint" style="margin-top:8px">${t('reset.groups_restored')}</p>`}
+ ${stragglers.length > 0 && html`
+ <p class="settings-hint" style="margin-top:8px">${t('reset.needs_operator')}</p>
+ <ul style="margin:0 0 8px 18px">
+ ${stragglers.map(g => html`<li>${g.name}${g.reason ? ` — ${g.reason}` : ''}</li>`)}
+ </ul>`}
+ <button style="margin-top:12px" onClick=${() => navigate('/')}>
+ ${t('reset.go_app')}
+ </button>
+ </div></div>
+ `;
+ }
+
+ return html`
+ <div class="page-center"><div class="card login-card">
+ <h2>${t('reset.title')}</h2>
+ ${phase === 'request' && html`
+ <p style="margin-bottom:12px; color:var(--text-secondary)">${t('reset.request_intro')}</p>
+ <form onSubmit=${requestCode}>
+ <input type="text" placeholder="${t('login.username')}" value=${username}
+ onInput=${e => setUsername(e.target.value)}
+ autocomplete="username" required autofocus />
+ <input type="email" placeholder="${t('register.email')}" value=${email}
+ onInput=${e => setEmail(e.target.value)}
+ autocomplete="email" required />
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <button type="submit" disabled=${busy}>${t('reset.send_code')}</button>
+ </form>`}
+
+ ${phase === 'form' && html`
+ <p style="margin-bottom:12px; color:var(--text-secondary)">${t('reset.form_intro')}</p>
+ <form onSubmit=${doReset}>
+ <input type="text" placeholder="${t('reset.code')}" value=${code}
+ onInput=${e => setCode(e.target.value)}
+ inputmode="numeric" maxlength="6" required autofocus
+ style="text-align:center;font-size:1.3em;letter-spacing:0.3em" />
+ <textarea placeholder="${t('reset.recovery_key')}" value=${recovery}
+ onInput=${e => setRecovery(e.target.value)} rows="2"
+ style="width:100%;font-family:monospace;font-size:0.9em;
+ letter-spacing:0.08em;resize:vertical"></textarea>
+ <p style="font-size:0.8em;color:var(--text-dim);margin:-4px 0 8px">
+ ${t('reset.recovery_key_hint')}
+ </p>
+ <input type="password" placeholder="${t('reset.new_pass')}" value=${password}
+ onInput=${e => setPassword(e.target.value)}
+ autocomplete="new-password" required />
+ <input type="password" placeholder="${t('reset.new_pass_repeat')}" value=${confirm}
+ onInput=${e => setConfirm(e.target.value)}
+ autocomplete="new-password" required />
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <button type="submit" disabled=${busy}>${t('reset.submit')}</button>
+ </form>`}
+
+ <div class="login-footer">
+ <a href="#/login">${t('reset.back_to_login')}</a>
+ </div>
+ </div></div>
+ `;
+}