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 --- .../meshbay-hub/src/meshbay_hub/static/platform.js | 38 ++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/platform.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index fcc866e..ae04e53 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -87,6 +87,39 @@ export const secrets = { }, }; +/** + * This device's key for signing in to the hub. + * + * Ed25519, generated and held by the main process — the interface asks for a + * signature and never sees a key. The passphrase is still the account's + * credential and its only recovery path; this is what saves deriving a key from + * it on every launch. + * + * Not a per-node identity key. Those are generated per node, pinned there, and + * never leave that relationship: nothing here correlates a person across + * operators, and nothing wraps a group key for it. + * + * Absent in a browser, where a passphrase is entered every time and the + * keypair bundle on each node is what a second browser recovers — which is + * finding C4, and the reason the application exists. + */ +export const device = { + available: Boolean(bridge && bridge.device), + async ensure() { + return bridge && bridge.device ? bridge.device.ensure() : null; + }, + async publicKey() { + return bridge && bridge.device ? bridge.device.publicKey() : null; + }, + /** `{timestamp, signature}` over `meshbay:user_auth::`. */ + async sign(username) { + return bridge && bridge.device ? bridge.device.sign(username) : null; + }, + async forget() { + return bridge && bridge.device ? bridge.device.forget() : false; + }, +}; + /** * Call the hub. * @@ -134,7 +167,8 @@ export async function nativeSave(suggestedName, size) { return bridge.saveFile(suggestedName, size); } -export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch }; +export default { isNative, hubBase, capabilities, secrets, nativeSave, + apiFetch, device }; // Also a global, because `transport.js` is loaded as a classic script — it // predates the module graph and exposes `MeshBayTransport` the same way. The @@ -142,5 +176,5 @@ export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch // hub end up disagreeing about how to reach it. if (typeof window !== 'undefined') { window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets, - nativeSave, apiFetch }; + nativeSave, apiFetch, device }; } -- cgit v1.2.3