aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/keyderive.py
blob: 497f8773ed2a64cef55eaeaefe8f3432f6ed91ea (plain) (blame)
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"]),
    )