summaryrefslogtreecommitdiffstats
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.js127
1 files changed, 112 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 63baff5..ff3da33 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -23,6 +23,26 @@
const PBKDF2_ITERATIONS = 600000; // OWASP 2023 recommendation for PBKDF2-SHA512
const HUB = ''; // same origin
+// ── Auth key derivation (password split) ──────────────────────────────────────
+
+/**
+ * Derive an auth key from password + username using PBKDF2-SHA512.
+ * This key is sent to the hub for authentication — the raw password never leaves the browser.
+ * Uses a different salt domain than deriveEncryptionKey (bundle key), so the two
+ * derived values are cryptographically independent.
+ */
+async function deriveAuthKey(password, username) {
+ const enc = new TextEncoder();
+ const km = await crypto.subtle.importKey(
+ 'raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
+ const salt = await crypto.subtle.digest(
+ 'SHA-256', enc.encode(`meshbay:auth:v1:${username}`));
+ const bits = await crypto.subtle.deriveBits(
+ { name: 'PBKDF2', hash: 'SHA-512', salt, iterations: PBKDF2_ITERATIONS },
+ km, 256);
+ return btoa(String.fromCharCode(...new Uint8Array(bits)));
+}
+
// ── Key generation ────────────────────────────────────────────────────────────
/**
@@ -105,20 +125,21 @@ async function decryptBundle(bundleB64, password, username) {
* Full registration flow:
* 1. Generate random keypairs
* 2. Encrypt bundle with password
- * 3. POST to hub (public keys + encrypted bundle)
+ * 3. POST to hub (public keys only — no keypair bundle)
+ * 4. Store encrypted bundle locally for backup to node on first connect
*
* Returns the raw private keys for immediate use after registration.
*/
async function registerUser(username, email, password) {
const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
- // Convert SPKI public keys to raw 32-byte format expected by hub
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);
+ const authKey = await deriveAuthKey(password, username);
const resp = await fetch(`${HUB}/v1/users/register`, {
method: 'POST',
@@ -126,39 +147,115 @@ async function registerUser(username, email, password) {
body: JSON.stringify({
username,
email,
- password,
+ auth_key: authKey,
pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)),
pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)),
- keypair_bundle: encBundle, // encrypted, hub stores but cannot read
}),
});
if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`);
- return { skEdRaw, skXRaw, pkEdBytes, pkXBytes };
+
+ // 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 };
+}
+
+/**
+ * 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) {
+ 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));
}
/**
- * Login and recover private keys from the encrypted bundle.
+ * Login and recover private keys.
+ *
+ * If localStorage has a keypair bundle (new registration, not yet pushed to node),
+ * decrypts it and returns the keys + encrypted bundle for push to node.
+ * Otherwise returns bundleKey so the caller can fetch from node during handshake.
*/
async function loginAndRecover(username, password) {
+ const authKey = await deriveAuthKey(password, username);
+
const resp = await fetch(`${HUB}/v1/users/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, password }),
+ body: JSON.stringify({ username, auth_key: authKey }),
});
+
if (!resp.ok) throw new Error(`Login failed: ${await resp.text()}`);
const data = await resp.json();
- const bundle = data.keypair_bundle;
- if (!bundle) throw new Error('No keypair bundle in response — account may have been created via CLI');
-
- const keys = await decryptBundle(bundle, password, username);
- return {
+ const result = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
- skEdB64: keys.skEd,
- skXB64: keys.skX,
+ bundleKey: await deriveEncryptionKey(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;
+ }
+
+ 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,
+ };
+}
+
+async function signChallenge(skEdPkcs8B64, challengeB64) {
+ 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);
+ return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
-window.MeshBayKeys = { registerUser, loginAndRecover, generateKeypairs };
+window.MeshBayKeys = {
+ registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signChallenge,
+ deriveAuthKey, decryptBundleWithKey,
+};