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:
+4
-2
@@ -3,8 +3,10 @@ POSTGRES_PASSWORD=lyra
|
|||||||
POSTGRES_DB=lyra
|
POSTGRES_DB=lyra
|
||||||
DATABASE_URL=postgresql://lyra:lyra@db:5432/lyra
|
DATABASE_URL=postgresql://lyra:lyra@db:5432/lyra
|
||||||
|
|
||||||
# 32 random bytes, base64. Generate with: openssl rand -base64 32
|
# Encryption key for stored credentials — 32 random bytes, base64. YOU MUST REPLACE THIS.
|
||||||
LYRA_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
|
# 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
|
# 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
|
# (one shared password, no per-user accounts). LEAVE UNSET AND THE APP IS OPEN to anyone
|
||||||
|
|||||||
@@ -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)
|
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
|
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
|
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
|
**Settings** (they persist across rebuilds), then follow an artist or request an album
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
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 {
|
function key(): Buffer {
|
||||||
const b64 = process.env.LYRA_SECRET_KEY;
|
const b64 = process.env.LYRA_SECRET_KEY;
|
||||||
if (!b64) throw new Error("LYRA_SECRET_KEY is not set");
|
if (!b64) throw new Error("LYRA_SECRET_KEY is not set");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,36 @@ import os
|
|||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
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:
|
def _key() -> bytes:
|
||||||
b64 = os.environ.get("LYRA_SECRET_KEY")
|
b64 = os.environ.get("LYRA_SECRET_KEY")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ import psycopg
|
|||||||
|
|
||||||
from lyra_worker.claim import claim_next
|
from lyra_worker.claim import claim_next
|
||||||
from lyra_worker.config import get_config
|
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.db import wait_for_db
|
||||||
from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, run_discovery
|
from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, run_discovery
|
||||||
from lyra_worker.library import clear_staging_root
|
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
|
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:
|
def run_forever() -> None:
|
||||||
|
_require_secret_key()
|
||||||
conn = wait_for_db()
|
conn = wait_for_db()
|
||||||
clear_staging_root(STAGING_ROOT) # sweep any staging dirs orphaned by a prior crash
|
clear_staging_root(STAGING_ROOT) # sweep any staging dirs orphaned by a prior crash
|
||||||
adapters = build_adapters(get_config(conn))
|
adapters = build_adapters(get_config(conn))
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user