diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 01:03:43 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 01:03:43 +0200 |
| commit | fe30860c58e0f1b1efd457ff5eb5146d1e592da0 (patch) | |
| tree | 99a3e96994738c4e96f969a365679475dc4cf5cd /packages/meshbay-hub/src/meshbay_hub/static/group-page.js | |
| parent | 51d2d734c228f1e46670962480258abfe586d6c4 (diff) | |
| download | meshbay-fe30860c58e0f1b1efd457ff5eb5146d1e592da0.tar.gz | |
feat: passphrase change and account recovery (auth-confirm)
The passphrase derives two independent client-side values: auth_key (the
hub verifier) and bundle_key (AES-GCM key for the per-node identity
bundles, which live on nodes and never on the hub). Changing or
recovering a passphrase is therefore two operations — swap the hub
verifier, and re-wrap every reachable node's identity bundle.
Flow A — change a known passphrase (Profile page)
- POST /v1/users/password re-proves the current passphrase, swaps
pw_hash/salt/version, revokes every refresh token and returns a fresh
pair so the tab that made the change stays signed in.
- MeshBayTransport.rewrapAllNodes: for every group's online node, connect
with the old key, read the identity off the handshake, store it back
under the new key. Returns updated / unreachable / failed so the UI can
point at the operator-unpin fallback for the gaps. Always-shown
confirmation dialog listing reachable and unreachable groups.
Recovery key
- keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and
deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>).
- Every per-node identity gets a second copy wrapped under the recovery
key: keypair_bundles.bundle_enc_recovery (node-only column, added in
_SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs),
carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive.
- session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded
on connect, so a group joined in any later session still leaves a
recovery copy.
- Shown once at registration; optionally folded into the verification
e-mail as a pass-through the hub never stores or logs, with an opt-out.
- Profile -> Recovery key re-loads R and backfills every reachable node
via rewrapAllNodes in bundleKey mode (no passphrase re-entry).
Flow B — recover a lost passphrase (#/reset, linked from sign-in)
- POST /v1/users/password/reset-request {username, email}: both must be
the pair on file, checked against the blind email_hash (never
decrypted). A mismatch — wrong e-mail, unknown username, non-active
account — takes the identical no-op path (no code, no mail, same 200),
so it reveals nothing and cannot be used to spray reset mail from a
username alone. 5/min, 1-hour single-use code.
- POST /v1/users/password/reset {username, code, new_auth_key}: same
expiry / attempts / single-use checks as e-mail verification; revokes
every session and deletes every registered device key so a stored one
cannot sign back in past the reset.
- ResetPasswordPage: request code -> code + optional recovery key + new
passphrase -> reset + sign-in -> fan-out. connect() falls back to the
recovery-wrapped copy when the passphrase key cannot open bundle_enc.
Without a recovery key: sign-in is restored and each group needs the
operator-unpin fallback.
Supporting fixes (found in live testing)
- member unpin now also deletes the keypair bundle; connect() mints a
fresh identity when handed a bundle it cannot open (unless _rewrapOnly,
set by rewrapAllNodes), so a rejoin completes instead of dead-ending
before the invite-code prompt.
- A browser with no bundle key gets a passphrase prompt on the group page
instead of a "go back to the browser you registered on" message.
- RegisterPage / LoginPage / ResetPasswordPage trim the username so every
key derivation matches the hub's stored form.
Docs: docs/auth-confirm.md. Locale keys across all ten catalogues.
Tests: test_password_change, test_password_reset, test_recovery_email,
test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus
additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492
passed; node suite 741 passed (the lone test_packaging_units failure is a
pre-existing RPM-spec flake, reproducible on main).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-page.js | 72 |
1 files changed, 68 insertions, 4 deletions
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> |