diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/keyderive.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/keyderive.js | 127 |
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, +}; |