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
21 changes: 21 additions & 0 deletions scripts/init-db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", `
Expand Down
2 changes: 1 addition & 1 deletion servers/gateway/dashboard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
23 changes: 14 additions & 9 deletions servers/gateway/dashboard/totp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions servers/gateway/migrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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;
}
170 changes: 170 additions & 0 deletions tests/dashboard-2fa-login.test.js
Original file line number Diff line number Diff line change
@@ -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`);
}
});
Loading