/** * MeshBay Browser Key Management — keyderive.js * * Web registration flow (avoids algorithm mismatch with Python Argon2id): * * REGISTRATION: * 1. Browser generates RANDOM Ed25519 + X25519 keypairs via WebCrypto * 2. Bundle (sk_ed || sk_x) is encrypted with AES-256-GCM * using a key derived from password via PBKDF2-SHA512 * 3. Encrypted bundle + public keys sent to hub for storage * * LOGIN (new device): * 1. Hub returns the encrypted bundle * 2. Browser decrypts it locally with the password * 3. Private keys loaded into memory (never leave the browser) * * Password change: re-encrypt bundle with new password-derived key. * * Keys never leave the browser in cleartext. * Hub stores: public keys + encrypted bundle (cannot read private keys). */ 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 ──────────────────────────────────────────────────────────── /** * Generate random Ed25519 + X25519 keypairs using WebCrypto. * Returns raw bytes for both (not CryptoKey objects, for easier serialisation). */ async function generateKeypairs() { // Ed25519 (signing) const edKey = await crypto.subtle.generateKey( { name: 'Ed25519' }, true, ['sign', 'verify']); const skEdRaw = await crypto.subtle.exportKey('pkcs8', edKey.privateKey); const pkEdRaw = await crypto.subtle.exportKey('spki', edKey.publicKey); // X25519 (key agreement) const xKey = await crypto.subtle.generateKey( { name: 'X25519' }, true, ['deriveBits']); const skXRaw = await crypto.subtle.exportKey('pkcs8', xKey.privateKey); const pkXRaw = await crypto.subtle.exportKey('spki', xKey.publicKey); return { skEdRaw, pkEdRaw, skXRaw, pkXRaw }; } // ── 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) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey( 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']); const salt = await crypto.subtle.digest( 'SHA-256', enc.encode(`meshbay:bundle:v1:${username}`)); return crypto.subtle.deriveKey( { name: 'PBKDF2', hash: 'SHA-512', salt, iterations: PBKDF2_ITERATIONS }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } // ── Bundle encryption ───────────────────────────────────────────────────────── /** * Encrypt the keypair bundle with the password-derived AES key. * Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) } */ async function encryptBundle(skEdRaw, skXRaw, password, username) { const aesKey = await deriveEncryptionKey(password, username); 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); return btoa(String.fromCharCode(...out)); } /** * 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)); } // ── Registration ────────────────────────────────────────────────────────────── /** * Full registration flow: * 1. Generate random keypairs * 2. Encrypt bundle with password * 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(); 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', 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)), }), }); if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`); // 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. * * 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, auth_key: authKey }), }); if (!resp.ok) throw new Error(`Login failed: ${await resp.text()}`); const data = await resp.json(); const result = { accessToken: data.access_token, refreshToken: data.refresh_token, 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, regenerateKeys, generateKeypairs, signChallenge, deriveAuthKey, decryptBundleWithKey, };