import { Router } from 'express'; import crypto from 'node:crypto'; import { env } from '../config/env.js'; import { createLogger } from '../lib/logger.js'; import { clearOwnerCookie, isOwnerAuthenticated, setOwnerCookie, verifyOwnerSecret, } from '../lib/ownerAuth.js'; const log = createLogger('owner'); const router = Router(); // ─── Device pair-code store (in-memory) ───────────────────────────────────── // 6-digit short codes minted by an already-logged-in owner session so a // touchscreen device (the Tesla in-car browser) can log in without retyping // the long OWNER_SECRET. // // Defence in depth — 6 digits is only ~20 bits of entropy on its own: // - single-use (delete on success) // - 5-minute TTL // - per-code failure cap: a code self-destructs after 5 wrong guesses // - per-IP rate limit (req.ip via 'trust proxy' = 1, not spoofable) // - global rate limit across all IPs (defeats parallel-IP brute force) const PAIR_CODE_TTL_MS = 5 * 60 * 1000; const MAX_FAILED_PER_CODE = 3; // tighter — was 5 const MAX_REDEEM_PER_IP_PER_MIN = 8; const MAX_REDEEM_GLOBAL_PER_MIN = 30; const LOCKOUT_TRIP_FAILURES_5MIN = 100; // after this many failures… const LOCKOUT_DURATION_MS = 10 * 60 * 1000; // …block the endpoint for 10 min interface PairCodeEntry { expiresAt: number; failures: number } const pairCodes = new Map(); const redeemAttemptsByIp = new Map(); let redeemAttemptsGlobal: number[] = []; let failuresLast5Min: number[] = []; let lockedUntil = 0; function cleanPairCodes(): void { const now = Date.now(); for (const [k, v] of pairCodes) if (v.expiresAt < now) pairCodes.delete(k); } function recordAttempt(ip: string): { ipLimited: boolean; globalLimited: boolean } { const now = Date.now(); const ipArr = (redeemAttemptsByIp.get(ip) || []).filter(t => now - t < 60_000); ipArr.push(now); redeemAttemptsByIp.set(ip, ipArr); redeemAttemptsGlobal = redeemAttemptsGlobal.filter(t => now - t < 60_000); redeemAttemptsGlobal.push(now); return { ipLimited: ipArr.length > MAX_REDEEM_PER_IP_PER_MIN, globalLimited: redeemAttemptsGlobal.length > MAX_REDEEM_GLOBAL_PER_MIN, }; } router.get('/api/auth/owner/status', (req, res) => { res.json({ authenticated: isOwnerAuthenticated(req), required: !!env.ownerSecret, }); }); router.post('/api/auth/owner', (req, res) => { if (!env.ownerSecret) { res.status(503).json({ ok: false, reason: 'owner_auth_not_configured' }); return; } const { secret } = (req.body || {}) as { secret?: unknown }; if (!verifyOwnerSecret(secret)) { res.status(401).json({ ok: false, reason: 'invalid_secret' }); return; } setOwnerCookie(res); res.json({ ok: true }); }); router.post('/api/auth/owner/logout', (_req, res) => { clearOwnerCookie(res); res.json({ ok: true }); }); // ─── Pair code: mint one (requires owner session) ────────────────────────── router.post('/api/auth/owner/pair-code', (req, res) => { if (!isOwnerAuthenticated(req)) { res.status(401).json({ ok: false, reason: 'auth_required' }); return; } cleanPairCodes(); // Single-tenant app: only one active pair code at a time. Minting a new // one burns any prior code (its window is what owner-attacker brute-force // would target). pairCodes.clear(); const code = String(crypto.randomInt(100_000, 1_000_000)); pairCodes.set(code, { expiresAt: Date.now() + PAIR_CODE_TTL_MS, failures: 0 }); res.json({ ok: true, code, expiresInS: Math.floor(PAIR_CODE_TTL_MS / 1000) }); }); // Mark a failed redeem against every currently-active code. With 6-digit // entropy, an attacker brute-forcing the keyspace doesn't know which code // the owner minted — but every wrong guess they make burns life from the // real code. Three misses during a code's TTL is enough to invalidate it // and force the owner to re-mint, which defeats parallel-IP brute force. // Also record the failure on a global 5-minute window for endpoint lockout. function markFailureAgainstActiveCodes(): void { for (const [code, entry] of pairCodes) { entry.failures += 1; if (entry.failures > MAX_FAILED_PER_CODE) { pairCodes.delete(code); } } const now = Date.now(); failuresLast5Min = failuresLast5Min.filter(t => now - t < 5 * 60_000); failuresLast5Min.push(now); if (failuresLast5Min.length > LOCKOUT_TRIP_FAILURES_5MIN) { lockedUntil = now + LOCKOUT_DURATION_MS; failuresLast5Min = []; log.error({ until: new Date(lockedUntil).toISOString() }, 'pair-redeem endpoint locked due to brute force'); } } // ─── Pair code: redeem on a new device ────────────────────────────────────── router.post('/api/auth/owner/pair-redeem', (req, res) => { // Hard endpoint lockout after a brute-force storm. if (Date.now() < lockedUntil) { res.status(429).json({ ok: false, reason: 'endpoint_locked' }); return; } // req.ip is trustworthy because we set 'trust proxy' = 1 in server/index.ts. // Do NOT fall back to the X-Forwarded-For header — it's client-controlled. const ip = req.ip || 'unknown'; const { ipLimited, globalLimited } = recordAttempt(ip); if (ipLimited || globalLimited) { log.warn({ ip, ipLimited, globalLimited }, 'pair-redeem rate limited'); res.status(429).json({ ok: false, reason: 'rate_limited' }); return; } const { code } = (req.body || {}) as { code?: unknown }; if (typeof code !== 'string' || !/^\d{6}$/.test(code)) { markFailureAgainstActiveCodes(); res.status(400).json({ ok: false, reason: 'bad_code' }); return; } cleanPairCodes(); const entry = pairCodes.get(code); if (!entry) { markFailureAgainstActiveCodes(); res.status(401).json({ ok: false, reason: 'invalid_or_expired' }); return; } // Success: consume the code, set the cookie. pairCodes.delete(code); setOwnerCookie(res); log.info({ ip }, 'Device paired via owner pair code'); res.json({ ok: true }); }); export default router;