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:
@@ -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";
|
||||
|
||||
// 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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user