feat: add TOTP multifactor authentication

This commit is contained in:
jb
2026-08-07 13:10:05 -04:00
parent 4777a3abd5
commit e67c3d4cdb
50 changed files with 6646 additions and 539 deletions
+204
View File
@@ -0,0 +1,204 @@
import { connect } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import process from 'node:process';
const usage = 'Usage: npm run admin:bootstrap -- <verified-email>';
const suppliedEmails = process.argv.slice(2);
if (suppliedEmails.length !== 1) throw new Error(usage);
const email = suppliedEmails[0]
.normalize('NFKC')
.trim()
.toLocaleLowerCase('en-US');
if (
email.length < 3 ||
email.length > 320 ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) ||
/[\r\n]/u.test(email)
) {
throw new Error('Provide one valid verified email address.');
}
const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
);
const database = await connect(databasePath);
let transactionStarted = false;
try {
const now = new Date().toISOString();
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
const matchingAccounts = await (
await database.prepare(`
SELECT users.id
FROM users
JOIN user_emails ON user_emails.user_id=users.id
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE user_emails.email=? COLLATE NOCASE
AND user_emails.verified_at IS NOT NULL
AND deleted_accounts.user_id IS NULL
AND user_status.blocked_at IS NULL
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
`)
).all(email, now);
if (matchingAccounts.length !== 1)
throw new Error(
'The email must identify exactly one active verified account.',
);
const targetId = String(matchingAccounts[0].id);
const activeAdministrators = await (
await database.prepare(`
SELECT users.id
FROM users
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE deleted_accounts.user_id IS NULL
AND user_status.blocked_at IS NULL
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
AND EXISTS (
SELECT 1 FROM user_roles
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
)
`)
).all(now);
const targetAlreadyAdministrator = activeAdministrators.some(
(administrator) => String(administrator.id) === targetId,
);
if (activeAdministrators.length > 0 && !targetAlreadyAdministrator)
throw new Error('An active full administrator already exists.');
if (!targetAlreadyAdministrator) {
const administratorRole = await (
await database.prepare(`
SELECT roles.id
FROM roles
JOIN role_permissions ON role_permissions.role_id=roles.id
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE roles.id='role-admin' AND permissions.name='system.admin'
LIMIT 1
`)
).get();
if (!administratorRole)
throw new Error('The canonical administrator role is unavailable.');
const activeFactor = await (
await database.prepare(
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
)
).get(targetId);
if (!activeFactor)
throw new Error('The target account must have an active TOTP factor.');
const unusedRecoveryCode = await (
await database.prepare(
'SELECT id FROM mfa_recovery_codes WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL LIMIT 1',
)
).get(targetId);
if (!unusedRecoveryCode)
throw new Error('The target account must have an unused recovery code.');
const before = {
roleIds: (
await (
await database.prepare(
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
)
).all(targetId)
).map((row) => String(row.role_id)),
};
await (
await database.prepare(
"INSERT OR IGNORE INTO user_roles(user_id,role_id) VALUES(?,'role-admin')",
)
).run(targetId);
await (
await database.prepare(
"UPDATE users SET role='user' WHERE id=? AND role='admin'",
)
).run(targetId);
const targetSessions = await (
await database.prepare('SELECT id FROM sessions WHERE user_id=?')
).all(targetId);
const sessionIds = targetSessions.map((row) => String(row.id));
if (sessionIds.length) {
const placeholders = sessionIds.map(() => '?').join(',');
await (
await database.prepare(
`UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id IN (${placeholders})`,
)
).run(now, 'administrator_bootstrap', ...sessionIds);
await (
await database.prepare(
`DELETE FROM sessions WHERE id IN (${placeholders})`,
)
).run(...sessionIds);
}
await (
await database.prepare(`
INSERT INTO audit_log(
id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at
) VALUES(?,?,?,?,?,?,?,?)
`)
).run(
randomUUID(),
targetId,
'role_management',
targetId,
'administrator.bootstrapped',
JSON.stringify(before),
JSON.stringify({
roleId: 'role-admin',
sessionsRevoked: sessionIds.length,
mfaRequired: true,
}),
now,
);
}
const resultingAdministrators = await (
await database.prepare(`
SELECT COUNT(DISTINCT users.id) AS count
FROM users
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE deleted_accounts.user_id IS NULL
AND user_status.blocked_at IS NULL
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
AND EXISTS (
SELECT 1 FROM user_roles
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
)
`)
).get(now);
if (Number(resultingAdministrators?.count) < 1)
throw new Error('The database must retain an active full administrator.');
await database.exec('COMMIT');
transactionStarted = false;
console.log(
targetAlreadyAdministrator
? 'The target account already has unrestricted administration.'
: 'Initial unrestricted administration granted.',
);
} catch (error) {
if (transactionStarted) {
try {
await database.exec('ROLLBACK');
} catch {
// Preserve the original failure without exposing database details.
}
}
console.error(
error instanceof Error ? error.message : 'Administrator bootstrap failed.',
);
process.exitCode = 1;
} finally {
await database.close();
}
+151
View File
@@ -0,0 +1,151 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
const run = promisify(execFile);
let directory: string;
let database: Database;
let databasePath: string;
async function invoke() {
try {
const result = await run(
process.execPath,
['scripts/admin-bootstrap.mjs', 'admin@example.com'],
{
cwd: process.cwd(),
env: {
...process.env,
TDR_DATABASE_PATH: databasePath,
NODE_ENV: 'test',
},
},
);
return { status: 0, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
const failure = error as {
code?: number;
stdout?: string;
stderr?: string;
};
return {
status: Number(failure.code ?? 1),
stdout: failure.stdout ?? '',
stderr: failure.stderr ?? '',
};
}
}
describe('administrator bootstrap preconditions', () => {
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'shd-admin-bootstrap-'));
databasePath = join(directory, 'database.db');
database = await connect(databasePath);
await database.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, role TEXT, created_at TEXT);
CREATE TABLE user_emails (user_id TEXT, email TEXT, verified_at TEXT);
CREATE TABLE user_status (user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
CREATE TABLE deleted_accounts (user_id TEXT PRIMARY KEY);
CREATE TABLE roles (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE permissions (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE role_permissions (role_id TEXT, permission_id TEXT);
CREATE TABLE user_roles (user_id TEXT, role_id TEXT, PRIMARY KEY(user_id,role_id));
CREATE TABLE mfa_totp_factors (id TEXT PRIMARY KEY, user_id TEXT, state TEXT);
CREATE TABLE mfa_recovery_codes (id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT, revoked_at TEXT);
CREATE TABLE sessions (id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE login_events (id TEXT PRIMARY KEY, session_id TEXT, session_ended_at TEXT, session_end_reason TEXT);
CREATE TABLE audit_log (id TEXT PRIMARY KEY, actor_id TEXT, entity_type TEXT, entity_id TEXT, action TEXT, before_json TEXT, after_json TEXT, created_at TEXT);
INSERT INTO users VALUES ('user-1','agent','user','2026-08-07T00:00:00.000Z');
INSERT INTO user_emails VALUES ('user-1','admin@example.com','2026-08-07T00:00:00.000Z');
INSERT INTO roles VALUES ('role-admin','Admin');
INSERT INTO permissions VALUES ('permission-system-admin','system.admin');
INSERT INTO role_permissions VALUES ('role-admin','permission-system-admin');
`);
});
afterEach(async () => {
await database.close();
await rm(directory, { recursive: true, force: true });
});
it('rejects accounts without both active TOTP and unused recovery code', async () => {
await database.close();
const result = await invoke();
expect(result.status).toBe(1);
expect(result.stderr).toContain('active TOTP factor');
database = await connect(databasePath);
const user = await (await database.prepare('SELECT role FROM users')).get();
expect(user.role).toBe('user');
});
it('grants only after MFA readiness and records sanitized bootstrap evidence', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
INSERT INTO sessions VALUES ('session-1','user-1');
`);
await database.close();
const result = await invoke();
expect(result.status).toBe(0);
database = await connect(databasePath);
const user = await (await database.prepare('SELECT role FROM users')).get();
const role = await (
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
).get('user-1');
const sessions = await (
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
).get();
const audit = await (
await database.prepare(
'SELECT entity_type,action,after_json FROM audit_log',
)
).get();
expect(user.role).toBe('user');
expect(role.role_id).toBe('role-admin');
expect(Number(sessions.count)).toBe(0);
expect(audit).toMatchObject({
entity_type: 'role_management',
action: 'administrator.bootstrapped',
});
expect(String(audit.after_json)).not.toContain('factor-1');
expect(String(audit.after_json)).not.toContain('code-1');
});
it('is idempotent for an already canonical administrator', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
`);
await database.close();
expect((await invoke()).status).toBe(0);
database = await connect(databasePath);
const firstAuditCount = await (
await database.prepare(
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
)
).get();
await database.close();
expect((await invoke()).status).toBe(0);
database = await connect(databasePath);
const secondAuditCount = await (
await database.prepare(
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
)
).get();
expect(Number(firstAuditCount.count)).toBe(1);
expect(Number(secondAuditCount.count)).toBe(1);
});
it('keeps only the local development bootstrap command', async () => {
const packageJson = JSON.parse(await readFile('package.json', 'utf8')) as {
scripts: Record<string, string>;
};
expect(packageJson.scripts['admin:bootstrap']).toBeTruthy();
expect(packageJson.scripts['admin:bootstrap:staging']).toBeUndefined();
expect(packageJson.scripts['admin:bootstrap:production']).toBeUndefined();
});
});
+152 -2
View File
@@ -769,6 +769,138 @@ for (const statement of statements) {
await database.exec(statement);
}
// Authentication transactions created by older releases did not retain the
// primary provider. Keep those rows intact and leave their provider unknown;
// new login transactions write the validated provider at creation time.
const authenticationTransactionColumns = await (
await database.prepare("PRAGMA table_info('authentication_transactions')")
).all();
if (
authenticationTransactionColumns.length &&
!authenticationTransactionColumns.some((column) => column.name === 'provider')
) {
console.log('Adding primary providers to authentication transactions.');
await database.exec(
"ALTER TABLE authentication_transactions ADD COLUMN provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey'))",
);
}
// Move legacy administrator rows onto the canonical relationship before the
// legacy users.role projection is cleared. The operation is repeatable and
// keeps the existing account IDs and authored data unchanged.
const legacyAdministratorRows = await (
await database.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id")
).all();
if (legacyAdministratorRows.length) {
const administratorRole = await (
await database.prepare("SELECT id FROM roles WHERE id='role-admin'")
).get();
if (!administratorRole)
throw new Error('Cannot migrate legacy administrators without role-admin.');
await database.exec('BEGIN IMMEDIATE');
try {
for (const user of legacyAdministratorRows) {
await (
await database.prepare(
"INSERT OR IGNORE INTO user_roles(user_id,role_id) VALUES(?,'role-admin')",
)
).run(String(user.id));
}
await database.exec("UPDATE users SET role='user' WHERE role='admin'");
const remainingLegacyAdministrators = await (
await database.prepare(
"SELECT COUNT(*) AS count FROM users WHERE role='admin'",
)
).get();
if (Number(remainingLegacyAdministrators?.count) !== 0)
throw new Error('Legacy administrator authority could not be cleared.');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
}
// MFA rotations keep the current active factor while a replacement is pending.
// Older databases used one partial index for both states, which prevented that
// safe overlap. Drop it and install the two independent idempotent constraints.
const legacyTotpCurrentIndex = await (
await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='mfa_totp_factors_one_current_idx'",
)
).get();
if (legacyTotpCurrentIndex) {
await database.exec('DROP INDEX mfa_totp_factors_one_current_idx');
}
await database.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx ON mfa_totp_factors(user_id) WHERE state='active'",
);
await database.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx ON mfa_totp_factors(user_id) WHERE state='pending'",
);
// Existing sessions were issued before authentication assurance levels were
// recorded. They cannot be upgraded safely, so invalidate only those legacy
// rows after adding the nullable migration columns. New sessions are written
// with a non-null AAL evidence timestamp by the auth session service.
const sessionTable = await (
await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='sessions'",
)
).get();
if (sessionTable) {
let sessionColumns = await (
await database.prepare("PRAGMA table_info('sessions')")
).all();
if (!sessionColumns.some((column) => column.name === 'aal')) {
await database.exec(
'ALTER TABLE sessions ADD COLUMN aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2))',
);
sessionColumns = [...sessionColumns, { name: 'aal' }];
}
if (!sessionColumns.some((column) => column.name === 'aal_verified_at')) {
await database.exec('ALTER TABLE sessions ADD COLUMN aal_verified_at TEXT');
sessionColumns = [...sessionColumns, { name: 'aal_verified_at' }];
}
if (!sessionColumns.some((column) => column.name === 'aal2_verified_at')) {
await database.exec(
'ALTER TABLE sessions ADD COLUMN aal2_verified_at TEXT',
);
}
const legacySessions = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM sessions WHERE aal_verified_at IS NULL',
)
).get();
if (Number(legacySessions?.count) > 0) {
console.log(
`Invalidating ${Number(legacySessions.count)} legacy session(s) without AAL evidence.`,
);
await database.exec('DELETE FROM sessions WHERE aal_verified_at IS NULL');
}
}
const loginEventsTable = await (
await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='login_events'",
)
).get();
if (loginEventsTable) {
const loginEventColumns = await (
await database.prepare("PRAGMA table_info('login_events')")
).all();
if (!loginEventColumns.some((column) => column.name === 'aal')) {
await database.exec(
'ALTER TABLE login_events ADD COLUMN aal INTEGER CHECK(aal IS NULL OR aal IN (1,2))',
);
}
if (!loginEventColumns.some((column) => column.name === 'aal2_verified_at')) {
await database.exec(
'ALTER TABLE login_events ADD COLUMN aal2_verified_at TEXT',
);
}
}
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
BEFORE INSERT ON specialization_ability_skill_chip_links
BEGIN
@@ -2142,10 +2274,12 @@ if (
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
aal INTEGER CHECK(aal IS NULL OR aal IN (1,2)),
aal2_verified_at TEXT,
created_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at)
SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`);
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,aal,aal2_verified_at,created_at)
SELECT id,user_id,provider,succeeded,NULL,NULL,created_at FROM login_events_legacy`);
await database.exec('DROP TABLE login_events_legacy');
await database.exec(
'CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)',
@@ -2170,6 +2304,8 @@ for (const [name, definition] of [
['session_ended_at', 'TEXT'],
['session_end_reason', 'TEXT'],
['sensitive_purged_at', 'TEXT'],
['aal', 'INTEGER CHECK(aal IS NULL OR aal IN (1,2))'],
['aal2_verified_at', 'TEXT'],
]) {
if (!loginEventColumns.some((column) => column.name === name)) {
await database.exec(
@@ -2178,6 +2314,20 @@ for (const [name, definition] of [
}
}
// Earlier releases logged the primary factor as a successful login even when
// the AAL2 challenge was still pending. Retain those events as failure
// telemetry, but prevent abandoned MFA challenges from remaining successful.
await (
await database.prepare(`
UPDATE login_events
SET succeeded=0,
failure_reason=COALESCE(failure_reason,'mfa_required'),
aal=COALESCE(aal,1)
WHERE succeeded=1
AND instr(',' || COALESCE(risk_flags,'') || ',', ',primary_authenticated_mfa_required,')>0
`)
).run();
const proposalColumns = await (
await database.prepare("PRAGMA table_info('data_change_proposals')")
).all();
@@ -0,0 +1,81 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
import { isFullAdministrator } from '../src/lib/server/auth/bootstrap';
const run = promisify(execFile);
let directory: string | undefined;
let database: Database | undefined;
afterEach(async () => {
await database?.close();
if (directory) await rm(directory, { recursive: true, force: true });
database = undefined;
directory = undefined;
});
describe('legacy administrator migration', () => {
it('moves legacy administrator rows to role-admin and is repeatable', async () => {
directory = await mkdtemp(join(tmpdir(), 'shd-admin-migration-'));
const databasePath = join(directory, 'database.db');
database = await connect(databasePath);
await database.exec(await readFile('database/schema.sql', 'utf8'));
await (
await database.prepare(
"INSERT INTO users(id,username,role,created_at) VALUES('legacy-admin','legacy','admin',?)",
)
).run(new Date().toISOString());
await database.close();
database = undefined;
const environment = {
...process.env,
TDR_DATABASE_PATH: databasePath,
SKIP_DEFAULT_USERS: 'true',
};
await run(process.execPath, ['scripts/db-migrate.mjs'], {
cwd: process.cwd(),
env: environment,
});
database = await connect(databasePath);
let user = await (
await database.prepare('SELECT role FROM users WHERE id=?')
).get('legacy-admin');
let relationship = await (
await database.prepare(
'SELECT role_id FROM user_roles WHERE user_id=? AND role_id=?',
)
).get('legacy-admin', 'role-admin');
expect(user.role).toBe('user');
expect(relationship.role_id).toBe('role-admin');
await database.close();
database = undefined;
await run(process.execPath, ['scripts/db-migrate.mjs'], {
cwd: process.cwd(),
env: environment,
});
database = await connect(databasePath);
user = await (
await database.prepare('SELECT role FROM users WHERE id=?')
).get('legacy-admin');
relationship = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM user_roles WHERE user_id=? AND role_id=?',
)
).get('legacy-admin', 'role-admin');
expect(user.role).toBe('user');
expect(Number(relationship.count)).toBe(1);
expect(await isFullAdministrator(database, 'legacy-admin')).toBe(true);
await (
await database.prepare(
'DELETE FROM user_roles WHERE user_id=? AND role_id=?',
)
).run('legacy-admin', 'role-admin');
expect(await isFullAdministrator(database, 'legacy-admin')).toBe(false);
});
});
+59
View File
@@ -5,6 +5,7 @@ const APP_REQUIRED = [
'APP_ORIGIN',
'ORIGIN',
'PASSKEY_RP_ID',
'MFA_TOTP_ENCRYPTION_KEYS',
'SCHEDULER_TOKEN',
'TDR_DATABASE_PATH',
'TDR_AVATAR_STORAGE_PATH',
@@ -48,6 +49,7 @@ export function validateRuntimeEnvironment(mode, environment) {
requireValues(APP_REQUIRED);
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
validateMfaTotpEncryptionKeys(get('MFA_TOTP_ENCRYPTION_KEYS'), errors);
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
const adapterOrigin = validateOrigin('ORIGIN', get('ORIGIN'), errors);
@@ -230,3 +232,60 @@ function validateSchedulerToken(value, errors) {
);
}
}
/**
* Validate the app-only TOTP encryption keyring without including configured
* values in an error. The first entry is the active write key.
*
* @param {string} value
* @param {string[]} errors
*/
function validateMfaTotpEncryptionKeys(value, errors) {
if (!value) return;
const entries = value.split(',').map((entry) => entry.trim());
if (
entries.length < 1 ||
entries.length > 3 ||
entries.some((entry) => !entry)
) {
errors.push(
'MFA_TOTP_ENCRYPTION_KEYS must contain 1 through 3 key entries.',
);
return;
}
const ids = new Set();
const keys = new Set();
for (const entry of entries) {
const separator = entry.indexOf(':');
const keyId = separator > 0 ? entry.slice(0, separator) : '';
const encodedKey = separator > 0 ? entry.slice(separator + 1) : '';
if (
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
!/^[A-Za-z0-9_-]+$/.test(encodedKey) ||
encodedKey.length !== 43
) {
errors.push(
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
);
continue;
}
let decoded;
try {
decoded = Buffer.from(encodedKey, 'base64url');
} catch {
decoded = Buffer.alloc(0);
}
if (decoded.length !== 32 || decoded.toString('base64url') !== encodedKey) {
errors.push(
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
);
continue;
}
if (ids.has(keyId) || keys.has(encodedKey)) {
errors.push('MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.');
}
ids.add(keyId);
keys.add(encodedKey);
}
}
+23
View File
@@ -5,6 +5,8 @@ const validAppEnvironment = {
APP_ORIGIN: 'https://stage.shd.build',
ORIGIN: 'https://stage.shd.build',
PASSKEY_RP_ID: 'shd.build',
MFA_TOTP_ENCRYPTION_KEYS:
'primary-2026:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
@@ -21,6 +23,7 @@ describe('validateRuntimeEnvironment', () => {
'APP_ORIGIN is required.',
'ORIGIN is required.',
'PASSKEY_RP_ID is required.',
'MFA_TOTP_ENCRYPTION_KEYS is required.',
'SCHEDULER_TOKEN is required.',
'TDR_DATABASE_PATH is required.',
'TDR_AVATAR_STORAGE_PATH is required.',
@@ -158,4 +161,24 @@ describe('validateRuntimeEnvironment', () => {
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
).toThrow('Unsupported runtime mode: worker');
});
it('validates the MFA encryption keyring without echoing secrets', () => {
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
MFA_TOTP_ENCRYPTION_KEYS:
'one:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,one:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
}),
).toEqual(['MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.']);
const errors = validateRuntimeEnvironment('app', {
...validAppEnvironment,
MFA_TOTP_ENCRYPTION_KEYS:
'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
});
expect(errors).toEqual([
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
]);
expect(errors.join('\n')).not.toContain('primary:');
});
});
+1
View File
@@ -7,6 +7,7 @@ import { validateRuntimeEnvironment } from './runtime-environment.js';
const APP_SECRET_NAMES = new Set([
'APP_ORIGIN',
'MFA_TOTP_ENCRYPTION_KEYS',
'DISCORD_CLIENT_ID',
'DISCORD_CLIENT_SECRET',
'DISCORD_REDIRECT_URI',