diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 14:50:22 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 14:50:22 +0200 |
| commit | aed220d9f0bab42efd57b56851319e840ab8ae26 (patch) | |
| tree | e8b72fbe9016635438e6b7046a35e47ec3dbe93a /packages/meshbay-hub/src/meshbay_hub/static/keyderive.js | |
| parent | 608d3a705d065d6b378f4889322ff9d1bc41d147 (diff) | |
| download | meshbay-aed220d9f0bab42efd57b56851319e840ab8ae26.tar.gz | |
feat: password-based key derivation + operational QUICKSTART
keyderive.py: derive Ed25519+X25519 from username+password via Argon2id.
Same credentials → same keys on any device. Encrypt/decrypt keypair
bundle (AES-256-GCM) for hub storage (web clients).
7/7 tests. Full suite: 81/81.
keyderive.js: browser counterpart using PBKDF2-SHA512 + random keypairs
encrypted for hub storage. Avoids algorithm mismatch with Python.
hub/models.py + users.py: keypair_bundle field added to User, stored on
registration, returned in login response for web client key recovery.
QUICKSTART.md: fully rewritten. 3 operational scripts in QE/demo-v1/:
setup_demo.py — create accounts, group, distribute GEK
run_node.py — start HTTP node (watches shared/ directory)
download.py — bob login → GEK fetch → decrypt → save
All tested locally end-to-end. No invented URLs.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <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.js | 164 |
1 files changed, 164 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js new file mode 100644 index 0000000..63baff5 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -0,0 +1,164 @@ +/** + * 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 + +// ── 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 + encrypted bundle) + * + * 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 resp = await fetch(`${HUB}/v1/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + email, + password, + 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 }; +} + +/** + * Login and recover private keys from the encrypted bundle. + */ +async function loginAndRecover(username, password) { + const resp = await fetch(`${HUB}/v1/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + 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 { + accessToken: data.access_token, + refreshToken: data.refresh_token, + skEdB64: keys.skEd, + skXB64: keys.skX, + }; +} + +window.MeshBayKeys = { registerUser, loginAndRecover, generateKeypairs }; |