summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/store.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/store.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/store.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/store.py251
1 files changed, 251 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/store.py b/packages/meshbay-cli/src/meshbay_cli/client/store.py
new file mode 100644
index 0000000..c48fe89
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/store.py
@@ -0,0 +1,251 @@
+"""
+What the CLI remembers between runs.
+
+Two files in the data directory. `state.json` holds what is not secret: the hub,
+the account, the device's id, each node's pinned key. `secrets.enc` holds the
+rest — tokens, the device's hub key, the bundle key `M`, the identity on each
+node — under AES-256-GCM with a 32-byte **store key**.
+
+The store key lives in the OS key storage (Windows Credential Manager, Secret
+Service on a Linux desktop). One key there and a file beside it, rather than
+every secret there: Credential Manager caps a credential at 2560 bytes, and the
+identities grow with the number of nodes. Where there is no key storage — an
+SSH session on a server — the key is kept in a file only this user can read,
+and `login` says so once (`file_key_notice`).
+
+Every write goes through `locked()`: the hub rotates refresh tokens and revokes
+the whole session when a spent one comes back, so two runs renewing at once
+must take turns.
+"""
+
+import contextlib
+import hashlib
+import json
+import logging
+import os
+import secrets
+import sys
+import time
+from collections.abc import Iterator
+from pathlib import Path
+
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+
+from ..errors import AUTH, CliError
+
+log = logging.getLogger(__name__)
+
+KEYRING_SERVICE = "meshbay-cli"
+SECRETS_MAGIC = b"MBC1"
+SECRETS_AAD = b"meshbay-cli:secrets:v1"
+
+KEY_OS = "os"
+KEY_FILE = "file"
+
+FILE_KEY_NOTICE = (
+ "No OS key storage here: the session is kept in a file only your user can "
+ "read (like an SSH key without a passphrase). `meshbay-cli logout` removes it."
+)
+
+
+def data_dir() -> Path:
+ """Never the node's directories (`~/.config/meshbay`, `%LOCALAPPDATA%\\meshbay`)."""
+ override = os.environ.get("MESHBAY_CLI_HOME")
+ if override:
+ return Path(override)
+ if sys.platform == "win32":
+ return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") \
+ / "meshbay-cli"
+ base = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
+ return Path(base) / "meshbay-cli"
+
+
+def _write_private(path: Path, data: bytes) -> None:
+ """Atomically, readable by this user only."""
+ tmp = path.with_name(path.name + ".tmp")
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ try:
+ os.write(fd, data)
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+ os.replace(tmp, path)
+
+
+class _Keyring:
+ """
+ The OS key storage. One entry per store directory, so two stores (another
+ `MESHBAY_CLI_HOME`) never overwrite each other's key.
+ """
+
+ def __init__(self, root: Path):
+ digest = hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:16]
+ self.entry = f"store-key:{digest}"
+
+ def usable(self) -> bool:
+ try:
+ import keyring
+ from keyring.backends import fail
+ backend = keyring.get_keyring()
+ if isinstance(backend, fail.Keyring):
+ return False
+ # A Secret Service that is installed but not running answers the
+ # first call with an exception rather than at selection time.
+ keyring.get_password(KEYRING_SERVICE, self.entry)
+ return True
+ except Exception as e:
+ log.debug("OS key storage unavailable: %s", e)
+ return False
+
+ def get(self) -> bytes | None:
+ import keyring
+ value = keyring.get_password(KEYRING_SERVICE, self.entry)
+ return bytes.fromhex(value) if value else None
+
+ def set(self, key: bytes) -> None:
+ import keyring
+ keyring.set_password(KEYRING_SERVICE, self.entry, key.hex())
+
+ def delete(self) -> None:
+ import keyring
+ with contextlib.suppress(Exception):
+ keyring.delete_password(KEYRING_SERVICE, self.entry)
+
+
+class Store:
+ def __init__(self, root: Path | None = None):
+ self.root = root or data_dir()
+ self._keyring = _Keyring(self.root)
+ self.state: dict = {}
+ self.secrets: dict = {}
+
+ @property
+ def _state_path(self) -> Path:
+ return self.root / "state.json"
+
+ @property
+ def _secrets_path(self) -> Path:
+ return self.root / "secrets.enc"
+
+ @property
+ def _key_path(self) -> Path:
+ return self.root / "store.key"
+
+ def exists(self) -> bool:
+ return self._state_path.exists() and self._secrets_path.exists()
+
+ # ── Locking ──────────────────────────────────────────────────────────────
+
+ @contextlib.contextmanager
+ def locked(self) -> Iterator[None]:
+ """An exclusive lock on the store, across processes. Blocks."""
+ self.root.mkdir(parents=True, exist_ok=True)
+ fd = os.open(self.root / "store.lock", os.O_RDWR | os.O_CREAT, 0o600)
+ try:
+ if sys.platform == "win32":
+ import msvcrt
+ while True:
+ try:
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
+ break
+ except OSError:
+ time.sleep(0.1)
+ else:
+ import fcntl
+ fcntl.flock(fd, fcntl.LOCK_EX)
+ yield
+ finally:
+ if sys.platform == "win32":
+ import msvcrt
+ with contextlib.suppress(OSError):
+ os.lseek(fd, 0, os.SEEK_SET)
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
+ os.close(fd)
+
+ # ── The store key ────────────────────────────────────────────────────────
+
+ def _load_key(self) -> bytes:
+ kind = self.state.get("key_store")
+ if kind == KEY_FILE:
+ try:
+ return bytes.fromhex(self._key_path.read_text().strip())
+ except OSError as e:
+ raise CliError(AUTH, f"The session's key file is missing ({e.strerror}); "
+ "run `meshbay-cli login`.") from e
+ if not self._keyring.usable():
+ raise CliError(AUTH, "The session was saved in the OS key storage, which this "
+ "session cannot reach. Run `meshbay-cli login` here, or "
+ "pass --password.")
+ key = self._keyring.get()
+ if key is None:
+ raise CliError(AUTH, "The session's key is gone from the OS key storage; "
+ "run `meshbay-cli login`.")
+ return key
+
+ def _new_key(self) -> tuple[bytes, str]:
+ key = secrets.token_bytes(32)
+ if self._keyring.usable():
+ try:
+ self._keyring.set(key)
+ return key, KEY_OS
+ except Exception as e:
+ log.debug("OS key storage refused the key: %s", e)
+ _write_private(self._key_path, key.hex().encode())
+ return key, KEY_FILE
+
+ # ── Reading and writing ──────────────────────────────────────────────────
+
+ def peek(self) -> dict:
+ """state.json alone — the hub and the account, which are not secret."""
+ try:
+ return json.loads(self._state_path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return {}
+
+ def load(self) -> bool:
+ """Read both files. False when nothing is stored."""
+ if not self.exists():
+ self.state, self.secrets = {}, {}
+ return False
+ self.state = json.loads(self._state_path.read_text(encoding="utf-8"))
+ raw = self._secrets_path.read_bytes()
+ if raw[:4] != SECRETS_MAGIC:
+ raise CliError(AUTH, "The saved session is unreadable; run `meshbay-cli login`.")
+ try:
+ plain = AESGCM(self._load_key()).decrypt(raw[4:16], raw[16:], SECRETS_AAD)
+ except CliError:
+ raise
+ except Exception as e:
+ raise CliError(AUTH, "The saved session does not open with its key; run "
+ "`meshbay-cli login`.") from e
+ self.secrets = json.loads(plain)
+ return True
+
+ def save(self, *, new: bool = False) -> None:
+ """Write both files. `new` makes a fresh store key (a new sign-in)."""
+ self.root.mkdir(parents=True, exist_ok=True)
+ if new or "key_store" not in self.state:
+ self._forget_key()
+ key, kind = self._new_key()
+ self.state["key_store"] = kind
+ else:
+ key = self._load_key()
+ nonce = secrets.token_bytes(12)
+ ct = AESGCM(key).encrypt(nonce, json.dumps(self.secrets).encode(), SECRETS_AAD)
+ _write_private(self._secrets_path, SECRETS_MAGIC + nonce + ct)
+ _write_private(self._state_path, json.dumps(self.state, indent=1).encode())
+
+ def _forget_key(self) -> None:
+ kind = self.state.get("key_store")
+ if kind == KEY_OS:
+ self._keyring.delete()
+ with contextlib.suppress(FileNotFoundError):
+ self._key_path.unlink()
+
+ def erase(self) -> None:
+ """Everything, key included."""
+ self._forget_key()
+ for p in (self._secrets_path, self._state_path):
+ with contextlib.suppress(FileNotFoundError):
+ p.unlink()
+ self.state, self.secrets = {}, {}