From 7c46b4e7dc2893974a37d6a701123a95803fcb95 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 18 Aug 2026 11:00:32 +0200 Subject: feat(client): hybrid sign-in — passphrase once, then this device's key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D4. The passphrase stays the account's credential and its only recovery path; what changes is that it is not asked for on every launch. **The renderer never holds the device key.** It is generated, stored and used entirely in the main process, which signs `meshbay:user_auth::` on request. Same rule as the save dialog, for the same reason: the renderer is the part of this application that parses decrypted content from nodes, which is attacker-controlled input. And this key is *not* a per-node identity key — those are generated per node and never leave that relationship, so nothing here correlates a person across operators. First run asks which hub, with no default. A client that picks its own hub is a client that can be pointed at one, and the address is the whole of what this application trusts a hub for — the interface comes from the package. Verified against a hub running this code, not against the deployed one: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → `/v1/users/me` 200 → a stranger's key 401. The signature was also checked directly against the hub's own Python verifier before any of that. Inside the running application, over the debugging protocol: the bridge reaches the main process, the renderer calls the hub **through it** (200 — the CORS fix working end to end), and a call to a host that is not the configured hub is refused. **Not verified:** safeStorage persisting the key. This session has no secret service, and standing one up in xvfb did not succeed. The application behaves correctly there — it *refuses* rather than storing unprotected, and now says so in Settings, which is a real case rather than a hypothetical one since it is exactly what a headless or minimal desktop looks like. Worth remembering for next time: meshbay.org runs whatever was last deployed. It answered 405 on the Stage-C endpoints and reported MNP 0.2 while the tree had 0.3, so a local `uvicorn meshbay_hub.app:create_app --factory` on SQLite is what tests hub changes. Nothing was deployed to production for this. 799 tests pass; e2e.py passes end to end. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 139 ++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 24ef43f..34f146a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -651,6 +651,52 @@ function Sidebar({ groups, presence, route, menuOpen, role }) { // ── Login Page ─────────────────────────────────────────────────────────────── +/** + * Which hub, asked once on a desktop build. + * + * There is no default. A client that picks its own hub is a client that can be + * pointed at one, and the address is the whole of what the application trusts + * the hub for — its API, and nothing else: the interface comes from the package. + * + * Changing it restarts the window, because the address reaches the interface as + * a process argument. Reloading in place would leave it talking to the old hub + * with nothing on screen to say so. + */ +function FirstRunPage({ onSet }) { + const [url, setUrl] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async (e) => { + e.preventDefault(); + setError(''); + setBusy(true); + try { + await window.meshbay.setHubBase(url.trim()); + onSet(); + } catch (err) { + setError(err.message || String(err)); + setBusy(false); + } + }; + + return html` +
+

${t('firstrun.title')}

+

${t('firstrun.hint')}

+
+ setUrl(e.target.value)} /> + +
+ ${error && html`

${error}

`} +

${t('firstrun.note')}

+
+ `; +} + function LoginPage() { const auth = useAuth(); const [username, setUsername] = useState(''); @@ -4069,6 +4115,9 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { ${keyBackend === 'unprotected_fallback' && html`

${t('settings.keys_unprotected')}

`} + ${keyBackend === 'unavailable' && html` +

${t('settings.keys_unavailable')}

+ `} `} @@ -4485,6 +4534,13 @@ function App() { const route = useRoute(); const [theme, setTheme] = useState(getInitialTheme); const [user, setUser] = useState(loadAuth); + // A desktop build with a remembered device signs in without asking. Null + // until it has tried, so nothing renders a sign-in form the user is about to + // be taken past. + const [deviceTried, setDeviceTried] = useState(!platform.device.available); + // Native, and nowhere to talk to yet. + const [needsHub, setNeedsHub] = useState( + platform.isNative && !platform.hubBase()); const [groups, setGroups] = useState([]); const [menuOpen, setMenuOpen] = useState(false); const [notifications, setNotifications] = useState([]); @@ -4597,12 +4653,79 @@ function App() { }); }, [user]); + // Sign in with this device's key, once, at startup. + // + // The passphrase stays the account's credential and its recovery path; this + // is what saves entering it every launch. A refusal is not an error worth + // showing — the key may have been retired from another device, or the hub may + // have forgotten it — so it falls through to the ordinary form. + useEffect(() => { + // Nothing to do when a session was restored from storage, or when this is + // a browser. `user` is read once here on purpose: this runs at startup and + // must not re-fire when the session it just created lands. + if (deviceTried || user) { setDeviceTried(true); return; } + let cancelled = false; + (async () => { + try { + // `loadAuth` keeps the username even when the tokens in it are stale, + // and `app://meshbay` is a stable origin, so localStorage survives a + // relaunch. A fresh install has nothing here and asks for a passphrase, + // which is right: the first sign-in is what registers the device. + const saved = loadAuth(); + const username = saved && saved.username; + if (!username) return; + const signed = await platform.device.sign(username); + if (!signed) return; + const data = await hubFetch('/v1/users/auth', { + method: 'POST', + body: { username, timestamp: signed.timestamp, + signature: signed.signature }, + }); + const me = await hubFetch('/v1/users/me', { token: data.access_token }); + if (cancelled) return; + const u = { username, userId: me.user_id, token: data.access_token, + refreshToken: data.refresh_token, role: me.role }; + setAuth(u); + setUser(u); + } catch { + // Falls through to the sign-in form, which is the honest outcome. + } finally { + if (!cancelled) setDeviceTried(true); + } + })(); + return () => { cancelled = true; }; + }, []); + useEffect(() => { setMenuOpen(false); }, [route]); const changeTheme = useCallback((val) => { setTheme(val); }, []); + /** + * Register this device's hub key, once, after a passphrase sign-in. + * + * Deliberately not fatal: a hub that refuses it, or a machine with no key + * storage, means the passphrase is asked for again next time — which is + * exactly what a browser does, and is a worse experience rather than a + * broken one. + */ + const registerThisDevice = useCallback(async (token) => { + if (!platform.device.available) return; + try { + const backend = await platform.secrets.backend(); + if (backend === 'unavailable') return; + const pk = await platform.device.ensure(); + if (!pk) return; + await hubFetch('/v1/users/devices', { + method: 'POST', token, + body: { pk_auth_ed25519: pk, label: t('device.this_device') }, + }); + } catch (err) { + console.warn('device not registered:', err.message); + } + }, []); + const authCtx = { user, login: async (username, password) => { @@ -4625,6 +4748,10 @@ function App() { } const me = await hubFetch('/v1/users/me', { token }); const u = { username, userId: me.user_id, token, refreshToken, role: me.role }; + // On a desktop build, remember this device so the next launch does not ask + // for the passphrase again. The key is generated and held by the main + // process; what travels here is only its public half. + await registerThisDevice(token); // setAuth, not saveAuth: it is the one writer that also updates the copy // hubFetch renews from. Storing the session without it left the renewal // path with no refresh token to present. @@ -4654,7 +4781,17 @@ function App() { const refreshAuth = useCallback(() => refreshAccessToken(), []); let page; - if (route === '/login' || route === '/register') { + // A desktop build with no hub configured cannot do anything at all, so it + // asks before showing a sign-in form that could not work. Deliberately not + // defaulted to meshbay.org: a client that picks its own hub is a client that + // can be pointed at one. + if (needsHub) { + page = html`<${FirstRunPage} onSet=${() => setNeedsHub(false)} />`; + } else if (!deviceTried) { + // 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`

${t('status.connecting')}

`; + } else if (route === '/login' || route === '/register') { page = route === '/register' ? html`<${RegisterPage} />` : html`<${LoginPage} />`; -- cgit v1.2.3