summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/keys.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
commit422647e7898b8df67cd11f8fb060aec23dec84aa (patch)
tree7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packages/meshbay-cli/src/meshbay_cli/client/keys.py
parent28c88908b88c58cba4de08710f6bb268ede02d57 (diff)
downloadmeshbay-422647e7898b8df67cd11f8fb060aec23dec84aa.tar.gz
feat(cli): meshbay-cli, a command-line client for members
Signs in to the hub, lists the account's groups and their folders, and downloads and uploads files directly with each group's node, end-to-end encrypted. With no argument it opens a two-pane interactive mode (Textual). Session and device keys are kept encrypted under the system's key storage, or a 0600 key file where there is none; node keys are pinned at first contact; a new machine links as a device. Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a man page generated from the parser) and frozen next to meshbay-node in the full Windows installer, not the light one, with a self-test the build runs. client/keys.py ports keyderive.js and is LGPL like it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/keys.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/keys.py137
1 files changed, 137 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/keys.py b/packages/meshbay-cli/src/meshbay_cli/client/keys.py
new file mode 100644
index 0000000..faca01e
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/keys.py
@@ -0,0 +1,137 @@
+# SPDX-License-Identifier: LGPL-3.0-or-later
+"""
+The keys a member derives from a passphrase, and the identities they open.
+
+Byte for byte what `static/keyderive.js` does (docs/MESHBAY_DESIGN.md §3.1,
+§3.7); `test_bundle_kdf_parity.py` holds this module to it. A mismatch does not
+look like an error — it looks like an account nobody can open.
+"""
+
+import base64
+import hashlib
+import json
+from dataclasses import dataclass
+
+from argon2.low_level import Type, hash_secret_raw
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
+
+PBKDF2_ITERATIONS = 600_000
+ARGON2_MEM_KIB = 131072 # 128 MB
+ARGON2_TIME = 3
+ARGON2_LANES = 1
+
+BUNDLE_MAGIC = b"MBK3"
+LEGACY_MAGIC = b"MBK2" # TRANSITIONAL — read, never written
+
+
+def b64(data: bytes) -> str:
+ return base64.b64encode(data).decode()
+
+
+def unb64(text: str) -> bytes:
+ return base64.b64decode(text)
+
+
+def hkdf(key: bytes, info: str) -> bytes:
+ return HKDF(algorithm=hashes.SHA256(), length=32, salt=None,
+ info=info.encode()).derive(key)
+
+
+def auth_key(username: str, passphrase: str) -> str:
+ """What the hub checks at sign-in. The passphrase itself never leaves here."""
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return b64(PBKDF2HMAC(algorithm=hashes.SHA512(), length=32, salt=salt,
+ iterations=PBKDF2_ITERATIONS).derive(passphrase.encode()))
+
+
+def passphrase_half(username: str, passphrase: str) -> bytes:
+ """`A`: Argon2id of the passphrase, salted by the account name."""
+ salt = hashlib.sha256(f"meshbay:bundle:v2:{username}".encode()).digest()[:16]
+ return hash_secret_raw(passphrase.encode(), salt, time_cost=ARGON2_TIME,
+ memory_cost=ARGON2_MEM_KIB, parallelism=ARGON2_LANES,
+ hash_len=32, type=Type.ID)
+
+
+def bundle_master_key(a: bytes, pepper: bytes, user_id: str) -> bytes:
+ """`M`, from `A` and the pepper the hub hands out at sign-in."""
+ return hkdf(a + pepper, f"meshbay:bundle-master:v3|{user_id}")
+
+
+def node_bundle_key(master: bytes, node_pk: str) -> bytes:
+ return hkdf(master, f"meshbay:bundle:v3|node|{node_pk}")
+
+
+class BundleError(Exception):
+ pass
+
+
+def open_bundle(bundle_b64: str, *, master: bytes | None, legacy: bytes | None,
+ user_id: str, node_pk: str) -> dict:
+ """
+ The `{skEd, skX}` sealed in a keypair bundle.
+
+ `MBK3` opens under the node's key derived from `M`, with the account and the
+ node as associated data. `MBK2` opens under `A` alone; it is read so a member
+ is not locked out of a node still holding one, and never rewritten here —
+ the application or a browser replaces it. Anything older is refused by name.
+ """
+ try:
+ raw = unb64(bundle_b64)
+ except Exception as e:
+ raise BundleError("the node's copy of your keys is unreadable") from e
+ try:
+ if raw[:4] == BUNDLE_MAGIC:
+ if master is None:
+ raise BundleError("no bundle key in this session")
+ aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode()
+ plain = AESGCM(node_bundle_key(master, node_pk)).decrypt(raw[5:17], raw[17:], aad)
+ elif raw[:4] == LEGACY_MAGIC:
+ if legacy is None:
+ raise BundleError("this node holds an older copy of your keys; sign in "
+ "again with your passphrase to open it")
+ plain = AESGCM(legacy).decrypt(raw[4:16], raw[16:], None)
+ else:
+ raise BundleError("this node holds your keys in a format no longer read; "
+ "ask its operator to unpin you and send a fresh code")
+ except BundleError:
+ raise
+ except Exception as e:
+ raise BundleError("the node's copy of your keys does not open with this "
+ "passphrase") from e
+ return json.loads(plain)
+
+
+@dataclass
+class Identity:
+ """One account's keys on one node."""
+
+ sk_ed: Ed25519PrivateKey
+ sk_x: X25519PrivateKey
+
+ @classmethod
+ def from_pkcs8(cls, keys: dict) -> "Identity":
+ sk_ed = serialization.load_der_private_key(unb64(keys["skEd"]), password=None)
+ sk_x = serialization.load_der_private_key(unb64(keys["skX"]), password=None)
+ if not isinstance(sk_ed, Ed25519PrivateKey) or not isinstance(sk_x, X25519PrivateKey):
+ raise BundleError("the node's copy of your keys holds the wrong kind of key")
+ return cls(sk_ed, sk_x)
+
+ def to_pkcs8(self) -> dict:
+ def der(k) -> str:
+ return b64(k.private_bytes(serialization.Encoding.DER,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption()))
+ return {"skEd": der(self.sk_ed), "skX": der(self.sk_x)}
+
+ @property
+ def pk_ed(self) -> str:
+ return b64(self.sk_ed.public_key().public_bytes_raw())
+
+ @property
+ def pk_x(self) -> str:
+ return b64(self.sk_x.public_key().public_bytes_raw())