diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-14 22:20:15 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-14 22:20:15 +0200 |
| commit | 422647e7898b8df67cd11f8fb060aec23dec84aa (patch) | |
| tree | 7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packages/meshbay-cli/src/meshbay_cli/client/store.py | |
| parent | 28c88908b88c58cba4de08710f6bb268ede02d57 (diff) | |
| download | meshbay-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.py | 251 |
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 = {}, {} |