summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 17:51:48 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 17:51:48 +0200
commitf0984e86d9cb596a282ce6feb7cfc2f075b2794b (patch)
tree192198a5d596a06d15095bb1d3540dd4a8d397c8 /packages/meshbay-hub/src/meshbay_hub/static/app.js
parent9fa2117de1caf4d713cc0b7a310b9549467738c3 (diff)
downloadmeshbay-f0984e86d9cb596a282ce6feb7cfc2f075b2794b.tar.gz
feat!: identity keys per node — C4's blast radius drops to one operator
One keypair was copied to every node its owner joined, so cracking the bundle on any single node yielded the identity used on all of them: their content on other operators' machines, and the ability to sign as them anywhere. That lateral reach was the part of C4 worth attacking. Each node now gets its own keypair, generated the first time its owner joins it and left with that node alone. An operator who cracks what sits on their own disk holds a key that is a stranger to every other node — and on their own node, one that unlocks nothing they did not already hold: they serve the content, the index and every byte of it by design. Nothing changes for the user. A first contact with a node already needed that operator's code, and the key is created in the same step; a second browser still recovers it from the node with the passphrase alone. Two operators can also no longer tell they host the same person by comparing keys. BREAKING, and deliberately without a compatibility path — the deployment is wiped for the next demo: - users.pk_ed25519 / pk_x25519 dropped (migration a7c31f9e40b2) - registration no longer sends or stores a key - PUT /v1/users/me/keys and regenerateKeys() gone; rotation is now `member unpin` plus a fresh code, decided on the machine that pinned it - /pubkeys returns an account id and a node's linking key. It was the directory H3 read, and nothing wraps for it any more - the pk_user JWT claim is gone That last one closed a live defect the inventory turned up: the node recorded pk_user as the uploader's identity and authorized deletion against it, so a hub issuing a token naming its own key could delete anyone's uploads on any node. Attribution now uses the key the node itself pinned. A simplification falls out. Registration generates nothing, so a scripted signup is a real account: `demo.py bootstrap` takes a wiped hub and node to a working demo with no browser, which was impossible while keys were born in one. Also fixes, found by running it on a wiped deployment: the key handed back on a join now belongs to the group the connection is for, not the group named in the invitation — an operator pairs node-wide but redeems the code while opening a group, and expects to read it. Tests: 343, including the two that state the property — a key pinned by one node is refused at another, and someone else's code does not admit it. Verified end to end against a wiped hub and node: bootstrap, pair, invite, join, download, stream, second browser, revoke. Design: docs/per-node-identity-v1.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js142
1 files changed, 25 insertions, 117 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 300059d..3087e0e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -65,9 +65,10 @@ async function getAllCachedIndexes() {
// ── Auth persistence ─────────────────────────────────────────────────────────
-let _sessionKeys = null;
+// The key that opens a node's keypair bundle, derived once at sign-in. There is
+// no global identity to keep: identity keys belong to a node and are fetched from
+// it (transport.js), so nothing of that kind lives here.
let _bundleKey = null;
-let _pendingBundlePush = null;
// A one-time pairing code the user just typed, consumed by the next connection
// attempt. Deliberately not persisted: it is single-use and short-lived.
let _pendingJoinCode = null;
@@ -108,11 +109,6 @@ async function _clearKeyDB() {
db.close();
} catch {}
}
-function _saveSessionKeys() {
- try {
- if (_sessionKeys) sessionStorage.setItem('meshbay_sk', JSON.stringify(_sessionKeys));
- } catch {}
-}
/**
* Rough passphrase strength, in bits, and what it is up against.
*
@@ -144,35 +140,6 @@ function passwordBits(pw) {
const PASSWORD_MIN_BITS = 60; // refuse below this
const PASSWORD_MIN_LEN = 12;
-/**
- * Re-encrypt a bundle written under the old KDF before it is stored again.
- *
- * PBKDF2 bundles are still readable, but leaving one on a node keeps the weak
- * protection alive for as long as it sits there. Any backup is an opportunity to
- * replace it with the Argon2id form, and it costs nothing the user notices.
- */
-async function _upgradedBundle(bundleEnc) {
- try {
- if (!window.MeshBayKeys || !_sessionKeys || !_bundleKey) return bundleEnc;
- if (window.MeshBayKeys.bundleVersion(bundleEnc) === 2) return bundleEnc;
- const b64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
- return await window.MeshBayKeys.encryptBundleWithKey(
- b64(_sessionKeys.skEdB64), b64(_sessionKeys.skXB64), _bundleKey.v2);
- } catch (e) {
- console.warn('[MeshBay] bundle upgrade skipped:', e.message);
- return bundleEnc;
- }
-}
-
-function _restoreSessionKeys() {
- try {
- if (!_sessionKeys) {
- const sk = sessionStorage.getItem('meshbay_sk');
- if (sk) _sessionKeys = JSON.parse(sk);
- }
- } 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));
@@ -183,35 +150,6 @@ async function _pkXFromSk(skPkcs8B64) {
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));
@@ -225,11 +163,8 @@ function saveAuth(auth) {
localStorage.setItem(AUTH_KEY, JSON.stringify(auth));
} else {
localStorage.removeItem(AUTH_KEY);
- _sessionKeys = null;
_bundleKey = null;
- _pendingBundlePush = null;
_clearKeyDB();
- try { sessionStorage.removeItem('meshbay_sk'); } catch {}
}
}
@@ -932,8 +867,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
setError('');
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;
@@ -942,14 +875,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
return;
}
- // Session keys for the P2P key exchange. skEdB64 belongs here too: the
- // node identifies us by the Ed25519 identity, and join_request signs both
- // public keys with it — without it we can neither join nor pair.
- const sessionKeys = _sessionKeys ? {
- skXB64: _sessionKeys.skXB64,
- skEdB64: _sessionKeys.skEdB64,
- pkXB64: _sessionKeys.pkXB64,
- } : null;
+ // No keys are carried in: the transport fetches this node's identity
+ // from the node, or creates one there on a first join.
+ const sessionKeys = null;
setStatus('connecting');
const nodeId = nodesData.nodes[0].node_id;
@@ -963,34 +891,15 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
- // If transport recovered different session keys from node during handshake
- if (transport.sessionKeys) {
- const recovered = transport.sessionKeys;
- if (!_sessionKeys || recovered.skXB64 !== _sessionKeys.skXB64) {
- _sessionKeys = recovered;
- if (!_sessionKeys.pkXB64) {
- const pubkeys = await hubFetch(
- `/v1/users/${username}/pubkeys`, { token });
- _sessionKeys.pkXB64 = pubkeys.pk_x25519;
- }
- _pendingBundlePush = null;
- try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {}
- _saveSessionKeys();
- }
- }
-
- // Back the encrypted keys up to the node. This is what lets any other
- // browser recover them with the passphrase, which is the ordinary
- // expectation; the protection that matters is the KDF guarding the
- // bundle, not withholding the bundle.
- if (transport.connected && _pendingBundlePush) {
+ // A first join to this node generated an identity for it; leave it with
+ // the node so any other browser can become the same person here with the
+ // passphrase. It is this node's key and no other's.
+ if (transport.connected && transport.newNodeBundle) {
try {
- await transport.storeKeypairBundle(
- await _upgradedBundle(_pendingBundlePush));
- try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {}
- _pendingBundlePush = null;
+ await transport.storeKeypairBundle(transport.newNodeBundle);
+ transport.newNodeBundle = null;
} catch (e) {
- console.warn('[MeshBay] Bundle push to node deferred:', e.message);
+ console.warn('[MeshBay] could not leave our key with the node:', e.message);
}
}
@@ -1134,8 +1043,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
try {
// Signs an explicit transcript built by transport.js, not opaque bytes from
// the node — see MeshBayCrypto.adminTranscript and finding H5.
- const signFn = (_sessionKeys && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript)
+ // Signed with the identity this node pinned for us — the only one it
+ // will accept, and the only one we hold here.
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
await transport.deleteFile(entry.id, signFn);
const indexMsg = await transport.fetchIndex();
@@ -1601,8 +1513,11 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef,
// code it never learns — the code goes to a human, out of band.
const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
- const signFn = (_sessionKeys && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript)
+ // Signed with the identity this node pinned for us — the only one it
+ // will accept, and the only one we hold here.
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
const result = await transport.createInvite(
account.user_id, groupId, username, signFn);
@@ -2748,12 +2663,10 @@ function App() {
const data = await window.MeshBayKeys.loginAndRecover(username, password);
token = data.accessToken;
refreshToken = data.refreshToken;
+ // The only thing sign-in produces: the key that opens a node's bundle.
+ // Which identity we use is decided per node, when we get there.
_bundleKey = data.bundleKey;
await _storeBundleKey(_bundleKey);
- if (data.skXB64) {
- _sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 };
- _pendingBundlePush = data.keypairBundleEnc;
- }
} else {
const data = await hubFetch('/v1/users/login', {
method: 'POST',
@@ -2763,11 +2676,6 @@ function App() {
refreshToken = data.refresh_token;
}
const me = await hubFetch('/v1/users/me', { token });
- if (_sessionKeys) {
- const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
- _sessionKeys.pkXB64 = pubkeys.pk_x25519;
- _saveSessionKeys();
- }
const u = { username, userId: me.user_id, token, refreshToken, role: me.role };
setUser(u);
saveAuth(u);