summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/keyderive.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/keyderive.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/keyderive.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js109
1 files changed, 36 insertions, 73 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index af119c7..a27522d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -189,34 +189,43 @@ async function decryptBundle(bundleB64, password, username) {
* Returns the raw private keys for immediate use after registration.
*/
async function registerUser(username, email, password) {
- const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
-
- const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']);
- const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []);
- const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto));
- const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto));
-
- const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username);
+ // No keypair here any more. Identity keys are per node: one is generated the
+ // first time this account joins a given node, encrypted under the passphrase,
+ // and left with that node. So an operator who cracks what sits on their own
+ // disk holds a key that is worthless anywhere else — and on their own node,
+ // one that unlocks nothing they did not already have.
+ //
+ // It also means the hub stores no user key to publish, which is what H3 read.
const authKey = await deriveAuthKey(password, username);
const resp = await fetch(`${HUB}/v1/users/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- username,
- email,
- auth_key: authKey,
- pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)),
- pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)),
- }),
+ body: JSON.stringify({ username, email, auth_key: authKey }),
});
if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`);
+ return { registered: true };
+}
- // Store encrypted bundle locally — will be backed up to node on first group connect
- try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {}
-
- return { skEdRaw, skXRaw, pkEdBytes, pkXBytes, keypairBundleEnc: encBundle };
+/**
+ * A fresh identity for one node, encrypted under the passphrase-derived key.
+ *
+ * Returns { skEdB64, skXB64, pkXB64, bundleEnc } — the bundle goes to that node
+ * and nowhere else, and is what any other browser fetches to become the same
+ * person there.
+ */
+async function generateNodeIdentity(bundleKey) {
+ const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
+ const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
+ const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, { name: 'X25519' }, true, []);
+ const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto));
+ return {
+ skEdB64: b64(skEdRaw),
+ skXB64: b64(skXRaw),
+ pkXB64: b64(pkXBytes),
+ bundleEnc: await encryptBundleWithKey(skEdRaw, skXRaw, bundleKey.v2 || bundleKey),
+ };
}
/**
@@ -269,63 +278,17 @@ async function loginAndRecover(username, password) {
},
};
- // localStorage bundle = new registration, not yet pushed to node
- const bundleEnc = (typeof localStorage !== 'undefined'
- && localStorage.getItem(`meshbay_kp_${username}`)) || null;
-
- if (bundleEnc) {
- // Reuse the keys just derived — decryptBundle() would run the KDF again,
- // and at these parameters that is another 0.6 s for nothing.
- const keys = await decryptBundleWithKey(bundleEnc, result.bundleKey);
- result.skEdB64 = keys.skEd;
- result.skXB64 = keys.skX;
- result.keypairBundleEnc = bundleEnc;
- }
-
+ // Nothing else to recover at sign-in. Identity keys belong to a node, so they
+ // are fetched from the node being connected to (or generated there on a first
+ // join) — see transport.js. All that is needed here is the key that opens them.
return result;
}
-async function regenerateKeys(token, username, password) {
- const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
+// regenerateKeys() removed. Rotating an identity is now per node: the operator
+// runs `meshbay-node member unpin <user>` and issues a fresh code. A hub call
+// that silently changed what every node believed about someone was the wrong
+// shape for this.
- const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']);
- const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []);
- const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto));
- const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto));
-
- const resp = await fetch(`${HUB}/v1/users/me/keys`, {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`,
- },
- body: JSON.stringify({
- pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)),
- pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)),
- }),
- });
-
- if (!resp.ok) throw new Error(`Key rotation failed: ${await resp.text()}`);
-
- const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username);
- try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {}
-
- return {
- skEdB64: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))),
- skXB64: btoa(String.fromCharCode(...new Uint8Array(skXRaw))),
- pkEdB64: btoa(String.fromCharCode(...pkEdBytes)),
- pkXB64: btoa(String.fromCharCode(...pkXBytes)),
- keypairBundleEnc: encBundle,
- };
-}
-
-/**
- * Sign an explicit byte string with the user's Ed25519 identity key.
- *
- * Takes bytes rather than a base64 blob from the wire: callers are expected to
- * build the message themselves (see MeshBayCrypto.adminTranscript) so that the
- * user's identity key is never applied to content the peer chose. Finding H5.
- */
async function signBytes(skEdPkcs8B64, message) {
const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0));
const sk = await crypto.subtle.importKey(
@@ -335,6 +298,6 @@ async function signBytes(skEdPkcs8B64, message) {
}
window.MeshBayKeys = {
- registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signBytes,
+ registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes,
deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
};