9f489d1fb6
- Per-code failure threshold dropped 5 → 3. After 3 wrong guesses during a code's 5-min TTL, the code is burned and the owner must re-mint. Still tolerates the occasional typo while collapsing the brute-force window further. - Cap concurrent active codes to 1: minting a new code clears any prior code. Single-user app, no need to juggle multiple. - Add a global brute-force lockout: after 100 failed attempts in any 5-minute window, the redeem endpoint returns 429 endpoint_locked for the next 10 minutes, regardless of source IP. Logged at error level so the operator notices.
160 lines
6.1 KiB
TypeScript
160 lines
6.1 KiB
TypeScript
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<string, PairCodeEntry>();
|
|
const redeemAttemptsByIp = new Map<string, number[]>();
|
|
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;
|