Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** |

<!-- test-table:generated:end -->

```
Statements : 90.45% Branches : 87.36%
Statements : 90.45% Branches : 87.38%
Functions : 91.97% Lines : 92.22%
```

Expand Down
26 changes: 23 additions & 3 deletions src/lib/totpCrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
83 changes: 80 additions & 3 deletions src/tests/server/totpCrypto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>).NODE_ENV = "production";

const { encryptSecret: encryptFresh } = require("@/lib/totpCrypto");
expect(() => encryptFresh("JBSWY3DPEHPK3PXP")).toThrow(/TOTP_ENCRYPTION_KEY is not set/);

(process.env as Record<string, string | undefined>).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<string, string | undefined>).NODE_ENV = "production";

const { encryptSecret, decryptSecret } = require("@/lib/totpCrypto");
const secret = "JBSWY3DPEHPK3PXP";
expect(decryptSecret(encryptSecret(secret))).toBe(secret);

(process.env as Record<string, string | undefined>).NODE_ENV = savedNodeEnv;
process.env.TOTP_ENCRYPTION_KEY = savedTotp;
});
});

describe("encryptSecret / decryptSecret", () => {
Expand Down
Loading