diff --git a/.env.example b/.env.example index 9282b62..5fcd90b 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,12 @@ UPSTREAM_MODELS_PATH=/providers-models/openrouter.json NODE_A_ADMIN_PASSWORD=test-admin-pw NODE_B_ADMIN_PASSWORD=test-admin-pw +# Fernet key each node uses to encrypt secrets at rest (routstr-core #553). A +# committed default is baked into compose.yml; override per node here if desired. +# Must be a urlsafe-base64 32-byte key (python: Fernet.generate_key()). +NODE_A_ROUTSTR_SECRET_KEY=W5PvCGEnbMTde00OFubyfhPPO2-f6aQP5ullyqoBfRQ= +NODE_B_ROUTSTR_SECRET_KEY=W5PvCGEnbMTde00OFubyfhPPO2-f6aQP5ullyqoBfRQ= + # Paid-scenario auth (per run, not stored): # NODE_A_API_KEY — funded node api-key/cashu token for `real_inference` # X_CASHU_TOKENS — comma-separated funded tokens for `xcashu` diff --git a/README.md b/README.md index e846a30..e810289 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Set in `.env` (all gitignored): - `CASHU_MINTS` — the mint the token is from (e.g. `https://mint.chorus.community`). Both nodes must trust it. - `OPENROUTER_API_KEY` (+ optional `OPENROUTER_REFERER`) — to point the nodes at a real upstream. The node auto-seeds an `openrouter` provider at startup when this is present. - `NODE_A_ADMIN_PASSWORD` / `NODE_B_ADMIN_PASSWORD` — admin password per node (default `test-admin-pw`), needed for `routstr-cli` config. +- `NODE_A_ROUTSTR_SECRET_KEY` / `NODE_B_ROUTSTR_SECRET_KEY` — Fernet key each node uses to encrypt secrets at rest (routstr-core #553). A committed default is baked into `compose.yml`; override only if you want a per-run key (must be a urlsafe-base64 32-byte key from `Fernet.generate_key()`). ### 3. Start services diff --git a/compose.yml b/compose.yml index 694bd59..4475cd3 100644 --- a/compose.yml +++ b/compose.yml @@ -155,6 +155,11 @@ services: - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - OPENROUTER_REFERER=${OPENROUTER_REFERER:-https://routstr-testing.local} - ADMIN_PASSWORD=${NODE_A_ADMIN_PASSWORD:-test-admin-pw} + # Mandatory for #553 secret-at-rest: the node refuses to boot without it and + # uses it to Fernet-encrypt the stored nsec. A fixed key keeps the encrypted + # blob readable across restarts within the volume's lifetime. Older nodes that + # don't read this var simply ignore it. + - ROUTSTR_SECRET_KEY=${NODE_A_ROUTSTR_SECRET_KEY:-W5PvCGEnbMTde00OFubyfhPPO2-f6aQP5ullyqoBfRQ=} - NAME=NodeA - DESCRIPTION=Test node A - NPUB=${NODE_A_NPUB:-npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d} @@ -204,6 +209,8 @@ services: - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - OPENROUTER_REFERER=${OPENROUTER_REFERER:-https://routstr-testing.local} - ADMIN_PASSWORD=${NODE_B_ADMIN_PASSWORD:-test-admin-pw} + # See node-a: mandatory for #553 secret-at-rest, ignored by older nodes. + - ROUTSTR_SECRET_KEY=${NODE_B_ROUTSTR_SECRET_KEY:-W5PvCGEnbMTde00OFubyfhPPO2-f6aQP5ullyqoBfRQ=} - NAME=NodeB - DESCRIPTION=Test node B - NPUB=${NODE_B_NPUB:-npub1ccz8l9zpa47k6vz9gphftsrumpw80rjt3nhnefat4symjhrsnmjs38mnyd} diff --git a/scenarios/secret_lifecycle.yaml b/scenarios/secret_lifecycle.yaml new file mode 100644 index 0000000..f0f92b9 --- /dev/null +++ b/scenarios/secret_lifecycle.yaml @@ -0,0 +1,32 @@ +id: secret_lifecycle +name: Config-ownership secret lifecycle (boot-time) +description: > + Whole-system boot-time secret behaviours for routstr-core #553 that a single + long-lived node cannot show. Each test boots a throwaway node (via docker run, + on the compose network, against the built node image) with tailored env: a node + with a Nostr identity refuses to start without ROUTSTR_SECRET_KEY (and prints + the gen command); a fresh node generates + logs a first-run admin password that + then logs in; changing ROUTSTR_SECRET_KEY under an encrypted nsec bricks the + node; and an encrypted nsec survives a later boot after NSEC has left the env. + RED against a pre-#553 node (ignores the key, no generated-password log, no + brick); GREEN once secrets move into the Fernet/scrypt Secret store. No Cashu, + no real sats. + +# Bring up the stack so the node IMAGE is built and relay/mock-openai/primary-mint +# are reachable by the ephemeral nodes; a missing service then fails the run +# instead of silently skipping (SERVICES_REQUIRED=1). +services_required: true +target_profile: local +upstream_profile: mock + +selection: + paths: + - tests/integration/test_secret_lifecycle.py + markers: + - destructive + +expected_cost_sats: 0 +estimated_upstream_cost_usd: 0.0 +# Each test boots one or two throwaway nodes (~20s to healthy each), so the suite +# runs several sequential container boots. +timeout_seconds: 600 diff --git a/scenarios/secrets_at_rest.yaml b/scenarios/secrets_at_rest.yaml new file mode 100644 index 0000000..5c06f61 --- /dev/null +++ b/scenarios/secrets_at_rest.yaml @@ -0,0 +1,30 @@ +id: secrets_at_rest +name: Config-ownership secret storage at rest +description: > + Whole-system check for routstr-core #553 (config ownership / secret storage). + A real node is booted with the admin password and Nostr identity supplied the + way operators supply them — plain container env (ADMIN_PASSWORD, NSEC) — plus a + mandatory ROUTSTR_SECRET_KEY. The scenario asserts those secrets are migrated + into the encrypted Secret store at boot rather than persisted in the plaintext + settings blob: the env password still logs in, admin_password is no longer an + editable settings field, the node exposes a dedicated PATCH /admin/api/nsec + rotation endpoint, and neither the plaintext password nor the bech32 nsec + survives in the node's on-disk SQLite database. RED against a pre-#553 node that + keeps secrets in the settings blob; GREEN once they move into the + Fernet/scrypt-backed Secret store. No Cashu, no real sats. + +# Orchestrator provisions the stack; a missing service then fails the run instead +# of silently skipping (SERVICES_REQUIRED=1). +services_required: true +target_profile: local +upstream_profile: mock + +selection: + paths: + - tests/integration/test_secrets_at_rest.py + markers: + - destructive + +expected_cost_sats: 0 +estimated_upstream_cost_usd: 0.0 +timeout_seconds: 180 diff --git a/tests/integration/node_boot.py b/tests/integration/node_boot.py new file mode 100644 index 0000000..7890595 --- /dev/null +++ b/tests/integration/node_boot.py @@ -0,0 +1,329 @@ +"""Boot throwaway routstr-core nodes for whole-system secret-lifecycle e2e (#553). + +The standing compose ``node-a`` is one long-lived container with every secret +pre-supplied, so it cannot exercise the BOOT-TIME secret behaviours #553 adds: +refusing to start without ``ROUTSTR_SECRET_KEY``, generating + logging a +first-run admin password, bricking on a key change, and recovering an encrypted +nsec on a later boot after ``NSEC`` has left the env. Those need ephemeral nodes +booted with tailored env — two of them across a *pair* of boots sharing one +volume. + +This module drives the *already-built* node image (the orchestrator builds it for +the standing stack) with ``docker run``, attached to the compose network so the +node reaches ``relay`` / ``mock-openai`` / the mints by service name, writing to a +throwaway volume that is removed afterwards. Nothing here touches the standing +``node-a`` container or its volume. +""" +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +import time +import uuid +from contextlib import contextmanager +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Iterator + +import httpx + +from tests.integration.targets import is_remote, unavailable + +_REPO_ROOT = Path(__file__).resolve().parents[2] + +# Every ephemeral container carries this label so a hard-killed run's leftovers can +# be found and reaped without ever touching the compose stack. +_LIFECYCLE_LABEL = "routstr-lifecycle-e2e" + +# The in-container SQLite path the node writes to (see base_node_env's DATABASE_URL). +# Single source of truth so the boot env and copy_db can't drift apart. +_DB_PATH = "/data/node.db" + +# The Fernet key compose.yml bakes into node-a as its default. Tests that boot an +# ephemeral node with an encrypted secret must use this exact key so the blob is +# readable the same way the standing node reads it. +COMPOSE_SECRET_KEY = "W5PvCGEnbMTde00OFubyfhPPO2-f6aQP5ullyqoBfRQ=" + + +def require_local_docker() -> None: + """Skip/fail unless we can boot local containers (see targets.unavailable).""" + if is_remote(): + unavailable( + "secret-lifecycle e2e boots throwaway local containers; " + "it does not apply to a remote target" + ) + if shutil.which("docker") is None: + unavailable("docker CLI required to boot ephemeral nodes") + _reap_stale_lifecycle_nodes_once() + + +@lru_cache(maxsize=1) +def _reap_stale_lifecycle_nodes_once() -> None: + """Remove ephemeral nodes left behind by a previously hard-killed run. + + Serving nodes now publish to an ephemeral host port (no fixed-port wedge), so a + leaked container can't block a fresh run; this just keeps dead containers from + piling up. Scoped to our label, so it never removes a compose service. Runs at + most once per process (``lru_cache``, matching ``_project_name``). + """ + proc = subprocess.run( + ["docker", "ps", "-aq", "--filter", f"label={_LIFECYCLE_LABEL}"], + capture_output=True, + text=True, + ) + ids = proc.stdout.split() + if ids: + subprocess.run(["docker", "rm", "-f", *ids], capture_output=True, text=True) + + +def _compose(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["docker", "compose", *args], + cwd=str(_REPO_ROOT), + capture_output=True, + text=True, + ) + + +@lru_cache(maxsize=1) +def _project_name() -> str: + proc = _compose("config", "--format", "json") + if proc.returncode != 0: + unavailable( + f"could not read compose config: {(proc.stderr or proc.stdout)[:200]}" + ) + try: + return str(json.loads(proc.stdout)["name"]) + except (ValueError, KeyError) as exc: # pragma: no cover - defensive + unavailable(f"could not parse compose project name: {exc}") + + +def node_image() -> str: + """The image compose built for ``node-a`` (``-node-a`` by convention).""" + return f"{_project_name()}-node-a" + + +def compose_network() -> str: + """The default network compose created (``_default``).""" + return f"{_project_name()}_default" + + +def base_node_env() -> dict[str, str]: + """Minimal env to boot a node, mirroring the compose ``node-a`` essentials. + + Deliberately omits ``ROUTSTR_SECRET_KEY``, ``ADMIN_PASSWORD``, ``NSEC`` and + ``NPUB`` — each test layers in exactly the secret env it is exercising (and + proves the *absence* of one by leaving it out). ``DATABASE_URL`` points at the + mounted ``/data`` volume so state persists across a paired reboot. + + ``HTTP_URL`` is left at the node's ``http://localhost:8000`` sentinel on + purpose: a node treats that value as "no real endpoint" and skips the Nostr + listing publish entirely, so an ephemeral node carrying an nsec never announces + onto the shared ``relay`` (whose named volume would otherwise retain the dead + "LifecycleNode" listing for discovery scenarios on a reused stack). Clearing + ``RELAYS`` would be worse — an empty relay list falls back to *public* relays. + """ + return { + "DATABASE_URL": f"sqlite+aiosqlite:///{_DB_PATH}", + "RELAYS": "ws://relay:8080", + "UPSTREAM_BASE_URL": "http://mock-openai:3000", + "UPSTREAM_API_KEY": "test-key", + "CASHU_MINTS": "http://primary-mint:3338", + "NAME": "LifecycleNode", + "DESCRIPTION": "secret-lifecycle e2e node", + "HTTP_URL": "http://localhost:8000", + "CORS_ORIGINS": "*", + "FIXED_COST_PER_REQUEST": "1", + "FIXED_PER_1K_INPUT_TOKENS": "10", + "FIXED_PER_1K_OUTPUT_TOKENS": "30", + "FIXED_PRICING": "false", + } + + +@dataclass +class BootResult: + """Outcome of booting a node until it either exits or comes up serving.""" + + exited: bool + exit_code: int | None + logs: str + + +@dataclass +class ServingNode: + cid: str + base_url: str + + def logs(self) -> str: + return _logs(self.cid) + + +def _run_args(env: dict[str, str], *, volume: str, publish: bool) -> list[str]: + args = [ + "docker", "run", "-d", + "--label", _LIFECYCLE_LABEL, + "--network", compose_network(), + "-v", f"{volume}:/data", + ] + if publish: + # Publish to an ephemeral host port (``0`` = let docker pick a free one) + # rather than a fixed port, so a leaked container from a hard-killed run + # can never wedge later runs by holding the port. + args += ["-p", "0:8000"] + for key, value in env.items(): + args += ["-e", f"{key}={value}"] + args.append(node_image()) + return args + + +def _docker_run(env: dict[str, str], *, volume: str, publish: bool) -> str: + proc = subprocess.run( + _run_args(env, volume=volume, publish=publish), + cwd=str(_REPO_ROOT), + capture_output=True, + text=True, + ) + if proc.returncode != 0: + unavailable( + f"failed to start ephemeral node: {(proc.stderr or proc.stdout)[:300]}" + ) + return proc.stdout.strip() + + +def _published_host_port(cid: str) -> int: + """Read back the ephemeral host port docker assigned to container port 8000.""" + proc = subprocess.run( + ["docker", "port", cid, "8000"], capture_output=True, text=True + ) + for line in proc.stdout.splitlines(): + _, _, port = line.strip().rpartition(":") + if port.isdigit(): + return int(port) + unavailable( + "could not determine the ephemeral node's published port: " + f"{(proc.stdout or proc.stderr)[:200]}" + ) + + +def copy_db(cid: str) -> bytes: + """Copy an ephemeral node's SQLite state off the container for at-rest inspection. + + Reads ``_DB_PATH`` (the same path ``base_node_env``'s DATABASE_URL points at) plus + its ``-wal`` sidecar, and returns the concatenated bytes. The node runs SQLite in WAL + mode, so a freshly committed row lives in ``node.db-wal`` until it is checkpointed + into ``node.db``; copying only the main file would miss recent writes and report a + misleadingly clean database. The ``-wal`` may be absent (already checkpointed) — + that's fine, whatever is present is inspected. Must be called while ``cid`` is + still alive (i.e. inside the ``serving_node`` block). + """ + blobs: list[bytes] = [] + with tempfile.TemporaryDirectory() as tmp: + for suffix, required in (("", True), ("-wal", False)): + dest = Path(tmp) / f"node.db{suffix}" + proc = subprocess.run( + ["docker", "cp", f"{cid}:{_DB_PATH}{suffix}", str(dest)], + capture_output=True, + text=True, + ) + if proc.returncode != 0 or not dest.exists(): + if required: + unavailable( + "could not copy ephemeral node DB for at-rest inspection: " + f"{(proc.stderr or proc.stdout)[:300]}" + ) + continue + blobs.append(dest.read_bytes()) + return b"".join(blobs) + + +def _logs(cid: str) -> str: + proc = subprocess.run(["docker", "logs", cid], capture_output=True, text=True) + return proc.stdout + proc.stderr + + +def _state(cid: str) -> tuple[str, int]: + proc = subprocess.run( + ["docker", "inspect", "-f", "{{.State.Status}} {{.State.ExitCode}}", cid], + capture_output=True, + text=True, + ) + status, _, code = proc.stdout.strip().partition(" ") + return status or "unknown", int(code) if code.strip() else 0 + + +def _rm(cid: str) -> None: + subprocess.run(["docker", "rm", "-f", cid], capture_output=True, text=True) + + +@contextmanager +def throwaway_volume() -> Iterator[str]: + """A named docker volume, removed on exit. Survives container removal so a + paired reboot sees the first boot's on-disk state.""" + name = f"routstr-lifecycle-{uuid.uuid4().hex[:12]}" + subprocess.run(["docker", "volume", "create", name], capture_output=True, text=True) + try: + yield name + finally: + subprocess.run( + ["docker", "volume", "rm", "-f", name], capture_output=True, text=True + ) + + +def boot_until_settled( + env: dict[str, str], *, volume: str, timeout: int = 60 +) -> BootResult: + """Boot a node and report whether it fails fast (exits) or stays up. + + Returns as soon as the container exits (``exited=True`` with its exit code). + If it is still running after ``timeout`` it booted successfully + (``exited=False``) — that is the signal a fail-fast did *not* happen. Either + way the container is removed; the volume is the caller's to manage. + """ + cid = _docker_run(env, volume=volume, publish=False) + try: + deadline = time.time() + timeout + while time.time() < deadline: + status, code = _state(cid) + if status == "exited": + return BootResult(exited=True, exit_code=code, logs=_logs(cid)) + time.sleep(1) + return BootResult(exited=False, exit_code=None, logs=_logs(cid)) + finally: + _rm(cid) + + +@contextmanager +def serving_node( + env: dict[str, str], *, volume: str, timeout: int = 90 +) -> Iterator[ServingNode]: + """Boot a node, wait until it serves ``/v1/info``, yield a handle, then tear it + down. Fails (or skips, ad hoc) if it exits during boot or never comes up.""" + cid = _docker_run(env, volume=volume, publish=True) + base = f"http://localhost:{_published_host_port(cid)}" + try: + deadline = time.time() + timeout + last_err = "" + while time.time() < deadline: + status, code = _state(cid) + if status == "exited": + unavailable( + f"ephemeral node exited during boot (code {code}):\n" + f"{_logs(cid)[-1000:]}" + ) + try: + resp = httpx.get(f"{base}/v1/info", timeout=3) + if resp.status_code < 500: + yield ServingNode(cid=cid, base_url=base) + return + except httpx.HTTPError as exc: + last_err = str(exc) + time.sleep(1) + unavailable( + f"ephemeral node never became reachable at {base} ({last_err})\n" + f"{_logs(cid)[-1000:]}" + ) + finally: + _rm(cid) diff --git a/tests/integration/targets.py b/tests/integration/targets.py index c5a98e2..18535ce 100644 --- a/tests/integration/targets.py +++ b/tests/integration/targets.py @@ -86,14 +86,29 @@ def admin_token(i: int = 0) -> str | None: return env_tok if is_remote(): return None # no token supplied for this remote node + return mint_admin_token(node_api_url(i), ADMIN_PASSWORD) + + +def mint_admin_token(base_url: str, password: str) -> str | None: + """Log in at ``base_url`` and return an admin token, or None on any failure. + + The single home for the ``POST /admin/api/login`` contract (payload shape, + timeout, token extraction, error swallowing) — shared by the standing-node + ``admin_token`` above and the ephemeral-node boot harness in ``node_boot``. + """ try: - with httpx.Client(base_url=node_api_url(i), timeout=10) as c: - r = c.post("/admin/api/login", json={"password": ADMIN_PASSWORD}) + with httpx.Client(base_url=base_url, timeout=10) as c: + r = c.post("/admin/api/login", json={"password": password}) return r.json().get("token") if r.status_code == 200 else None except (httpx.HTTPError, ValueError): return None +def bearer_headers(token: str) -> dict[str, str]: + """Authorization header for an admin token minted by ``mint_admin_token``.""" + return {"Authorization": f"Bearer {token}"} + + def node_reachable(i: int = 0) -> bool: try: return httpx.get(f"{node_api_url(i)}/v1/info", timeout=5).status_code < 500 diff --git a/tests/integration/test_secret_lifecycle.py b/tests/integration/test_secret_lifecycle.py new file mode 100644 index 0000000..627312a --- /dev/null +++ b/tests/integration/test_secret_lifecycle.py @@ -0,0 +1,185 @@ +"""Boot-time secret lifecycle for routstr-core #553 (config ownership). + +Whole-system proof of the secret behaviours that only show up across a real +container *boot* — the things the standing single-boot stack (and the in-repo +unit/integration suite) structurally cannot exercise: + + * a node carrying a Nostr identity refuses to start without + ``ROUTSTR_SECRET_KEY`` and prints the key-generation command (issue step 2); + * a fresh node with no admin password GENERATES one, hashes it into the Secret + store, and logs it once with the admin URL — and that logged password logs in + (issue step 4 / bootstrap branch 3); + * changing ``ROUTSTR_SECRET_KEY`` under an already-encrypted nsec BRICKS the + node (fail fast) rather than silently losing the identity — #553 has no key + rotation, only detection; + * an nsec encrypted into the Secret store SURVIVES a later boot once ``NSEC`` + has left the env (the #553 upgrade path), instead of being clobbered back to + empty (bootstrap branch 1 + the strip-on-write regression guard). + +These each boot throwaway nodes with tailored env via ``node_boot`` (the standing +node-a can't be reconfigured per-test). Marked ``destructive`` so they auto-skip +under TARGET_PROFILE=remote — they need local docker, not a deployed node. +""" +from __future__ import annotations + +import re + +import httpx +import pytest + +from tests.integration import node_boot +from tests.integration.targets import bearer_headers, mint_admin_token + +pytestmark = pytest.mark.destructive + +# A second, unmistakably different valid Fernet key (urlsafe-b64 of 32 zero bytes), +# used to prove a key change bricks the node (vs node_boot.COMPOSE_SECRET_KEY). +FERNET_KEY_2 = "A" * 43 + "=" +# A valid 64-char hex private key the node's nsec parser accepts like a bech32 nsec. +SEED_NSEC = "1" * 64 +ADMIN_PW = "lifecycle-admin-pw" + +# The first-run log line bootstrap_secrets emits: "...shown only now): \nLog in at /admin..." +_GENERATED_PW = re.compile(r"shown only now\):\s*(\S+)") + + +@pytest.fixture(autouse=True) +def _local_docker() -> None: + node_boot.require_local_docker() + + +def test_node_refuses_to_boot_without_secret_key() -> None: + """A node with a Nostr identity must fail fast when ROUTSTR_SECRET_KEY is unset. + + ``ROUTSTR_SECRET_KEY`` is required to encrypt the nsec at rest; #553 makes its + absence a hard boot failure (no "secrets disabled" fallback), and the error + must hand the operator the generation command. Pre-#553 the node ignores the + var and boots anyway — that is the RED this discriminates. + """ + env = {**node_boot.base_node_env(), "NSEC": SEED_NSEC, "ADMIN_PASSWORD": ADMIN_PW} + # No ROUTSTR_SECRET_KEY on purpose. + with node_boot.throwaway_volume() as vol: + result = node_boot.boot_until_settled(env, volume=vol, timeout=50) + + assert result.exited and result.exit_code != 0, ( + "node booted without ROUTSTR_SECRET_KEY — #553 requires it to fail fast.\n" + f"{result.logs[-1200:]}" + ) + assert "ROUTSTR_SECRET_KEY" in result.logs, ( + f"fail-fast must name the missing key:\n{result.logs[-1200:]}" + ) + assert "Fernet.generate_key" in result.logs, ( + f"fail-fast must print the key-generation command:\n{result.logs[-1200:]}" + ) + + +def test_first_run_generates_and_logs_admin_password() -> None: + """A fresh node with no admin password generates, logs, and accepts one. + + Bootstrap branch 3: with no ADMIN_PASSWORD anywhere, the node mints a random + password, hashes it into the Secret store, and logs it once with the admin + URL (the first-run UX — there is no setup screen). The logged password must + actually authenticate. Pre-#553 there is no such generated-password log. + """ + env = {**node_boot.base_node_env(), "ROUTSTR_SECRET_KEY": node_boot.COMPOSE_SECRET_KEY} + # No ADMIN_PASSWORD, no NSEC. + with node_boot.throwaway_volume() as vol, node_boot.serving_node( + env, volume=vol + ) as node: + logs = node.logs() + match = _GENERATED_PW.search(logs) + assert match, ( + "first boot must generate + log an admin password:\n" + f"{logs[-1500:]}" + ) + generated = match.group(1) + assert "/admin" in logs, ( + f"the first-run log must point the operator at the admin URL:\n{logs[-1500:]}" + ) + token = mint_admin_token(node.base_url, generated) + assert token, "the generated admin password must actually log in" + + +def test_key_change_bricks_encrypted_nsec() -> None: + """Changing ROUTSTR_SECRET_KEY under an encrypted nsec bricks the node. + + #553 ships no key rotation (single Fernet key, not MultiFernet); a changed key + must be DETECTED and fail fast, never silently boot with a dead identity. Boot + once to encrypt the nsec, then reboot the same volume under a different key. + """ + base = node_boot.base_node_env() + with node_boot.throwaway_volume() as vol: + # Boot 1: store the encrypted nsec under the compose default key. + with node_boot.serving_node( + {**base, "ROUTSTR_SECRET_KEY": node_boot.COMPOSE_SECRET_KEY, "NSEC": SEED_NSEC, + "ADMIN_PASSWORD": ADMIN_PW}, + volume=vol, + ): + pass + + # Boot 2: same volume, only the key changed. + result = node_boot.boot_until_settled( + {**base, "ROUTSTR_SECRET_KEY": FERNET_KEY_2, "NSEC": SEED_NSEC, + "ADMIN_PASSWORD": ADMIN_PW}, + volume=vol, + timeout=50, + ) + + assert result.exited and result.exit_code != 0, ( + "a changed ROUTSTR_SECRET_KEY must brick the node (fail fast), not boot " + f"silently with an undecryptable nsec.\n{result.logs[-1200:]}" + ) + assert "Stored nsec cannot be decrypted" in result.logs, ( + f"the brick must explain the key mismatch:\n{result.logs[-1200:]}" + ) + + +def test_encrypted_nsec_survives_second_boot_without_env_nsec() -> None: + """An nsec encrypted at boot 1 survives boot 2 once NSEC has left the env. + + The #553 upgrade path: an operator drops the legacy ``NSEC`` from ``.env`` + after first boot has migrated it into the encrypted Secret store. The node + must decrypt it back into memory on the next boot — not clobber the identity + to empty while re-deriving settings from the (now nsec-less) env+blob. Proven + black-box: ``GET /admin/api/settings`` reports ``nsec: "[REDACTED]"`` while a + key is held and the npub stays stable across the env-less reboot. + """ + base = node_boot.base_node_env() + with node_boot.throwaway_volume() as vol: + # Boot 1: NSEC supplied via env -> encrypted into the Secret store. + with node_boot.serving_node( + {**base, "ROUTSTR_SECRET_KEY": node_boot.COMPOSE_SECRET_KEY, "NSEC": SEED_NSEC, + "ADMIN_PASSWORD": ADMIN_PW}, + volume=vol, + ) as node1: + token1 = mint_admin_token(node1.base_url, ADMIN_PW) + assert token1, "boot-1 admin login (env password) must work" + settings1 = httpx.get( + f"{node1.base_url}/admin/api/settings", headers=bearer_headers(token1), timeout=15 + ).json() + assert settings1.get("nsec") == "[REDACTED]", ( + f"boot 1 should hold the Nostr identity, got nsec={settings1.get('nsec')!r}" + ) + npub1 = httpx.get(f"{node1.base_url}/v1/info", timeout=15).json().get("npub") + assert npub1 and str(npub1).startswith("npub1"), f"boot 1 npub: {npub1!r}" + + # Boot 2: SAME volume + key, but NSEC removed from the env. + with node_boot.serving_node( + {**base, "ROUTSTR_SECRET_KEY": node_boot.COMPOSE_SECRET_KEY, "ADMIN_PASSWORD": ADMIN_PW}, + volume=vol, + ) as node2: + token2 = mint_admin_token(node2.base_url, ADMIN_PW) + assert token2, "boot-2 admin login must still work after the env-less reboot" + settings2 = httpx.get( + f"{node2.base_url}/admin/api/settings", headers=bearer_headers(token2), timeout=15 + ).json() + assert settings2.get("nsec") == "[REDACTED]", ( + "the Nostr identity was lost on the second boot once NSEC left the " + "env — the encrypted nsec must be decrypted from the Secret store, " + f"not clobbered to empty. got nsec={settings2.get('nsec')!r}" + ) + npub2 = httpx.get(f"{node2.base_url}/v1/info", timeout=15).json().get("npub") + assert npub2 == npub1, ( + f"the node's npub must be stable across the env-less reboot: " + f"{npub1!r} -> {npub2!r}" + ) diff --git a/tests/integration/test_secrets_at_rest.py b/tests/integration/test_secrets_at_rest.py new file mode 100644 index 0000000..0ef9027 --- /dev/null +++ b/tests/integration/test_secrets_at_rest.py @@ -0,0 +1,188 @@ +"""Config-ownership secret storage at rest (routstr-core #553). + +Whole-system proof that operator-supplied secrets handed to a real node via +container env (ADMIN_PASSWORD, NSEC) are migrated into the encrypted Secret store +at boot, instead of living in plaintext in the editable settings blob: + + * the env-seeded admin password still authenticates (POST /admin/api/login) — + it was hashed into the Secret store, not broken; + * ``admin_password`` is no longer an editable settings field (GET + /admin/api/settings omits it — it became a one-way hash, not config); + * the node exposes a dedicated nsec rotation endpoint (PATCH /admin/api/nsec) + that derives the npub — the Nostr identity is a rotatable secret, not a blob + field smuggled through the general settings PATCH; + * neither the plaintext admin password, the seeded nsec, nor a freshly rotated + nsec appears anywhere in the node's on-disk SQLite database. + +Each test boots its OWN throwaway node via ``node_boot`` (fresh volume, tailored +env), the same ephemeral mechanism ``test_secret_lifecycle`` uses. These are +boot-time / state-mutating behaviours (one rotates the identity), so they must run +against a pristine per-test node rather than the shared standing ``node-a`` — that +keeps each test order-independent, leaves no state to restore, and lets us seed the +plaintext directly through the front door (no compose/pytest ``.env`` divergence). + +Discriminating: RED against a node that keeps secrets in the plaintext settings +blob (pre-#553); GREEN once they move into the Fernet/scrypt-backed Secret store +and ``admin_password`` leaves the settings model. Marked ``destructive`` (needs +local docker, not a deployed node): auto-skips under TARGET_PROFILE=remote. +""" +from __future__ import annotations + +import httpx +import pytest + +from tests.integration import node_boot +from tests.integration.targets import bearer_headers, mint_admin_token + +pytestmark = pytest.mark.destructive + +ADMIN_PW = "at-rest-admin-pw" +# The nsec the node is seeded with via env. The node stores secrets verbatim (only +# ``.strip()``, no bech32<->hex normalization), so this exact string must NOT survive +# in the DB once #553 encrypts it. +SEED_NSEC = "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl" +# A valid 64-char hex private key the node's nsec parser accepts like a bech32 nsec. +# After PATCH /admin/api/nsec this is the node's *live* nsec (stored verbatim), so it +# too must be absent from disk — the check that guards the rotated identity. +ROTATE_NSEC_HEX = "1" * 64 + + +@pytest.fixture(autouse=True) +def _local_docker() -> None: + node_boot.require_local_docker() + + +def _seed_env() -> dict[str, str]: + """A node booted with a known admin password and nsec supplied via env.""" + return { + **node_boot.base_node_env(), + "ROUTSTR_SECRET_KEY": node_boot.COMPOSE_SECRET_KEY, + "ADMIN_PASSWORD": ADMIN_PW, + "NSEC": SEED_NSEC, + } + + +def test_admin_password_is_not_a_settings_field() -> None: + """admin_password must not be an editable settings field. + + Under #553 the admin password is a one-way scrypt hash in the Secret store, + not a value you read or write through the settings blob. GET /admin/api/settings + must therefore not carry an ``admin_password`` key at all (a node that still + exposes it — redacted or not — is keeping the password as blob config). + """ + with node_boot.throwaway_volume() as vol, node_boot.serving_node( + _seed_env(), volume=vol + ) as node: + token = mint_admin_token(node.base_url, ADMIN_PW) + assert token, ( + "the env-seeded admin password must log in — proving it was hashed into " + "the Secret store at boot, not broken by the migration" + ) + resp = httpx.get( + f"{node.base_url}/admin/api/settings", headers=bearer_headers(token), timeout=15 + ) + assert resp.status_code == 200, ( + f"GET /admin/api/settings failed: HTTP {resp.status_code}: {resp.text[:300]}" + ) + data = resp.json() + assert "admin_password" not in data, ( + "admin_password is still exposed as a settings field — under #553 it must " + "be a one-way hash in the Secret store, not an editable config value. " + f"settings keys: {sorted(data)[:40]}" + ) + + +def test_nsec_rotation_endpoint_derives_npub() -> None: + """The node exposes a dedicated nsec rotation endpoint that derives the npub. + + The Nostr identity is a rotatable secret with its own write path + (PATCH /admin/api/nsec), not a field smuggled through the general settings + PATCH (which strips it). A 404/405 means that write path is missing. + """ + with node_boot.throwaway_volume() as vol, node_boot.serving_node( + _seed_env(), volume=vol + ) as node: + token = mint_admin_token(node.base_url, ADMIN_PW) + assert token, "the env-seeded admin password must log in" + resp = httpx.patch( + f"{node.base_url}/admin/api/nsec", + json={"nsec": ROTATE_NSEC_HEX}, + headers=bearer_headers(token), + timeout=15, + ) + assert resp.status_code == 200, ( + "PATCH /admin/api/nsec should rotate the node's Nostr identity (200) — a " + f"404/405 means the dedicated nsec write path is missing. " + f"Got HTTP {resp.status_code}: {resp.text[:300]}" + ) + body = resp.json() + assert body.get("ok") is True, f"expected ok=True, got {body!r}" + assert str(body.get("npub", "")).startswith("npub1"), ( + f"endpoint should derive and return the npub for the new key, got {body!r}" + ) + + +def test_no_plaintext_secret_in_node_database() -> None: + """The operator's plaintext password and nsec must not survive on disk. + + The flagship #553 guarantee: secrets supplied via env are hashed/encrypted + into the Secret store, never persisted in the plaintext settings blob. + + The settings blob is written lazily (a fresh node leaves it empty), so a bare + "grep the DB" would pass vacuously on a node that *would* persist secrets the + moment the blob is touched. We therefore first force a settings persist with a + harmless edit, then rotate the identity (so the check also covers the *live* + secret after a rotation, not only the env seed), assert the probe actually + landed on disk (so the secret-absence check is meaningful, not vacuous), then + assert the raw DB bytes contain none of the plaintext secrets. A node that keeps + secrets in the settings blob (pre-#553) writes them alongside the probe and + fails here. + """ + probe = "at-rest-probe-node-name" + with node_boot.throwaway_volume() as vol, node_boot.serving_node( + _seed_env(), volume=vol + ) as node: + token = mint_admin_token(node.base_url, ADMIN_PW) + assert token, "the env-seeded admin password must log in" + headers = bearer_headers(token) + with httpx.Client(base_url=node.base_url, timeout=15) as client: + resp = client.patch( + "/admin/api/settings", json={"name": probe}, headers=headers + ) + assert resp.status_code == 200, ( + "settings PATCH (to force a blob persist) failed: " + f"HTTP {resp.status_code}: {resp.text[:300]}" + ) + resp = client.patch( + "/admin/api/nsec", json={"nsec": ROTATE_NSEC_HEX}, headers=headers + ) + assert resp.status_code == 200, ( + "nsec rotation (to make ROTATE_NSEC_HEX the live secret) failed: " + f"HTTP {resp.status_code}: {resp.text[:300]}" + ) + db_bytes = node_boot.copy_db(node.cid) + + # Guard against a vacuous pass: the probe must have been persisted, proving a + # settings blob was actually written to disk for the secret-absence check to mean + # anything. A pre-#553 node persists admin_password/nsec into that same blob. + assert probe.encode() in db_bytes, ( + "the settings blob was not persisted to disk after a PATCH, so the " + "plaintext-secret check below would be vacuous — the node's persistence " + "path changed; pick a field that actually writes the blob" + ) + + assert ADMIN_PW.encode() not in db_bytes, ( + "the plaintext admin password is present in the node's on-disk database — " + "it must be stored only as a one-way scrypt hash in the Secret store, never " + "in the settings blob" + ) + assert SEED_NSEC.encode() not in db_bytes, ( + "the seeded plaintext nsec is present in the node's on-disk database — the " + "Nostr identity must be Fernet-encrypted in the Secret store, not kept in " + "the settings blob" + ) + assert ROTATE_NSEC_HEX.encode() not in db_bytes, ( + "the rotated plaintext nsec is present in the node's on-disk database — " + "after PATCH /admin/api/nsec the node's live Nostr identity is this value, " + "and it must be Fernet-encrypted in the Secret store, not persisted verbatim" + )