diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:05:16 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:05:16 +0200 |
| commit | 6b6b1a9d2febaa75da7609db671a82a458b40b4b (patch) | |
| tree | 4865497ae89be70aed4f83e81f059d2a6f56a0e3 /packages/meshbay-node | |
| parent | e3af7d272bec0bdc426bdad16ad0ad2f203e8113 (diff) | |
| download | meshbay-6b6b1a9d2febaa75da7609db671a82a458b40b4b.tar.gz | |
feat(node): add keystore module with 3 unlock modes
Argon2id + AES-256-GCM encryption at rest. Unlock via env var
(MESHBAY_UNLOCK_KEY), unlock.key file (chmod 600), or interactive
getpass. 10/10 tests passing.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/keystore.py | 225 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_keystore.py | 100 |
2 files changed, 325 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/keystore.py b/packages/meshbay-node/src/meshbay_node/keystore.py new file mode 100644 index 0000000..3777af0 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/keystore.py @@ -0,0 +1,225 @@ +""" +MeshBay Node keystore — encrypted local storage for node identity keys. + +Stores Ed25519 + X25519 keypairs and the GEK (Group Encryption Key) +encrypted at rest with AES-256-GCM, key derived via Argon2id. + +Three unlock modes (checked in order): + 1. MESHBAY_UNLOCK_KEY env var — for headless/systemd deployments + 2. unlock.key file — lazy mode, chmod 600, documented risk + 3. Interactive prompt — default secure mode + +Keystore file format (JSON): + { + "version": 1, + "argon2_salt_b64": "...", + "iv_b64": "...", + "tag_b64": "...", + "ciphertext_b64": "..." + } + +Plaintext payload (msgpack, inside the AES-256-GCM envelope): + { + "sk_ed25519_b64": "...", + "sk_x25519_b64": "...", + "gek_b64": "..." # may be absent until group is joined + } +""" + +import base64 +import getpass +import json +import logging +import os +from dataclasses import dataclass +from pathlib import Path + +import msgpack +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + +from meshbay_common.crypto import ( + decrypt_keystore, + derive_keystore_key, + encrypt_keystore, + generate_gek, + pk_to_b64, + sk_to_b64, + sk_to_raw, +) + +log = logging.getLogger(__name__) + +KEYSTORE_VERSION = 1 +DEFAULT_KEYSTORE_PATH = Path.home() / ".config" / "meshbay" / "keystore.enc" +DEFAULT_UNLOCK_FILE = Path.home() / ".config" / "meshbay" / "unlock.key" + + +@dataclass +class NodeKeys: + sk_ed25519: Ed25519PrivateKey + sk_x25519: X25519PrivateKey + gek: bytes | None = None # None until group is joined + + @property + def pk_ed25519_b64(self) -> str: + return pk_to_b64(self.sk_ed25519.public_key()) + + @property + def pk_x25519_b64(self) -> str: + return pk_to_b64(self.sk_x25519.public_key()) + + +# ── Password resolution ─────────────────────────────────────────────────────── + +def _resolve_password(unlock_file: Path | None = None) -> str: + """ + Resolve the keystore password from (in order): + 1. MESHBAY_UNLOCK_KEY environment variable + 2. unlock.key file (if it exists and chmod 600) + 3. Interactive getpass prompt + """ + # 1. Environment variable (systemd EnvironmentFile= pattern) + env_key = os.environ.get("MESHBAY_UNLOCK_KEY") + if env_key: + log.debug("Keystore password from MESHBAY_UNLOCK_KEY") + return env_key + + # 2. Unlock key file + key_file = unlock_file or DEFAULT_UNLOCK_FILE + if key_file.exists(): + mode = oct(key_file.stat().st_mode)[-3:] + if mode != "600": + log.warning( + "unlock.key permissions are %s (expected 600) — fix with: chmod 600 %s", + mode, key_file, + ) + log.debug("Keystore password from %s", key_file) + return key_file.read_text().strip() + + # 3. Interactive prompt + return getpass.getpass("MeshBay node keystore password: ") + + +# ── Keystore I/O ────────────────────────────────────────────────────────────── + +def _serialize_keys(keys: NodeKeys) -> bytes: + payload = { + "sk_ed25519_b64": sk_to_b64(keys.sk_ed25519), + "sk_x25519_b64": sk_to_b64(keys.sk_x25519), + } + if keys.gek is not None: + payload["gek_b64"] = base64.b64encode(keys.gek).decode() + return msgpack.packb(payload, use_bin_type=True) + +def _deserialize_keys(data: bytes) -> NodeKeys: + payload = msgpack.unpackb(data, raw=False) + sk_ed = Ed25519PrivateKey.from_private_bytes( + base64.b64decode(payload["sk_ed25519_b64"])) + sk_x = X25519PrivateKey.from_private_bytes( + base64.b64decode(payload["sk_x25519_b64"])) + gek = base64.b64decode(payload["gek_b64"]) if "gek_b64" in payload else None + return NodeKeys(sk_ed25519=sk_ed, sk_x25519=sk_x, gek=gek) + + +def create_keystore( + path: Path | None = None, + password: str | None = None, + unlock_file: Path | None = None, +) -> NodeKeys: + """ + Generate a new node identity, encrypt it, and save to disk. + Fails if the keystore already exists. + """ + path = path or DEFAULT_KEYSTORE_PATH + if path.exists(): + raise FileExistsError(f"Keystore already exists: {path} — use load_keystore()") + + path.parent.mkdir(parents=True, exist_ok=True) + + pwd = password or _resolve_password(unlock_file) + if len(pwd) < 8: + raise ValueError("Password must be at least 8 characters") + + keys = NodeKeys( + sk_ed25519=Ed25519PrivateKey.generate(), + sk_x25519=X25519PrivateKey.generate(), + gek=None, + ) + + _write_keystore(path, keys, pwd) + log.info("New keystore created at %s", path) + return keys + + +def load_keystore( + path: Path | None = None, + password: str | None = None, + unlock_file: Path | None = None, +) -> NodeKeys: + """Load and decrypt an existing keystore.""" + path = path or DEFAULT_KEYSTORE_PATH + if not path.exists(): + raise FileNotFoundError(f"Keystore not found: {path} — run: meshbay-node init") + + pwd = password or _resolve_password(unlock_file) + envelope = json.loads(path.read_text()) + + if envelope.get("version") != KEYSTORE_VERSION: + raise ValueError(f"Unsupported keystore version: {envelope.get('version')}") + + salt = base64.b64decode(envelope["argon2_salt_b64"]) + iv = base64.b64decode(envelope["iv_b64"]) + tag = base64.b64decode(envelope["tag_b64"]) + ct = base64.b64decode(envelope["ciphertext_b64"]) + + aes_key = derive_keystore_key(pwd, salt) + try: + plaintext = decrypt_keystore(iv, ct, tag, aes_key) + except Exception: + raise ValueError("Wrong password or corrupted keystore") + + log.info("Keystore loaded from %s", path) + return _deserialize_keys(plaintext) + + +def save_keystore( + keys: NodeKeys, + path: Path | None = None, + password: str | None = None, + unlock_file: Path | None = None, +) -> None: + """Re-encrypt and save updated keys (e.g. after GEK is set).""" + path = path or DEFAULT_KEYSTORE_PATH + pwd = password or _resolve_password(unlock_file) + _write_keystore(path, keys, pwd) + log.debug("Keystore updated at %s", path) + + +def _write_keystore(path: Path, keys: NodeKeys, password: str) -> None: + salt = os.urandom(16) + aes_key = derive_keystore_key(password, salt) + plaintext = _serialize_keys(keys) + iv, ct, tag = encrypt_keystore(plaintext, aes_key) + + envelope = { + "version": KEYSTORE_VERSION, + "argon2_salt_b64": base64.b64encode(salt).decode(), + "iv_b64": base64.b64encode(iv).decode(), + "tag_b64": base64.b64encode(tag).decode(), + "ciphertext_b64": base64.b64encode(ct).decode(), + } + path.write_text(json.dumps(envelope, indent=2)) + path.chmod(0o600) + + +def load_or_create_keystore( + path: Path | None = None, + password: str | None = None, + unlock_file: Path | None = None, +) -> NodeKeys: + """Load if exists, create if not. Convenience for daemon startup.""" + path = path or DEFAULT_KEYSTORE_PATH + if path.exists(): + return load_keystore(path, password, unlock_file) + return create_keystore(path, password, unlock_file) diff --git a/packages/meshbay-node/tests/test_keystore.py b/packages/meshbay-node/tests/test_keystore.py new file mode 100644 index 0000000..b3c2b56 --- /dev/null +++ b/packages/meshbay-node/tests/test_keystore.py @@ -0,0 +1,100 @@ +"""Tests for meshbay_node.keystore.""" + +import pytest +from pathlib import Path +from meshbay_node.keystore import ( + NodeKeys, + create_keystore, + load_keystore, + save_keystore, + load_or_create_keystore, +) +from meshbay_common.crypto import generate_gek + + +def test_create_and_load(tmp_path): + path = tmp_path / "keystore.enc" + keys = create_keystore(path=path, password="testpass99") + + assert keys.sk_ed25519 is not None + assert keys.sk_x25519 is not None + assert keys.gek is None + assert len(keys.pk_ed25519_b64) == 44 # 32 bytes → 44 base64 chars + assert len(keys.pk_x25519_b64) == 44 + assert path.exists() + assert oct(path.stat().st_mode)[-3:] == "600" + + loaded = load_keystore(path=path, password="testpass99") + assert loaded.pk_ed25519_b64 == keys.pk_ed25519_b64 + assert loaded.pk_x25519_b64 == keys.pk_x25519_b64 + assert loaded.gek is None + + +def test_wrong_password_rejected(tmp_path): + path = tmp_path / "keystore.enc" + create_keystore(path=path, password="correctpass") + with pytest.raises(ValueError, match="Wrong password"): + load_keystore(path=path, password="wrongpass") + + +def test_create_fails_if_exists(tmp_path): + path = tmp_path / "keystore.enc" + create_keystore(path=path, password="pass12345") + with pytest.raises(FileExistsError): + create_keystore(path=path, password="pass12345") + + +def test_load_fails_if_missing(tmp_path): + with pytest.raises(FileNotFoundError): + load_keystore(path=tmp_path / "nonexistent.enc", password="x") + + +def test_save_with_gek(tmp_path): + path = tmp_path / "keystore.enc" + keys = create_keystore(path=path, password="testpass99") + gek = generate_gek() + keys.gek = gek + + save_keystore(keys, path=path, password="testpass99") + + reloaded = load_keystore(path=path, password="testpass99") + assert reloaded.gek == gek + + +def test_load_or_create_creates(tmp_path): + path = tmp_path / "keystore.enc" + keys = load_or_create_keystore(path=path, password="testpass99") + assert keys.sk_ed25519 is not None + assert path.exists() + + +def test_load_or_create_loads(tmp_path): + path = tmp_path / "keystore.enc" + k1 = load_or_create_keystore(path=path, password="testpass99") + k2 = load_or_create_keystore(path=path, password="testpass99") + assert k1.pk_ed25519_b64 == k2.pk_ed25519_b64 + + +def test_keys_unique_per_creation(tmp_path): + k1 = create_keystore(path=tmp_path / "k1.enc", password="p1234567") + k2 = create_keystore(path=tmp_path / "k2.enc", password="p1234567") + assert k1.pk_ed25519_b64 != k2.pk_ed25519_b64 + + +def test_env_var_unlock(tmp_path, monkeypatch): + path = tmp_path / "keystore.enc" + create_keystore(path=path, password="envpass42") + monkeypatch.setenv("MESHBAY_UNLOCK_KEY", "envpass42") + keys = load_keystore(path=path) # no password arg + assert keys.sk_ed25519 is not None + + +def test_unlock_file(tmp_path): + path = tmp_path / "keystore.enc" + kf_path = tmp_path / "unlock.key" + kf_path.write_text("filepass42") + kf_path.chmod(0o600) + + create_keystore(path=path, password="filepass42") + keys = load_keystore(path=path, unlock_file=kf_path) # no password arg + assert keys.sk_ed25519 is not None |