fix(security): fail fast on a missing/placeholder/public LYRA_SECRET_KEY

.env.example shipped a valid, PUBLIC base64 key — if a user didn't replace it,
every credential got encrypted with a publicly-known key and everything still
"worked," so the footgun was silent.

- .env.example now ships an obvious placeholder (CHANGE_ME_generate_with_...)
  with a "you MUST replace this" note and the openssl hint.
- web + worker validate LYRA_SECRET_KEY at startup: missing / placeholder /
  wrong-length ⇒ loud FATAL banner and refuse to start (web via the Next.js
  instrumentation hook, worker via _require_secret_key before touching the DB).
  The old public example key ⇒ a loud WARNING but not fatal, since an existing
  install may have encrypted its creds under it (rotating needs re-entry).
- Validation lives in an edge-safe web/src/lib/secret-key.ts (no node:crypto)
  so the instrumentation hook bundles for both runtimes; crypto.ts re-exports.
- README emphasizes generating a fresh key + keeping it stable.

Verified live: worker exits 1 on placeholder/unset before any DB call; web
instrumentation throws and refuses to serve on the placeholder. web 138 tests,
worker 204/7-skip, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 10:19:46 +02:00
parent 190e0ef4b6
commit 8cd5392ec3
9 changed files with 201 additions and 2 deletions
+30
View File
@@ -4,6 +4,36 @@ import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# The obvious placeholder shipped in .env.example — must be replaced with a real key.
PLACEHOLDER_SECRET_KEY = "CHANGE_ME_generate_with_openssl_rand_base64_32"
# The valid-but-PUBLIC key git used to ship in .env.example. Anyone can decrypt creds
# encrypted with it, so warn — but don't hard-fail, since an existing install may have
# encrypted its credentials under it and hard-failing would brick it mid-flight.
_LEGACY_PUBLIC_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
def secret_key_problem() -> tuple[str, bool] | None:
"""Return (message, fatal) if LYRA_SECRET_KEY is unusable or insecure, else None.
fatal=True ⇒ the app should refuse to start; fatal=False ⇒ start but warn loudly."""
b64 = os.environ.get("LYRA_SECRET_KEY")
if not b64:
return ("LYRA_SECRET_KEY is not set", True)
if b64 == PLACEHOLDER_SECRET_KEY:
return ("LYRA_SECRET_KEY is still the .env.example placeholder", True)
try:
k = base64.b64decode(b64)
except Exception:
return ("LYRA_SECRET_KEY is not valid base64", True)
if len(k) != 32:
return ("LYRA_SECRET_KEY must decode to 32 bytes", True)
if b64 == _LEGACY_PUBLIC_KEY:
return (
"LYRA_SECRET_KEY is the old PUBLIC example key that shipped in git — anyone "
"can decrypt your stored credentials; generate a fresh key and re-enter them",
False,
)
return None
def _key() -> bytes:
b64 = os.environ.get("LYRA_SECRET_KEY")
+21
View File
@@ -1,4 +1,5 @@
import os
import sys
import time
import uuid
@@ -6,6 +7,7 @@ import psycopg
from lyra_worker.claim import claim_next
from lyra_worker.config import get_config
from lyra_worker.crypto import secret_key_problem
from lyra_worker.db import wait_for_db
from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, run_discovery
from lyra_worker.library import clear_staging_root
@@ -165,7 +167,26 @@ def maybe_run_discovery(conn, sources, browser, config, dcfg, now, last_discover
return now if due else last_discover_tick
def _require_secret_key() -> None:
"""Fail fast (or warn loudly) on a missing/placeholder/public LYRA_SECRET_KEY before
the worker touches any encrypted credential."""
problem = secret_key_problem()
if problem is None:
return
msg, fatal = problem
banner = "=" * 72
if fatal:
print(
f"\n{banner}\nFATAL: {msg}.\nGenerate one: openssl rand -base64 32\n"
f"Then set LYRA_SECRET_KEY in your .env and restart.\n{banner}\n",
flush=True,
)
sys.exit(1)
print(f"\n{banner}\nWARNING: {msg}.\n{banner}\n", flush=True)
def run_forever() -> None:
_require_secret_key()
conn = wait_for_db()
clear_staging_root(STAGING_ROOT) # sweep any staging dirs orphaned by a prior crash
adapters = build_adapters(get_config(conn))
+40
View File
@@ -0,0 +1,40 @@
import base64
import pytest
from lyra_worker.crypto import PLACEHOLDER_SECRET_KEY, secret_key_problem
_GOOD = base64.b64encode(b"x" * 32).decode()
_LEGACY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.delenv("LYRA_SECRET_KEY", raising=False)
def test_missing_key_is_fatal(monkeypatch):
assert secret_key_problem() == ("LYRA_SECRET_KEY is not set", True)
def test_placeholder_is_fatal(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", PLACEHOLDER_SECRET_KEY)
msg, fatal = secret_key_problem()
assert fatal is True and "placeholder" in msg
def test_wrong_length_is_fatal(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", base64.b64encode(b"short").decode())
msg, fatal = secret_key_problem()
assert fatal is True and "32 bytes" in msg
def test_legacy_public_key_warns_not_fatal(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", _LEGACY)
msg, fatal = secret_key_problem()
assert fatal is False and "PUBLIC" in msg
def test_good_key_is_fine(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", _GOOD)
assert secret_key_problem() is None