aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/keyderive.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 14:50:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 14:50:22 +0200
commitaed220d9f0bab42efd57b56851319e840ab8ae26 (patch)
treee8b72fbe9016635438e6b7046a35e47ec3dbe93a /packages/meshbay-common/src/meshbay_common/keyderive.py
parent608d3a705d065d6b378f4889322ff9d1bc41d147 (diff)
downloadmeshbay-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-common/src/meshbay_common/keyderive.py')
-rw-r--r--packages/meshbay-common/src/meshbay_common/keyderive.py128
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"]),
+ )