// Access control for browser voice: server-password check and brute-force limits. import { createHash, timingSafeEqual } from "node:crypto"; const digest = (value) => createHash("sha256").update(String(value), "utf8").digest(); /** * Constant-time comparison of the typed password against the configured one. * An unset/empty configured password never matches: voice requires a password. */ export function passwordMatches(input, expected) { if (typeof input !== "string" || typeof expected !== "string" || expected.length === 0) return false; if (input.length > 512) return false; return timingSafeEqual(digest(input), digest(expected)); } /** Per-IP failed-login tracking with a temporary lockout. */ export class LoginLimiter { constructor({ maxFailures = 5, lockoutMs = 10 * 60_000, maxTracked = 4096 } = {}, clock = Date.now) { this.maxFailures = maxFailures; this.lockoutMs = lockoutMs; this.maxTracked = maxTracked; this.clock = clock; this.peers = new Map(); // ip -> { failures, firstAt, lockedUntil } } /** Milliseconds the peer is still locked out for (0 = allowed). */ lockedFor(ip) { const entry = this.peers.get(ip); if (!entry) return 0; const now = this.clock(); if (entry.lockedUntil > now) return entry.lockedUntil - now; if (now - entry.firstAt > this.lockoutMs) this.peers.delete(ip); return 0; } recordFailure(ip) { const now = this.clock(); let entry = this.peers.get(ip); if (!entry || now - entry.firstAt > this.lockoutMs) { this.prune(now); entry = { failures: 0, firstAt: now, lockedUntil: 0 }; this.peers.set(ip, entry); } entry.failures += 1; if (entry.failures >= this.maxFailures) entry.lockedUntil = now + this.lockoutMs; } recordSuccess(ip) { this.peers.delete(ip); } prune(now) { for (const [ip, entry] of this.peers) { if (entry.lockedUntil <= now && now - entry.firstAt > this.lockoutMs) this.peers.delete(ip); } while (this.peers.size >= this.maxTracked) { const oldest = this.peers.keys().next().value; if (oldest === undefined) break; this.peers.delete(oldest); } } } /** Same-host Origin check (plus configured extra origins) for the voice WebSocket. */ export function originAllowed(origin, host, allowedOrigins = []) { if (!origin) return false; // browsers always send Origin on WebSocket upgrades const normalized = origin.replace(/\/+$/, "").toLowerCase(); if (allowedOrigins.includes("*") || allowedOrigins.includes(normalized)) return true; try { return new URL(normalized).host === String(host ?? "").toLowerCase(); } catch { return false; } } /** Trim, drop control characters and enforce TeamSpeak's 3..30 character nickname rule. */ export function sanitizeNickname(input, prefix = "") { if (typeof input !== "string") return null; const cleaned = input.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim(); if (!cleaned) return null; const full = `${prefix}${cleaned}`.slice(0, 30).trim(); return [...full].length >= 3 ? full : null; }