diff --git a/.env.example b/.env.example index 121dff4..5283179 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,10 @@ POSTGRES_PASSWORD=lyra POSTGRES_DB=lyra DATABASE_URL=postgresql://lyra:lyra@db:5432/lyra -# 32 random bytes, base64. Generate with: openssl rand -base64 32 -LYRA_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= +# Encryption key for stored credentials — 32 random bytes, base64. YOU MUST REPLACE THIS. +# Generate your own: openssl rand -base64 32 +# Leaving the placeholder (or an empty key) makes web + worker refuse to start. +LYRA_SECRET_KEY=CHANGE_ME_generate_with_openssl_rand_base64_32 # Shared password protecting the whole web UI + API. When set, every request must sign in # (one shared password, no per-user accounts). LEAVE UNSET AND THE APP IS OPEN to anyone diff --git a/README.md b/README.md index ea98ea1..dc8dfaa 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,13 @@ cp .env.example .env # then edit: set LYRA_SECRET_KEY (openssl rand -ba docker compose up -d --build # web on http://localhost:8770 ; NEVER `down -v` (wipes the Postgres volume) ``` +**Generate your own `LYRA_SECRET_KEY`** (`openssl rand -base64 32`) before first run — it +encrypts your stored Qobuz/Soulseek/Last.fm credentials. `.env.example` ships an obvious +placeholder, and both web and worker **refuse to start** while the key is missing or still +the placeholder. Once set, keep it stable: changing it makes already-stored credentials +undecryptable (you'd re-enter them in Settings). Optionally set `LYRA_PASSWORD` too (see +[Security](#security)). + The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema changes reach the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in **Settings** (they persist across rebuilds), then follow an artist or request an album diff --git a/web/src/instrumentation.ts b/web/src/instrumentation.ts new file mode 100644 index 0000000..4aec695 --- /dev/null +++ b/web/src/instrumentation.ts @@ -0,0 +1,21 @@ +// Runs once on server startup (Next.js instrumentation hook). Fail fast (or warn loudly) +// on a missing/placeholder/public LYRA_SECRET_KEY so a publicly-known key can't silently +// encrypt every credential. +import { secretKeyProblem } from "@/lib/secret-key"; + +export async function register() { + if (process.env.NEXT_RUNTIME !== "nodejs") return; // check once, in the Node runtime + const problem = secretKeyProblem(); + if (!problem) return; + + const banner = "=".repeat(72); + if (problem.fatal) { + console.error( + `\n${banner}\nFATAL: ${problem.message}.\n` + + `Generate one: openssl rand -base64 32\n` + + `Then set LYRA_SECRET_KEY in your .env and restart.\n${banner}\n`, + ); + throw new Error(problem.message); // fail fast + } + console.warn(`\n${banner}\nWARNING: ${problem.message}.\n${banner}\n`); +} diff --git a/web/src/lib/crypto.ts b/web/src/lib/crypto.ts index 1670ff4..76039bc 100644 --- a/web/src/lib/crypto.ts +++ b/web/src/lib/crypto.ts @@ -1,5 +1,9 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +// Re-exported for existing callers; the checks live in an edge-safe module (secret-key.ts) +// so the instrumentation hook can import them without pulling in node:crypto. +export { secretKeyProblem, PLACEHOLDER_SECRET_KEY } from "./secret-key"; + function key(): Buffer { const b64 = process.env.LYRA_SECRET_KEY; if (!b64) throw new Error("LYRA_SECRET_KEY is not set"); diff --git a/web/src/lib/secret-key.test.ts b/web/src/lib/secret-key.test.ts new file mode 100644 index 0000000..133bc6e --- /dev/null +++ b/web/src/lib/secret-key.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { secretKeyProblem, PLACEHOLDER_SECRET_KEY } from "./secret-key"; + +const GOOD = Buffer.from("x".repeat(32)).toString("base64"); +const LEGACY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; +const original = process.env.LYRA_SECRET_KEY; + +afterEach(() => { + if (original === undefined) delete process.env.LYRA_SECRET_KEY; + else process.env.LYRA_SECRET_KEY = original; +}); + +describe("secretKeyProblem", () => { + it("is fatal when missing", () => { + delete process.env.LYRA_SECRET_KEY; + expect(secretKeyProblem()).toEqual({ message: "LYRA_SECRET_KEY is not set", fatal: true }); + }); + + it("is fatal on the placeholder", () => { + process.env.LYRA_SECRET_KEY = PLACEHOLDER_SECRET_KEY; + expect(secretKeyProblem()?.fatal).toBe(true); + }); + + it("is fatal on wrong length", () => { + process.env.LYRA_SECRET_KEY = Buffer.from("short").toString("base64"); + expect(secretKeyProblem()).toMatchObject({ fatal: true }); + expect(secretKeyProblem()?.message).toContain("32 bytes"); + }); + + it("warns (not fatal) on the old public key", () => { + process.env.LYRA_SECRET_KEY = LEGACY; + const p = secretKeyProblem(); + expect(p?.fatal).toBe(false); + expect(p?.message).toContain("PUBLIC"); + }); + + it("accepts a proper key", () => { + process.env.LYRA_SECRET_KEY = GOOD; + expect(secretKeyProblem()).toBeNull(); + }); +}); diff --git a/web/src/lib/secret-key.ts b/web/src/lib/secret-key.ts new file mode 100644 index 0000000..49a3f39 --- /dev/null +++ b/web/src/lib/secret-key.ts @@ -0,0 +1,33 @@ +// Edge-safe LYRA_SECRET_KEY validation (no node:crypto / Buffer), so the Next.js +// instrumentation hook — which is bundled for both the Node and Edge runtimes — can import +// it. crypto.ts re-exports these for its own callers. + +// The obvious placeholder shipped in .env.example — must be replaced with a real key. +export const PLACEHOLDER_SECRET_KEY = "CHANGE_ME_generate_with_openssl_rand_base64_32"; +// The valid-but-PUBLIC key git used to ship in .env.example. Warn (don't hard-fail): an +// existing install may have encrypted its credentials under it. +const LEGACY_PUBLIC_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + +/** Describe an unusable/insecure LYRA_SECRET_KEY, or null if it's fine. Used at startup. */ +export function secretKeyProblem(): { message: string; fatal: boolean } | null { + const b64 = process.env.LYRA_SECRET_KEY; + if (!b64) return { message: "LYRA_SECRET_KEY is not set", fatal: true }; + if (b64 === PLACEHOLDER_SECRET_KEY) + return { message: "LYRA_SECRET_KEY is still the .env.example placeholder", fatal: true }; + let byteLen: number; + try { + byteLen = atob(b64).length; + } catch { + return { message: "LYRA_SECRET_KEY is not valid base64", fatal: true }; + } + if (byteLen !== 32) + return { message: "LYRA_SECRET_KEY must decode to 32 bytes", fatal: true }; + if (b64 === LEGACY_PUBLIC_KEY) + return { + message: + "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", + fatal: false, + }; + return null; +} diff --git a/worker/lyra_worker/crypto.py b/worker/lyra_worker/crypto.py index 7a9dd13..c9a2059 100644 --- a/worker/lyra_worker/crypto.py +++ b/worker/lyra_worker/crypto.py @@ -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") diff --git a/worker/lyra_worker/main.py b/worker/lyra_worker/main.py index 819f258..0ca0d5d 100644 --- a/worker/lyra_worker/main.py +++ b/worker/lyra_worker/main.py @@ -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)) diff --git a/worker/tests/test_secret_key.py b/worker/tests/test_secret_key.py new file mode 100644 index 0000000..48c272f --- /dev/null +++ b/worker/tests/test_secret_key.py @@ -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