aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/keyderive.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js218
1 files changed, 130 insertions, 88 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index ff3da33..a27522d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -67,11 +67,35 @@ async function generateKeypairs() {
// ── Password → AES key ────────────────────────────────────────────────────────
-/**
- * Derive an AES-256 key from password + username using PBKDF2-SHA512.
- * Used for encrypting the keypair bundle.
- */
-async function deriveEncryptionKey(password, username) {
+// Argon2id parameters for the keypair bundle.
+//
+// This is the one KDF in the browser that guards something an adversary can take
+// away and attack at leisure: the bundle is stored on every node whose group its
+// owner joins (finding C4). PBKDF2 was the wrong tool — it is compute-only, which
+// is exactly what a GPU is good at, so 600k iterations bought far less than the
+// wall-clock time suggested.
+//
+// 128 MB / t=3 / p=1 measured at ~640 ms through this WASM build on a desktop.
+// Memory is the lever, not time: each guess must hold 128 MB, so a 24 GB card
+// fits ~187 in parallel and its bandwidth caps it near 2k guesses/s, against no
+// ceiling at all for PBKDF2. 256 MB would double that again at ~1.3 s, which is
+// too much to ask of a phone for something paid at every sign-in.
+const ARGON2_MEM_KIB = 131072; // 128 MB
+const ARGON2_TIME = 3;
+const ARGON2_LANES = 1;
+
+// Bundles written before this carry no marker and are read with the old KDF.
+// They are re-encrypted the first time their owner signs in (see upgradeBundle).
+const BUNDLE_V2_MAGIC = 'MBK2';
+
+function _argon2() {
+ const a = (typeof window !== 'undefined' && window.argon2) || globalThis.argon2;
+ if (!a) throw new Error('Argon2 unavailable — vendor/argon2.min.js did not load');
+ return a;
+}
+
+/** Legacy: PBKDF2-SHA512. Kept to read bundles written before the change. */
+async function deriveEncryptionKeyV1(password, username) {
const enc = new TextEncoder();
const km = await crypto.subtle.importKey(
'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
@@ -86,6 +110,27 @@ async function deriveEncryptionKey(password, username) {
);
}
+/**
+ * Derive the bundle key with Argon2id.
+ *
+ * The salt stays deterministic and domain-separated per user, as before: it is
+ * what lets the key be derived once at sign-in and kept, instead of holding the
+ * passphrase in memory to re-derive it whenever a bundle turns up. It is unique
+ * per account, so it does what a salt is for — no shared precomputation.
+ */
+async function deriveEncryptionKey(password, username) {
+ const enc = new TextEncoder();
+ const salt = new Uint8Array(await crypto.subtle.digest(
+ 'SHA-256', enc.encode(`meshbay:bundle:v2:${username}`))).slice(0, 16);
+ const out = await _argon2().hash({
+ pass: password, salt,
+ time: ARGON2_TIME, mem: ARGON2_MEM_KIB, parallelism: ARGON2_LANES,
+ hashLen: 32, type: _argon2().ArgonType.Argon2id,
+ });
+ return crypto.subtle.importKey(
+ 'raw', out.hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
+}
+
// ── Bundle encryption ─────────────────────────────────────────────────────────
/**
@@ -94,29 +139,42 @@ async function deriveEncryptionKey(password, username) {
*/
async function encryptBundle(skEdRaw, skXRaw, password, username) {
const aesKey = await deriveEncryptionKey(password, username);
+ return encryptBundleWithKey(skEdRaw, skXRaw, aesKey);
+}
+
+/** Same, when the key was already derived at sign-in. Always writes v2. */
+async function encryptBundleWithKey(skEdRaw, skXRaw, aesKey) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode(JSON.stringify({
skEd: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))),
skX: btoa(String.fromCharCode(...new Uint8Array(skXRaw))),
}));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, data);
- // Return base64(nonce || ciphertext)
- const out = new Uint8Array(nonce.length + ct.byteLength);
- out.set(nonce);
- out.set(new Uint8Array(ct), nonce.length);
+ // base64( "MBK2" || nonce || ciphertext ). The marker is what tells a reader
+ // which KDF produced the key, so old bundles stay readable and new ones are
+ // never fed to the old derivation.
+ const magic = new TextEncoder().encode(BUNDLE_V2_MAGIC);
+ const out = new Uint8Array(magic.length + nonce.length + ct.byteLength);
+ out.set(magic);
+ out.set(nonce, magic.length);
+ out.set(new Uint8Array(ct), magic.length + nonce.length);
return btoa(String.fromCharCode(...out));
}
+function bundleVersion(bundleB64) {
+ try {
+ return atob(bundleB64).startsWith(BUNDLE_V2_MAGIC) ? 2 : 1;
+ } catch { return 1; }
+}
+
/**
* Decrypt a keypair bundle. Throws if password is wrong.
*/
async function decryptBundle(bundleB64, password, username) {
- const aesKey = await deriveEncryptionKey(password, username);
- const raw = Uint8Array.from(atob(bundleB64), c => c.charCodeAt(0));
- const nonce = raw.slice(0, 12);
- const ct = raw.slice(12);
- const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ct);
- return JSON.parse(new TextDecoder().decode(plain));
+ const key = bundleVersion(bundleB64) === 2
+ ? await deriveEncryptionKey(password, username)
+ : await deriveEncryptionKeyV1(password, username);
+ return decryptBundleWithKey(bundleB64, key);
}
// ── Registration ──────────────────────────────────────────────────────────────
@@ -131,45 +189,62 @@ 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),
+ };
}
/**
* Decrypt a keypair bundle using a pre-derived AES-256 CryptoKey.
* Used when the bundle is fetched from the node (bundleKey was derived at login).
*/
-async function decryptBundleWithKey(bundleB64, aesKey) {
+async function decryptBundleWithKey(bundleB64, aesKeyOrPair) {
+ const v2 = bundleVersion(bundleB64) === 2;
+ // Callers derive both keys at sign-in and pass the pair, because which one a
+ // bundle needs is only known once it has been read — and the passphrase is
+ // deliberately not kept around to derive the other one later.
+ const key = (aesKeyOrPair && aesKeyOrPair.v2)
+ ? (v2 ? aesKeyOrPair.v2 : aesKeyOrPair.v1)
+ : aesKeyOrPair;
const raw = Uint8Array.from(atob(bundleB64), c => c.charCodeAt(0));
- const nonce = raw.slice(0, 12);
- const ct = raw.slice(12);
- const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ct);
+ const off = v2 ? BUNDLE_V2_MAGIC.length : 0;
+ const nonce = raw.slice(off, off + 12);
+ const ct = raw.slice(off + 12);
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
return JSON.parse(new TextDecoder().decode(plain));
}
@@ -195,67 +270,34 @@ async function loginAndRecover(username, password) {
const result = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
- bundleKey: await deriveEncryptionKey(password, username),
+ // Both, so a bundle written before the KDF changed can still be opened —
+ // and re-written with the new one on the next backup.
+ bundleKey: {
+ v2: await deriveEncryptionKey(password, username),
+ v1: await deriveEncryptionKeyV1(password, username),
+ },
};
- // localStorage bundle = new registration, not yet pushed to node
- const bundleEnc = (typeof localStorage !== 'undefined'
- && localStorage.getItem(`meshbay_kp_${username}`)) || null;
-
- if (bundleEnc) {
- const keys = await decryptBundle(bundleEnc, password, username);
- 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();
-
- 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,
- };
-}
+// 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.
-async function signChallenge(skEdPkcs8B64, challengeB64) {
+async function signBytes(skEdPkcs8B64, message) {
const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0));
const sk = await crypto.subtle.importKey(
'pkcs8', skRaw, { name: 'Ed25519' }, false, ['sign']);
- const challenge = Uint8Array.from(atob(challengeB64), c => c.charCodeAt(0));
- const sig = await crypto.subtle.sign('Ed25519', sk, challenge);
+ const sig = await crypto.subtle.sign('Ed25519', sk, message);
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
window.MeshBayKeys = {
- registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signChallenge,
- deriveAuthKey, decryptBundleWithKey,
+ registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes,
+ deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
};