263 lines · 11.0 KB
import { env } from "cloudflare:workers";
import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server";
import { first, run } from "./db";
import { accountDb, stmt } from "./scoped-db";
import { decryptSecret, encryptSecret, isLegacySecret, randomToken, sha256Hex, toHex } from "./crypto";
import type { SealContext } from "./crypto";
import { attemptAuthToken, deleteAuthToken, peekAuthToken, storeAuthToken } from "./auth-tokens";
import { badRequest, HttpError } from "./errors";
import { notifySecurityEvent } from "./account-security";
import { cookieAttributes, cookieName, cookieSecure } from "./auth";
import { generateTotpSecret, totpUri, verifyTotp } from "./totp";
import { userChanged } from "./user-row";

const PENDING_COOKIE_BASE = "gitfor_2fa";
const PENDING_TTL_SECONDS = 5 * 60;
const SETUP_TTL_SECONDS = 10 * 60;
const MAX_PENDING_ATTEMPTS = 5;
const RECOVERY_CODE_COUNT = 10;
const RECOVERY_HALF_BYTES = 3;

function masterKey(): string {
  if (!env.SECRETS_KEY) throw badRequest("SECRETS_KEY must be configured to use two-factor authentication");
  return env.SECRETS_KEY;
}

/** TOTP seeds use their own derived key and are bound to the user row, so a seed cannot be moved between users (AUTH-A10). */
function totpContext(userId: string): SealContext {
  return { purpose: "totp", aad: `user:${userId}` };
}

export async function secondFactorMethods(userId: string): Promise<{ totp: boolean; passkey: boolean }> {
  const [row, keys] = await Promise.all([
    first<{ totp: number | null }>("SELECT totp_enabled_at AS totp FROM users WHERE account_id = ?", userId),
    accountDb(userId).first<{ passkeys: number }>(
      "SELECT COUNT(*) AS passkeys FROM passkeys WHERE user_id = ?",
      userId,
    ),
  ]);
  return { totp: typeof row?.totp === "number", passkey: (keys?.passkeys ?? 0) > 0 };
}