diff options
Diffstat (limited to 'packages/meshbay-common/src')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/keyderive.py | 128 |
1 files changed, 128 insertions, 0 deletions
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:<username>'.""" + 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"]), + ) |