test: end-to-end coverage for encrypted secret storage#5
Conversation
Add a scenario asserting the admin password and nsec are never persisted in plaintext: GET /admin/api/settings omits admin_password, PATCH /admin/api/nsec derives an npub, and the on-disk SQLite holds no plaintext password or bech32 nsec. Supply a fixed ROUTSTR_SECRET_KEY to both compose nodes (mandatory for the node under test, ignored by older nodes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an ephemeral node-boot harness and a scenario covering behaviours only observable at startup: the node fails fast without ROUTSTR_SECRET_KEY, generates and logs a first-run admin password, bricks an encrypted nsec when the key changes, and decrypts a stored nsec on a later boot without the env value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for this — the scenario design is solid (probe-guard against vacuous passes, red→green verified against the vendored node, ephemeral boots that don't disturb the standing stack). Findings below were cross-checked against the actual node implementation in Routstr/routstr-core#570.
Worth fixing now
1. The at-rest grep misses the secret it should most care about (test_secrets_at_rest.py:169-178)
Two independent ways the "no plaintext secrets on disk" check can pass vacuously:
- Rotated nsec is never grepped.
test_nsec_rotation_endpoint_derives_npubruns first (definition order) and replaces the stored nsec withROTATE_NSEC_HEX("1"*64). The DB check then only asserts the original bech32NODE_NSECis absent. The node stores the nsec verbatim as supplied (no bech32↔hex normalization — the hex/bech32 parsing in #570 is validation-only), so after rotation the node's live secret is literally the string"1"*64, and a regression persisting it in plaintext still passes GREEN. One-line fix: also assertROTATE_NSEC_HEX.encode() not in db_bytes. .envdivergence.NODE_NSECfalls back to the compose default from the pytest process env, but the documented operator path is settingNODE_A_NSECin.env, which compose reads and pytest never loads. In that setup the node boots with the real nsec while the test greps the default. Worth either loading.envin the test/conftest or documenting that these tests assume the compose defaults.
2. Fixed host port 8077 can wedge the suite (node_boot.py:37)
serving_node always binds host port 8077, containers are started without --name or a label, and cleanup lives only in finally blocks. A run killed hard (SIGKILL/CI timeout) leaves an anonymous container serving on 8077 indefinitely; every subsequent run then fails docker run → pytest.fail under SERVICES_REQUIRED=1 until someone cleans up by hand. Suggested: use an ephemeral host port (read it back via docker inspect/docker port), or at least label the containers and remove stale ones at module setup.
Worth considering
- Rotation permanently mutates the standing node-a (
test_secrets_at_rest.py:106). Fine under the default orchestrated run (down -vteardown), but withKEEP_UP=1or the documentedmake upreuse flow, node-a keeps announcing under the rotated npub for later discovery scenarios, and a subsequent restart with envNSEC≠ stored nsec can brick the stack. Restoring the original nsec at the end of the test would make the scenario safe under stack reuse. - Ephemeral nodes announce on the shared relay (
node_boot.py:93). Lifecycle nodes joinws://relay:8080withHTTP_URL=http://localhost:8000; the node announces unconditionally whenever an nsec is present (no disable flag in #570), and the relay's named volume keeps the dead "LifecycleNode" announcement after the container is gone, which discovery scenarios (e.g.routstrd_cheapest) on a reused stack may then see. Pointing ephemeral nodes at no relay (or a throwaway one) avoids the pollution. admin_login()duplicatestargets.admin_token()(node_boot.py:242) — same endpoint, payload, timeout, token extraction and exception handling. Extracting a shared_mint_admin_token(base_url, password)helper intargets.pykeeps the login contract in one place.- Document the new compose vars.
NODE_A_ROUTSTR_SECRET_KEY/NODE_B_ROUTSTR_SECRET_KEYare added incompose.ymlbut absent from.env.exampleand the README config section, so operators have no cue that a second mandatory secret var now exists (with a publicly committed default).
Checked and found fine
- Key-change brick test keeping
NSECin the boot-2 env (test_secret_lifecycle.py:127): I initially read this as over-constraining (an implementation could recover by re-encrypting the env nsec under the new key), but #570 makes fail-fast the deliberate contract —bootstrap_secrets()never consults the env nsec when a stored ciphertext exists, and the docstring documents that a wrongROUTSTR_SECRET_KEY"surfaces as a clear fail-fast". KeepingNSECin the env actually strengthens the coverage: it proves the node refuses to silently re-migrate even when it could. - The pinned log strings ("Stored nsec cannot be decrypted…", "Fernet.generate_key", the "shown only now" password line) all exist verbatim in the node.
- The 600s
secret_lifecyclebudget has ~30% headroom over the all-worst-case wall clock, andboot_until_settled's full-window wait only costs time on runs that have already failed.
Out of scope but observed
Under TARGET_PROFILE=remote, destructive tests are collected-but-skipped, so pytest exits 0 and the orchestrator reports green with zero verification (the rc == 5 "no tests collected" guard doesn't fire). That property predates this PR — three existing destructive scenarios behave the same — but it's worth a harness-level fix (an all-skipped guard) in a separate issue, since these two scenarios inherit it.
Publish ephemeral nodes on a dynamic host port (read back via `docker port`) instead of a fixed 8077, and label every container so a hard-killed run's leftovers are reaped once at setup — a leaked container can no longer hold a port and wedge later runs. Add `copy_db()` to read a node's SQLite (main file + WAL sidecar) off the container for at-rest inspection, and fold the container DB path and the compose default secret key into single constants so the boot env and the copy path cannot drift. Extract the `POST /admin/api/login` contract into `targets.mint_admin_token` and add `bearer_headers`, so the standing-node and ephemeral-boot paths share one login home; adopt both in the boot-time lifecycle scenario. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the at-rest secret checks off the shared standing node-a onto the same ephemeral node_boot harness the boot-time lifecycle scenario uses: each test boots its own throwaway node on a fresh volume, seeded with a known admin password and nsec through container env. This removes the definition-order coupling that let the "no plaintext secrets on disk" grep pass vacuously: the DB-inspection test now seeds, forces a settings persist, rotates the identity, and asserts the admin password, the seeded nsec, and the freshly rotated nsec are all absent from disk — on one isolated node. Seeding through the front door also removes the compose/pytest `.env` divergence (the test controls the exact plaintext), and per-test throwaway volumes leave no standing-node state to restore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NODE_A_ROUTSTR_SECRET_KEY / NODE_B_ROUTSTR_SECRET_KEY are consumed by compose.yml (routstr-core #553 encrypts secrets at rest) but were absent from .env.example and the README config section, giving operators no cue that a second secret var exists with a publicly committed default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough pass — every finding landed. Pushed a three-commit response ( Worth fixing now 1. At-rest grep vacuous passes — fixed at the root rather than with a one-liner. Both failure modes shared one cause: the test ran against the shared standing node-a with definition-order coupling. Moved the whole file onto the ephemeral 2. Fixed port 8077 wedge — fixed. Worth considering
Out of scope — agreed on the |
What
End-to-end coverage for the node's encrypted secret storage
(Routstr/routstr-core#570). These exercise whole-system behaviours that the
node's own unit/integration tests can't reach: a real container boot, secrets
supplied through the real environment, the real on-disk SQLite file, and the
live HTTP API.
Two scenarios:
secrets_at_rest— secrets are never stored in plaintextAgainst a running node (3 tests):
GET /admin/api/settingsdoes not carry anadmin_passwordfield.PATCH /admin/api/nsecaccepts an nsec and returns the derived npub.sidecar) contains no plaintext admin password and no bech32 nsec — guarded
against a vacuous pass by first asserting a probe value did land on disk.
secret_lifecycle— boot-time behaviourBoots throwaway nodes with tailored environments to cover what only happens at
startup (4 tests):
needs
ROUTSTR_SECRET_KEYbut it is absent;password logs in;
npub stays stable.
How it works
tests/integration/node_boot.py) drives thealready-built node image directly with
docker runon the compose network,using throwaway volumes, so each test gets a node with its own environment
without disturbing the standing stack.
destructiveand require services to be up(
services_required: true), so they don't pass vacuously when the stack isskipped.
ROUTSTR_SECRET_KEYis added to both compose nodes (required by thenode under test, ignored by older nodes).
Verified red→green by toggling the corresponding node implementation in the
vendored checkout: red on the pre-change node, green after.
Independent of the in-flight input-fee scenario (#4).
🤖 Generated with Claude Code