import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { type Db, getSetting, setSetting } from '../db/index.js'; import type { SecretBox } from '../core/secret-box.js'; import { recordAudit } from '../core/audit-log.js'; const COOKIE_NAME = 'arb_session'; const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000; export interface AuthContext { tokenId: string; label: string; } export class AuthService { private readonly secret: Buffer; constructor( private readonly db: Db, box?: SecretBox, ) { // server_secret chiffré au repos quand un SecretBox est fourni (prod). Migration douce : // une valeur en clair pré-existante est re-chiffrée à la lecture. const stored = getSetting(db, 'server_secret'); let secretHex: string; if (!stored) { secretHex = randomBytes(32).toString('hex'); setSetting(db, 'server_secret', box ? box.encrypt(secretHex) : secretHex); recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'server_secret' }); } else { secretHex = box ? box.decrypt(stored) : stored; if (box && !box.isEncrypted(stored)) setSetting(db, 'server_secret', box.encrypt(secretHex)); } this.secret = Buffer.from(secretHex, 'hex'); } get cookieName(): string { return COOKIE_NAME; } /** Crée le token initial au premier démarrage et renvoie sa valeur en clair (affichée une seule fois). */ ensureBootstrapToken(): string | null { const row = this.db .prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL') .get() as { n: number }; if (row.n > 0) return null; return this.createToken('initial'); } createToken(label: string): string { return this.createTokenRecord(label).token; } /** Comme createToken mais renvoie aussi l'id (pour l'API de gestion des tokens). */ createTokenRecord(label: string): { id: string; token: string } { const id = randomUUID(); const raw = `arb_${randomBytes(24).toString('hex')}`; this.db .prepare('INSERT INTO auth_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, ?)') .run(id, label, sha256(raw), new Date().toISOString()); return { id, token: raw }; } /** Tokens actifs (non révoqués), du plus ancien au plus récent. Ne renvoie JAMAIS le hash. */ listTokens(): Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null }> { return this.db .prepare( 'SELECT id, label, created_at AS createdAt, last_used_at AS lastUsedAt FROM auth_tokens WHERE revoked_at IS NULL ORDER BY created_at', ) .all() as Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null }>; } /** Nombre de tokens actifs (non révoqués). */ countActiveTokens(): number { return (this.db.prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL').get() as { n: number }).n; } /** * Révoque un token. Refuse de révoquer le DERNIER token actif (sinon lock-out total) → 'last'. * 'ok' = révoqué ; 'not_found' = id inconnu ou déjà révoqué. */ revokeToken(id: string): 'ok' | 'last' | 'not_found' { // Transaction : le check « dernier token » et l'UPDATE doivent être atomiques (garde // anti lock-out robuste, même si un refactor futur introduisait de la concurrence). this.db.exec('BEGIN IMMEDIATE'); try { const row = this.db.prepare('SELECT id FROM auth_tokens WHERE id = ? AND revoked_at IS NULL').get(id) as | { id: string } | undefined; let result: 'ok' | 'last' | 'not_found'; if (!row) { result = 'not_found'; } else if (this.countActiveTokens() <= 1) { result = 'last'; } else { this.db.prepare('UPDATE auth_tokens SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id); result = 'ok'; } this.db.exec('COMMIT'); return result; } catch (err) { this.db.exec('ROLLBACK'); throw err; } } verifyRawToken(raw: string): AuthContext | null { if (typeof raw !== 'string' || raw.length < 8 || raw.length > 128) return null; const row = this.db .prepare('SELECT id, label, token_hash FROM auth_tokens WHERE revoked_at IS NULL AND token_hash = ?') .get(sha256(raw)) as { id: string; label: string; token_hash: string } | undefined; if (!row) return null; // double vérification en temps constant (le lookup par hash est déjà non-oracle, ceinture+bretelles) if (!timingSafeEqual(Buffer.from(row.token_hash), Buffer.from(sha256(raw)))) return null; this.db.prepare('UPDATE auth_tokens SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), row.id); return { tokenId: row.id, label: row.label }; } issueCookie(ctx: AuthContext): string { const expires = Date.now() + COOKIE_TTL_MS; const payload = `${ctx.tokenId}.${expires}`; return `${payload}.${this.sign(payload)}`; } verifyCookie(value: string | undefined): AuthContext | null { if (!value) return null; const lastDot = value.lastIndexOf('.'); if (lastDot <= 0) return null; const payload = value.slice(0, lastDot); const sig = value.slice(lastDot + 1); const expected = this.sign(payload); if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; const [tokenId, expiresStr] = payload.split('.'); if (!tokenId || !expiresStr) return null; const expires = Number(expiresStr); if (!Number.isFinite(expires) || expires < Date.now()) return null; const row = this.db .prepare('SELECT id, label FROM auth_tokens WHERE id = ? AND revoked_at IS NULL') .get(tokenId) as { id: string; label: string } | undefined; return row ? { tokenId: row.id, label: row.label } : null; } private sign(payload: string): string { return createHmac('sha256', this.secret).update(payload).digest('hex'); } } function sha256(s: string): string { return createHash('sha256').update(s).digest('hex'); } /** * Rate limit GLOBAL du login (pas par IP : derrière Tailscale Serve toutes les * requêtes arrivent de 127.0.0.1) + backoff exponentiel sur échecs consécutifs. */ export class LoginRateLimiter { private attempts: number[] = []; private consecutiveFailures = 0; private lockedUntil = 0; constructor( private readonly maxPerMinute = 5, private readonly now: () => number = Date.now, ) {} /** null = autorisé ; sinon délai d'attente en ms */ check(): number | null { const t = this.now(); if (t < this.lockedUntil) return this.lockedUntil - t; this.attempts = this.attempts.filter((a) => t - a < 60_000); if (this.attempts.length >= this.maxPerMinute) return 60_000 - (t - (this.attempts[0] ?? t)); this.attempts.push(t); return null; } recordFailure(): void { this.consecutiveFailures += 1; if (this.consecutiveFailures >= 3) { const backoff = Math.min(2 ** (this.consecutiveFailures - 3) * 30_000, 30 * 60_000); this.lockedUntil = this.now() + backoff; } } recordSuccess(): void { this.consecutiveFailures = 0; this.lockedUntil = 0; } }