From e67c3d4cdbbe05af6c8001553e0573e082a4f4cb Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 7 Aug 2026 13:10:05 -0400 Subject: [PATCH] feat: add TOTP multifactor authentication --- .env.example | 4 + README.md | 19 +- database/schema.sql | 58 +- deploy/runtime-start.test.ts | 6 + docs/DEPLOYMENT.md | 84 +- docs/architecture.md | 48 +- package-lock.json | 25 + package.json | 2 + scripts/admin-bootstrap.mjs | 204 +++++ scripts/admin-bootstrap.test.ts | 151 ++++ scripts/db-migrate.mjs | 154 +++- .../legacy-administrator-migration.test.ts | 81 ++ scripts/runtime-environment.js | 59 ++ scripts/runtime-environment.test.ts | 23 + scripts/runtime-launcher.mjs | 1 + src/hooks.server.ts | 65 ++ src/lib/components/AccountControl.svelte | 8 +- src/lib/server/auth/account-deletion.test.ts | 191 +++++ src/lib/server/auth/account-deletion.ts | 307 ++++--- .../auth/authentication-transactions.ts | 338 ++++++++ src/lib/server/auth/authority-change.test.ts | 182 ++++ src/lib/server/auth/authority-change.ts | 272 ++++++ src/lib/server/auth/authorization.test.ts | 78 ++ src/lib/server/auth/authorization.ts | 78 +- src/lib/server/auth/bootstrap.ts | 97 ++- src/lib/server/auth/mfa-crypto.ts | 227 +++++ src/lib/server/auth/mfa-foundation.test.ts | 397 +++++++++ src/lib/server/auth/mfa-recovery.ts | 157 ++++ src/lib/server/auth/mfa-totp.ts | 416 +++++++++ .../auth/primary-authentication.test.ts | 104 +++ src/lib/server/auth/primary-authentication.ts | 120 +++ src/lib/server/auth/session.test.ts | 103 +++ src/lib/server/auth/session.ts | 157 +++- src/routes/account/+page.server.ts | 37 +- src/routes/account/security/+page.server.ts | 803 ++++++++++++++++++ src/routes/account/security/+page.svelte | 67 ++ .../account/security/security-action.test.ts | 364 ++++++++ src/routes/auth/discord/callback/+server.ts | 20 +- src/routes/auth/magic-link/+server.ts | 22 +- .../passkey/authenticate/verify/+server.ts | 15 +- src/routes/auth/two-factor/+page.server.ts | 239 ++++++ src/routes/auth/two-factor/+page.svelte | 47 + src/routes/auth/two-factor/server.test.ts | 280 ++++++ src/routes/isac/audit/+page.server.ts | 137 ++- src/routes/isac/audit/access-revert.test.ts | 100 +++ src/routes/isac/roles/+page.server.ts | 360 +++++--- .../isac/roles/authority-action.test.ts | 158 ++++ src/routes/isac/users/[id]/+page.server.ts | 153 +--- .../isac/users/[id]/access-action.test.ts | 137 +++ src/routes/sign-in/+page.server.ts | 30 +- 50 files changed, 6646 insertions(+), 539 deletions(-) create mode 100644 scripts/admin-bootstrap.mjs create mode 100644 scripts/admin-bootstrap.test.ts create mode 100644 scripts/legacy-administrator-migration.test.ts create mode 100644 src/lib/server/auth/account-deletion.test.ts create mode 100644 src/lib/server/auth/authentication-transactions.ts create mode 100644 src/lib/server/auth/authority-change.test.ts create mode 100644 src/lib/server/auth/authority-change.ts create mode 100644 src/lib/server/auth/authorization.test.ts create mode 100644 src/lib/server/auth/mfa-crypto.ts create mode 100644 src/lib/server/auth/mfa-foundation.test.ts create mode 100644 src/lib/server/auth/mfa-recovery.ts create mode 100644 src/lib/server/auth/mfa-totp.ts create mode 100644 src/lib/server/auth/primary-authentication.test.ts create mode 100644 src/lib/server/auth/primary-authentication.ts create mode 100644 src/lib/server/auth/session.test.ts create mode 100644 src/routes/account/security/+page.server.ts create mode 100644 src/routes/account/security/+page.svelte create mode 100644 src/routes/account/security/security-action.test.ts create mode 100644 src/routes/auth/two-factor/+page.server.ts create mode 100644 src/routes/auth/two-factor/+page.svelte create mode 100644 src/routes/auth/two-factor/server.test.ts create mode 100644 src/routes/isac/audit/access-revert.test.ts create mode 100644 src/routes/isac/roles/authority-action.test.ts create mode 100644 src/routes/isac/users/[id]/access-action.test.ts diff --git a/.env.example b/.env.example index c0582b6..3f6b00b 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,10 @@ ORIGIN=https://dev.shd.build # accessing development directly at http://localhost:5173. PASSKEY_RP_ID=shd.build +# One to three comma-separated key-id:canonical-base64url-32-byte-key entries. +# The first key is used for new MFA TOTP factors; retain old keys for rotation. +MFA_TOTP_ENCRYPTION_KEYS= + # SMTP delivery for email verification and magic-link authentication. # Port 465 normally uses SMTP_SECURE=true. Port 587 normally uses false and STARTTLS. SMTP_HOST= diff --git a/README.md b/README.md index a0de5fe..5c7aa16 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,23 @@ npm run db:seed-users The seed creates `Aewyn` and `Quartermaster` with the standard user role. It generates and displays strong temporary passwords once, then stores only salted -Argon2id hashes. The separate bootstrap policy promotes the first authenticated -account when no administrator exists. +Argon2id hashes. `DEFAULT_ADMIN_PASSWORD` is the password variable name for +`Aewyn`; it does not grant administrator authority. + +Sign-in never grants administrator authority automatically. For local +development, give the target account a verified email, enroll TOTP at +`/account/security`, and save its one-time recovery codes. Stop the development +server, then run: + +```sh +npm run admin:bootstrap -- +``` + +The offline command requires an active TOTP factor and an unused recovery code, +revokes the target account's sessions, and refuses to run when an active full +administrator already exists. Restart the development server after it succeeds. +See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for staging and production +operation. ## Develop diff --git a/database/schema.sql b/database/schema.sql index 134dd07..2c56e70 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -555,7 +555,7 @@ CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT N CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1); CREATE TABLE IF NOT EXISTS stat_equipment_types (stat_id TEXT NOT NULL REFERENCES stat_definitions(id), equipment_type_id TEXT NOT NULL REFERENCES equipment_types(id), PRIMARY KEY(stat_id, equipment_type_id)); CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL REFERENCES users(id), entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, created_at TEXT NOT NULL); -CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2)), aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT); CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT); CREATE TABLE IF NOT EXISTS deleted_roles (role_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, deleted_by TEXT NOT NULL REFERENCES users(id)); CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT); @@ -591,6 +591,8 @@ CREATE TABLE IF NOT EXISTS login_events ( 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, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS user_emails ( @@ -643,6 +645,60 @@ CREATE TABLE IF NOT EXISTS deleted_accounts ( reason TEXT ); CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC); +CREATE TABLE IF NOT EXISTS mfa_totp_factors ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + state TEXT NOT NULL CHECK(state IN ('pending','active','revoked')), + encrypted_secret TEXT NOT NULL, + encryption_key_id TEXT NOT NULL, + issuer TEXT NOT NULL DEFAULT 'SHD.build', + account_label TEXT NOT NULL, + algorithm TEXT NOT NULL DEFAULT 'SHA1' CHECK(algorithm='SHA1'), + digits INTEGER NOT NULL DEFAULT 6 CHECK(digits=6), + period_seconds INTEGER NOT NULL DEFAULT 30 CHECK(period_seconds=30), + last_counter INTEGER, + failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK(failed_attempts BETWEEN 0 AND 5), + locked_until TEXT, + expires_at TEXT, + created_at TEXT NOT NULL, + activated_at TEXT, + revoked_at TEXT, + CHECK((state='pending' AND expires_at IS NOT NULL AND activated_at IS NULL AND revoked_at IS NULL) + OR (state='active' AND activated_at IS NOT NULL AND expires_at IS NULL AND revoked_at IS NULL) + OR (state='revoked' AND revoked_at IS NOT NULL)) +); +CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx + ON mfa_totp_factors(user_id) WHERE state='active'; +CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx + ON mfa_totp_factors(user_id) WHERE state='pending'; +CREATE INDEX IF NOT EXISTS mfa_totp_factors_expiry_idx + ON mfa_totp_factors(state,expires_at) WHERE state='pending'; +CREATE TABLE IF NOT EXISTS mfa_recovery_codes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + consumed_at TEXT, + revoked_at TEXT, + UNIQUE(user_id,code_hash) +); +CREATE INDEX IF NOT EXISTS mfa_recovery_codes_available_idx + ON mfa_recovery_codes(user_id,consumed_at,revoked_at); +CREATE TABLE IF NOT EXISTS authentication_transactions ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey')), + kind TEXT NOT NULL CHECK(kind IN ('login','step_up')), + return_path TEXT NOT NULL CHECK(length(return_path) BETWEEN 1 AND 2048 AND substr(return_path,1,1)='/'), + required_aal INTEGER NOT NULL DEFAULT 2 CHECK(required_aal=2), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 5), + expires_at TEXT NOT NULL, + consumed_at TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS authentication_transactions_expiry_idx + ON authentication_transactions(expires_at); INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data Editor','Access to ISAC game-data management'); INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users'); INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-access-manager','Access Manager','Manage user roles and direct permissions'); diff --git a/deploy/runtime-start.test.ts b/deploy/runtime-start.test.ts index ee9fc76..1c4b514 100644 --- a/deploy/runtime-start.test.ts +++ b/deploy/runtime-start.test.ts @@ -140,6 +140,10 @@ describe('container runtime startup', () => { writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }), writeExport(appExport, [ { key: 'APP_ORIGIN', value: 'https://exported.invalid' }, + { + key: 'MFA_TOTP_ENCRYPTION_KEYS', + value: 'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, { key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' }, { key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' }, ]), @@ -218,6 +222,8 @@ describe('container runtime startup', () => { ); expect(childEnvironment).toMatchObject({ APP_ORIGIN: 'https://stage.shd.build', + MFA_TOTP_ENCRYPTION_KEYS: + 'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', DISCORD_CLIENT_ID: 'exported-discord-client', DISCORD_CLIENT_SECRET: 'exported-discord-secret', ORIGIN: 'https://stage.shd.build', diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index edf21b1..0bd10c0 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -98,9 +98,81 @@ remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea, and delete local and hosted dotenv key material. Removing encrypted files from the current Git tree does not revoke values stored in Git history. -The staging container disables default-user seeding. On a brand-new staging -database, the existing bootstrap policy grants Admin to the first real account -that signs in instead of writing generated credentials into deployment logs. +## Authentication bootstrap and MFA secrets + +Set the required app-only `MFA_TOTP_ENCRYPTION_KEYS` secret under `/app` in the +`dev`, `staging`, and `prod` Infisical environments before starting each app. +The value contains one to three comma-separated, unique +`key-id:canonical-base64url-32-byte-key` entries. Each key is a random 32-byte +value encoded as canonical Base64URL. The first entry is the active write key; +following entries are retained decryption keys for older TOTP factors. Do not +put this secret under `/shared/scheduler`, in a Gitea variable, in the image, or +in a deployment log. A missing, malformed, duplicate, or overlong keyring fails +app runtime validation. + +Rotate the keyring without losing access to existing factors: + +1. Generate a new random 32-byte key in the approved secret-management process + and assign it a new key ID. Do not copy the value into a document or shell + history. +2. Prepend the new `key-id` entry and retain the old entries in Infisical. +3. Deploy or restart the app, then verify an existing TOTP sign-in and a new + enrollment. A successful verification rewraps that factor under the first + key. +4. Retain each old key until every factor encrypted with it has been rewrapped + and verified. Never remove old entries merely because new enrollments use the + new key; losing a required old key prevents decryption of those factors. + +TOTP enrollment issues ten recovery codes and displays them once. The database +stores only their domain-separated hashes. The account owner must save the codes +in an offline password manager or another protected offline record; do not put +their values in Infisical, logs, tickets, backups, audit snapshots, or this +documentation. Each code works once. Regenerating codes immediately revokes all +unused previous codes and displays a replacement set once. Disabling TOTP also +revokes its recovery codes. + +Sign-in never grants administrator authority automatically. Before offline +elevation, the target administrator must have a verified email, an active TOTP +factor, and at least one unused recovery code. The target enrolls TOTP at +`/account/security` while the app is running. Then stop every app and scheduler +service that can access the database and run the environment-specific command. +The command requires that no active full administrator already exists, assigns +the canonical administrator role, revokes the target's sessions, and records +`role_management / administrator.bootstrapped`. + +For local development: + +```sh +npm run admin:bootstrap -- +``` + +For staging, from `/opt/shd-build-stage`: + +```sh +docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app +docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \ + app node scripts/admin-bootstrap.mjs +docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler +``` + +For production, from `/opt/shd-build-production`: + +```sh +docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app +docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \ + app node scripts/admin-bootstrap.mjs +docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler +``` + +The script requires exactly one active, unblocked account with a verified email, +an active TOTP factor, and an unused recovery code. It exits without changing +roles when an active full administrator already exists. The Compose commands +restart their services; restart the local development server after its command. +The target must sign in again because its old sessions were revoked. + +The staging container disables default-user seeding. A first sign-in never +receives Admin; use the offline flow above after a new database or an initial +data bootstrap. ## Gitea Actions runner @@ -388,6 +460,12 @@ import. The importer verifies the bundle target, creates a production safety backup, and refuses to replace a production database containing users, builds, audit events, or proposals. Remove the remote host copy after verification. +If the imported database has no active full administrator, complete the offline +administrator flow with the production Compose command in +[Authentication bootstrap and MFA secrets](#authentication-bootstrap-and-mfa-secrets) +before reopening the site. Do not rely on the first production sign-in for +elevation. + ### Promote one News article Export a locally authored News article by ID or slug while the development diff --git a/docs/architecture.md b/docs/architecture.md index c5d16cb..c287801 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,8 +9,8 @@ The team builds the project in vertical slices. data - Auth.js-compatible server authentication with Discord OAuth and credentials providers -- Argon2id password hashing, server-side sessions, CSRF protection, and role - checks +- Argon2id password hashing, TOTP MFA with encrypted secrets, server-side + sessions, CSRF protection, and role checks - Browser `localStorage` only for anonymous build drafts ## Product boundaries @@ -41,6 +41,43 @@ Local credentials use Argon2id PHC strings with a unique random salt per password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane minimum profile. Successful login rehashes credentials with outdated parameters. +## Authentication and MFA + +Sign-in never grants administrator authority. The first full administrator is +created through an offline database operation after the target account has +enrolled TOTP. The local command is: + +```sh +npm run admin:bootstrap -- +``` + +Staging and production use the stopped-container Compose workflow documented in +[`DEPLOYMENT.md`](DEPLOYMENT.md), not a host-side npm command. The bootstrap +operation accepts exactly one active, unblocked account with a verified email, +requires an active TOTP factor and an unused recovery code, requires that no +active full administrator exists, assigns the canonical `role-admin` +relationship, revokes the target's sessions, and records +`role_management / administrator.bootstrapped`. Administrators must enroll TOTP +before offline elevation. + +`MFA_TOTP_ENCRYPTION_KEYS` is a required app-only secret. Its value contains one +to three comma-separated, unique `key-id:canonical-base64url-32-byte-key` +entries. The first entry is the active encryption key for new factors; later +entries retain decryption access to older factors during rotation. There is no +generated-key fallback. Rotate by prepending a newly generated 32-byte key with +a new ID, deploying it with the old entries retained, and verifying existing +TOTP authentication. A successful verification rewraps that factor under the +first key. Do not remove an old entry until every factor encrypted with it has +been rewrapped and verified. Keep key material in the secret manager and never +place it in source, logs, audit snapshots, or documentation. + +TOTP enrollment issues ten one-time recovery codes. The interface displays them +only once; the database stores only domain-separated hashes. Save the codes in +an offline password manager or another protected offline record. Each code is +consumed once. Regenerating codes immediately revokes all unused previous codes +and displays a new set once. Disabling TOTP revokes its recovery codes, and a +full administrator cannot disable TOTP. + ## Database runtime The application uses Turso Database in-process through its native Node package. @@ -244,10 +281,9 @@ does not become part of the canonical action replay. # Administrator bootstrap -The authentication service promotes the first authenticated user to `admin` if -the database does not contain an administrator. Promotion uses one conditional -database update so concurrent first sign-ins cannot both become administrators. -Once an administrator exists, later sign-ins never alter roles. +The first full administrator is created only through the offline flow documented +in [Authentication and MFA](#authentication-and-mfa). Sign-in never grants +administrator authority or changes an account's roles. # Database backup and restore diff --git a/package-lock.json b/package-lock.json index 47a105d..d72bc4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "argon2": "^0.45.1", "nodemailer": "^9.0.3", "openai": "^6.49.0", + "otpauth": "9.5.1", "sharp": "^0.35.3" }, "devDependencies": { @@ -706,6 +707,18 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.139.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", @@ -3486,6 +3499,18 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/otpauth": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz", + "integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", diff --git a/package.json b/package.json index c01af3b..13838f2 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:migrate:production": "infisical run --env=prod --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs", "db:seed-users": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs", "db:seed-users:reset": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs --reset-passwords", + "admin:bootstrap": "infisical run --env=dev --path=/app -- node scripts/admin-bootstrap.mjs", "db:backup": "infisical run --env=dev --path=/app -- node scripts/db-backup.mjs", "db:backup:staging": "infisical run --env=staging --path=/app -- node scripts/db-backup.mjs", "db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs", @@ -79,6 +80,7 @@ "argon2": "^0.45.1", "nodemailer": "^9.0.3", "openai": "^6.49.0", + "otpauth": "9.5.1", "sharp": "^0.35.3" } } diff --git a/scripts/admin-bootstrap.mjs b/scripts/admin-bootstrap.mjs new file mode 100644 index 0000000..56775c7 --- /dev/null +++ b/scripts/admin-bootstrap.mjs @@ -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 -- '; +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(); +} diff --git a/scripts/admin-bootstrap.test.ts b/scripts/admin-bootstrap.test.ts new file mode 100644 index 0000000..d33d74b --- /dev/null +++ b/scripts/admin-bootstrap.test.ts @@ -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; + }; + expect(packageJson.scripts['admin:bootstrap']).toBeTruthy(); + expect(packageJson.scripts['admin:bootstrap:staging']).toBeUndefined(); + expect(packageJson.scripts['admin:bootstrap:production']).toBeUndefined(); + }); +}); diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index de3284f..edd84c8 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -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(); diff --git a/scripts/legacy-administrator-migration.test.ts b/scripts/legacy-administrator-migration.test.ts new file mode 100644 index 0000000..9bd4f2e --- /dev/null +++ b/scripts/legacy-administrator-migration.test.ts @@ -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); + }); +}); diff --git a/scripts/runtime-environment.js b/scripts/runtime-environment.js index 3cf1ff3..e7556df 100644 --- a/scripts/runtime-environment.js +++ b/scripts/runtime-environment.js @@ -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); + } +} diff --git a/scripts/runtime-environment.test.ts b/scripts/runtime-environment.test.ts index 6ea55f4..8b71fcf 100644 --- a/scripts/runtime-environment.test.ts +++ b/scripts/runtime-environment.test.ts @@ -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:'); + }); }); diff --git a/scripts/runtime-launcher.mjs b/scripts/runtime-launcher.mjs index e96c7dd..33ca6c1 100644 --- a/scripts/runtime-launcher.mjs +++ b/scripts/runtime-launcher.mjs @@ -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', diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 623b6a8..4648963 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,6 +2,15 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit'; import { stringify } from 'devalue'; import { randomUUID } from 'node:crypto'; import { getSessionUser } from '$lib/server/auth/session'; +import { + hasActiveTotpFactor, + isFullAdministrator, +} from '$lib/server/auth/bootstrap'; +import { + createAuthenticationTransaction, + getAuthenticationTransaction, +} from '$lib/server/auth/authentication-transactions'; +import { getDatabase } from '$lib/server/database'; import { getSiteAccessBlock, getSiteAccessSettings, @@ -49,6 +58,62 @@ export const handle: Handle = async ({ event, resolve }) => { { status: 404 }, ); } + + const isIsacRoute = + event.url.pathname === '/isac' || event.url.pathname.startsWith('/isac/'); + if (sessionUser && isIsacRoute) { + const database = await getDatabase(); + if (await isFullAdministrator(database, sessionUser.id)) { + const hasFactor = await hasActiveTotpFactor(database, sessionUser.id); + const mutation = !['GET', 'HEAD'].includes(event.request.method); + if (!hasFactor) { + if (mutation) + return json( + { error: 'Administrator MFA enrollment is required.' }, + { status: 403, headers: { 'cache-control': 'no-store' } }, + ); + return new Response(null, { + status: 303, + headers: { + location: '/account/security?required=admin', + 'cache-control': 'no-store', + 'referrer-policy': 'no-referrer', + }, + }); + } + if (sessionUser.aal !== 2) { + if (mutation) + return json( + { error: 'AAL2 authentication is required.' }, + { status: 403, headers: { 'cache-control': 'no-store' } }, + ); + const returnPath = `${event.url.pathname}${event.url.search}`; + const existing = await getAuthenticationTransaction( + event.cookies, + database, + ); + if ( + !existing || + existing.userId !== sessionUser.id || + existing.kind !== 'step_up' + ) { + await createAuthenticationTransaction(event.cookies, database, { + userId: sessionUser.id, + kind: 'step_up', + returnPath, + }); + } + return new Response(null, { + status: 303, + headers: { + location: '/auth/two-factor', + 'cache-control': 'no-store', + 'referrer-policy': 'no-referrer', + }, + }); + } + } + } if (event.request.method === 'POST') { const match = event.url.pathname.match(/^\/isac\/([^/]+)$/); const module = match?.[1] as DataModule | undefined; diff --git a/src/lib/components/AccountControl.svelte b/src/lib/components/AccountControl.svelte index 797bfce..6032194 100644 --- a/src/lib/components/AccountControl.svelte +++ b/src/lib/components/AccountControl.svelte @@ -172,6 +172,10 @@ discordLinkPending = true}>Link Discord {/if} {/if} +
+

ACCOUNT HARDENING

MULTI-FACTOR AUTHENTICATION

{account.mfa?.enabled ? 'Authenticator active.' : 'Optional for standard accounts.'}
+ REVIEW SECURITY → +
{:else}