security: MFA backup codes — issue on enable, redeem at login, regenerate on demand (#43)
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled

Adds a one-time-use backup code set so users with a lost authenticator are not
locked out. Codes are Crockford base32 (XXXXX-XXXXX), hashed with argon2id, and
redeemed under a WHERE-guarded delete so a concurrent replay race fails closed.

- New MfaBackupCode model + migration
- Issue 10 codes inside the enable transaction; show plaintext exactly once
- Sign-in page accepts TOTP or backup code, reporting remaining count
- regenerateBackupCodes tRPC mutation wipes + reissues atomically
- Unit coverage for generator, normalizer, verify, redeem, and race path

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 18:47:18 +02:00
parent 9dc1ffd3ad
commit fe79810a85
16 changed files with 890 additions and 136 deletions
+155 -28
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import QRCode from "qrcode";
import { trpc } from "~/lib/trpc/client.js";
type SetupStep = "idle" | "show-secret" | "verify" | "done";
type SetupStep = "idle" | "show-secret" | "verify" | "show-backup-codes" | "done";
export function MfaSetup() {
const [step, setStep] = useState<SetupStep>("idle");
@@ -12,6 +12,7 @@ export function MfaSetup() {
const [uri, setUri] = useState("");
const [qrDataUrl, setQrDataUrl] = useState("");
const [token, setToken] = useState("");
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
@@ -33,6 +34,7 @@ export function MfaSetup() {
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
const generateMutation = trpc.user.generateTotpSecret.useMutation();
const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation();
const regenerateBackupCodesMutation = trpc.user.regenerateBackupCodes.useMutation();
async function handleGenerate() {
setError(null);
@@ -49,9 +51,9 @@ export function MfaSetup() {
async function handleVerify() {
setError(null);
try {
await verifyMutation.mutateAsync({ token });
setStep("done");
setSuccess("MFA has been enabled successfully.");
const result = await verifyMutation.mutateAsync({ token });
setBackupCodes(result.backupCodes ?? null);
setStep("show-backup-codes");
setSecret("");
setUri("");
setToken("");
@@ -61,33 +63,111 @@ export function MfaSetup() {
}
}
if (mfaStatus?.totpEnabled && step !== "done") {
async function handleRegenerateBackupCodes() {
setError(null);
try {
const result = await regenerateBackupCodesMutation.mutateAsync();
setBackupCodes(result.codes);
setStep("show-backup-codes");
await refetch();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not regenerate backup codes");
}
}
function handleFinishBackupCodes() {
setBackupCodes(null);
setStep("done");
setSuccess("MFA is active. Keep your backup codes in a safe place.");
}
function copyBackupCodes() {
if (!backupCodes) return;
void navigator.clipboard.writeText(backupCodes.join("\n"));
}
function downloadBackupCodes() {
if (!backupCodes) return;
const blob = new Blob(
[
`CapaKraken MFA Backup Codes\nGenerated: ${new Date().toISOString()}\n\nEach code works exactly once. Keep this file somewhere safe.\n\n${backupCodes.join("\n")}\n`,
],
{ type: "text/plain" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "capakraken-backup-codes.txt";
a.click();
URL.revokeObjectURL(url);
}
if (mfaStatus?.totpEnabled && step !== "done" && step !== "show-backup-codes") {
const remaining = mfaStatus.backupCodesRemaining ?? 0;
const lowCodes = remaining <= 3;
return (
<div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
<svg
className="h-5 w-5 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div className="space-y-4">
<div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
<svg
className="h-5 w-5 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<div>
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">
MFA Enabled
</h3>
<p className="text-sm text-green-700 dark:text-green-400">
Two-factor authentication is active on your account.
</p>
</div>
</div>
</div>
<div
className={`rounded-xl border p-6 ${
lowCodes
? "border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20"
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900"
}`}
>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Backup codes
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{remaining === 0
? "You have no backup codes left. Generate a new set to avoid being locked out if you lose your device."
: `You have ${remaining} backup code${remaining === 1 ? "" : "s"} remaining.`}{" "}
{lowCodes && remaining > 0 && <span className="font-medium">Regenerate soon.</span>}
</p>
</div>
<button
type="button"
onClick={handleRegenerateBackupCodes}
disabled={regenerateBackupCodesMutation.isPending}
className="shrink-0 inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<div>
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">
MFA Enabled
</h3>
<p className="text-sm text-green-700 dark:text-green-400">
Two-factor authentication is active on your account.
</p>
{regenerateBackupCodesMutation.isPending ? "Generating…" : "Regenerate codes"}
</button>
</div>
{error && (
<div className="mt-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-2 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
</div>
</div>
);
@@ -250,6 +330,53 @@ export function MfaSetup() {
</div>
</div>
)}
{step === "show-backup-codes" && backupCodes && (
<div className="rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-6 space-y-4">
<div>
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-200">
Save your backup codes
</h3>
<p className="mt-1 text-sm text-amber-800 dark:text-amber-300">
Each code works exactly once. Store them in a password manager or print them. You will
not see them again regenerating invalidates the whole set.
</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-lg bg-white dark:bg-gray-900 p-4 font-mono text-sm">
{backupCodes.map((code) => (
<code
key={code}
className="rounded bg-gray-100 dark:bg-gray-800 px-3 py-2 text-center tracking-wider select-all"
>
{code}
</code>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={copyBackupCodes}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Copy all
</button>
<button
type="button"
onClick={downloadBackupCodes}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Download .txt
</button>
<button
type="button"
onClick={handleFinishBackupCodes}
className="ml-auto inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700"
>
I've saved them
</button>
</div>
</div>
)}
</div>
);
}