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