diff --git a/scripts/init-db.js b/scripts/init-db.js index fee9f1b6..b612afea 100644 --- a/scripts/init-db.js +++ b/scripts/init-db.js @@ -449,6 +449,27 @@ await initTable("OAuth tables", ` CREATE INDEX IF NOT EXISTS idx_tokens_type ON oauth_tokens(token_type); `); +// --- Pending 2FA tokens --- +// Deliberately NOT in oauth_tokens: that table's token_type CHECK is +// ('access','refresh'), and SQLite cannot ALTER a CHECK, so storing the +// pending-2FA token there needed a full table rebuild. dashboard/totp.js used +// to INSERT token_type='pending_2fa' into it, which failed the CHECK on every +// 2FA login — attemptLogin swallowed it as a soft DB error and the operator +// saw "Login temporarily unavailable" for a CORRECT password, with no way in +// from the UI. Installs with 2FA off never hit it (sessions use 'access'). +// A dedicated table fixes it additively: no rebuild on a live crow.db. +// Rows are short-lived (5 min TTL) and swept on each create. +await initTable("dashboard_pending_2fa table", ` + CREATE TABLE IF NOT EXISTS dashboard_pending_2fa ( + token TEXT PRIMARY KEY, + meta TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_pending_2fa_expires ON dashboard_pending_2fa(expires_at); +`); + // --- P2P Sharing Tables --- await initTable("contacts table", ` diff --git a/servers/gateway/dashboard/index.js b/servers/gateway/dashboard/index.js index e80c0b07..6e08768d 100644 --- a/servers/gateway/dashboard/index.js +++ b/servers/gateway/dashboard/index.js @@ -43,7 +43,7 @@ import { getPeerCreds } from "../../shared/peer-credentials.js"; import { signTicket, verifyTicket, isSafeDestPath } from "../../shared/sso-ticket.js"; import { auditCrossHostCall } from "../../shared/cross-host-auth.js"; import { getOrCreateLocalInstanceId } from "../instance-registry.js"; -import { SUPPORTED_LANGS } from "./shared/i18n.js"; +import { SUPPORTED_LANGS, t } from "./shared/i18n.js"; import { resolve } from "node:path"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; diff --git a/servers/gateway/dashboard/totp.js b/servers/gateway/dashboard/totp.js index d45ad972..9be694f2 100644 --- a/servers/gateway/dashboard/totp.js +++ b/servers/gateway/dashboard/totp.js @@ -4,7 +4,7 @@ * - Secret generation, QR code rendering, verification * - Recovery codes (SHA-256 hashed, single-use) * - Device trust cookies - * - Pending 2FA tokens (stored in oauth_tokens) + * - Pending 2FA tokens (stored in dashboard_pending_2fa) */ import { createHash, randomBytes } from "node:crypto"; @@ -217,7 +217,12 @@ export async function getRecoveryCodeCount() { } } -// --- Pending 2FA Tokens (oauth_tokens table) --- +// --- Pending 2FA Tokens (dashboard_pending_2fa table) --- +// +// NOT oauth_tokens: its token_type CHECK is ('access','refresh') and SQLite +// cannot ALTER a CHECK, so an INSERT of token_type='pending_2fa' there failed +// on every 2FA login and locked the dashboard out (attemptLogin reported it as +// a soft "server database error" for a correct password). See init-db.js. /** * Create a pending 2FA token after successful password verification. @@ -227,19 +232,19 @@ export async function createPending2faToken(meta = null) { const token = randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + PENDING_2FA_TTL).toISOString(); // Optional context (e.g. cross-instance SSO {src, dest}) bound to THIS - // single-use token, stashed in the otherwise-unused `resource` column. This + // single-use token, stored in the `meta` column. This // ties the SSO handoff to the specific 2FA flow so a stale cookie can't make // a later normal login look like SSO. const resource = meta ? JSON.stringify(meta) : null; const db = createDbClient(); try { await db.execute({ - sql: "INSERT INTO oauth_tokens (token, token_type, client_id, scopes, resource, expires_at) VALUES (?, 'pending_2fa', 'dashboard', '2fa', ?, ?)", + sql: "INSERT INTO dashboard_pending_2fa (token, meta, expires_at) VALUES (?, ?, ?)", args: [sha256(token), resource, expiresAt], }); // Clean up expired pending tokens await db.execute({ - sql: "DELETE FROM oauth_tokens WHERE token_type = 'pending_2fa' AND expires_at < datetime('now')", + sql: "DELETE FROM dashboard_pending_2fa WHERE expires_at < datetime('now')", args: [], }); return token; @@ -259,10 +264,10 @@ export async function getPending2faContext(token) { const db = createDbClient(); try { const result = await db.execute({ - sql: "SELECT resource FROM oauth_tokens WHERE token = ? AND token_type = 'pending_2fa' AND expires_at > datetime('now')", + sql: "SELECT meta FROM dashboard_pending_2fa WHERE token = ? AND expires_at > datetime('now')", args: [sha256(token)], }); - const raw = result.rows[0]?.resource; + const raw = result.rows[0]?.meta; if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } finally { @@ -279,14 +284,14 @@ export async function verifyPending2faToken(token) { const db = createDbClient(); try { const result = await db.execute({ - sql: "SELECT token FROM oauth_tokens WHERE token = ? AND token_type = 'pending_2fa' AND expires_at > datetime('now')", + sql: "SELECT token FROM dashboard_pending_2fa WHERE token = ? AND expires_at > datetime('now')", args: [sha256(token)], }); if (result.rows.length === 0) return false; // Consume token await db.execute({ - sql: "DELETE FROM oauth_tokens WHERE token = ? AND token_type = 'pending_2fa'", + sql: "DELETE FROM dashboard_pending_2fa WHERE token = ?", args: [sha256(token)], }); return true; diff --git a/servers/gateway/migrations.js b/servers/gateway/migrations.js index c37229e5..c7fead90 100644 --- a/servers/gateway/migrations.js +++ b/servers/gateway/migrations.js @@ -166,6 +166,45 @@ async function ensureSyncConflictsOpColumn(db) { return { ran: true }; } +/** + * Migration: ensure the `dashboard_pending_2fa` table exists. + * + * dashboard/totp.js stores the short-lived pending-2FA token here. It used to + * INSERT token_type='pending_2fa' into oauth_tokens, whose + * CHECK(token_type IN ('access','refresh')) rejected it — so on any install + * with dashboard 2FA enabled, a CORRECT password produced "Login temporarily + * unavailable (server database error)" (attemptLogin's DB-failure path) and + * the dashboard was unreachable. Installs with 2FA off never hit it, because + * sessions use token_type='access'. + * + * init-db.js creates the table, but a host that pulls code and restarts + * WITHOUT running init-db would still be locked out — the same deploy-ordering + * window ensureSyncConflictsOpColumn closes above. Purely additive + * (CREATE TABLE IF NOT EXISTS): no rebuild of a live table, nothing dropped. + * Idempotent by construction, so no state marker. + */ +async function ensurePending2faTable(db) { + const { rows } = await db.execute({ + sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='dashboard_pending_2fa'", + args: [], + }); + if (rows.length > 0) return { ran: false, reason: "already-present" }; + await db.execute({ + sql: `CREATE TABLE IF NOT EXISTS dashboard_pending_2fa ( + token TEXT PRIMARY KEY, + meta TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + )`, + args: [], + }); + await db.execute({ + sql: "CREATE INDEX IF NOT EXISTS idx_pending_2fa_expires ON dashboard_pending_2fa(expires_at)", + args: [], + }); + return { ran: true }; +} + /** * Run all startup migrations. Safe to call multiple times (each migration * tracks its own run state and skips if already applied). @@ -183,5 +222,10 @@ export async function runGatewayMigrations(db) { } catch (err) { results.push({ id: "2026-06-11_sync_conflicts_op_column", error: err.message }); } + try { + results.push({ id: "2026-09-09_dashboard_pending_2fa_table", ...(await ensurePending2faTable(db)) }); + } catch (err) { + results.push({ id: "2026-09-09_dashboard_pending_2fa_table", error: err.message }); + } return results; } diff --git a/tests/dashboard-2fa-login.test.js b/tests/dashboard-2fa-login.test.js new file mode 100644 index 00000000..98cffbfe --- /dev/null +++ b/tests/dashboard-2fa-login.test.js @@ -0,0 +1,170 @@ +// Regression tests for the two defects that made dashboard login unreachable on +// any install with 2FA enabled (found on the R4 instance, 2026-09-09): +// +// 1. dashboard/totp.js stored the pending-2FA token in `oauth_tokens`, whose +// CHECK(token_type IN ('access','refresh')) rejected token_type +// ='pending_2fa'. attemptLogin's DB-failure path swallowed it, so a +// CORRECT password rendered "Login temporarily unavailable (server +// database error)" with no way in from the UI. Fixed additively with a +// dedicated `dashboard_pending_2fa` table (no rebuild of a live table). +// 2. dashboard/index.js called t() at 17 sites without importing it, so the +// lockout / 2FA-error / password-reset-error paths threw +// `ReferenceError: t is not defined` — and because an unhandled rejection +// is fatal there, each one took the whole gateway process down. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, ".."); + +// --- Defect 1: the pending-2FA token round-trip ----------------------------- + +const PENDING_DDL = ` + CREATE TABLE IF NOT EXISTS dashboard_pending_2fa ( + token TEXT PRIMARY KEY, + meta TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_pending_2fa_expires ON dashboard_pending_2fa(expires_at); +`; + +// oauth_tokens exactly as it exists on a live crow.db — the narrow CHECK is the +// whole point of this fix, so the test asserts against the real constraint. +const OAUTH_DDL = ` + CREATE TABLE oauth_tokens ( + token TEXT PRIMARY KEY, + token_type TEXT NOT NULL CHECK(token_type IN ('access', 'refresh')), + client_id TEXT NOT NULL, + scopes TEXT DEFAULT '', + resource TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); +`; + +function freshDb() { + const dir = mkdtempSync(join(tmpdir(), "crow-2fa-test-")); + const path = join(dir, "crow.db"); + const d = new Database(path); + d.exec(OAUTH_DDL); + d.exec(PENDING_DDL); + d.close(); + return { dir, path }; +} + +test("the old storage really was impossible: oauth_tokens rejects token_type='pending_2fa'", () => { + const { dir, path } = freshDb(); + const d = new Database(path); + assert.throws( + () => d.prepare( + "INSERT INTO oauth_tokens (token, token_type, client_id, scopes, resource, expires_at) VALUES (?, 'pending_2fa', 'dashboard', '2fa', ?, ?)" + ).run("t1", null, new Date(Date.now() + 60000).toISOString()), + /CHECK constraint failed/, + "the pre-fix INSERT must still fail — that is the bug this table replaces" + ); + d.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("pending-2FA token: create → read context → single-use verify", async () => { + const { dir, path } = freshDb(); + process.env.CROW_DB_PATH = path; + const totp = await import("../servers/gateway/dashboard/totp.js"); + + const token = await totp.createPending2faToken({ src: "a", dest: "/dashboard" }); + assert.equal(typeof token, "string"); + assert.ok(token.length >= 32, "token should be a long random string"); + + // The row is stored HASHED, never in the clear. + const d = new Database(path); + const rows = d.prepare("SELECT token, meta FROM dashboard_pending_2fa").all(); + assert.equal(rows.length, 1); + assert.notEqual(rows[0].token, token, "the plaintext token must not be stored"); + d.close(); + + assert.deepEqual(await totp.getPending2faContext(token), { src: "a", dest: "/dashboard" }); + // getPending2faContext must NOT consume the token. + assert.deepEqual(await totp.getPending2faContext(token), { src: "a", dest: "/dashboard" }); + + assert.equal(await totp.verifyPending2faToken(token), true, "first verify succeeds"); + assert.equal(await totp.verifyPending2faToken(token), false, "token is single-use"); + assert.equal(await totp.getPending2faContext(token), null, "context gone after consumption"); + + delete process.env.CROW_DB_PATH; + rmSync(dir, { recursive: true, force: true }); +}); + +test("pending-2FA token: an expired token is rejected and swept", async () => { + const { dir, path } = freshDb(); + process.env.CROW_DB_PATH = path; + const totp = await import("../servers/gateway/dashboard/totp.js"); + + const d = new Database(path); + d.prepare("INSERT INTO dashboard_pending_2fa (token, meta, expires_at) VALUES (?, NULL, ?)") + .run("stale-hash", "2000-01-01T00:00:00.000Z"); + d.close(); + + assert.equal(await totp.verifyPending2faToken("anything"), false); + // Creating a new token sweeps expired rows. + await totp.createPending2faToken(null); + const d2 = new Database(path); + const stale = d2.prepare("SELECT count(*) n FROM dashboard_pending_2fa WHERE token='stale-hash'").get(); + d2.close(); + assert.equal(stale.n, 0, "expired rows are swept on create"); + + delete process.env.CROW_DB_PATH; + rmSync(dir, { recursive: true, force: true }); +}); + +test("no code path stores a pending-2FA token in oauth_tokens any more", () => { + const src = readFileSync(join(REPO_ROOT, "servers/gateway/dashboard/totp.js"), "utf8"); + const statements = src.split("\n").filter((l) => /sql:\s*"/.test(l)); + for (const line of statements) { + assert.ok( + !/oauth_tokens/.test(line), + "totp.js SQL must not touch oauth_tokens: " + line.trim() + ); + } +}); + +// --- Defect 2: the missing i18n import (static rot-guard) ------------------- + +test("dashboard/index.js imports every i18n helper it calls", () => { + const src = readFileSync(join(REPO_ROOT, "servers/gateway/dashboard/index.js"), "utf8"); + const importLine = src.split("\n").find((l) => l.includes('from "./shared/i18n.js"')); + assert.ok(importLine, "index.js must import from shared/i18n.js"); + const imported = new Set( + (importLine.match(/\{([^}]*)\}/)?.[1] || "").split(",").map((x) => x.trim()).filter(Boolean) + ); + + // Bare t(...) / tJs(...) calls, excluding member calls like foo.t(...). + const called = new Set(); + for (const m of src.matchAll(/(^|[^\w.$])(t|tJs)\(/g)) called.add(m[2]); + + for (const fn of called) { + assert.ok( + imported.has(fn), + `index.js calls ${fn}() but does not import it — every such call site throws ` + + `ReferenceError at runtime, and an unhandled rejection here kills the gateway` + ); + } + assert.ok(called.has("t"), "guard is only meaningful while index.js still calls t()"); +}); + +test("the i18n keys index.js asks for actually exist", async () => { + const { t } = await import("../servers/gateway/dashboard/shared/i18n.js"); + const src = readFileSync(join(REPO_ROOT, "servers/gateway/dashboard/index.js"), "utf8"); + const keys = [...src.matchAll(/(?:^|[^\w.$])t(?:Js)?\("([a-zA-Z0-9._]+)"/g)].map((m) => m[1]); + assert.ok(keys.length > 0, "expected some t() keys in index.js"); + for (const k of new Set(keys)) { + // t() returns the key itself when the entry is missing. + assert.notEqual(t(k, "en"), k, `i18n key "${k}" is missing from shared/i18n.js`); + } +});