1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
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"]),
)
|