diff --git a/.env.example b/.env.example index fb41f48..0fdcc72 100644 --- a/.env.example +++ b/.env.example @@ -11,9 +11,13 @@ AUTH_GITHUB_SECRET="" # CRON_SECRET gates /api/cron/* (Vercel Cron sends it automatically once set). # AUDIT_HMAC_SECRET keys the audit-log hash chain — generate fresh, never reuse, # and never rotate casually (old entries verify against the key that wrote them). -# Generate both with: openssl rand -base64 32 +# TOTP_ENCRYPTION_KEY encrypts stored 2FA secrets (AES-256-GCM). Without it, 2FA +# setup/verify fails closed in production (dev falls back to AUTH_SECRET). Never +# rotate casually — stored secrets decrypt only with the key that encrypted them. +# Generate all three with: openssl rand -base64 32 CRON_SECRET="" AUDIT_HMAC_SECRET="" +TOTP_ENCRYPTION_KEY="" # Optional: enables `npm run i18n:translate` (auto-translation via Claude API). # Without it, translate new keys by hand — see the i18n skill. diff --git a/README.md b/README.md index 3989ff5..26168ac 100644 --- a/README.md +++ b/README.md @@ -304,15 +304,15 @@ HRManager is designed as a standalone, independently deployable application — | Feature component tests | 981 | Per-feature UI + actions: persons, teams, departments, admin, reviews, realtime, leave, employee, 2FA, dashboard, reports | | Shared component tests | 105 | Reusable components shared across features: LeaveManager tabs (types, balances, requests) | | Shared suites (a11y, schemas, permissions) | 828 | Cross-cutting: axe-core WCAG AA, Zod schemas, RBAC permissions, i18n, themes, telemetry, tutorial, middleware | -| Server integration | 896 | Real PostgreSQL + MongoDB — server actions, queries, auth, audit hash chain, rate limiting, health, sessions | +| Server integration | 900 | Real PostgreSQL + MongoDB — server actions, queries, auth, audit hash chain, rate limiting, health, sessions | | Jobs | 21 | pg-boss queue setup, worker registration, and the serverless fetch-based drain | | E2E (Playwright) | 75 | Auth, CRUD, detail editing, dashboard, profile, data I/O, form validation, full workflow | -| **Total** | **2906** (2831 Jest + 75 Playwright) | **92.2% line coverage · 92.0% function coverage** | +| **Total** | **2910** (2835 Jest + 75 Playwright) | **92.2% line coverage · 92.0% function coverage** | ``` -Statements : 90.45% Branches : 87.36% +Statements : 90.45% Branches : 87.38% Functions : 91.97% Lines : 92.22% ``` diff --git a/src/lib/totpCrypto.ts b/src/lib/totpCrypto.ts index aad5cc1..d70f488 100644 --- a/src/lib/totpCrypto.ts +++ b/src/lib/totpCrypto.ts @@ -8,12 +8,32 @@ const ISSUER = "HRManager"; /** * Derives a 256-bit AES key from the TOTP_ENCRYPTION_KEY env var. - * Falls back to a deterministic key derived from NEXTAUTH_SECRET for dev convenience. + * + * Fallback order matters for decryptability of existing secrets: + * - NEXTAUTH_SECRET (the NextAuth v4 name) is honored in every environment — + * the k8s templates still set it, and any deployment that encrypted TOTP + * secrets under that fallback must keep decrypting them. + * - AUTH_SECRET (the v5 name this app actually configures) is a dev/test + * convenience only. In production it deliberately does NOT count: 2FA + * fails closed until a dedicated TOTP_ENCRYPTION_KEY is set, mirroring + * getHmacSecret() in auditHashChain.ts. */ function getEncryptionKey(): Buffer { - const envKey = process.env.TOTP_ENCRYPTION_KEY ?? process.env.NEXTAUTH_SECRET; + // || not ??: .env.example ships TOTP_ENCRYPTION_KEY="" and an empty string is + // not nullish, so ?? would let an unfilled template value block the fallbacks. + // Truthiness also matches getHmacSecret's `if (secret) return secret`. + let envKey = process.env.TOTP_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET; + if (!envKey && process.env.NODE_ENV !== "production") { + envKey = process.env.AUTH_SECRET; + } if (!envKey) { - throw new Error("TOTP_ENCRYPTION_KEY or NEXTAUTH_SECRET must be set"); + throw new Error( + process.env.NODE_ENV === "production" + ? "TOTP_ENCRYPTION_KEY is not set. Two-factor secrets cannot be encrypted or " + + "decrypted without a dedicated key in production. Set TOTP_ENCRYPTION_KEY " + + "to a strong random value (openssl rand -base64 32)." + : "TOTP_ENCRYPTION_KEY, NEXTAUTH_SECRET, or AUTH_SECRET must be set", + ); } // Derive a 32-byte key via SHA-256 so any-length secret works return createHash("sha256").update(envKey).digest(); diff --git a/src/tests/server/totpCrypto.test.ts b/src/tests/server/totpCrypto.test.ts index ac46bd8..f83ff0c 100644 --- a/src/tests/server/totpCrypto.test.ts +++ b/src/tests/server/totpCrypto.test.ts @@ -32,23 +32,100 @@ describe("TOTP Crypto Utilities", () => { process.env.TOTP_ENCRYPTION_KEY = saved; }); - // Throws when both TOTP_ENCRYPTION_KEY and NEXTAUTH_SECRET are absent — covers line 16. - it("throws when neither TOTP_ENCRYPTION_KEY nor NEXTAUTH_SECRET is set", () => { + // An unfilled template value must not block the fallbacks: .env.example + // ships TOTP_ENCRYPTION_KEY="" and "" is not nullish, so with ?? a copied + // template would break k8s deployments whose key is NEXTAUTH_SECRET. + it("treats an empty TOTP_ENCRYPTION_KEY as unset (falls through to NEXTAUTH_SECRET)", () => { + const savedTotp = process.env.TOTP_ENCRYPTION_KEY; + process.env.TOTP_ENCRYPTION_KEY = ""; + + const { encryptSecret, decryptSecret } = require("@/lib/totpCrypto"); + const secret = "JBSWY3DPEHPK3PXP"; + expect(decryptSecret(encryptSecret(secret))).toBe(secret); + + process.env.TOTP_ENCRYPTION_KEY = savedTotp; + }); + + // Falls back to AUTH_SECRET (the NextAuth v5 name) outside production — + // previously only the v4 NEXTAUTH_SECRET counted, which nothing v5 sets. + it("should use AUTH_SECRET as fallback when the other two are missing", () => { + const savedTotp = process.env.TOTP_ENCRYPTION_KEY; + const savedNext = process.env.NEXTAUTH_SECRET; + const savedAuth = process.env.AUTH_SECRET; + delete process.env.TOTP_ENCRYPTION_KEY; + delete process.env.NEXTAUTH_SECRET; + process.env.AUTH_SECRET = "auth-secret-v5-fallback-for-testing-32-chars"; + + const { encryptSecret, decryptSecret } = require("@/lib/totpCrypto"); + const secret = "JBSWY3DPEHPK3PXP"; + expect(decryptSecret(encryptSecret(secret))).toBe(secret); + + process.env.TOTP_ENCRYPTION_KEY = savedTotp; + process.env.NEXTAUTH_SECRET = savedNext; + if (savedAuth === undefined) delete process.env.AUTH_SECRET; + else process.env.AUTH_SECRET = savedAuth; + }); + + // Throws when all three key sources are absent — covers the dev error branch. + it("throws when no key source is set", () => { const savedTotp = process.env.TOTP_ENCRYPTION_KEY; const savedNext = process.env.NEXTAUTH_SECRET; + const savedAuth = process.env.AUTH_SECRET; delete process.env.TOTP_ENCRYPTION_KEY; delete process.env.NEXTAUTH_SECRET; + delete process.env.AUTH_SECRET; jest.resetModules(); const { encryptSecret: encryptFresh } = require("@/lib/totpCrypto"); expect(() => encryptFresh("JBSWY3DPEHPK3PXP")).toThrow( - "TOTP_ENCRYPTION_KEY or NEXTAUTH_SECRET must be set", + "TOTP_ENCRYPTION_KEY, NEXTAUTH_SECRET, or AUTH_SECRET must be set", ); process.env.TOTP_ENCRYPTION_KEY = savedTotp; process.env.NEXTAUTH_SECRET = savedNext; + if (savedAuth !== undefined) process.env.AUTH_SECRET = savedAuth; jest.resetModules(); }); + + // In production AUTH_SECRET deliberately does NOT satisfy the key lookup — + // 2FA fails closed until a dedicated TOTP_ENCRYPTION_KEY is set (mirrors + // the AUDIT_HMAC_SECRET pattern in auditHashChain.ts). + it("throws in production even when AUTH_SECRET is set", () => { + const savedTotp = process.env.TOTP_ENCRYPTION_KEY; + const savedNext = process.env.NEXTAUTH_SECRET; + const savedAuth = process.env.AUTH_SECRET; + const savedNodeEnv = process.env.NODE_ENV; + delete process.env.TOTP_ENCRYPTION_KEY; + delete process.env.NEXTAUTH_SECRET; + process.env.AUTH_SECRET = "auth-secret-v5-fallback-for-testing-32-chars"; + (process.env as Record).NODE_ENV = "production"; + + const { encryptSecret: encryptFresh } = require("@/lib/totpCrypto"); + expect(() => encryptFresh("JBSWY3DPEHPK3PXP")).toThrow(/TOTP_ENCRYPTION_KEY is not set/); + + (process.env as Record).NODE_ENV = savedNodeEnv; + process.env.TOTP_ENCRYPTION_KEY = savedTotp; + process.env.NEXTAUTH_SECRET = savedNext; + if (savedAuth === undefined) delete process.env.AUTH_SECRET; + else process.env.AUTH_SECRET = savedAuth; + }); + + // The legacy NEXTAUTH_SECRET fallback keeps working in production — the k8s + // templates still set it, and TOTP secrets encrypted under it must stay + // decryptable. + it("honors NEXTAUTH_SECRET in production (k8s back-compat)", () => { + const savedTotp = process.env.TOTP_ENCRYPTION_KEY; + const savedNodeEnv = process.env.NODE_ENV; + delete process.env.TOTP_ENCRYPTION_KEY; + (process.env as Record).NODE_ENV = "production"; + + const { encryptSecret, decryptSecret } = require("@/lib/totpCrypto"); + const secret = "JBSWY3DPEHPK3PXP"; + expect(decryptSecret(encryptSecret(secret))).toBe(secret); + + (process.env as Record).NODE_ENV = savedNodeEnv; + process.env.TOTP_ENCRYPTION_KEY = savedTotp; + }); }); describe("encryptSecret / decryptSecret", () => {