Skip to content

fix(2fa): fail closed in production without TOTP_ENCRYPTION_KEY; accept AUTH_SECRET in dev - #45

Merged
MikkoNumminen merged 4 commits into
mainfrom
fix/totp-key-fallback-and-docs
Jul 24, 2026
Merged

fix(2fa): fail closed in production without TOTP_ENCRYPTION_KEY; accept AUTH_SECRET in dev#45
MikkoNumminen merged 4 commits into
mainfrom
fix/totp-key-fallback-and-docs

Conversation

@MikkoNumminen

Copy link
Copy Markdown
Owner

Fixes the second production-config finding from the #39 follow-ups: 2FA was broken in production — every TOTP setup/verify threw at runtime.

Root cause

getEncryptionKey() read TOTP_ENCRYPTION_KEY ?? NEXTAUTH_SECRET. The second name is NextAuth v4; this app is on v5 and configures AUTH_SECRET. The Vercel project sets neither v4 name nor a dedicated key, so the "dev convenience" fallback was dead code in the exact environment it was meant to help — confirmed by vercel env ls: AUTH_SECRET present in all three environments, TOTP_ENCRYPTION_KEY/NEXTAUTH_SECRET absent everywhere. The variable was also documented nowhere — not in .env.example, not in k8s.

Fix (mirrors getHmacSecret in auditHashChain.ts)

Precedence, chosen for decryptability of existing secrets:

  1. TOTP_ENCRYPTION_KEY — always.
  2. NEXTAUTH_SECRET — honored in every environment. The k8s templates still set it, and TOTP secrets encrypted under that fallback must stay decryptable; dropping it would strand them.
  3. AUTH_SECRET — dev/test only. In production it deliberately does not count: 2FA fails closed with an actionable error until a dedicated key is set, rather than silently deriving 2FA crypto from the session-signing secret.

No production ciphertexts are at risk: with both lookup names unset, encryptSecret always threw, so nothing was ever encrypted under a key this change replaces.

Environment side (already done via CLI)

Tests

Four behaviors pinned in totpCrypto.test.ts (22/22, plus the 37 2FA action tests green):

scenario expected
only NEXTAUTH_SECRET, dev works (pre-existing)
only AUTH_SECRET, dev works (new — was broken)
only AUTH_SECRET, production throws TOTP_ENCRYPTION_KEY is not set
only NEXTAUTH_SECRET, production works (k8s back-compat)

.env.example now documents the variable next to AUDIT_HMAC_SECRET, including the do-not-rotate warning.

Second commit: README test table regenerated (2906 → 2909, branches 87.36 → 87.38) with npm run readme:test-table — its first production use on Windows via the #41 fix. Both CI gates verified locally.

Still open (not fixable from here)

MONGODB_URL is unset on Vercel, so the production audit trail silently no-ops (deferAudit returns early; drainAuditOutbox returns without draining, so if the audit-use-outbox flag is ON, outbox rows accumulate in Postgres unboundedly). Needs a real MongoDB — e.g. an Atlas M0 free cluster — then npx vercel env add MONGODB_URL production with the connection string. /api/ready reports mongodb: ok, latencyMs: 0 when the URL is absent by design, so don't take that probe as evidence it's wired.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hr-manager Ignored Ignored Preview Jul 24, 2026 2:37am

@github-actions

Copy link
Copy Markdown

Mutation Testing

Score: 70.82% (break threshold 60% — ✅ pass). 318 detected / 449 valid mutants (killed 191, timeout 127, survived 30, no-coverage 101). Full report.

MikkoNumminen and others added 4 commits July 24, 2026 05:27
…pt AUTH_SECRET in dev

The encryption-key fallback read NEXTAUTH_SECRET — the NextAuth v4 name.
This app is on v5 and configures AUTH_SECRET, so the "dev convenience"
fallback was dead: on Vercel neither TOTP_ENCRYPTION_KEY nor NEXTAUTH_SECRET
is set, and every 2FA setup/verify threw at runtime.

Rework getEncryptionKey to mirror getHmacSecret in auditHashChain.ts:

- TOTP_ENCRYPTION_KEY first, always.
- NEXTAUTH_SECRET stays honored in EVERY environment — the k8s templates
  still set it, and TOTP secrets encrypted under that fallback must remain
  decryptable. Dropping it would strand them.
- AUTH_SECRET now works as the dev/test fallback, but deliberately does NOT
  count in production: 2FA fails closed with an actionable message until a
  dedicated key is set, instead of silently deriving 2FA crypto from the
  session-signing secret.

No production ciphertexts are affected: with both lookup names unset,
encryptSecret always threw, so nothing was ever encrypted under a key this
change replaces.

Tests pin all four behaviors (v4 fallback, v5 dev fallback, production
fail-closed with AUTH_SECRET set, production NEXTAUTH_SECRET back-compat),
and .env.example now documents the variable — it was documented nowhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…06 -> 2909, branches 87.38)

Generated with `npm run readme:test-table` — its first run on this Windows
machine, via the portability fix from #41. Both CI gates verified locally:
check-coverage-claims and check-test-count pass against the fresh run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding on the previous commit: the first lookup used ?? — but
.env.example now ships TOTP_ENCRYPTION_KEY="" and an empty string is not
nullish, so a copied-but-unfilled template value would block the
NEXTAUTH_SECRET fallback entirely. In production on k8s (where
NEXTAUTH_SECRET is the configured key) that combination would throw even
though a valid legacy key was present.

Use || instead: an empty string is never a valid key, and truthiness is what
the mirrored getHmacSecret pattern already uses (`if (secret) return secret`).
Pinned with a test that fails under ??.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…test

Verified against a fresh full run: 2835 Jest green, both README gates pass,
coverage percentages unchanged (the || swap adds no branch counts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MikkoNumminen
MikkoNumminen force-pushed the fix/totp-key-fallback-and-docs branch from bf695b7 to f46bbb1 Compare July 24, 2026 02:36
@github-actions

Copy link
Copy Markdown

Mutation Testing

Score: 71.33% (break threshold 60% — ✅ pass). 321 detected / 450 valid mutants (killed 194, timeout 127, survived 28, no-coverage 101). Full report.

@MikkoNumminen
MikkoNumminen merged commit 1dab8e1 into main Jul 24, 2026
4 checks passed
MikkoNumminen added a commit that referenced this pull request Jul 24, 2026
Merging #46 — pure k8s manifests and Helm chart — triggered a full Vercel
production rebuild of identical app code, because k8s/ was in scope for the
ignore command. Kubernetes is a different deployment target entirely; nothing
under k8s/ can affect what Vercel builds.

Verified against real history: the #46 merge diff now skips, the #45 (app
code) diff still builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant