From 8f9ac4d931e43358cb0e4e82085f49e916419a8b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 04:02:20 +0200 Subject: fix(client): recover identity keys from what the browser already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering in Firefox and coming back to it said "This browser does not hold your keys" — while both halves of those keys were on disk a few bytes apart. _sessionKeys lives in sessionStorage, which dies with the tab. The encrypted keypair bundle is in localStorage from registration, and the key that opens it is in IndexedDB from login, but nothing ever put the two together again: only the login path did, and a returning user is restored from stored auth without logging in. So closing a tab looked identical to never having registered there. Recovery now happens before connecting: bundle from localStorage, key from IndexedDB, public half derived from our own secret rather than read back from the hub. The bundle is also queued for backup to the node, which is what lets a second browser recover the same keys with the password. Not hardening, and not from the invite redesign — an oversight in session restore that the redesign made visible, because joining is now the first thing that needs those keys. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 6fefe0f..0aa74b4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -122,6 +122,45 @@ function _restoreSessionKeys() { } catch {} } +/** Public X25519 key from our own secret — never read back from the hub. */ +async function _pkXFromSk(skPkcs8B64) { + const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); + const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'X25519' }, true, ['deriveBits']); + const jwk = await crypto.subtle.exportKey('jwk', sk); + const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/'); + const pad = b64.length % 4; + return pad ? b64 + '='.repeat(4 - pad) : b64; +} + +/** + * Recover our identity keys from what this browser already holds. + * + * sessionStorage dies with the tab, but the encrypted keypair bundle sits in + * localStorage from registration and the key that opens it is in IndexedDB from + * login. Without this, closing the tab looked exactly like never having + * registered here — "this browser does not hold your keys", while both halves + * were on disk a few bytes apart. + */ +async function _recoverLocalKeys(username) { + if (_sessionKeys || !username) return; + try { + if (!_bundleKey) _bundleKey = await _loadBundleKey(); + if (!_bundleKey || !window.MeshBayKeys) return; + const enc = localStorage.getItem(`meshbay_kp_${username}`); + if (!enc) return; + const keys = await window.MeshBayKeys.decryptBundleWithKey(enc, _bundleKey); + _sessionKeys = { + skXB64: keys.skX, + skEdB64: keys.skEd, + pkXB64: await _pkXFromSk(keys.skX), + }; + _pendingBundlePush = enc; // still to be backed up to a node + _saveSessionKeys(); + } catch (e) { + console.warn('[MeshBay] could not recover local keys:', e); + } +} + function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY)); @@ -824,6 +863,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { gekRef.current = null; if (!_bundleKey) _bundleKey = await _loadBundleKey(); _restoreSessionKeys(); + await _recoverLocalKeys(username); try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); if (cancelled) return; -- cgit v1.2.3