""" 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 import msgpack from cryptography.hazmat.primitives.ciphers.aead import AESGCM from meshbay_common.crypto import sk_to_raw # 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"]), )