From aed220d9f0bab42efd57b56851319e840ab8ae26 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 9 Aug 2026 14:50:22 +0200 Subject: feat: password-based key derivation + operational QUICKSTART MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../meshbay-common/src/meshbay_common/keyderive.py | 128 ++++++++++++++++ packages/meshbay-common/tests/test_keyderive.py | 57 +++++++ packages/meshbay-hub/src/meshbay_hub/api/users.py | 11 +- packages/meshbay-hub/src/meshbay_hub/db/models.py | 5 +- .../src/meshbay_hub/static/keyderive.js | 164 +++++++++++++++++++++ 5 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 packages/meshbay-common/src/meshbay_common/keyderive.py create mode 100644 packages/meshbay-common/tests/test_keyderive.py create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/keyderive.js (limited to 'packages') diff --git a/packages/meshbay-common/src/meshbay_common/keyderive.py b/packages/meshbay-common/src/meshbay_common/keyderive.py new file mode 100644 index 0000000..497f877 --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/keyderive.py @@ -0,0 +1,128 @@ +""" +MeshBay — Key derivation from username + password. + +Allows Ed25519 + X25519 keypairs to be derived deterministically +from credentials. Same inputs → same keys on any device. + +Algorithm: Argon2id (Python CLI / native clients) + salt = SHA-256("meshbay:v1:" + username) + seed = Argon2id(password, salt, length=64, ...) + sk_ed = Ed25519PrivateKey.from_private_bytes(seed[:32]) + sk_x25519 = X25519PrivateKey.from_private_bytes(seed[32:]) + +Browser alternative (keyderive.js): uses PBKDF2-SHA512 because +WebCrypto does not support Argon2. The two algorithms produce +DIFFERENT keys from the same password — a user registered via Python +CLI and via web browser will have different keypairs. + +Resolution: the web client generates RANDOM keypairs on first login +(WebCrypto, stored encrypted in hub), and uses derive_keys_from_password +only to encrypt/decrypt the stored keypair bundle. This avoids the +algorithm mismatch problem entirely. + +See keyderive.js for the browser-side implementation. +""" + +import hashlib +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + + +# Argon2id parameters — same as keystore (see crypto.py) +_ITERATIONS = 3 +_MEMORY_COST = 65536 # 64 MB — increase to 262144 for production +_LANES = 4 +_SEED_LENGTH = 64 # 32 bytes Ed25519 + 32 bytes X25519 + + +def _derive_salt(username: str) -> bytes: + """Deterministic salt: SHA-256 of 'meshbay:v1:'.""" + return hashlib.sha256(f"meshbay:v1:{username}".encode()).digest() + + +def derive_keys_from_password( + username: str, + password: str, +) -> tuple[Ed25519PrivateKey, X25519PrivateKey]: + """ + Derive Ed25519 + X25519 keypairs deterministically from username + password. + + Properties: + - Same credentials always produce the same keypairs + - Different usernames produce different keys (even with same password) + - Password cannot be recovered from the public keys + - Changing the password invalidates all GEK bundles stored on the hub + + Use for: + - CLI / native node registration (Argon2id available) + - Recovery of lost keypairs from credentials + + Do NOT use for: + - Web browser registration (use random keypairs + encrypted bundle instead) + """ + salt = _derive_salt(username) + kdf = Argon2id( + salt=salt, length=_SEED_LENGTH, + iterations=_ITERATIONS, lanes=_LANES, memory_cost=_MEMORY_COST, + ) + seed = kdf.derive(password.encode()) + return ( + Ed25519PrivateKey.from_private_bytes(seed[:32]), + X25519PrivateKey.from_private_bytes(seed[32:]), + ) + + +def encrypt_keypair_bundle( + sk_ed: Ed25519PrivateKey, + sk_x: X25519PrivateKey, + password: str, + username: str, +) -> bytes: + """ + Encrypt a keypair bundle with a password-derived key (for hub storage). + Used by web clients: random keypairs encrypted with password, stored on hub. + Returns: AES-256-GCM ciphertext (nonce prepended). + """ + import os + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from meshbay_common.crypto import sk_to_raw + import msgpack + + # Derive an AES key from the password (different info string from key derivation) + salt = hashlib.sha256(f"meshbay:bundle:v1:{username}".encode()).digest() + kdf = Argon2id(salt=salt, length=32, iterations=_ITERATIONS, + lanes=_LANES, memory_cost=_MEMORY_COST) + aes_key = kdf.derive(password.encode()) + + payload = msgpack.packb({ + "sk_ed": sk_to_raw(sk_ed), + "sk_x": sk_to_raw(sk_x), + }, use_bin_type=True) + + nonce = os.urandom(12) + ct = AESGCM(aes_key).encrypt(nonce, payload, None) + return nonce + ct + + +def decrypt_keypair_bundle( + bundle: bytes, + password: str, + username: str, +) -> tuple[Ed25519PrivateKey, X25519PrivateKey]: + """Decrypt a keypair bundle. Raises on wrong password.""" + import msgpack + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + salt = hashlib.sha256(f"meshbay:bundle:v1:{username}".encode()).digest() + kdf = Argon2id(salt=salt, length=32, iterations=_ITERATIONS, + lanes=_LANES, memory_cost=_MEMORY_COST) + aes_key = kdf.derive(password.encode()) + + nonce, ct = bundle[:12], bundle[12:] + payload = AESGCM(aes_key).decrypt(nonce, ct, None) + data = msgpack.unpackb(payload, raw=False) + return ( + Ed25519PrivateKey.from_private_bytes(data["sk_ed"]), + X25519PrivateKey.from_private_bytes(data["sk_x"]), + ) diff --git a/packages/meshbay-common/tests/test_keyderive.py b/packages/meshbay-common/tests/test_keyderive.py new file mode 100644 index 0000000..0aa6201 --- /dev/null +++ b/packages/meshbay-common/tests/test_keyderive.py @@ -0,0 +1,57 @@ +"""Tests for password-based key derivation.""" + +import pytest +from meshbay_common.keyderive import ( + derive_keys_from_password, + encrypt_keypair_bundle, + decrypt_keypair_bundle, +) +from meshbay_common.crypto import pk_to_b64 + + +def test_deterministic(): + """Same credentials → same keys.""" + sk_ed1, sk_x1 = derive_keys_from_password("alice", "correct-horse") + sk_ed2, sk_x2 = derive_keys_from_password("alice", "correct-horse") + assert pk_to_b64(sk_ed1.public_key()) == pk_to_b64(sk_ed2.public_key()) + assert pk_to_b64(sk_x1.public_key()) == pk_to_b64(sk_x2.public_key()) + + +def test_different_users_different_keys(): + sk_ed_a, _ = derive_keys_from_password("alice", "samepassword") + sk_ed_b, _ = derive_keys_from_password("bob", "samepassword") + assert pk_to_b64(sk_ed_a.public_key()) != pk_to_b64(sk_ed_b.public_key()) + + +def test_different_passwords_different_keys(): + sk_ed1, _ = derive_keys_from_password("alice", "password1") + sk_ed2, _ = derive_keys_from_password("alice", "password2") + assert pk_to_b64(sk_ed1.public_key()) != pk_to_b64(sk_ed2.public_key()) + + +def test_ed_and_x_keys_independent(): + sk_ed, sk_x = derive_keys_from_password("user", "pass12345") + from meshbay_common.crypto import sk_to_raw + assert sk_to_raw(sk_ed) != sk_to_raw(sk_x) + + +def test_bundle_encrypt_decrypt(): + sk_ed, sk_x = derive_keys_from_password("alice", "strongpass!") + bundle = encrypt_keypair_bundle(sk_ed, sk_x, "password123", "alice") + sk_ed2, sk_x2 = decrypt_keypair_bundle(bundle, "password123", "alice") + assert pk_to_b64(sk_ed.public_key()) == pk_to_b64(sk_ed2.public_key()) + assert pk_to_b64(sk_x.public_key()) == pk_to_b64(sk_x2.public_key()) + + +def test_bundle_wrong_password_rejected(): + sk_ed, sk_x = derive_keys_from_password("alice", "correctpass") + bundle = encrypt_keypair_bundle(sk_ed, sk_x, "correctpass", "alice") + with pytest.raises(Exception): + decrypt_keypair_bundle(bundle, "wrongpass", "alice") + + +def test_bundle_wrong_username_rejected(): + sk_ed, sk_x = derive_keys_from_password("alice", "pass12345") + bundle = encrypt_keypair_bundle(sk_ed, sk_x, "pass12345", "alice") + with pytest.raises(Exception): + decrypt_keypair_bundle(bundle, "pass12345", "bob") # wrong username salt diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 0b615a4..5a7a3b4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -42,8 +42,9 @@ class RegisterRequest(BaseModel): username: str email: str password: str - pk_user_ed25519: str # base64 raw 32B - pk_user_x25519: str # base64 raw 32B + pk_user_ed25519: str # base64 raw 32B + pk_user_x25519: str # base64 raw 32B + keypair_bundle: str | None = None # AES-GCM encrypted bundle (web clients) @field_validator("username") @classmethod @@ -95,6 +96,7 @@ async def register( pk_ed25519=body.pk_user_ed25519, pk_x25519=body.pk_user_x25519, hub_id=hub_id, + keypair_bundle=body.keypair_bundle, ) db.add(user) db.add(IPLog( @@ -142,12 +144,15 @@ async def login( db.add(IPLog(user_id=user.id, event="login", ip_address=ip)) await db.commit() - return { + resp = { "access_token": access_token, "refresh_token": raw_rt, "token_type": "bearer", "expires_in": _ttl(), } + if user.keypair_bundle: + resp["keypair_bundle"] = user.keypair_bundle # encrypted, for web clients + return resp @router.post("/token/refresh") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index b76870d..2aeae41 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -44,8 +44,9 @@ class User(Base): pw_salt: Mapped[bytes] = mapped_column(nullable=False) pk_ed25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B pk_x25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B - hub_id: Mapped[str] = mapped_column(String(128), nullable=False) - status: Mapped[str] = mapped_column(String(16), default="active") # active|suspended|revoked + hub_id: Mapped[str] = mapped_column(String(128), nullable=False) + keypair_bundle: Mapped[str | None] = mapped_column(Text) # AES-GCM encrypted, web clients only + status: Mapped[str] = mapped_column(String(16), default="active") # active|suspended|revoked created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) nodes: Mapped[list["Node"]] = relationship(back_populates="user") 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 }; -- cgit v1.2.3