diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
19 files changed, 1793 insertions, 57 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 953ad1b..fa74368 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -67,6 +67,10 @@ class RegisterRequest(BaseModel): email: str password: str | None = None # deprecated — legacy native clients auth_key: str | None = None # PBKDF2-derived, new clients + # Client-generated account recovery key (docs/auth-confirm.md §4.4). Pure + # pass-through: appended to the verification e-mail so the user's mailbox + # backs it up, then dropped. Never written to any table, never logged. + recovery_key: str | None = None @field_validator("username") @classmethod @@ -125,7 +129,8 @@ async def register( if found: if found.status == "pending" and found.email_hash == eh: # Same person retrying before validation — resend a code - await _create_and_send_verification(db, found, body.email, eh) + await _create_and_send_verification( + db, found, body.email, eh, body.recovery_key) await db.commit() return {"user_id": found.id, "email_verification_required": True} raise HTTPException(status_code=409, detail="Username already taken") @@ -161,7 +166,7 @@ async def register( detail=body.username, )) - await _create_and_send_verification(db, user, body.email, eh) + await _create_and_send_verification(db, user, body.email, eh, body.recovery_key) await db.commit() return {"user_id": user.id, "email_verification_required": True} @@ -169,6 +174,7 @@ async def register( async def _create_and_send_verification( db: AsyncSession, user: User, email: str, eh: str, + recovery_key: str | None = None, ) -> None: user.status = "pending" # Invalidate any previous pending verification for this user @@ -190,7 +196,7 @@ async def _create_and_send_verification( expires_at=datetime.now(timezone.utc) + timedelta(seconds=VERIFICATION_TTL), )) await db.flush() - mail.send_verification_code(email, code) + mail.send_verification_code(email, code, recovery_key=recovery_key) class VerifyEmailRequest(BaseModel): @@ -716,6 +722,220 @@ async def verify_email_change( return {"status": "verified", "email": email} +# ── Passphrase change (Flow A) ────────────────────────────────────────────── +# +# docs/auth-confirm.md §3. The passphrase derives two independent values on the +# client: auth_key (verified here) and bundle_key (AES-GCM key for the per-node +# identity bundles, which live on nodes and never on the hub). The client +# re-wraps those bundles from the old bundle_key to the new one on every +# reachable node *before* calling this; the hub only swaps its auth_key +# verifier. There is no email round-trip — the current passphrase is the second +# factor, exactly as for account deletion. + + +class ChangePasswordRequest(BaseModel): + old_auth_key: str + new_auth_key: str + + +@router.post("/password") +@limiter.limit("5/minute") +async def change_password( + body: ChangePasswordRequest, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + if not verify_password(body.old_auth_key, current_user.pw_hash, + current_user.pw_salt, current_user.pw_version): + raise HTTPException(status_code=403, + detail="Current passphrase does not match") + if body.new_auth_key == body.old_auth_key: + raise HTTPException(status_code=400, + detail="New passphrase must differ from the current one") + + new_hash, new_salt = hash_password(body.new_auth_key) + current_user.pw_hash = new_hash + current_user.pw_salt = new_salt + current_user.pw_version = current_pw_version() + + # Every existing session goes stale. Revoke all refresh tokens, then hand + # this caller a fresh pair so the tab that made the change stays signed in; + # other browsers fail their next renewal and drop to the sign-in form. + await db.execute( + update(RefreshToken) + .where(RefreshToken.user_id == current_user.id) + .values(revoked=True)) + + memberships = await db.execute( + select(GroupMember.group_id).where(GroupMember.user_id == current_user.id)) + group_ids = [gid for (gid,) in memberships.all()] + access_token = issue_access_token(current_user.id, ttl=_ttl(), groups=group_ids) + raw_rt, rt_hash = generate_refresh_token() + expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) + db.add(RefreshToken( + user_id=current_user.id, token_hash=rt_hash, + family_id=str(uuid.uuid4()), expires_at=expires_at, + )) + db.add(IPLog(user_id=current_user.id, event="password_change", + ip_address=client_ip(request))) + await db.commit() + + return { + "status": "changed", + "access_token": access_token, + "refresh_token": raw_rt, + "token_type": "bearer", + "expires_in": _ttl(), + } + + +# ── Passphrase reset (Flow B) ────────────────────────────────────────────── +# +# docs/auth-confirm.md §4.2. An e-mail code re-opens hub login for someone who +# has lost their passphrase. It recovers no group content — that needs the +# recovery key, which the client applies on its own after the reset. +# reset-request never reveals whether an account exists. + +PASSWORD_RESET_TTL = 3600 # 1 hour — shorter than sign-up verification + + +class ResetRequestRequest(BaseModel): + username: str + email: str # must match the address on file for `username` + + @field_validator("email") + @classmethod + def email_shape(cls, v: str) -> str: + v = v.strip() + local, sep, domain = v.partition("@") + if (not sep or not local or not domain + or "." not in domain + or len(v) > 254 + or any(c.isspace() or ord(c) < 32 for c in v)): + raise ValueError("invalid email address") + return v + + +class ResetPasswordRequest(BaseModel): + username: str + code: str + new_auth_key: str + + +@router.post("/password/reset-request") +@limiter.limit("5/minute") +async def password_reset_request( + body: ResetRequestRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.username == body.username)) + user = result.scalar_one_or_none() + + # The username and the e-mail must be the pair on file. A mismatch is + # answered exactly like an unknown account — no reset code is created, no + # mail is sent — so this reveals nothing and cannot be used to spray reset + # mail at someone by knowing only their username. + matched = ( + user is not None + and user.status == "active" + and user.email_hash == hash_email_blind(body.email) + ) + + if matched: + prev = await db.execute( + select(EmailVerification).where( + EmailVerification.user_id == user.id, + EmailVerification.purpose == "password_reset", + EmailVerification.verified_at.is_(None), + )) + for old in prev.scalars().all(): + await db.delete(old) + + code = _generate_code() + db.add(EmailVerification( + email_hash=user.email_hash or "", + code=code, + purpose="password_reset", + user_id=user.id, + expires_at=datetime.now(timezone.utc) + + timedelta(seconds=PASSWORD_RESET_TTL), + )) + db.add(IPLog(user_id=user.id, event="password_reset_request", + ip_address=client_ip(request))) + await db.flush() + try: + mail.send_password_reset_code(decrypt_email(user.email), code) + except Exception: + log.exception("Failed to send passphrase reset code") + await db.commit() + else: + db.add(IPLog( + user_id=user.id if user else None, + event="password_reset_request", + ip_address=client_ip(request), detail=body.username)) + await db.commit() + + # Same answer whether or not the username/e-mail pair matched an account. + return {"status": "sent_if_exists"} + + +@router.post("/password/reset") +@limiter.limit("10/minute") +async def password_reset( + body: ResetPasswordRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + now = datetime.now(timezone.utc) + result = await db.execute(select(User).where(User.username == body.username)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=400, detail="Invalid code") + + vres = await db.execute( + select(EmailVerification).where( + EmailVerification.user_id == user.id, + EmailVerification.purpose == "password_reset", + EmailVerification.verified_at.is_(None), + ).order_by(EmailVerification.created_at.desc())) + verif = vres.scalar_one_or_none() + if not verif: + raise HTTPException(status_code=404, + detail="No pending reset for this account") + if verif.expires_at.replace(tzinfo=timezone.utc) < now: + raise HTTPException(status_code=410, detail="Reset code expired") + if verif.attempts >= VERIFICATION_MAX_ATTEMPTS: + raise HTTPException(status_code=429, detail="Too many attempts") + + verif.attempts += 1 + if verif.code != body.code.strip(): + await db.commit() + raise HTTPException(status_code=400, detail="Invalid code") + + verif.verified_at = now + new_hash, new_salt = hash_password(body.new_auth_key) + user.pw_hash = new_hash + user.pw_salt = new_salt + user.pw_version = current_pw_version() + + # A lost passphrase is a "control may be lost" event: every session dies and + # every registered device key is dropped, so a stored one cannot sign back + # in past the reset. Each device re-enrols with the new passphrase. + await db.execute( + update(RefreshToken).where(RefreshToken.user_id == user.id) + .values(revoked=True)) + await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id)) + db.add(IPLog(user_id=user.id, event="password_reset", + ip_address=client_ip(request))) + await db.commit() + + # The client already holds new_auth_key; it signs in through the normal + # path next, which issues tokens and the membership claim. + return {"status": "reset"} + + # ── User preferences ──────────────────────────────────────────────────────── ALLOWED_PREF_KEYS = frozenset([ diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index ce6e264..4f776bf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -31,23 +31,46 @@ def _send(msg: EmailMessage) -> bool: return False -def send_verification_code(to: str, code: str) -> None: - msg = EmailMessage() - msg["From"] = f"noreply@{_hub_domain}" - msg["To"] = to - msg["Subject"] = f"MeshBay — Your verification code: {code}" - msg.set_content( +def send_verification_code(to: str, code: str, recovery_key: str | None = None) -> None: + """ + Registration verification e-mail. When `recovery_key` is given it is + appended to the body so the recipient's mailbox becomes the backup for it + (docs/auth-confirm.md §4.4). + + `recovery_key` is a **pass-through**: it is generated on the client, never + stored anywhere on the hub, and never logged — only whether one was present. + """ + body = ( f"Your verification code is: {code}\n" "\n" "Enter this code to verify your email address.\n" "This code expires in 24 hours.\n" + ) + if recovery_key: + body += ( + "\n" + "---- Account recovery key ----\n" + "\n" + "Keep this message. If you ever forget your passphrase, this key is\n" + "what restores your access to your groups. It is not stored on the\n" + f"server and nobody at {_hub_domain} can recover it for you.\n" + "\n" + f" {recovery_key}\n" + ) + body += ( "\n" "If you did not create a MeshBay account, ignore this email.\n" "\n" f"{_hub_url}\n" ) + msg = EmailMessage() + msg["From"] = f"noreply@{_hub_domain}" + msg["To"] = to + msg["Subject"] = f"MeshBay — Your verification code: {code}" + msg.set_content(body) _send(msg) - log.info("Verification code sent to %s", _mask_email(to)) + log.info("Verification code sent to %s (recovery_key=%s)", + _mask_email(to), bool(recovery_key)) def send_email_change_code(to: str, code: str) -> None: @@ -69,6 +92,32 @@ def send_email_change_code(to: str, code: str) -> None: log.info("Email change code sent to %s", _mask_email(to)) +def send_password_reset_code(to: str, code: str) -> None: + """ + Passphrase-reset code (docs/auth-confirm.md §4.2). This only re-opens hub + login; it recovers no group content — that needs the recovery key. + """ + msg = EmailMessage() + msg["From"] = f"noreply@{_hub_domain}" + msg["To"] = to + msg["Subject"] = f"MeshBay — Passphrase reset code: {code}" + msg.set_content( + f"Your passphrase reset code is: {code}\n" + "\n" + "Enter it to set a new passphrase. This code expires in 1 hour.\n" + "\n" + "This restores your sign-in only. If you also have your recovery key,\n" + "you can restore access to your groups in the same step.\n" + "\n" + "If you did not request this, ignore this email — your account is\n" + "unchanged.\n" + "\n" + f"{_hub_url}\n" + ) + _send(msg) + log.info("Passphrase reset code sent to %s", _mask_email(to)) + + def send_invite_notification( to: str, code: str, inviter: str, group_name: str, ) -> None: diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index acd4c1d..994cb73 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -20,7 +20,7 @@ import { MusicPlayerBar } from './music-player.js'; import { SettingsPage } from './settings-page.js'; import { ProfilePage } from './profile-page.js'; import { ExplorePage } from './explore-page.js'; -import { FirstRunPage, LoginPage, RegisterPage } from './auth-page.js'; +import { FirstRunPage, LoginPage, RegisterPage, ResetPasswordPage } from './auth-page.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -804,10 +804,12 @@ function App() { // Signing in with this device's key. Showing a form here would be showing // one the user is about to be taken past. page = html`<p class="page-message">${t('status.connecting')}</p>`; - } else if (route === '/login' || route === '/register') { + } else if (route === '/login' || route === '/register' || route === '/reset') { page = route === '/register' ? html`<${RegisterPage} />` - : html`<${LoginPage} onLogin=${authCtx.login} />`; + : route === '/reset' + ? html`<${ResetPasswordPage} onLogin=${authCtx.login} />` + : html`<${LoginPage} onLogin=${authCtx.login} />`; } else if (!user) { page = html`<${LoginPage} onLogin=${authCtx.login} />`; } else if (route === '/search') { 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> + `; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index acfcfc8..d7c81ad 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -6,7 +6,8 @@ import { Icon } from './icon.js'; import { transfers } from './transfers.js'; import { downloadEntry } from './file-utils.js'; import { - HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey, + HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, + _loadBundleKey, _loadRecoveryKey, _storeBundleKey, } from './hub-client.js'; import { visibleApps } from './apps.js'; import { GroupName } from './group-name.js'; @@ -114,6 +115,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const [needsDevice, setNeedsDevice] = useState(false); const [deviceCode, setDeviceCode] = useState(''); const [codeInput, setCodeInput] = useState(''); + // This browser has never derived the passphrase-bundle key (fresh browser, + // cleared storage, or a device-key sign-in). Ask for the passphrase here + // rather than sending someone back to the browser they registered on. + const [needsPass, setNeedsPass] = useState(false); + const [passInput, setPassInput] = useState(''); + const [passBusy, setPassBusy] = useState(false); const [retryKey, setRetryKey] = useState(0); const transportRef = useRef(null); const gekRef = useRef(null); @@ -125,7 +132,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // set to `true` while looking at one group would otherwise silently disable // the retry for every group opened afterward in the same session, forever. const refreshedRef = useRef(false); - useEffect(() => { refreshedRef.current = false; }, [groupId]); + useEffect(() => { + refreshedRef.current = false; + setNeedsPass(false); + }, [groupId]); const submitJoinCode = useCallback((e) => { e.preventDefault(); @@ -138,6 +148,31 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setRetryKey(k => k + 1); }, [codeInput]); + const submitPass = useCallback(async (e) => { + e.preventDefault(); + const pass = passInput; + if (!pass || !window.MeshBayKeys) return; + setPassBusy(true); + setError(''); + try { + // Same derivation as sign-in — the token is already ours, only the key + // that opens node bundles is missing here. Persisted so this browser is + // set up from now on. + session.bundleKey = { + v2: await window.MeshBayKeys.deriveEncryptionKey(pass, username), + v1: await window.MeshBayKeys.deriveEncryptionKeyV1(pass, username), + }; + await _storeBundleKey(session.bundleKey); + setPassInput(''); + setNeedsPass(false); + setRetryKey(k => k + 1); + } catch (err) { + setError(err.message); + } finally { + setPassBusy(false); + } + }, [passInput, username]); + // One place that takes an index from the node and puts it everywhere it has to // go. Deleting a file used to refresh the table and leave the cache alone, so // the search page went on offering a file that no longer existed until the @@ -191,6 +226,16 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setError(''); gekRef.current = null; if (!session.bundleKey) session.bundleKey = await _loadBundleKey(); + // Persisted (docs/auth-confirm.md §4.3) so a group joined in a later + // session still leaves a recovery-wrapped identity copy on its node. + if (!session.recoveryKey) session.recoveryKey = await _loadRecoveryKey(); + if (!session.bundleKey && window.MeshBayKeys) { + // Nothing to sign or unwrap with in this browser yet — ask for the + // passphrase instead of failing into a "go back to your other browser" + // message. + if (!cancelled) { setNeedsPass(true); setStatus('idle'); } + return; + } try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); if (cancelled) return; @@ -225,7 +270,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const ack = await transport.connect( nodeId, live, groupId, null, sessionKeys, session.bundleKey, username, - userId, session.pendingJoinCode); + userId, session.pendingJoinCode, session.recoveryKey); session.pendingJoinCode = null; if (cancelled) return; setIsNodeAdmin(!!ack.is_node_admin); @@ -281,8 +326,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // passphrase. It is this node's key and no other's. if (transport.connected && transport.newNodeBundle) { try { - await transport.storeKeypairBundle(transport.newNodeBundle); + await transport.storeKeypairBundle( + transport.newNodeBundle, transport.newNodeBundleRecovery); transport.newNodeBundle = null; + transport.newNodeBundleRecovery = null; } catch (e) { console.warn('[MeshBay] could not leave our key with the node:', e.message); } @@ -349,6 +396,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // one-time code from the operator before it will hand over the group // key. Not an error to shout about — a step in joining. if (err.reason === 'code_required') setNeedsCode(true); + // The node has no bundle for us and this browser derived no key to make + // one — the passphrase form below is the way in, not a support request. + if (err.reason === 'no_keys') setNeedsPass(true); // A key this node has never pinned, for an account it knows. The way in // is a device already trusted here, not an operator — which is the // whole point of device linking: a second browser or a native client @@ -549,6 +599,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, `} </div> `} + ${needsPass && html` + <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitPass}> + <h4>${t('group.pass_title')}</h4> + <p class="settings-hint">${t('group.pass_hint')}</p> + <div style="display:flex;gap:8px"> + <input type="password" placeholder=${t('login.password')} + autocomplete="current-password" + value=${passInput} onInput=${e => setPassInput(e.target.value)} required /> + <button class="admin-btn" type="submit" disabled=${passBusy}> + ${passBusy ? '…' : t('group.pass_btn')} + </button> + </div> + </form> + `} ${needsCode && html` <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}> <h4>${t('group.join_code_title')}</h4> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js index 16dce9f..e72961c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -89,7 +89,12 @@ async function clearAllCachedIndexes() { // a code is single-use and short-lived. A plain object, not two bare `let`s, // so importing modules can update either field without this module handing // out a rebindable export. -const session = { bundleKey: null, pendingJoinCode: null }; +// +// `recoveryKey` (docs/auth-confirm.md §4.3) is the AES key that wraps the +// *recovery* copy of an identity bundle. In-memory only, and set only when the +// user has just generated or entered the recovery secret (registration, or the +// Flow B screen) — it cannot be re-derived from the passphrase. +const session = { bundleKey: null, pendingJoinCode: null, recoveryKey: null }; function _openKeyDB() { return new Promise((resolve, reject) => { @@ -99,25 +104,33 @@ function _openKeyDB() { req.onerror = () => reject(req.error); }); } -async function _storeBundleKey(key) { +async function _storeKey(slot, key) { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readwrite'); - tx.objectStore('k').put(key, 'bk'); + tx.objectStore('k').put(key, slot); await new Promise(r => { tx.oncomplete = r; }); db.close(); } catch {} } -async function _loadBundleKey() { +async function _loadKey(slot) { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readonly'); - const g = tx.objectStore('k').get('bk'); + const g = tx.objectStore('k').get(slot); const val = await new Promise(r => { g.onsuccess = () => r(g.result); }); db.close(); return val || null; } catch { return null; } } +// 'bk' = passphrase-derived bundle key; 'rk' = recovery key (docs/auth-confirm.md +// §4.3). Persisting 'rk' is what lets a group joined in a *later* session still +// get a recovery-wrapped identity copy, instead of only groups joined in the +// unbroken session that generated it. Cleared with everything else on sign-out. +const _storeBundleKey = (key) => _storeKey('bk', key); +const _loadBundleKey = () => _loadKey('bk'); +const _storeRecoveryKey = (key) => _storeKey('rk', key); +const _loadRecoveryKey = () => _loadKey('rk'); async function _clearKeyDB() { try { const db = await _openKeyDB(); @@ -142,6 +155,7 @@ function saveAuth(auth) { } else { localStorage.removeItem(AUTH_KEY); session.bundleKey = null; + session.recoveryKey = null; _clearKeyDB(); } } @@ -274,7 +288,7 @@ async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { export { HUB, navigate, session, cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes, - _storeBundleKey, _loadBundleKey, _clearKeyDB, + _storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch, }; 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, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 909fec5..363df86 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -29,6 +29,25 @@ export default { 'login.loading': 'Anmeldung läuft …', 'login.no_account': 'Noch kein Konto?', 'login.register_link': 'Konto erstellen', + 'login.forgot': 'Passphrase vergessen?', + 'reset.title': 'Passphrase zurücksetzen', + 'reset.request_intro': 'Geben Sie Ihren Benutzernamen und die E-Mail des Kontos ein. Stimmen sie überein, wird ein Zurücksetzungscode an diese Adresse gesendet.', + 'reset.send_code': 'Code senden', + 'reset.form_intro': 'Geben Sie den Code aus Ihrer E-Mail ein und wählen Sie eine neue Passphrase. Wenn Sie Ihren Wiederherstellungsschlüssel haben, fügen Sie ihn hinzu, um auch den Zugang zu Ihren Gruppen wiederherzustellen.', + 'reset.code': 'Zurücksetzungscode', + 'reset.recovery_key': 'Wiederherstellungsschlüssel (optional)', + 'reset.recovery_key_hint': 'Leer lassen, wenn Sie ihn nicht haben. Ohne ihn wird Ihre Anmeldung wiederhergestellt, aber jeder Gruppe müssen Sie erneut beitreten.', + 'reset.new_pass': 'Neue Passphrase', + 'reset.new_pass_repeat': 'Neue Passphrase wiederholen', + 'reset.submit': 'Passphrase zurücksetzen', + 'reset.working': 'Ihre Gruppenidentitäten werden wiederhergestellt…', + 'reset.signin_restored': 'Ihre Anmeldung wurde wiederhergestellt.', + 'reset.groups_restored': 'Ihre Gruppenidentitäten wurden auf jedem erreichbaren Knoten wiederhergestellt.', + 'reset.no_recovery': 'Ihre Gruppenidentitäten wurden nicht wiederhergestellt. Bitten Sie für jede Gruppe deren Betreiber, Ihre Anheftung zu entfernen und einen neuen Einladungscode zu senden, und treten Sie dann erneut bei.', + 'reset.needs_operator': 'Diese Gruppen erfordern noch eine Aktion — bitten Sie jeden Betreiber, Ihre Anheftung zu entfernen und einen neuen Code zu senden:', + 'reset.go_app': 'Zur App', + 'reset.back_to_login': 'Zurück zur Anmeldung', + 'reset.err_unsupported': 'Das Zurücksetzen der Passphrase benötigt das Krypto-Modul des Browsers, das hier nicht verfügbar ist.', // Register 'register.title': 'Konto erstellen', @@ -40,6 +59,16 @@ export default { 'register.loading': 'Konto wird erstellt …', 'register.has_account': 'Sie haben bereits ein Konto?', 'register.login_link': 'Anmelden', + 'register.recovery_title': 'Speichern Sie Ihren Wiederherstellungsschlüssel', + 'register.recovery_intro': 'Dieser Schlüssel stellt Ihre Gruppenidentitäten wieder her, falls Sie jemals Ihre Passphrase vergessen. Bewahren Sie ihn in einem Passwortmanager oder an einem sicheren Ort auf — er wird nur einmal angezeigt, und der Hub sieht ihn nie.', + 'register.recovery_copy': 'Kopieren', + 'register.recovery_copied': 'Kopiert', + 'register.recovery_warning': 'Wenn Sie sowohl Ihre Passphrase als auch diesen Schlüssel verlieren, bleiben Ihre Dateien auf ihren Knoten, aber Sie treten jeder Gruppe mit einer neuen Identität erneut bei.', + 'register.recovery_saved': 'Ich habe meinen Wiederherstellungsschlüssel an einem sicheren Ort gespeichert.', + 'register.recovery_continue': 'Weiter', + 'register.recovery_email_opt': 'Diesen Wiederherstellungsschlüssel zur Sicherung auch per E-Mail an mich senden', + 'register.recovery_emailed': 'Eine Kopie wurde auch an Ihre E-Mail-Adresse gesendet, in derselben Nachricht wie Ihr Bestätigungscode.', + 'register.recovery_not_emailed': 'Dies wurde nicht per E-Mail gesendet. Speichern Sie es jetzt — es wird nur dieses eine Mal angezeigt.', 'register.success_title': 'E-Mail bestätigen', 'register.success_msg': 'Ein Bestätigungscode wurde an Ihre E-Mail-Adresse gesendet. Geben Sie ihn unten ein, um Ihr Konto zu aktivieren.', 'register.go_login': 'Zur Anmeldung', @@ -294,6 +323,36 @@ export default { other: '{n} gemerkt', }, 'settings.node_pins_clear': 'Gemerkte Identitäten löschen', + 'settings.passphrase': 'Passphrase', + 'settings.passphrase_hint': 'Ändert die Passphrase dieses Kontos. Ihre Identitätsschlüssel werden in jeder Gruppe neu verpackt, deren Knoten online ist; einer Gruppe, deren Knoten offline ist, müssen Sie danach erneut beitreten.', + 'settings.passphrase_change': 'Passphrase ändern', + 'settings.passphrase_current': 'Aktuelle Passphrase', + 'settings.passphrase_new': 'Neue Passphrase', + 'settings.passphrase_new_repeat': 'Neue Passphrase wiederholen', + 'settings.continue': 'Weiter', + 'settings.done': 'Fertig', + 'settings.passphrase_confirm_intro': 'Ihre Identitätsschlüssel werden jetzt in jeder Gruppe neu verpackt, deren Knoten erreichbar ist:', + 'settings.passphrase_reachable': 'Jetzt aktualisiert', + 'settings.passphrase_unreachable': 'Nicht erreichbar', + 'settings.passphrase_fallback_note': 'Bitten Sie bei einer nicht erreichbaren Gruppe deren Betreiber, Ihre Anheftung zu entfernen und einen neuen Einladungscode zu senden, und treten Sie dann erneut bei. Ihre Dateien und Nachrichten gehen nicht verloren.', + 'settings.passphrase_confirm_btn': 'Ändern', + 'settings.passphrase_working': 'Ihre Schlüssel werden neu verpackt…', + 'settings.passphrase_done': 'Passphrase geändert.', + 'settings.passphrase_needs_operator': 'Diese Gruppen erfordern noch eine Aktion — bitten Sie jeden Betreiber, Ihre Anheftung zu entfernen und einen neuen Code zu senden:', + 'settings.pw_too_short': 'Die neue Passphrase muss mindestens 12 Zeichen lang sein.', + 'settings.pw_mismatch': 'Die beiden neuen Passphrasen stimmen nicht überein.', + 'settings.pw_same': 'Die neue Passphrase muss sich von der aktuellen unterscheiden.', + 'settings.pw_wrong_current': 'Die aktuelle Passphrase ist nicht korrekt. Es wurde nichts geändert.', + 'settings.recovery': 'Wiederherstellungsschlüssel', + 'settings.recovery_hint': 'Fügt jeder Gruppe eine mit Ihrem Wiederherstellungsschlüssel verschlüsselte Kopie Ihrer Identität hinzu. Machen Sie das einmal in jedem Browser, den Sie nutzen; ohne sie bedeutet eine verlorene Passphrase, jeder Gruppe von Hand erneut beizutreten.', + 'settings.recovery_loaded': 'In diesem Browser geladen — neue Gruppen sind automatisch abgedeckt.', + 'settings.recovery_open': 'Wiederherstellungsschlüssel eingeben', + 'settings.recovery_input_ph': 'Fügen Sie Ihren Wiederherstellungsschlüssel ein', + 'settings.recovery_submit': 'Sicherungskopien hinzufügen', + 'settings.recovery_working': 'Ihre Gruppen werden aktualisiert…', + 'settings.recovery_done': 'Ihre Gruppen haben jetzt eine Wiederherstellungskopie.', + 'settings.recovery_partial': 'Diese Gruppen waren nicht erreichbar. Öffnen Sie sie später oder führen Sie dies erneut aus:', + 'settings.recovery_need_relogin': 'Bitte melden Sie sich ab und wieder an und versuchen Sie es erneut.', 'settings.danger': 'Konto löschen', 'settings.delete_hint': 'Entfernt Ihr Konto, Ihre Gruppenmitgliedschaften und Ihre ' + 'Benachrichtigungen vom Hub und gibt Ihren Benutzernamen frei. Was auf den Nodes ' @@ -527,6 +586,9 @@ export default { + 'Einmalcode und geben Sie ihn hier ein. Danach ist dieser Browser bekannt und Sie ' + 'werden nicht erneut gefragt.', 'group.join_code_btn': 'Beitreten', + 'group.pass_title': 'Geben Sie Ihre Passphrase ein', + 'group.pass_hint': 'Dieser Browser hat Ihre Schlüssel noch nicht entsperrt. Geben Sie Ihre Passphrase einmal ein, um Ihre Gruppen hier zu öffnen.', + 'group.pass_btn': 'Entsperren', 'notif.purge': 'Alle löschen', 'notif.title': 'Benachrichtigungen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index a6f77b3..14a42a9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -32,6 +32,25 @@ export default { 'login.loading': 'Logging in...', 'login.no_account': 'No account?', 'login.register_link': 'Register', + 'login.forgot': 'Forgot your passphrase?', + 'reset.title': 'Reset passphrase', + 'reset.request_intro': 'Enter your username and the email on the account. If they match, a reset code is sent to it.', + 'reset.send_code': 'Send code', + 'reset.form_intro': 'Enter the code from your email and choose a new passphrase. If you have your recovery key, add it to also restore access to your groups.', + 'reset.code': 'Reset code', + 'reset.recovery_key': 'Recovery key (optional)', + 'reset.recovery_key_hint': 'Leave blank if you do not have it. Without it your sign-in is restored, but each group must be rejoined.', + 'reset.new_pass': 'New passphrase', + 'reset.new_pass_repeat': 'Repeat new passphrase', + 'reset.submit': 'Reset passphrase', + 'reset.working': 'Restoring your group identities…', + 'reset.signin_restored': 'Your sign-in has been restored.', + 'reset.groups_restored': 'Your group identities were restored on every reachable node.', + 'reset.no_recovery': 'Your group identities were not restored. For each group, ask its operator to unpin you and send a new invitation code, then rejoin.', + 'reset.needs_operator': 'These groups still need action — ask each operator to unpin you and send a new code:', + 'reset.go_app': 'Go to the app', + 'reset.back_to_login': 'Back to sign in', + 'reset.err_unsupported': 'Passphrase reset needs the browser crypto module, which is not available here.', // Register 'register.title': 'Register', @@ -43,6 +62,16 @@ export default { 'register.loading': 'Creating account...', 'register.has_account': 'Already have an account?', 'register.login_link': 'Login', + 'register.recovery_title': 'Save your recovery key', + 'register.recovery_intro': 'This key restores your group identities if you ever forget your passphrase. Store it in a password manager or somewhere safe — it is shown only once, and the hub never sees it.', + 'register.recovery_copy': 'Copy', + 'register.recovery_copied': 'Copied', + 'register.recovery_warning': 'If you lose both your passphrase and this key, your files stay on their nodes but you rejoin each group with a new identity.', + 'register.recovery_saved': 'I have saved my recovery key somewhere safe.', + 'register.recovery_continue': 'Continue', + 'register.recovery_email_opt': 'Also email this recovery key to me as a backup', + 'register.recovery_emailed': 'A copy has also been sent to your email, in the same message as your verification code.', + 'register.recovery_not_emailed': 'This was not emailed. Save it now — this is the only time it is shown.', 'register.success_title': 'Verify your email', 'register.success_msg': 'A verification code has been sent to your email address. Enter it below to activate your account.', 'register.go_login': 'Go to login', @@ -288,6 +317,36 @@ export default { other: '{n} pinned', }, 'settings.node_pins_clear': 'Clear pinned identities', + 'settings.passphrase': 'Passphrase', + 'settings.passphrase_hint': "Change this account's passphrase. Your identity keys are re-wrapped on every group whose node is online; a group whose node is offline has to be rejoined afterwards.", + 'settings.passphrase_change': 'Change passphrase', + 'settings.passphrase_current': 'Current passphrase', + 'settings.passphrase_new': 'New passphrase', + 'settings.passphrase_new_repeat': 'Repeat new passphrase', + 'settings.continue': 'Continue', + 'settings.done': 'Done', + 'settings.passphrase_confirm_intro': 'Your identity keys will be re-wrapped now on every group whose node can be reached:', + 'settings.passphrase_reachable': 'Updated now', + 'settings.passphrase_unreachable': 'Cannot be reached', + 'settings.passphrase_fallback_note': 'For a group that cannot be reached, ask its operator to unpin you and send a fresh invitation code, then rejoin. Your files and messages are not lost.', + 'settings.passphrase_confirm_btn': 'Change it', + 'settings.passphrase_working': 'Re-wrapping your keys…', + 'settings.passphrase_done': 'Passphrase changed.', + 'settings.passphrase_needs_operator': 'These groups still need action — ask each operator to unpin you and send a new code:', + 'settings.pw_too_short': 'The new passphrase must be at least 12 characters.', + 'settings.pw_mismatch': 'The two new passphrases do not match.', + 'settings.pw_same': 'The new passphrase must differ from the current one.', + 'settings.pw_wrong_current': 'The current passphrase is not correct. Nothing was changed.', + 'settings.recovery': 'Recovery key', + 'settings.recovery_hint': 'Add a recovery-wrapped copy of your identity to every group. Do this once in each browser you use; without it, a lost passphrase means rejoining each group by hand.', + 'settings.recovery_loaded': 'Loaded in this browser — new groups are covered automatically.', + 'settings.recovery_open': 'Enter recovery key', + 'settings.recovery_input_ph': 'Paste your recovery key', + 'settings.recovery_submit': 'Add backup copies', + 'settings.recovery_working': 'Updating your groups…', + 'settings.recovery_done': 'Your groups now have a recovery copy.', + 'settings.recovery_partial': 'These groups could not be reached. Open them later, or run this again:', + 'settings.recovery_need_relogin': 'Please sign out and back in, then try again.', 'settings.danger': 'Delete account', 'settings.delete_hint': 'Removes your account, your group memberships and your ' + 'notifications from the hub, and frees your username. It cannot reach what ' @@ -604,6 +663,9 @@ export default { 'group.join_code_hint': 'Ask whoever invited you for the one-time code, and enter ' + 'it here. After that this browser is recognised and you will not be asked again.', 'group.join_code_btn': 'Join', + 'group.pass_title': 'Enter your passphrase', + 'group.pass_hint': 'This browser has not unlocked your keys yet. Enter your passphrase once to open your groups here.', + 'group.pass_btn': 'Unlock', 'notif.purge': 'Clear all', 'notif.title': 'Notifications', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 640eb91..ab1829a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -28,6 +28,25 @@ export default { 'login.loading': 'Iniciando sesión...', 'login.no_account': '¿No tiene cuenta?', 'login.register_link': 'Crear una cuenta', + 'login.forgot': '¿Olvidó su frase de contraseña?', + 'reset.title': 'Restablecer la frase de contraseña', + 'reset.request_intro': 'Introduzca su nombre de usuario y el correo de la cuenta. Si coinciden, se envía un código de restablecimiento a esa dirección.', + 'reset.send_code': 'Enviar código', + 'reset.form_intro': 'Introduzca el código de su correo y elija una nueva frase de contraseña. Si tiene su clave de recuperación, añádala para restaurar también el acceso a sus grupos.', + 'reset.code': 'Código de restablecimiento', + 'reset.recovery_key': 'Clave de recuperación (opcional)', + 'reset.recovery_key_hint': 'Déjelo en blanco si no la tiene. Sin ella, su inicio de sesión se restaura, pero hay que volver a unirse a cada grupo.', + 'reset.new_pass': 'Nueva frase de contraseña', + 'reset.new_pass_repeat': 'Repetir la nueva frase de contraseña', + 'reset.submit': 'Restablecer la frase de contraseña', + 'reset.working': 'Restaurando sus identidades de grupo…', + 'reset.signin_restored': 'Su inicio de sesión se ha restaurado.', + 'reset.groups_restored': 'Sus identidades de grupo se restauraron en todos los nodos alcanzables.', + 'reset.no_recovery': 'Sus identidades de grupo no se restauraron. Para cada grupo, pida a su operador que le quite la fijación y le envíe un nuevo código de invitación, y vuelva a unirse.', + 'reset.needs_operator': 'Estos grupos aún requieren acción: pida a cada operador que le quite la fijación y le envíe un nuevo código:', + 'reset.go_app': 'Ir a la aplicación', + 'reset.back_to_login': 'Volver al inicio de sesión', + 'reset.err_unsupported': 'El restablecimiento de la frase de contraseña necesita el módulo de cifrado del navegador, que no está disponible aquí.', // Register 'register.title': 'Crear una cuenta', @@ -39,6 +58,16 @@ export default { 'register.loading': 'Creando la cuenta...', 'register.has_account': '¿Ya tiene una cuenta?', 'register.login_link': 'Iniciar sesión', + 'register.recovery_title': 'Guarde su clave de recuperación', + 'register.recovery_intro': 'Esta clave restaura sus identidades de grupo si alguna vez olvida su frase de contraseña. Guárdela en un gestor de contraseñas o en un lugar seguro: solo se muestra una vez y el hub nunca la ve.', + 'register.recovery_copy': 'Copiar', + 'register.recovery_copied': 'Copiado', + 'register.recovery_warning': 'Si pierde tanto su frase de contraseña como esta clave, sus archivos permanecen en sus nodos pero se vuelve a unir a cada grupo con una nueva identidad.', + 'register.recovery_saved': 'He guardado mi clave de recuperación en un lugar seguro.', + 'register.recovery_continue': 'Continuar', + 'register.recovery_email_opt': 'Enviarme también esta clave de recuperación por correo como copia de seguridad', + 'register.recovery_emailed': 'También se ha enviado una copia a su correo electrónico, en el mismo mensaje que su código de verificación.', + 'register.recovery_not_emailed': 'Esto no se envió por correo. Guárdela ahora: es la única vez que se muestra.', 'register.success_title': 'Verifique su correo', 'register.success_msg': 'Se ha enviado un código de verificación a su dirección de correo electrónico. Introdúzcalo a continuación para activar su cuenta.', 'register.go_login': 'Ir al inicio de sesión', @@ -291,6 +320,36 @@ export default { other: '{n} fijadas', }, 'settings.node_pins_clear': 'Borrar las identidades fijadas', + 'settings.passphrase': 'Frase de contraseña', + 'settings.passphrase_hint': 'Cambia la frase de contraseña de esta cuenta. Sus claves de identidad se vuelven a cifrar en cada grupo cuyo nodo esté en línea; un grupo cuyo nodo esté fuera de línea tendrá que volver a unirse después.', + 'settings.passphrase_change': 'Cambiar la frase de contraseña', + 'settings.passphrase_current': 'Frase de contraseña actual', + 'settings.passphrase_new': 'Nueva frase de contraseña', + 'settings.passphrase_new_repeat': 'Repetir la nueva frase de contraseña', + 'settings.continue': 'Continuar', + 'settings.done': 'Listo', + 'settings.passphrase_confirm_intro': 'Sus claves de identidad se volverán a cifrar ahora en cada grupo cuyo nodo se pueda alcanzar:', + 'settings.passphrase_reachable': 'Actualizado ahora', + 'settings.passphrase_unreachable': 'No se puede alcanzar', + 'settings.passphrase_fallback_note': 'Para un grupo que no se pueda alcanzar, pida a su operador que le quite la fijación y le envíe un nuevo código de invitación, y vuelva a unirse. Sus archivos y mensajes no se pierden.', + 'settings.passphrase_confirm_btn': 'Cambiarla', + 'settings.passphrase_working': 'Volviendo a cifrar sus claves…', + 'settings.passphrase_done': 'Frase de contraseña cambiada.', + 'settings.passphrase_needs_operator': 'Estos grupos aún requieren acción: pida a cada operador que le quite la fijación y le envíe un nuevo código:', + 'settings.pw_too_short': 'La nueva frase de contraseña debe tener al menos 12 caracteres.', + 'settings.pw_mismatch': 'Las dos nuevas frases de contraseña no coinciden.', + 'settings.pw_same': 'La nueva frase de contraseña debe ser distinta de la actual.', + 'settings.pw_wrong_current': 'La frase de contraseña actual no es correcta. No se cambió nada.', + 'settings.recovery': 'Clave de recuperación', + 'settings.recovery_hint': 'Añade a cada grupo una copia de su identidad cifrada con su clave de recuperación. Hágalo una vez en cada navegador que use; sin ella, una frase de contraseña perdida obliga a volver a unirse a cada grupo a mano.', + 'settings.recovery_loaded': 'Cargada en este navegador: los grupos nuevos quedan cubiertos automáticamente.', + 'settings.recovery_open': 'Introducir la clave de recuperación', + 'settings.recovery_input_ph': 'Pegue su clave de recuperación', + 'settings.recovery_submit': 'Añadir copias de seguridad', + 'settings.recovery_working': 'Actualizando sus grupos…', + 'settings.recovery_done': 'Sus grupos ya tienen una copia de recuperación.', + 'settings.recovery_partial': 'No se pudo contactar con estos grupos. Ábralos más tarde o vuelva a ejecutar esto:', + 'settings.recovery_need_relogin': 'Cierre sesión y vuelva a entrar, luego inténtelo de nuevo.', 'settings.danger': 'Eliminar la cuenta', 'settings.delete_hint': 'Elimina su cuenta, sus pertenencias a grupos y sus ' + 'notificaciones del hub, y libera su nombre de usuario. No alcanza lo que vive ' @@ -523,6 +582,9 @@ export default { + 'introdúzcalo aquí. Después, este navegador queda reconocido y no se le volverá a ' + 'preguntar.', 'group.join_code_btn': 'Unirse', + 'group.pass_title': 'Introduzca su frase de contraseña', + 'group.pass_hint': 'Este navegador aún no ha desbloqueado sus claves. Introduzca su frase de contraseña una vez para abrir sus grupos aquí.', + 'group.pass_btn': 'Desbloquear', 'notif.purge': 'Borrar todo', 'notif.title': 'Notificaciones', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index de6b7c8..bfa707c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -28,6 +28,25 @@ export default { 'login.loading': 'Connexion en cours...', 'login.no_account': 'Pas encore de compte ?', 'login.register_link': 'Créer un compte', + 'login.forgot': 'Phrase secrète oubliée ?', + 'reset.title': 'Réinitialiser la phrase secrète', + 'reset.request_intro': 'Saisissez votre nom d’utilisateur et l’e-mail du compte. S’ils correspondent, un code de réinitialisation est envoyé à cette adresse.', + 'reset.send_code': 'Envoyer le code', + 'reset.form_intro': 'Saisissez le code reçu par e-mail et choisissez une nouvelle phrase secrète. Si vous avez votre clé de récupération, ajoutez-la pour rétablir aussi l’accès à vos groupes.', + 'reset.code': 'Code de réinitialisation', + 'reset.recovery_key': 'Clé de récupération (facultatif)', + 'reset.recovery_key_hint': 'Laissez vide si vous ne l’avez pas. Sans elle, votre connexion est rétablie, mais chaque groupe devra être rejoint.', + 'reset.new_pass': 'Nouvelle phrase secrète', + 'reset.new_pass_repeat': 'Répéter la nouvelle phrase secrète', + 'reset.submit': 'Réinitialiser la phrase secrète', + 'reset.working': 'Restauration de vos identités de groupe…', + 'reset.signin_restored': 'Votre connexion a été rétablie.', + 'reset.groups_restored': 'Vos identités de groupe ont été restaurées sur chaque nœud joignable.', + 'reset.no_recovery': 'Vos identités de groupe n’ont pas été restaurées. Pour chaque groupe, demandez à son opérateur de vous désépingler et de vous envoyer un nouveau code d’invitation, puis rejoignez-le.', + 'reset.needs_operator': 'Ces groupes nécessitent encore une action — demandez à chaque opérateur de vous désépingler et de vous envoyer un nouveau code :', + 'reset.go_app': 'Aller à l’application', + 'reset.back_to_login': 'Retour à la connexion', + 'reset.err_unsupported': 'La réinitialisation de la phrase secrète nécessite le module de chiffrement du navigateur, indisponible ici.', // Register 'register.title': 'Créer un compte', @@ -39,6 +58,16 @@ export default { 'register.loading': 'Création du compte...', 'register.has_account': 'Vous avez déjà un compte ?', 'register.login_link': 'Se connecter', + 'register.recovery_title': 'Enregistrez votre clé de récupération', + 'register.recovery_intro': "Cette clé restaure vos identités de groupe si vous oubliez un jour votre phrase secrète. Conservez-la dans un gestionnaire de mots de passe ou en lieu sûr — elle n'est affichée qu'une seule fois, et le hub ne la voit jamais.", + 'register.recovery_copy': 'Copier', + 'register.recovery_copied': 'Copié', + 'register.recovery_warning': "Si vous perdez à la fois votre phrase secrète et cette clé, vos fichiers restent sur leurs nœuds mais vous rejoignez chaque groupe avec une nouvelle identité.", + 'register.recovery_saved': "J'ai enregistré ma clé de récupération en lieu sûr.", + 'register.recovery_continue': 'Continuer', + 'register.recovery_email_opt': 'M’envoyer aussi cette clé de récupération par e-mail comme sauvegarde', + 'register.recovery_emailed': 'Une copie a également été envoyée à votre adresse e-mail, dans le même message que votre code de vérification.', + 'register.recovery_not_emailed': 'Elle n’a pas été envoyée par e-mail. Enregistrez-la maintenant — c’est la seule fois où elle est affichée.', 'register.success_title': 'Vérifiez votre e-mail', 'register.success_msg': 'Un code de vérification a été envoyé à votre adresse e-mail. Saisissez-le ci-dessous pour activer votre compte.', 'register.go_login': 'Aller à la connexion', @@ -293,6 +322,36 @@ export default { other: '{n} épinglées', }, 'settings.node_pins_clear': 'Effacer les identités épinglées', + 'settings.passphrase': 'Phrase secrète', + 'settings.passphrase_hint': "Change la phrase secrète de ce compte. Vos clés d'identité sont re-chiffrées sur chaque groupe dont le nœud est en ligne ; un groupe dont le nœud est hors ligne devra être rejoint ensuite.", + 'settings.passphrase_change': 'Changer la phrase secrète', + 'settings.passphrase_current': 'Phrase secrète actuelle', + 'settings.passphrase_new': 'Nouvelle phrase secrète', + 'settings.passphrase_new_repeat': 'Répéter la nouvelle phrase secrète', + 'settings.continue': 'Continuer', + 'settings.done': 'Terminé', + 'settings.passphrase_confirm_intro': "Vos clés d'identité vont être re-chiffrées maintenant sur chaque groupe dont le nœud est joignable :", + 'settings.passphrase_reachable': 'Mis à jour maintenant', + 'settings.passphrase_unreachable': 'Injoignable', + 'settings.passphrase_fallback_note': "Pour un groupe injoignable, demandez à son opérateur de vous désépingler et de vous envoyer un nouveau code d'invitation, puis rejoignez-le. Vos fichiers et messages ne sont pas perdus.", + 'settings.passphrase_confirm_btn': 'Changer', + 'settings.passphrase_working': 'Re-chiffrement de vos clés…', + 'settings.passphrase_done': 'Phrase secrète changée.', + 'settings.passphrase_needs_operator': 'Ces groupes nécessitent encore une action — demandez à chaque opérateur de vous désépingler et de vous envoyer un nouveau code :', + 'settings.pw_too_short': 'La nouvelle phrase secrète doit comporter au moins 12 caractères.', + 'settings.pw_mismatch': 'Les deux nouvelles phrases secrètes ne correspondent pas.', + 'settings.pw_same': "La nouvelle phrase secrète doit être différente de l'actuelle.", + 'settings.pw_wrong_current': "La phrase secrète actuelle est incorrecte. Rien n'a été changé.", + 'settings.recovery': 'Clé de récupération', + 'settings.recovery_hint': "Ajoute à chaque groupe une copie de votre identité chiffrée avec votre clé de récupération. À faire une fois dans chaque navigateur que vous utilisez ; sans cela, une phrase secrète perdue oblige à rejoindre chaque groupe à la main.", + 'settings.recovery_loaded': 'Chargée dans ce navigateur — les nouveaux groupes sont couverts automatiquement.', + 'settings.recovery_open': 'Saisir la clé de récupération', + 'settings.recovery_input_ph': 'Collez votre clé de récupération', + 'settings.recovery_submit': 'Ajouter les copies de secours', + 'settings.recovery_working': 'Mise à jour de vos groupes…', + 'settings.recovery_done': 'Vos groupes ont maintenant une copie de récupération.', + 'settings.recovery_partial': 'Ces groupes n’ont pas pu être joints. Ouvrez-les plus tard, ou relancez l’opération :', + 'settings.recovery_need_relogin': 'Veuillez vous déconnecter puis vous reconnecter, et réessayer.', 'settings.danger': 'Supprimer le compte', 'settings.delete_hint': 'Supprime votre compte, vos adhésions aux groupes et vos ' + 'notifications du hub, et libère votre nom d’utilisateur. Cela n’atteint pas ' @@ -526,6 +585,9 @@ export default { + 'invité, et saisissez-le ici. Ensuite, ce navigateur est reconnu et la question ' + 'ne vous sera plus posée.', 'group.join_code_btn': 'Rejoindre', + 'group.pass_title': 'Saisissez votre phrase secrète', + 'group.pass_hint': 'Ce navigateur n’a pas encore déverrouillé vos clés. Saisissez votre phrase secrète une fois pour ouvrir vos groupes ici.', + 'group.pass_btn': 'Déverrouiller', 'notif.purge': 'Tout effacer', 'notif.title': 'Notifications', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 52f6279..0f92692 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -29,6 +29,25 @@ export default { 'login.loading': 'Accesso in corso...', 'login.no_account': 'Non ha ancora un account?', 'login.register_link': 'Crea un account', + 'login.forgot': 'Ha dimenticato la passphrase?', + 'reset.title': 'Reimposta la passphrase', + 'reset.request_intro': 'Inserisca il suo nome utente e l’e-mail dell’account. Se corrispondono, un codice di reimpostazione viene inviato a quell’indirizzo.', + 'reset.send_code': 'Invia codice', + 'reset.form_intro': 'Inserisca il codice ricevuto per e-mail e scelga una nuova passphrase. Se ha la sua chiave di recupero, la aggiunga per ripristinare anche l’accesso ai suoi gruppi.', + 'reset.code': 'Codice di reimpostazione', + 'reset.recovery_key': 'Chiave di recupero (facoltativa)', + 'reset.recovery_key_hint': 'La lasci vuota se non la possiede. Senza di essa il suo accesso viene ripristinato, ma dovrà riunirsi a ogni gruppo.', + 'reset.new_pass': 'Nuova passphrase', + 'reset.new_pass_repeat': 'Ripeti la nuova passphrase', + 'reset.submit': 'Reimposta la passphrase', + 'reset.working': 'Ripristino delle sue identità di gruppo…', + 'reset.signin_restored': 'Il suo accesso è stato ripristinato.', + 'reset.groups_restored': 'Le sue identità di gruppo sono state ripristinate su ogni nodo raggiungibile.', + 'reset.no_recovery': 'Le sue identità di gruppo non sono state ripristinate. Per ogni gruppo, chieda al suo operatore di rimuovere il suo pin e di inviarle un nuovo codice di invito, poi si riunisca.', + 'reset.needs_operator': 'Questi gruppi richiedono ancora un intervento — chieda a ogni operatore di rimuovere il suo pin e di inviarle un nuovo codice:', + 'reset.go_app': 'Vai all’app', + 'reset.back_to_login': 'Torna all’accesso', + 'reset.err_unsupported': 'La reimpostazione della passphrase richiede il modulo di cifratura del browser, non disponibile qui.', // Register 'register.title': 'Crea un account', @@ -40,6 +59,16 @@ export default { 'register.loading': "Creazione dell'account...", 'register.has_account': 'Ha già un account?', 'register.login_link': 'Accedi', + 'register.recovery_title': 'Salvi la sua chiave di recupero', + 'register.recovery_intro': 'Questa chiave ripristina le sue identità di gruppo se dovesse dimenticare la passphrase. La conservi in un gestore di password o in un luogo sicuro: viene mostrata una sola volta e il hub non la vede mai.', + 'register.recovery_copy': 'Copia', + 'register.recovery_copied': 'Copiato', + 'register.recovery_warning': 'Se perde sia la passphrase sia questa chiave, i suoi file restano sui rispettivi nodi ma si riunisce a ogni gruppo con una nuova identità.', + 'register.recovery_saved': 'Ho salvato la mia chiave di recupero in un luogo sicuro.', + 'register.recovery_continue': 'Continua', + 'register.recovery_email_opt': 'Inviami anche questa chiave di recupero via e-mail come backup', + 'register.recovery_emailed': 'Una copia è stata inviata anche alla sua e-mail, nello stesso messaggio del codice di verifica.', + 'register.recovery_not_emailed': 'Non è stata inviata via e-mail. La salvi ora: è l’unica volta che viene mostrata.', 'register.success_title': 'Verifichi la sua e-mail', 'register.success_msg': 'Un codice di verifica è stato inviato al suo indirizzo e-mail. Lo inserisca qui sotto per attivare il suo account.', 'register.go_login': "Vai all'accesso", @@ -293,6 +322,36 @@ export default { other: '{n} fissate', }, 'settings.node_pins_clear': 'Cancella le identità fissate', + 'settings.passphrase': 'Passphrase', + 'settings.passphrase_hint': "Cambia la passphrase di questo account. Le tue chiavi di identità vengono ricodificate su ogni gruppo il cui nodo è online; a un gruppo il cui nodo è offline dovrai riunirti in seguito.", + 'settings.passphrase_change': 'Cambia la passphrase', + 'settings.passphrase_current': 'Passphrase attuale', + 'settings.passphrase_new': 'Nuova passphrase', + 'settings.passphrase_new_repeat': 'Ripeti la nuova passphrase', + 'settings.continue': 'Continua', + 'settings.done': 'Fatto', + 'settings.passphrase_confirm_intro': 'Le tue chiavi di identità verranno ora ricodificate su ogni gruppo il cui nodo è raggiungibile:', + 'settings.passphrase_reachable': 'Aggiornato ora', + 'settings.passphrase_unreachable': 'Non raggiungibile', + 'settings.passphrase_fallback_note': "Per un gruppo non raggiungibile, chiedi al suo operatore di rimuovere il tuo pin e di inviarti un nuovo codice di invito, poi riunisciti. I tuoi file e messaggi non vanno persi.", + 'settings.passphrase_confirm_btn': 'Cambia', + 'settings.passphrase_working': 'Ricodifica delle tue chiavi in corso…', + 'settings.passphrase_done': 'Passphrase cambiata.', + 'settings.passphrase_needs_operator': 'Questi gruppi richiedono ancora un intervento — chiedi a ogni operatore di rimuovere il tuo pin e di inviarti un nuovo codice:', + 'settings.pw_too_short': 'La nuova passphrase deve contenere almeno 12 caratteri.', + 'settings.pw_mismatch': 'Le due nuove passphrase non corrispondono.', + 'settings.pw_same': 'La nuova passphrase deve essere diversa da quella attuale.', + 'settings.pw_wrong_current': 'La passphrase attuale non è corretta. Non è stato cambiato nulla.', + 'settings.recovery': 'Chiave di recupero', + 'settings.recovery_hint': "Aggiunge a ogni gruppo una copia della sua identità cifrata con la sua chiave di recupero. Lo faccia una volta in ogni browser che usa; senza di essa, una passphrase persa costringe a riunirsi a ogni gruppo a mano.", + 'settings.recovery_loaded': 'Caricata in questo browser — i nuovi gruppi sono coperti automaticamente.', + 'settings.recovery_open': 'Inserire la chiave di recupero', + 'settings.recovery_input_ph': 'Incolli la sua chiave di recupero', + 'settings.recovery_submit': 'Aggiungi le copie di backup', + 'settings.recovery_working': 'Aggiornamento dei suoi gruppi…', + 'settings.recovery_done': 'I suoi gruppi ora hanno una copia di recupero.', + 'settings.recovery_partial': 'Non è stato possibile raggiungere questi gruppi. Li apra più tardi, o riesegua l’operazione:', + 'settings.recovery_need_relogin': 'Esca e rientri, poi riprovi.', 'settings.danger': "Elimina l'account", 'settings.delete_hint': "Rimuove il suo account, le sue adesioni ai gruppi e le sue " + "notifiche dall'hub, e libera il suo nome utente. Non arriva a ciò che si trova " @@ -525,6 +584,9 @@ export default { 'group.join_code_hint': 'Chieda il codice monouso a chi l’ha invitata e lo inserisca ' + 'qui. Dopodiché questo browser è riconosciuto e non le verrà più chiesto.', 'group.join_code_btn': 'Partecipa', + 'group.pass_title': 'Inserisca la sua passphrase', + 'group.pass_hint': 'Questo browser non ha ancora sbloccato le sue chiavi. Inserisca la sua passphrase una volta per aprire i suoi gruppi qui.', + 'group.pass_btn': 'Sblocca', 'notif.purge': 'Cancella tutto', 'notif.title': 'Notifiche', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 75880fa..1e5a9f5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -29,6 +29,25 @@ export default { 'login.loading': 'ログインしています…', 'login.no_account': 'アカウントをお持ちでないですか?', 'login.register_link': 'アカウントを作成', + 'login.forgot': 'パスフレーズをお忘れですか?', + 'reset.title': 'パスフレーズをリセット', + 'reset.request_intro': 'ユーザー名とアカウントのメールアドレスを入力してください。両方が一致する場合、そのアドレスにリセットコードを送信します。', + 'reset.send_code': 'コードを送信', + 'reset.form_intro': 'メールで届いたコードを入力し、新しいパスフレーズを選んでください。リカバリーキーをお持ちの場合は入力すると、グループへのアクセスも復元されます。', + 'reset.code': 'リセットコード', + 'reset.recovery_key': 'リカバリーキー(任意)', + 'reset.recovery_key_hint': 'お持ちでない場合は空欄のままにしてください。なしの場合、サインインは復元されますが、各グループには再参加が必要です。', + 'reset.new_pass': '新しいパスフレーズ', + 'reset.new_pass_repeat': '新しいパスフレーズをもう一度入力', + 'reset.submit': 'パスフレーズをリセット', + 'reset.working': 'グループの identity を復元しています…', + 'reset.signin_restored': 'サインインが復元されました。', + 'reset.groups_restored': '到達できたすべてのノードで、グループの identity が復元されました。', + 'reset.no_recovery': 'グループの identity は復元されませんでした。各グループについて、その運用者に固定の解除と新しい招待コードの送付を依頼し、再参加してください。', + 'reset.needs_operator': 'これらのグループにはまだ対応が必要です。各運用者に固定の解除と新しいコードの送付を依頼してください:', + 'reset.go_app': 'アプリへ', + 'reset.back_to_login': 'サインインに戻る', + 'reset.err_unsupported': 'パスフレーズのリセットにはブラウザの暗号モジュールが必要ですが、ここでは利用できません。', // Register 'register.title': 'アカウントを作成', @@ -40,6 +59,16 @@ export default { 'register.loading': 'アカウントを作成しています…', 'register.has_account': 'すでにアカウントをお持ちですか?', 'register.login_link': 'ログイン', + 'register.recovery_title': 'リカバリーキーを保存してください', + 'register.recovery_intro': 'このキーは、パスフレーズを忘れた場合にグループの identity を復元します。パスワードマネージャーや安全な場所に保管してください。表示されるのは一度だけで、ハブがこれを見ることはありません。', + 'register.recovery_copy': 'コピー', + 'register.recovery_copied': 'コピーしました', + 'register.recovery_warning': 'パスフレーズとこのキーの両方を失うと、ファイルは各ノードに残りますが、各グループには新しい identity で再参加することになります。', + 'register.recovery_saved': 'リカバリーキーを安全な場所に保存しました。', + 'register.recovery_continue': '続ける', + 'register.recovery_email_opt': 'バックアップとして、このリカバリーキーもメールで送る', + 'register.recovery_emailed': '確認コードと同じメールで、コピーもお使いのメールアドレスに送信されました。', + 'register.recovery_not_emailed': 'これはメールで送信されていません。今すぐ保存してください。表示されるのはこの一度だけです。', 'register.success_title': 'メールアドレスを確認してください', 'register.success_msg': '確認コードをメールアドレスに送信しました。以下に入力してアカウントを有効にしてください。', 'register.go_login': 'ログインへ進む', @@ -288,6 +317,36 @@ export default { other: '{n} 件を固定中', }, 'settings.node_pins_clear': '固定した識別情報を消去', + 'settings.passphrase': 'パスフレーズ', + 'settings.passphrase_hint': 'このアカウントのパスフレーズを変更します。ノードがオンラインのすべてのグループで identity キーが再ラップされます。ノードがオフラインのグループには、あとで再参加する必要があります。', + 'settings.passphrase_change': 'パスフレーズを変更', + 'settings.passphrase_current': '現在のパスフレーズ', + 'settings.passphrase_new': '新しいパスフレーズ', + 'settings.passphrase_new_repeat': '新しいパスフレーズをもう一度入力', + 'settings.continue': '続ける', + 'settings.done': '完了', + 'settings.passphrase_confirm_intro': '到達できるノードを持つすべてのグループで、identity キーが今すぐ再ラップされます:', + 'settings.passphrase_reachable': '今すぐ更新されます', + 'settings.passphrase_unreachable': '到達できません', + 'settings.passphrase_fallback_note': '到達できないグループについては、その運用者に固定の解除と新しい招待コードの送付を依頼し、再参加してください。ファイルとメッセージは失われません。', + 'settings.passphrase_confirm_btn': '変更する', + 'settings.passphrase_working': 'キーを再ラップしています…', + 'settings.passphrase_done': 'パスフレーズを変更しました。', + 'settings.passphrase_needs_operator': 'これらのグループにはまだ対応が必要です。各運用者に固定の解除と新しいコードの送付を依頼してください:', + 'settings.pw_too_short': '新しいパスフレーズは 12 文字以上にしてください。', + 'settings.pw_mismatch': '2 つの新しいパスフレーズが一致しません。', + 'settings.pw_same': '新しいパスフレーズは現在のものと異なる必要があります。', + 'settings.pw_wrong_current': '現在のパスフレーズが正しくありません。何も変更されていません。', + 'settings.recovery': 'リカバリーキー', + 'settings.recovery_hint': 'リカバリーキーで暗号化した identity のコピーを、すべてのグループに追加します。使用する各ブラウザで一度実行してください。これがないと、パスフレーズを失った場合は各グループに手作業で再参加することになります。', + 'settings.recovery_loaded': 'このブラウザに読み込み済み — 新しいグループは自動的に対象になります。', + 'settings.recovery_open': 'リカバリーキーを入力', + 'settings.recovery_input_ph': 'リカバリーキーを貼り付け', + 'settings.recovery_submit': 'バックアップコピーを追加', + 'settings.recovery_working': 'グループを更新しています…', + 'settings.recovery_done': 'グループにリカバリーコピーが作成されました。', + 'settings.recovery_partial': 'これらのグループに到達できませんでした。あとで開くか、もう一度実行してください:', + 'settings.recovery_need_relogin': '一度サインアウトして再度サインインしてから、もう一度お試しください。', 'settings.danger': 'アカウントを削除', 'settings.delete_hint': 'hub から、お客様のアカウント、グループへの参加、通知を' + '削除し、ユーザー名を解放します。node 上にあるものには及びません。' @@ -514,6 +573,9 @@ export default { 'group.join_code_hint': '招待してくれた方にワンタイムコードを聞いて、ここに入力してください。' + 'それ以降このブラウザーは認識され、再び尋ねられることはありません。', 'group.join_code_btn': '参加', + 'group.pass_title': 'パスフレーズを入力してください', + 'group.pass_hint': 'このブラウザーではまだキーのロックが解除されていません。パスフレーズを一度入力して、ここでグループを開いてください。', + 'group.pass_btn': 'ロック解除', 'notif.purge': 'すべて消去', 'notif.title': '通知', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 5b72314..3cd34c3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -29,6 +29,25 @@ export default { 'login.loading': 'Bezig met aanmelden...', 'login.no_account': 'Nog geen account?', 'login.register_link': 'Account aanmaken', + 'login.forgot': 'Wachtwoordzin vergeten?', + 'reset.title': 'Wachtwoordzin opnieuw instellen', + 'reset.request_intro': 'Voer uw gebruikersnaam en het e-mailadres van het account in. Als ze overeenkomen, wordt een herstelcode naar dat adres gestuurd.', + 'reset.send_code': 'Code versturen', + 'reset.form_intro': 'Voer de code uit uw e-mail in en kies een nieuwe wachtwoordzin. Als u uw herstelsleutel hebt, voeg die toe om ook de toegang tot uw groepen te herstellen.', + 'reset.code': 'Herstelcode', + 'reset.recovery_key': 'Herstelsleutel (optioneel)', + 'reset.recovery_key_hint': 'Laat leeg als u die niet hebt. Zonder de sleutel wordt uw aanmelding hersteld, maar moet u tot elke groep opnieuw toetreden.', + 'reset.new_pass': 'Nieuwe wachtwoordzin', + 'reset.new_pass_repeat': 'Nieuwe wachtwoordzin herhalen', + 'reset.submit': 'Wachtwoordzin opnieuw instellen', + 'reset.working': 'Uw groepsidentiteiten worden hersteld…', + 'reset.signin_restored': 'Uw aanmelding is hersteld.', + 'reset.groups_restored': 'Uw groepsidentiteiten zijn hersteld op elke bereikbare node.', + 'reset.no_recovery': 'Uw groepsidentiteiten zijn niet hersteld. Vraag voor elke groep de operator ervan om u los te maken en een nieuwe uitnodigingscode te sturen, en treed dan opnieuw toe.', + 'reset.needs_operator': 'Deze groepen vereisen nog actie — vraag elke operator om u los te maken en een nieuwe code te sturen:', + 'reset.go_app': 'Naar de app', + 'reset.back_to_login': 'Terug naar aanmelden', + 'reset.err_unsupported': 'Het opnieuw instellen van de wachtwoordzin vereist de crypto-module van de browser, die hier niet beschikbaar is.', // Register 'register.title': 'Account aanmaken', @@ -40,6 +59,16 @@ export default { 'register.loading': 'Account wordt aangemaakt...', 'register.has_account': 'Hebt u al een account?', 'register.login_link': 'Aanmelden', + 'register.recovery_title': 'Bewaar uw herstelsleutel', + 'register.recovery_intro': 'Deze sleutel herstelt uw groepsidentiteiten als u ooit uw wachtwoordzin vergeet. Bewaar hem in een wachtwoordmanager of op een veilige plek — hij wordt maar één keer getoond en de hub ziet hem nooit.', + 'register.recovery_copy': 'Kopiëren', + 'register.recovery_copied': 'Gekopieerd', + 'register.recovery_warning': 'Als u zowel uw wachtwoordzin als deze sleutel verliest, blijven uw bestanden op hun nodes staan, maar treedt u tot elke groep opnieuw toe met een nieuwe identiteit.', + 'register.recovery_saved': 'Ik heb mijn herstelsleutel op een veilige plek bewaard.', + 'register.recovery_continue': 'Doorgaan', + 'register.recovery_email_opt': 'Stuur deze herstelsleutel ook naar mij per e-mail als back-up', + 'register.recovery_emailed': 'Er is ook een kopie naar uw e-mailadres gestuurd, in hetzelfde bericht als uw verificatiecode.', + 'register.recovery_not_emailed': 'Dit is niet per e-mail verstuurd. Sla het nu op — dit is de enige keer dat het wordt getoond.', 'register.success_title': 'Verifieer uw e-mail', 'register.success_msg': 'Er is een verificatiecode naar uw e-mailadres gestuurd. Voer deze hieronder in om uw account te activeren.', 'register.go_login': 'Naar het aanmelden', @@ -294,6 +323,36 @@ export default { other: '{n} vastgezet', }, 'settings.node_pins_clear': 'Vastgezette identiteiten wissen', + 'settings.passphrase': 'Wachtwoordzin', + 'settings.passphrase_hint': 'Wijzigt de wachtwoordzin van dit account. Uw identiteitssleutels worden opnieuw verpakt op elke groep waarvan de node online is; een groep waarvan de node offline is, moet daarna opnieuw worden betreden.', + 'settings.passphrase_change': 'Wachtwoordzin wijzigen', + 'settings.passphrase_current': 'Huidige wachtwoordzin', + 'settings.passphrase_new': 'Nieuwe wachtwoordzin', + 'settings.passphrase_new_repeat': 'Nieuwe wachtwoordzin herhalen', + 'settings.continue': 'Doorgaan', + 'settings.done': 'Klaar', + 'settings.passphrase_confirm_intro': 'Uw identiteitssleutels worden nu opnieuw verpakt op elke groep waarvan de node bereikbaar is:', + 'settings.passphrase_reachable': 'Nu bijgewerkt', + 'settings.passphrase_unreachable': 'Niet bereikbaar', + 'settings.passphrase_fallback_note': 'Vraag voor een onbereikbare groep de operator ervan om u los te maken en een nieuwe uitnodigingscode te sturen, en treed dan opnieuw toe. Uw bestanden en berichten gaan niet verloren.', + 'settings.passphrase_confirm_btn': 'Wijzigen', + 'settings.passphrase_working': 'Uw sleutels worden opnieuw verpakt…', + 'settings.passphrase_done': 'Wachtwoordzin gewijzigd.', + 'settings.passphrase_needs_operator': 'Deze groepen vereisen nog actie — vraag elke operator om u los te maken en een nieuwe code te sturen:', + 'settings.pw_too_short': 'De nieuwe wachtwoordzin moet minstens 12 tekens lang zijn.', + 'settings.pw_mismatch': 'De twee nieuwe wachtwoordzinnen komen niet overeen.', + 'settings.pw_same': 'De nieuwe wachtwoordzin moet verschillen van de huidige.', + 'settings.pw_wrong_current': 'De huidige wachtwoordzin is niet juist. Er is niets gewijzigd.', + 'settings.recovery': 'Herstelsleutel', + 'settings.recovery_hint': 'Voegt aan elke groep een met uw herstelsleutel versleutelde kopie van uw identiteit toe. Doe dit één keer in elke browser die u gebruikt; zonder deze kopie betekent een verloren wachtwoordzin dat u elke groep handmatig opnieuw moet betreden.', + 'settings.recovery_loaded': 'Geladen in deze browser — nieuwe groepen zijn automatisch gedekt.', + 'settings.recovery_open': 'Herstelsleutel invoeren', + 'settings.recovery_input_ph': 'Plak uw herstelsleutel', + 'settings.recovery_submit': 'Back-upkopieën toevoegen', + 'settings.recovery_working': 'Uw groepen worden bijgewerkt…', + 'settings.recovery_done': 'Uw groepen hebben nu een herstelkopie.', + 'settings.recovery_partial': 'Deze groepen konden niet worden bereikt. Open ze later, of voer dit opnieuw uit:', + 'settings.recovery_need_relogin': 'Meld u af en weer aan en probeer het opnieuw.', 'settings.danger': 'Account verwijderen', 'settings.delete_hint': 'Verwijdert uw account, uw groepslidmaatschappen en uw ' + 'meldingen van de hub, en geeft uw gebruikersnaam vrij. Wat op de nodes staat, ' @@ -527,6 +586,9 @@ export default { + 'voer die hier in. Daarna is deze browser bekend en wordt het u niet opnieuw ' + 'gevraagd.', 'group.join_code_btn': 'Deelnemen', + 'group.pass_title': 'Voer uw wachtwoordzin in', + 'group.pass_hint': 'Deze browser heeft uw sleutels nog niet ontgrendeld. Voer uw wachtwoordzin één keer in om uw groepen hier te openen.', + 'group.pass_btn': 'Ontgrendelen', 'notif.purge': 'Alles wissen', 'notif.title': 'Meldingen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index bb2361e..10fe306 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -32,6 +32,25 @@ export default { 'login.loading': 'Logowanie...', 'login.no_account': 'Nie ma jeszcze konta?', 'login.register_link': 'Załóż konto', + 'login.forgot': 'Nie pamiętasz hasła-frazy?', + 'reset.title': 'Zresetuj hasło-frazę', + 'reset.request_intro': 'Wpisz swoją nazwę użytkownika i adres e-mail konta. Jeśli się zgadzają, kod resetowania zostanie wysłany na ten adres.', + 'reset.send_code': 'Wyślij kod', + 'reset.form_intro': 'Wpisz kod z e-maila i wybierz nową hasło-frazę. Jeśli masz klucz odzyskiwania, dodaj go, aby przywrócić też dostęp do swoich grup.', + 'reset.code': 'Kod resetowania', + 'reset.recovery_key': 'Klucz odzyskiwania (opcjonalnie)', + 'reset.recovery_key_hint': 'Pozostaw puste, jeśli go nie masz. Bez niego Twoje logowanie zostanie przywrócone, ale do każdej grupy trzeba będzie dołączyć ponownie.', + 'reset.new_pass': 'Nowa hasło-fraza', + 'reset.new_pass_repeat': 'Powtórz nową hasło-frazę', + 'reset.submit': 'Zresetuj hasło-frazę', + 'reset.working': 'Przywracanie Twoich tożsamości grupowych…', + 'reset.signin_restored': 'Twoje logowanie zostało przywrócone.', + 'reset.groups_restored': 'Twoje tożsamości grupowe zostały przywrócone na każdym osiągalnym węźle.', + 'reset.no_recovery': 'Twoje tożsamości grupowe nie zostały przywrócone. W przypadku każdej grupy poproś jej operatora o odpięcie Cię i przesłanie nowego kodu zaproszenia, a następnie dołącz ponownie.', + 'reset.needs_operator': 'Te grupy nadal wymagają działania — poproś każdego operatora o odpięcie Cię i przesłanie nowego kodu:', + 'reset.go_app': 'Przejdź do aplikacji', + 'reset.back_to_login': 'Powrót do logowania', + 'reset.err_unsupported': 'Resetowanie hasła-frazy wymaga modułu kryptograficznego przeglądarki, który jest tu niedostępny.', // Register 'register.title': 'Zakładanie konta', @@ -43,6 +62,16 @@ export default { 'register.loading': 'Tworzenie konta...', 'register.has_account': 'Konto już istnieje?', 'register.login_link': 'Zaloguj', + 'register.recovery_title': 'Zapisz swój klucz odzyskiwania', + 'register.recovery_intro': 'Ten klucz przywraca Twoje tożsamości grupowe, jeśli kiedykolwiek zapomnisz hasło-frazę. Przechowuj go w menedżerze haseł lub w bezpiecznym miejscu — jest pokazywany tylko raz, a hub nigdy go nie widzi.', + 'register.recovery_copy': 'Kopiuj', + 'register.recovery_copied': 'Skopiowano', + 'register.recovery_warning': 'Jeśli stracisz zarówno hasło-frazę, jak i ten klucz, Twoje pliki pozostaną na swoich węzłach, ale do każdej grupy dołączysz ponownie z nową tożsamością.', + 'register.recovery_saved': 'Zapisałem/-am mój klucz odzyskiwania w bezpiecznym miejscu.', + 'register.recovery_continue': 'Kontynuuj', + 'register.recovery_email_opt': 'Wyślij mi też ten klucz odzyskiwania e-mailem jako kopię zapasową', + 'register.recovery_emailed': 'Kopia została również wysłana na Twój adres e-mail, w tej samej wiadomości co kod weryfikacyjny.', + 'register.recovery_not_emailed': 'To nie zostało wysłane e-mailem. Zapisz teraz — jest pokazywane tylko ten jeden raz.', 'register.success_title': 'Zweryfikuj swój e-mail', 'register.success_msg': 'Kod weryfikacyjny został wysłany na Twój adres e-mail. Wpisz go poniżej, aby aktywować konto.', 'register.go_login': 'Przejdź do logowania', @@ -307,6 +336,36 @@ export default { other: '{n} przypiętej', }, 'settings.node_pins_clear': 'Wyczyść przypięte tożsamości', + 'settings.passphrase': 'Hasło-fraza', + 'settings.passphrase_hint': 'Zmienia hasło-frazę tego konta. Twoje klucze tożsamości są ponownie pakowane w każdej grupie, której węzeł jest online; do grupy, której węzeł jest offline, trzeba będzie dołączyć ponownie później.', + 'settings.passphrase_change': 'Zmień hasło-frazę', + 'settings.passphrase_current': 'Bieżąca hasło-fraza', + 'settings.passphrase_new': 'Nowa hasło-fraza', + 'settings.passphrase_new_repeat': 'Powtórz nową hasło-frazę', + 'settings.continue': 'Kontynuuj', + 'settings.done': 'Gotowe', + 'settings.passphrase_confirm_intro': 'Twoje klucze tożsamości zostaną teraz ponownie spakowane w każdej grupie, której węzeł jest osiągalny:', + 'settings.passphrase_reachable': 'Zaktualizowano teraz', + 'settings.passphrase_unreachable': 'Nieosiągalne', + 'settings.passphrase_fallback_note': 'W przypadku grupy, której nie można osiągnąć, poproś jej operatora o odpięcie Cię i przesłanie nowego kodu zaproszenia, a następnie dołącz ponownie. Twoje pliki i wiadomości nie zostaną utracone.', + 'settings.passphrase_confirm_btn': 'Zmień', + 'settings.passphrase_working': 'Ponowne pakowanie kluczy…', + 'settings.passphrase_done': 'Hasło-fraza zmieniona.', + 'settings.passphrase_needs_operator': 'Te grupy nadal wymagają działania — poproś każdego operatora o odpięcie Cię i przesłanie nowego kodu:', + 'settings.pw_too_short': 'Nowa hasło-fraza musi mieć co najmniej 12 znaków.', + 'settings.pw_mismatch': 'Obie nowe hasło-frazy nie są zgodne.', + 'settings.pw_same': 'Nowa hasło-fraza musi różnić się od bieżącej.', + 'settings.pw_wrong_current': 'Bieżąca hasło-fraza jest nieprawidłowa. Nic nie zostało zmienione.', + 'settings.recovery': 'Klucz odzyskiwania', + 'settings.recovery_hint': 'Dodaje do każdej grupy kopię Twojej tożsamości zaszyfrowaną Twoim kluczem odzyskiwania. Zrób to raz w każdej używanej przeglądarce; bez tego utrata hasła-frazy oznacza ponowne dołączanie do każdej grupy ręcznie.', + 'settings.recovery_loaded': 'Wczytany w tej przeglądarce — nowe grupy są objęte automatycznie.', + 'settings.recovery_open': 'Wprowadź klucz odzyskiwania', + 'settings.recovery_input_ph': 'Wklej swój klucz odzyskiwania', + 'settings.recovery_submit': 'Dodaj kopie zapasowe', + 'settings.recovery_working': 'Aktualizowanie Twoich grup…', + 'settings.recovery_done': 'Twoje grupy mają teraz kopię odzyskiwania.', + 'settings.recovery_partial': 'Nie udało się połączyć z tymi grupami. Otwórz je później lub uruchom to ponownie:', + 'settings.recovery_need_relogin': 'Wyloguj się i zaloguj ponownie, a następnie spróbuj jeszcze raz.', 'settings.danger': 'Usunięcie konta', 'settings.delete_hint': 'Usuwa z huba konto, członkostwa w grupach i powiadomienia ' + 'oraz zwalnia nazwę użytkownika. Nie sięga tego, co znajduje się na nodes: ' @@ -543,6 +602,9 @@ export default { + 'wpisać go tutaj. Od tej pory przeglądarka jest rozpoznawana i pytanie się nie ' + 'powtórzy.', 'group.join_code_btn': 'Dołącz', + 'group.pass_title': 'Wprowadź swoją hasło-frazę', + 'group.pass_hint': 'Ta przeglądarka nie odblokowała jeszcze Twoich kluczy. Wprowadź hasło-frazę raz, aby otworzyć tutaj swoje grupy.', + 'group.pass_btn': 'Odblokuj', 'notif.purge': 'Wyczyść wszystko', 'notif.title': 'Powiadomienia', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 3fbbb0b..9d69b85 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -30,6 +30,25 @@ export default { 'login.loading': 'Entrando...', 'login.no_account': 'Ainda não tem conta?', 'login.register_link': 'Criar uma conta', + 'login.forgot': 'Esqueceu sua frase secreta?', + 'reset.title': 'Redefinir a frase secreta', + 'reset.request_intro': 'Digite seu nome de usuário e o e-mail da conta. Se coincidirem, um código de redefinição é enviado para esse endereço.', + 'reset.send_code': 'Enviar código', + 'reset.form_intro': 'Digite o código do seu e-mail e escolha uma nova frase secreta. Se você tem sua chave de recuperação, adicione-a para também restaurar o acesso aos seus grupos.', + 'reset.code': 'Código de redefinição', + 'reset.recovery_key': 'Chave de recuperação (opcional)', + 'reset.recovery_key_hint': 'Deixe em branco se não a tiver. Sem ela, seu acesso é restaurado, mas é preciso reingressar em cada grupo.', + 'reset.new_pass': 'Nova frase secreta', + 'reset.new_pass_repeat': 'Repetir a nova frase secreta', + 'reset.submit': 'Redefinir a frase secreta', + 'reset.working': 'Restaurando suas identidades de grupo…', + 'reset.signin_restored': 'Seu acesso foi restaurado.', + 'reset.groups_restored': 'Suas identidades de grupo foram restauradas em todos os nós alcançáveis.', + 'reset.no_recovery': 'Suas identidades de grupo não foram restauradas. Para cada grupo, peça ao operador dele para remover sua fixação e enviar um novo código de convite, e então reingresse.', + 'reset.needs_operator': 'Estes grupos ainda precisam de ação — peça a cada operador para remover sua fixação e enviar um novo código:', + 'reset.go_app': 'Ir para o aplicativo', + 'reset.back_to_login': 'Voltar para o login', + 'reset.err_unsupported': 'A redefinição da frase secreta precisa do módulo de criptografia do navegador, que não está disponível aqui.', // Register 'register.title': 'Criar uma conta', @@ -41,6 +60,16 @@ export default { 'register.loading': 'Criando a conta...', 'register.has_account': 'Já tem uma conta?', 'register.login_link': 'Entrar', + 'register.recovery_title': 'Guarde sua chave de recuperação', + 'register.recovery_intro': 'Esta chave restaura suas identidades de grupo caso você esqueça sua frase secreta. Guarde-a em um gerenciador de senhas ou em local seguro — ela é mostrada apenas uma vez, e o hub nunca a vê.', + 'register.recovery_copy': 'Copiar', + 'register.recovery_copied': 'Copiado', + 'register.recovery_warning': 'Se você perder tanto a frase secreta quanto esta chave, seus arquivos permanecem em seus nós, mas você reingressa em cada grupo com uma nova identidade.', + 'register.recovery_saved': 'Guardei minha chave de recuperação em um local seguro.', + 'register.recovery_continue': 'Continuar', + 'register.recovery_email_opt': 'Enviar também esta chave de recuperação para o meu e-mail como backup', + 'register.recovery_emailed': 'Uma cópia também foi enviada para o seu e-mail, na mesma mensagem que o seu código de verificação.', + 'register.recovery_not_emailed': 'Isto não foi enviado por e-mail. Guarde agora — esta é a única vez que é mostrada.', 'register.success_title': 'Verifique seu e-mail', 'register.success_msg': 'Um código de verificação foi enviado para o seu e-mail. Digite-o abaixo para ativar sua conta.', 'register.go_login': 'Ir para o login', @@ -293,6 +322,36 @@ export default { other: '{n} fixadas', }, 'settings.node_pins_clear': 'Limpar as identidades fixadas', + 'settings.passphrase': 'Frase secreta', + 'settings.passphrase_hint': 'Altera a frase secreta desta conta. Suas chaves de identidade são recriptografadas em cada grupo cujo nó esteja on-line; um grupo cujo nó esteja off-line terá de ser reingressado depois.', + 'settings.passphrase_change': 'Alterar a frase secreta', + 'settings.passphrase_current': 'Frase secreta atual', + 'settings.passphrase_new': 'Nova frase secreta', + 'settings.passphrase_new_repeat': 'Repetir a nova frase secreta', + 'settings.continue': 'Continuar', + 'settings.done': 'Concluído', + 'settings.passphrase_confirm_intro': 'Suas chaves de identidade serão recriptografadas agora em cada grupo cujo nó possa ser alcançado:', + 'settings.passphrase_reachable': 'Atualizado agora', + 'settings.passphrase_unreachable': 'Não pode ser alcançado', + 'settings.passphrase_fallback_note': 'Para um grupo que não pode ser alcançado, peça ao operador dele para remover sua fixação e enviar um novo código de convite, e então reingresse. Seus arquivos e mensagens não são perdidos.', + 'settings.passphrase_confirm_btn': 'Alterar', + 'settings.passphrase_working': 'Recriptografando suas chaves…', + 'settings.passphrase_done': 'Frase secreta alterada.', + 'settings.passphrase_needs_operator': 'Estes grupos ainda precisam de ação — peça a cada operador para remover sua fixação e enviar um novo código:', + 'settings.pw_too_short': 'A nova frase secreta deve ter pelo menos 12 caracteres.', + 'settings.pw_mismatch': 'As duas novas frases secretas não coincidem.', + 'settings.pw_same': 'A nova frase secreta deve ser diferente da atual.', + 'settings.pw_wrong_current': 'A frase secreta atual não está correta. Nada foi alterado.', + 'settings.recovery': 'Chave de recuperação', + 'settings.recovery_hint': 'Adiciona a cada grupo uma cópia da sua identidade criptografada com a sua chave de recuperação. Faça isso uma vez em cada navegador que usar; sem ela, uma frase secreta perdida obriga a reingressar em cada grupo manualmente.', + 'settings.recovery_loaded': 'Carregada neste navegador — grupos novos ficam cobertos automaticamente.', + 'settings.recovery_open': 'Inserir a chave de recuperação', + 'settings.recovery_input_ph': 'Cole a sua chave de recuperação', + 'settings.recovery_submit': 'Adicionar cópias de backup', + 'settings.recovery_working': 'Atualizando os seus grupos…', + 'settings.recovery_done': 'Os seus grupos agora têm uma cópia de recuperação.', + 'settings.recovery_partial': 'Não foi possível alcançar estes grupos. Abra-os mais tarde, ou execute isto de novo:', + 'settings.recovery_need_relogin': 'Saia e entre novamente, depois tente de novo.', 'settings.danger': 'Excluir a conta', 'settings.delete_hint': 'Remove sua conta, suas participações em grupos e suas ' + 'notificações do hub, e libera seu nome de usuário. Não alcança o que está nos ' @@ -524,6 +583,9 @@ export default { 'group.join_code_hint': 'Peça o código de uso único a quem convidou você e digite-o ' + 'aqui. Depois disso este navegador fica reconhecido e a pergunta não se repete.', 'group.join_code_btn': 'Participar', + 'group.pass_title': 'Digite a sua frase secreta', + 'group.pass_hint': 'Este navegador ainda não desbloqueou as suas chaves. Digite a sua frase secreta uma vez para abrir os seus grupos aqui.', + 'group.pass_btn': 'Desbloquear', 'notif.purge': 'Limpar tudo', 'notif.title': 'Notificações', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 8dff38f..fc141c2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -29,6 +29,25 @@ export default { 'login.loading': '正在登录…', 'login.no_account': '还没有账户?', 'login.register_link': '注册', + 'login.forgot': '忘记密码短语?', + 'reset.title': '重置密码短语', + 'reset.request_intro': '输入您的用户名和账户的邮箱。如果二者匹配,重置码将发送至该邮箱。', + 'reset.send_code': '发送验证码', + 'reset.form_intro': '输入邮件中的验证码并设置新的密码短语。如果您有恢复密钥,请一并输入,以同时恢复对群组的访问。', + 'reset.code': '重置码', + 'reset.recovery_key': '恢复密钥(可选)', + 'reset.recovery_key_hint': '如果没有请留空。没有它,您的登录会恢复,但每个群组都需要重新加入。', + 'reset.new_pass': '新密码短语', + 'reset.new_pass_repeat': '再次输入新密码短语', + 'reset.submit': '重置密码短语', + 'reset.working': '正在恢复您的群组身份…', + 'reset.signin_restored': '您的登录已恢复。', + 'reset.groups_restored': '您的群组身份已在所有可达节点上恢复。', + 'reset.no_recovery': '您的群组身份未恢复。对于每个群组,请让其运营者取消对你的固定并发送新的邀请码,然后重新加入。', + 'reset.needs_operator': '这些群组仍需处理——请让每位运营者取消对你的固定并发送新的代码:', + 'reset.go_app': '前往应用', + 'reset.back_to_login': '返回登录', + 'reset.err_unsupported': '重置密码短语需要浏览器的加密模块,此处不可用。', // Register 'register.title': '注册', @@ -40,6 +59,16 @@ export default { 'register.loading': '正在创建账户…', 'register.has_account': '已经有账户了?', 'register.login_link': '登录', + 'register.recovery_title': '保存您的恢复密钥', + 'register.recovery_intro': '如果您忘记了密码短语,此密钥可恢复您的群组身份。请将其保存在密码管理器或安全的地方——它只显示一次,且 hub 永远看不到它。', + 'register.recovery_copy': '复制', + 'register.recovery_copied': '已复制', + 'register.recovery_warning': '如果您同时丢失了密码短语和此密钥,您的文件仍会保留在各自的节点上,但您将以新身份重新加入每个群组。', + 'register.recovery_saved': '我已将恢复密钥保存在安全的地方。', + 'register.recovery_continue': '继续', + 'register.recovery_email_opt': '同时将此恢复密钥通过邮件发送给我作为备份', + 'register.recovery_emailed': '副本也已发送至您的邮箱,与验证码在同一封邮件中。', + 'register.recovery_not_emailed': '这未通过邮件发送。请立即保存——它只显示这一次。', 'register.success_title': '验证您的邮箱', 'register.success_msg': '验证码已发送至您的邮箱。请在下方输入以激活账户。', 'register.go_login': '前往登录', @@ -284,6 +313,36 @@ export default { other: '已固定 {n} 个', }, 'settings.node_pins_clear': '清除已固定的身份', + 'settings.passphrase': '密码短语', + 'settings.passphrase_hint': '更改此账户的密码短语。你的身份密钥会在其节点在线的每个群组上重新封装;对于节点离线的群组,之后需要重新加入。', + 'settings.passphrase_change': '更改密码短语', + 'settings.passphrase_current': '当前密码短语', + 'settings.passphrase_new': '新密码短语', + 'settings.passphrase_new_repeat': '再次输入新密码短语', + 'settings.continue': '继续', + 'settings.done': '完成', + 'settings.passphrase_confirm_intro': '你的身份密钥现在将在其节点可达的每个群组上重新封装:', + 'settings.passphrase_reachable': '现在更新', + 'settings.passphrase_unreachable': '无法连接', + 'settings.passphrase_fallback_note': '对于无法连接的群组,请让其运营者取消对你的固定并发送新的邀请码,然后重新加入。你的文件和消息不会丢失。', + 'settings.passphrase_confirm_btn': '更改', + 'settings.passphrase_working': '正在重新封装你的密钥…', + 'settings.passphrase_done': '密码短语已更改。', + 'settings.passphrase_needs_operator': '这些群组仍需处理——请让每位运营者取消对你的固定并发送新的代码:', + 'settings.pw_too_short': '新密码短语至少需要 12 个字符。', + 'settings.pw_mismatch': '两个新密码短语不一致。', + 'settings.pw_same': '新密码短语必须与当前的不同。', + 'settings.pw_wrong_current': '当前密码短语不正确。未做任何更改。', + 'settings.recovery': '恢复密钥', + 'settings.recovery_hint': '为每个群组添加一份用你的恢复密钥加密的身份副本。在你使用的每个浏览器中执行一次;没有它,一旦丢失密码短语就只能手动重新加入每个群组。', + 'settings.recovery_loaded': '已在此浏览器中加载——新群组会自动覆盖。', + 'settings.recovery_open': '输入恢复密钥', + 'settings.recovery_input_ph': '粘贴你的恢复密钥', + 'settings.recovery_submit': '添加备份副本', + 'settings.recovery_working': '正在更新你的群组…', + 'settings.recovery_done': '你的群组现在有了恢复副本。', + 'settings.recovery_partial': '无法连接这些群组。稍后打开它们,或再次运行此操作:', + 'settings.recovery_need_relogin': '请先退出登录再重新登录,然后重试。', 'settings.danger': '删除账户', 'settings.delete_hint': '这会从 hub 上删除您的账户、您的群组成员身份和您的通知,' + '并释放您的用户名。它无法触及存放在各个 node 上的内容:您上传的文件仍留在其运营者' @@ -501,6 +560,9 @@ export default { 'group.join_code_hint': '向邀请您的人索取一次性代码,并在此输入。' + '之后此浏览器即被认可,不会再次询问。', 'group.join_code_btn': '加入', + 'group.pass_title': '输入你的密码短语', + 'group.pass_hint': '此浏览器尚未解锁你的密钥。输入一次密码短语即可在此打开你的群组。', + 'group.pass_btn': '解锁', 'notif.purge': '全部清除', 'notif.title': '通知', 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>`} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 286bc9d..f7ae256 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -296,13 +296,19 @@ class MeshBayTransport { get newNodeBundle() { return this._newNodeBundle || null; } set newNodeBundle(v) { this._newNodeBundle = v; } + /** The recovery-wrapped copy of that same first-join identity, when a recovery key was in hand. */ + get newNodeBundleRecovery() { return this._newNodeBundleRecovery || null; } + set newNodeBundleRecovery(v) { this._newNodeBundleRecovery = v; } + async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, - userId, joinCode) { + userId, joinCode, recoveryKey) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() // itself settles on below) after the WebRTC connection is declared // "failed" — see the pc.onconnectionstatechange handler further down. - this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode }; + this._connectArgs = { + nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey, + }; this._lastToken = jwtToken; // The constructor sets this once from whatever token the caller had at // the time — and the signaling POST below reads *this*, not `jwtToken`. @@ -316,9 +322,11 @@ class MeshBayTransport { this._gekRaw = gekRaw || null; this._sessionKeys = sessionKeys || null; this._bundleKey = bundleKey || null; + this._recoveryKey = recoveryKey || null; this._username = username || null; this._userId = userId || null; this._newNodeBundle = null; + this._newNodeBundleRecovery = null; this._joinError = null; this._pc = new RTCPeerConnection({ iceServers: [ @@ -548,20 +556,58 @@ class MeshBayTransport { const kpResp = await this._sendAndWait({ type: 'keypair_bundle_fetch', v: '0.1', }); + let keys = null; + let openErr = null; if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) { - const keys = await window.MeshBayKeys.decryptBundleWithKey( - kpResp.bundle_enc, this._bundleKey); + try { + keys = await window.MeshBayKeys.decryptBundleWithKey( + kpResp.bundle_enc, this._bundleKey); + } catch (e) { + openErr = e; + // The passphrase key did not open the bundle. If we hold a recovery + // key and the node kept a recovery copy, try that — Flow B + // (docs/auth-confirm.md §4.5): recovering an identity after a lost + // passphrase, before re-wrapping it under the new one. + if (this._recoveryKey && kpResp.bundle_enc_recovery) { + try { + keys = await window.MeshBayKeys.decryptBundleWithKey( + kpResp.bundle_enc_recovery, this._recoveryKey); + this._recoveredFromRecovery = true; + } catch { /* recovery copy did not open either */ } + } + } + } + + if (!keys && this._rewrapOnly) { + // A passphrase-change / backfill run must recover the *existing* + // identity or report the node — never mint a new one. These strings + // are shown on the reset / backfill screens. + throw new Error( + !kpResp.found ? 'no identity on this node' + : this._recoveryKey + ? (kpResp.bundle_enc_recovery + ? "recovery key does not open this node's bundle" + : 'no recovery copy on this node') + : (openErr && openErr.message) || 'could not open the stored identity'); + } + + if (keys) { const pkXB64 = await _pkFromSk(keys.skX); this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; } else { - // This node has never seen us. Generate the identity we will use here - // and nowhere else; it is stored on this node once the join succeeds, - // which is what lets another browser become the same person here. - const id = await window.MeshBayKeys.generateNodeIdentity(this._bundleKey); + // Either the node has never seen us, or it holds a stale bundle we + // cannot open (wrapped under a passphrase we no longer use, with no + // usable recovery copy — e.g. an unpin that left the old bundle + // behind). Mint a fresh identity and let the join path take over; a + // successful join overwrites whatever was stored. A recovery-wrapped + // copy is left too when a recovery key is in hand (§4.3). + const id = await window.MeshBayKeys.generateNodeIdentity( + this._bundleKey, this._recoveryKey); this._sessionKeys = { skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64, }; this._newNodeBundle = id.bundleEnc; + this._newNodeBundleRecovery = id.bundleEncRecovery || null; fresh = true; } } @@ -598,15 +644,11 @@ class MeshBayTransport { } if (!gekRaw && !this._sessionKeys) { - // No identity keys in this browser and none recoverable from the node: - // the keypair bundle is created where you register and only reaches a - // node after a first successful connection, so a brand-new member opening - // a second browser has nothing to sign or unwrap with. Say that, rather - // than blaming the GEK — a code prompt here would be useless, since a - // code proves who you are and we have no key to bind to. - const err = new Error( - 'This browser does not hold your keys. Open the group once from the ' - + 'browser where you registered — after that this one can recover them.'); + // No key in this browser to sign or unwrap with — `bundleKey` was null. + // The caller (group-page.js) shows a passphrase prompt on this reason + // and retries; a code prompt would be useless, since a code proves who + // you are and there is no key to bind it to. + const err = new Error('Your passphrase is needed to unlock your keys in this browser.'); err.reason = 'no_keys'; throw err; } @@ -1777,11 +1819,14 @@ class MeshBayTransport { return msg; } - async storeKeypairBundle(bundleEnc) { + async storeKeypairBundle(bundleEnc, recoveryEnc) { const msg = await this._sendAndWait({ type: 'keypair_bundle_store', v: '0.1', bundle_enc: bundleEnc, + // MNP 0.14, optional: the recovery-wrapped copy. Omitted for a plain + // re-backup; the node keeps any copy it already holds. + ...(recoveryEnc ? { bundle_enc_recovery: recoveryEnc } : {}), }); if (msg.type === 'error') throw new Error(msg.detail); return msg; @@ -2560,7 +2605,155 @@ function pinnedNodeCount() { } catch { return 0; } } +// ── Passphrase change: re-wrap every reachable identity bundle ─────────────── +// +// docs/auth-confirm.md §3.2. The passphrase-derived bundle_key encrypts this +// account's per-node identity on every node it has joined. Changing the +// passphrase changes that key, so each bundle must be read with the old key and +// written back with the new one — on the node, while both keys are in hand. +// +// The reachable set is the online nodes of the account's current groups. A node +// that is offline, or belongs to a group left since, cannot be reached here and +// is reported so the caller can tell the user to ask that group's operator to +// unpin them and issue a fresh code (§3.4). + +function _acHubFetch(hubUrl, path, init) { + const p = typeof window !== 'undefined' && window.MeshBayPlatform; + const url = (hubUrl || '') + path; + return (p && p.apiFetch) ? p.apiFetch(url, init) : fetch(url, init); +} + +async function _acHubGet(hubUrl, token, path) { + const r = await _acHubFetch(hubUrl, path, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!r.ok) throw new Error(`${path} → ${r.status}`); + return r.json(); +} + +function _acWithTimeout(promise, ms, label) { + let timer; + return Promise.race([ + promise.finally(() => clearTimeout(timer)), + new Promise((_, rej) => { + timer = setTimeout(() => rej(new Error(`${label} timed out`)), ms); + }), + ]); +} + +/** + * @param {object} o + * @param {string} o.hubUrl same base the SPA uses for the hub + * @param {string} o.token a fresh access token + * @param {string} o.username + * @param {string} o.userId + * @param {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy + * @param {string} o.newPassphrase + * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B, docs/auth-confirm.md §4.5). + * When given, the recovery-wrapped copy is read where the + * passphrase copy cannot be, and a fresh one is written back. + * @param {(p:{done:number,total:number})=>void} [o.onProgress] + * @returns {Promise<{updated:Array,unreachable:Array,failed:Array,newBundleKey:object}>} + */ +async function rewrapAllNodes(o) { + const K = window.MeshBayKeys; + if (!K || !K.deriveEncryptionKey) { + throw new Error('key module unavailable'); + } + let oldKey, newKey; + if (o.bundleKey) { + // "Keep the current passphrase key, just add / refresh the recovery copy" + // — the Profile backfill (docs/auth-confirm.md §4.3). `o.bundleKey` is the + // live {v2,v1} session key, so no passphrase is needed. + oldKey = newKey = o.bundleKey; + } else { + // Flow B has no old passphrase; connect will fail the passphrase decrypt and + // fall back to the recovery copy, so a placeholder key is fine for `oldKey`. + const oldPass = o.oldPassphrase || o.newPassphrase; + oldKey = { + v2: await K.deriveEncryptionKey(oldPass, o.username), + v1: await K.deriveEncryptionKeyV1(oldPass, o.username), + }; + newKey = { + v2: await K.deriveEncryptionKey(o.newPassphrase, o.username), + v1: await K.deriveEncryptionKeyV1(o.newPassphrase, o.username), + }; + } + const recoveryKey = o.recoveryKey + ? await K.deriveRecoveryKey(o.recoveryKey, o.username) + : null; + + const mine = await _acHubGet(o.hubUrl, o.token, '/v1/groups/mine'); + const groups = mine.groups || (Array.isArray(mine) ? mine : []); + const updated = [], unreachable = [], failed = []; + + for (const g of groups) { + const label = g.owner_username ? `${g.name}@${g.owner_username}` : g.name; + let nodes = []; + try { + const nd = await _acHubGet(o.hubUrl, o.token, `/v1/groups/${g.id}/nodes`); + nodes = nd.nodes || []; + } catch (e) { + failed.push({ groupId: g.id, name: label, reason: e.message }); + if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); + continue; + } + if (nodes.length === 0) { + unreachable.push({ groupId: g.id, name: label, reason: 'node offline' }); + if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); + continue; + } + + let anyOk = false, lastErr = null; + for (const n of nodes) { + const tp = new MeshBayTransport(o.hubUrl, o.token); + // Recover the *existing* identity or report this node — never mint a new + // one just because the stored bundle would not open. + tp._rewrapOnly = true; + try { + await _acWithTimeout( + tp.connect(n.node_id, o.token, g.id, null, null, oldKey, + o.username, o.userId, null, recoveryKey), + 30000, 'connect'); + if (tp.newNodeBundle) { + // No identity existed on this node — connect just minted one under + // the old key. Don't persist it: the next time this group is opened + // the normal flow creates one under the current key, and storing it + // here could also walk back a deliberate bundle withdrawal. Nothing + // is stranded, so this node needs no fix. + anyOk = true; + continue; + } + const sk = tp.sessionKeys; + if (!sk) { lastErr = new Error('identity not recovered'); continue; } + const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0)); + const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0)); + const reEnc = await K.encryptBundleWithKey(skEd, skX, newKey.v2); + // In Flow B, refresh the recovery copy too (same R) so the node's + // passphrase copy and recovery copy stay in step. + const reRecovery = recoveryKey + ? await K.encryptBundleWithKey(skEd, skX, recoveryKey) + : null; + await tp.storeKeypairBundle(reEnc, reRecovery); + anyOk = true; + } catch (e) { + lastErr = e; + } finally { + try { tp.close(); } catch { /* already gone */ } + } + } + + if (anyOk) updated.push({ groupId: g.id, name: label }); + else failed.push({ groupId: g.id, name: label, + reason: (lastErr && lastErr.message) || 'unreachable' }); + if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length }); + } + + return { updated, unreachable, failed, newBundleKey: newKey }; +} + // Export MeshBayTransport.clearNodePin = clearNodePin; MeshBayTransport.pinnedNodeCount = pinnedNodeCount; +MeshBayTransport.rewrapAllNodes = rewrapAllNodes; window.MeshBayTransport = MeshBayTransport; |