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 +++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/meshbay-common/src/meshbay_common/keyderive.py (limited to 'packages/meshbay-common/src') 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"]), + ) -- cgit v1.2.3