aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-node/src/meshbay_node/keystore.py225
-rw-r--r--packages/meshbay-node/tests/test_keystore.py100
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