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/hub-client.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/hub-client.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/hub-client.js | 26 |
1 files changed, 20 insertions, 6 deletions
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, }; |