diff --git a/src/servers/_middlewares/user-middleware.ts b/src/servers/_middlewares/user-middleware.ts index ff2bc0c9..f275c2d8 100644 --- a/src/servers/_middlewares/user-middleware.ts +++ b/src/servers/_middlewares/user-middleware.ts @@ -2,7 +2,7 @@ import type { MiddlewareHandler } from 'hono'; import { verify } from '@@/jwt'; import * as errors from '@@/custom-errors'; import { isOriginAllowed } from './origin-validation'; -import { isLockdown } from '../api/auth/distress'; +import { isLockdown, noteBlocked } from '../api/auth/panic'; import { getUserById, isTokenBlacklisted } from 'officerdb'; // Role permissions: which HTTP methods each role can use @@ -23,7 +23,10 @@ function isMethodAllowed(role: string | null, method: string): boolean { export const userMiddleware: MiddlewareHandler = async function (ctx, next) { // Duress lockdown: reject every authenticated request, cutting off all existing sessions. - if (isLockdown()) throw errors.UNAUTHORIZED(); + if (isLockdown()) { + noteBlocked(`${ctx.req.method} ${ctx.req.path}`); + throw errors.UNAUTHORIZED(); + } const { authorization } = ctx.req.header(); diff --git a/src/servers/api/auth/auth.ts b/src/servers/api/auth/auth.ts index 4e9c651b..a77321f4 100644 --- a/src/servers/api/auth/auth.ts +++ b/src/servers/api/auth/auth.ts @@ -19,7 +19,7 @@ import { forgotPasswordHandler } from './forgot-password'; import { resetPasswordHandler } from './reset-password'; import { bootstrapHandler } from './bootstrap'; import { usersMe } from './users-me'; -import { lockdownHandler } from './lockdown'; +import { panicHandler } from './panic-handler'; import { passkeyRouter } from './passkey-router'; export const authRouter = createRouter(); @@ -33,10 +33,10 @@ authRouter.use('/', async (ctx) => ctx.json({ officerAuthServer: 'ok' })); authRouter.get('/me', userMiddleware, usersMe); authRouter.post('/signin', signinRateLimiter, signinHandler); authRouter.post('/signout', userMiddleware, signoutHandler); -// Explicit "revoke the current token" alias (same effect as signout: blacklists this JWT). -authRouter.post('/blacklist-token', userMiddleware, signoutHandler); -// Trigger the duress lockdown programmatically (needs the distress password in the body). -authRouter.post('/lockdown', signinRateLimiter, lockdownHandler); +// Revoke the current token — blacklists this JWT (same effect as signout). The app's "distress". +authRouter.post('/revoke', userMiddleware, signoutHandler); +// Trigger the panic lockdown — authenticated, no password in the body. +authRouter.post('/panic', userMiddleware, panicHandler); authRouter.post('/signup', signupRateLimiter, signupHandler); authRouter.post('/bootstrap', signupRateLimiter, bootstrapHandler); authRouter.post('/verify', verifyHandler); diff --git a/src/servers/api/auth/client-ip.ts b/src/servers/api/auth/client-ip.ts new file mode 100644 index 00000000..45b6908a --- /dev/null +++ b/src/servers/api/auth/client-ip.ts @@ -0,0 +1,9 @@ +import type { Context } from 'hono'; + +// Best-effort client IP for audit logging. Behind a reverse proxy the real address is in +// x-forwarded-for (first hop); fall back to x-real-ip, then "unknown". +export const clientIp = (ctx: Context): string => { + const xff = ctx.req.header('x-forwarded-for'); + if (xff) return xff.split(',')[0]?.trim() || 'unknown'; + return ctx.req.header('x-real-ip') || 'unknown'; +}; diff --git a/src/servers/api/auth/distress.ts b/src/servers/api/auth/distress.ts deleted file mode 100644 index 562223a8..00000000 --- a/src/servers/api/auth/distress.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Duress lockdown. The distress password (entered at login) trips this in-memory flag; while it is -// set, ALL new logins and ALL existing sessions are refused. It is intentionally in-memory only, so -// the only way to clear it is a manual server restart. -let locked = false; - -export const isLockdown = (): boolean => locked; - -export const triggerLockdown = (): void => { - locked = true; -}; diff --git a/src/servers/api/auth/lockdown.ts b/src/servers/api/auth/lockdown.ts deleted file mode 100644 index 475ef3db..00000000 --- a/src/servers/api/auth/lockdown.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Handler } from 'hono'; -import * as errors from '@@/custom-errors'; -import { triggerLockdown } from './distress'; - -const { DISTRESS_PASSWORD } = process.env; - -// Trigger the duress lockdown programmatically — same effect as entering the distress password at -// login. Requires the distress password in the body so only its holder can trip it (rate-limited at -// the route). Once tripped, all logins and existing sessions are refused until the server restarts. -export const lockdownHandler: Handler = async (ctx) => { - const { password } = ctx.get('body') as { password?: string }; - if (!DISTRESS_PASSWORD || password !== DISTRESS_PASSWORD) throw errors.UNAUTHORIZED(); - triggerLockdown(); - return ctx.json({ ok: true }); -}; diff --git a/src/servers/api/auth/panic-handler.ts b/src/servers/api/auth/panic-handler.ts new file mode 100644 index 00000000..78b36e69 --- /dev/null +++ b/src/servers/api/auth/panic-handler.ts @@ -0,0 +1,12 @@ +import type { Handler } from 'hono'; +import { triggerLockdown } from './panic'; +import { clientIp } from './client-ip'; + +// Trigger the panic lockdown. Authenticated (Bearer token) — no password in the body. Once tripped, +// all logins and existing sessions are refused until the server is manually restarted. +export const panicHandler: Handler = async (ctx) => { + const user = ctx.get('user') as { email?: string } | undefined; + const origin = ctx.get('origin') as string | undefined; + triggerLockdown(`/panic (user=${user?.email || '?'}, origin=${origin || '-'}, ip=${clientIp(ctx)})`); + return ctx.json({ ok: true }); +}; diff --git a/src/servers/api/auth/panic.ts b/src/servers/api/auth/panic.ts new file mode 100644 index 00000000..afdcb839 --- /dev/null +++ b/src/servers/api/auth/panic.ts @@ -0,0 +1,30 @@ +// Panic lockdown. The panic password (entered at login, or via /auth/panic) trips this in-memory flag; +// while it is set, ALL new logins and ALL existing sessions are refused. It is intentionally in-memory +// only, so the only way to clear it is a manual server restart. +let locked = false; +let blockedCount = 0; +let lastBlockedLog = 0; + +export const isLockdown = (): boolean => locked; + +// Activate the panic lockdown. `source` describes where it was tripped from (login form / endpoint, +// with origin + ip) for the audit log. Logs once — re-triggering while already locked is a no-op. +export const triggerLockdown = (source: string): void => { + if (locked) return; + locked = true; + blockedCount = 0; + lastBlockedLog = 0; + console.warn(`[SECURITY] panic lockdown ACTIVATED — ${source} — ${new Date().toISOString()}`); +}; + +// Record a request that was refused because of the lockdown. Logs a throttled running total (at most +// once per 10s) so polling clients hammering the API don't flood the log. +export const noteBlocked = (what: string): void => { + if (!locked) return; + blockedCount++; + const now = Date.now(); + if (now - lastBlockedLog >= 10_000) { + lastBlockedLog = now; + console.warn(`[SECURITY] panic lockdown blocked ${blockedCount} request(s) since activation — latest: ${what}`); + } +}; diff --git a/src/servers/api/auth/passkey-router.ts b/src/servers/api/auth/passkey-router.ts index d5bfd719..29636539 100644 --- a/src/servers/api/auth/passkey-router.ts +++ b/src/servers/api/auth/passkey-router.ts @@ -4,7 +4,7 @@ import { createRouter } from '../../create-router'; import { userMiddleware, passkeyRateLimiter } from '../../_middlewares'; import { sign } from '../../jwt'; import * as errors from '../../custom-errors'; -import { isLockdown } from './distress'; +import { isLockdown, noteBlocked } from './panic'; import { getUserByEmail, getPasskeysByUserIdAndOrigin, @@ -129,7 +129,10 @@ passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet); // Verify passkey authentication and issue token const passkeyRouterPostVerify: Handler = async (ctx) => { - if (isLockdown()) throw errors.UNAUTHORIZED(); // duress lockdown blocks passkey logins too + if (isLockdown()) { + noteBlocked('passkey login'); + throw errors.UNAUTHORIZED(); + } // duress lockdown blocks passkey logins too const { email } = ctx.req.param(); const origin = ctx.get('origin') as string; const rpId = getRpId(origin); diff --git a/src/servers/api/auth/signin.ts b/src/servers/api/auth/signin.ts index 9d93a964..8cfb5c5b 100755 --- a/src/servers/api/auth/signin.ts +++ b/src/servers/api/auth/signin.ts @@ -6,23 +6,20 @@ import { sign } from '@@/jwt'; import { getClaudeDir } from '@@/data-path'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; -import { isLockdown, triggerLockdown } from './distress'; +import { isLockdown, noteBlocked } from './panic'; const TEST_USERS: number[] = []; -const { DISTRESS_PASSWORD } = process.env; export const signinHandler: Handler = async function (ctx) { const { email, password } = ctx.get('body'); + const origin = ctx.get('origin'); - // Duress: if lockdown is active, refuse everyone (looks like a normal failed login). If the distress - // password was entered, trip the lockdown now and then fail the same way, giving nothing away. - if (isLockdown()) throw errors.UNAUTHORIZED(); - if (DISTRESS_PASSWORD && typeof password === 'string' && password === DISTRESS_PASSWORD) { - triggerLockdown(); + // Panic lockdown active → refuse all logins (looks like a normal failed login). + if (isLockdown()) { + noteBlocked('login attempt'); throw errors.UNAUTHORIZED(); } - const origin = ctx.get('origin'); const dbUser = await getUserByEmail(email); if (!dbUser) throw errors.UNAUTHORIZED();