// ╔══════════════════════════════════════════════════════════════════════╗ // ║ KILL SYSTEM — DO NOT REMOVE ║ // ║ ║ // ║ Phase 68 (GUARD-03, GUARD-04, GUARD-10): Emergency session kill ║ // ║ via /kill and /unkill commands with passcode verification. ║ // ║ ║ // ║ Key exports: ║ // ║ - KILL_COMMANDS_RE: regex for /kill and /unkill commands ║ // ║ - handleKillCommand: initiate kill/unkill challenge ║ // ║ - handleKillPendingResponse: verify passcode + execute action ║ // ║ - hasPendingKillChallenge: check if sender awaits response ║ // ║ - isKillAllowed: allowList enforcement ║ // ║ - verifyKillPasscode: static + TOTP passcode verification ║ // ║ - generateTotpSecret: auto-generate TOTP secret on first use ║ // ║ - clearExpiredChallenges: prune stale challenge state ║ // ║ ║ // ║ Added 2026-03-29. DO NOT REMOVE — core kill system logic for ║ // ║ Phase 68+. Wired into inbound.ts by Plan 02. ║ // ╚══════════════════════════════════════════════════════════════════════╝ import { timingSafeEqual } from "node:crypto"; import * as OTPAuth from "otpauth"; import { createLogger } from "../../logger.js"; import { callWahaApi } from "../../http-client.js"; import { markGuardianStopped, clearGuardianStopped, sendGuardianAlert } from "./guardian.js"; import { sendWahaText } from "../../send.js"; const log = createLogger({ component: "kill-system" }); // --------------------------------------------------------------------------- // Regex — exported for inbound.ts routing // --------------------------------------------------------------------------- /** * Matches /kill and /unkill commands (case-insensitive, exact match). * DO NOT CHANGE — inbound.ts uses this to detect kill commands. */ export const KILL_COMMANDS_RE = /^\/(kill|unkill)$/i; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface KillChallenge { command: "kill" | "unkill"; createdAt: number; // Unix ms expiryMs: number; // expiry duration in ms — set from config at creation time. DO NOT hardcode. attempts: number; lockedUntil: number | null; // Unix ms, null = not locked pendingSessionList?: string[]; // populated when >1 session, awaiting selection authenticated: boolean; // true after passcode verified } export interface KillSystemConfig { enabled: boolean; passcodeMode: "static" | "totp"; staticPasscode?: string; totpSecret?: string; allowList: string[]; maxAttempts: number; cooldownSeconds: number; challengeExpirySeconds: number; } export type VerifyResult = | { success: true } | { success: false; reason: "no_challenge" | "locked" | "wrong" | "expired" }; // --------------------------------------------------------------------------- // Module-level state // --------------------------------------------------------------------------- // Map of senderJid → pending KillChallenge // DO NOT REMOVE — module-level singleton for challenge state. const pendingChallenges = new Map(); // Periodic cleanup of expired challenges to prevent unbounded memory growth. // Uses .unref() so the interval does not block process exit. DO NOT REMOVE. const _cleanupInterval = setInterval(() => { clearExpiredChallenges(); }, 60_000); if (typeof _cleanupInterval === "object" && _cleanupInterval && "unref" in _cleanupInterval) { (_cleanupInterval as NodeJS.Timeout).unref(); } // --------------------------------------------------------------------------- // isKillAllowed // --------------------------------------------------------------------------- /** * Returns true if the sender is in the allowList. * * Strips device suffix (:N) and @domain from JID before matching. * Supports "*" wildcard to allow any sender. * * Per CONTEXT.md: silently ignore non-allowed senders (return null from handleKillCommand). * Phase 68 (GUARD-10). DO NOT REMOVE. */ export function isKillAllowed(senderJid: string, allowList: string[]): boolean { if (!allowList || allowList.length === 0) return false; if (allowList.includes("*")) return true; // Strip device suffix (:N) and @domain — normalize to plain phone number const phone = senderJid.replace(/:[\d]+/, "").replace(/@[^@]+$/, ""); return allowList.includes(phone); } // --------------------------------------------------------------------------- // verifyKillPasscode // --------------------------------------------------------------------------- /** * Verifies a passcode attempt for a pending challenge. * * Checks: challenge exists, not expired, not locked, then validates static or TOTP. * Wrong attempts increment counter. At maxAttempts, locks for cooldownSeconds. * * Static mode: timingSafeEqual prevents timing attacks (per RESEARCH.md). * TOTP mode: OTPAuth.TOTP.validate with window:1 allows ±30s drift. * * Phase 68 (GUARD-03, GUARD-04). DO NOT REMOVE. */ export function verifyKillPasscode( senderJid: string, attempt: string, killCfg: KillSystemConfig ): VerifyResult { const challenge = pendingChallenges.get(senderJid); if (!challenge) { return { success: false, reason: "no_challenge" }; } const now = Date.now(); // Check cooldown lock if (challenge.lockedUntil !== null && now < challenge.lockedUntil) { return { success: false, reason: "locked" }; } // Check expiry const expiryMs = killCfg.challengeExpirySeconds * 1000; if (now - challenge.createdAt >= expiryMs) { pendingChallenges.delete(senderJid); return { success: false, reason: "expired" }; } // Validate passcode let valid = false; if (killCfg.passcodeMode === "static") { const expected = killCfg.staticPasscode ?? ""; // timingSafeEqual requires same-length buffers if (attempt.length === expected.length && expected.length > 0) { const a = Buffer.from(attempt); const b = Buffer.from(expected); valid = timingSafeEqual(a, b); } } else { // TOTP mode if (killCfg.totpSecret) { const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(killCfg.totpSecret), issuer: "Chatlytics Guardian", algorithm: "SHA1", digits: 6, period: 30, }); // validate returns delta (number) or null — null means invalid const delta = totp.validate({ token: attempt.trim(), window: 1 }); valid = delta !== null; } } if (valid) { return { success: true }; } // Wrong — increment attempts, lock if at max challenge.attempts++; if (challenge.attempts >= killCfg.maxAttempts) { challenge.lockedUntil = now + killCfg.cooldownSeconds * 1000; log.warn("kill-system: brute-force lockout triggered", { senderJid, attempts: challenge.attempts, lockedUntilMs: challenge.lockedUntil, }); } return { success: false, reason: "wrong" }; } // --------------------------------------------------------------------------- // generateTotpSecret // --------------------------------------------------------------------------- /** * Generates a new TOTP secret for an account. * Returns base32-encoded secret and otpauth:// URI. * * SECURITY: Never log the base32 or URI — contains the raw secret. * Phase 68 (GUARD-03). DO NOT REMOVE. */ export function generateTotpSecret(accountId: string): { base32: string; uri: string } { const secret = new OTPAuth.Secret({ size: 20 }); const totp = new OTPAuth.TOTP({ secret, issuer: "Chatlytics Guardian", label: accountId, algorithm: "SHA1", digits: 6, period: 30, }); return { base32: secret.base32, uri: totp.toString(), }; } // --------------------------------------------------------------------------- // hasPendingKillChallenge // --------------------------------------------------------------------------- /** * Returns true if the sender has a pending (non-expired) kill challenge. * Used by inbound.ts to route responses before LLM processing. * Phase 68 (GUARD-10). DO NOT REMOVE. */ export function hasPendingKillChallenge(senderJid: string): boolean { const challenge = pendingChallenges.get(senderJid); if (!challenge) return false; // Check expiry inline — uses per-challenge expiryMs (set from config at creation). DO NOT hardcode. if (Date.now() - challenge.createdAt >= challenge.expiryMs) { pendingChallenges.delete(senderJid); return false; } return true; } // --------------------------------------------------------------------------- // clearExpiredChallenges // --------------------------------------------------------------------------- /** * Prunes expired challenges from the map. * Called periodically or on each inbound check to prevent unbounded growth. * Phase 68. DO NOT REMOVE. */ export function clearExpiredChallenges(): void { const now = Date.now(); for (const [jid, challenge] of pendingChallenges) { if (now - challenge.createdAt >= challenge.expiryMs) { pendingChallenges.delete(jid); } } } // --------------------------------------------------------------------------- // handleKillCommand // --------------------------------------------------------------------------- /** * Handles /kill or /unkill command. * * Returns null if sender not in allowList (silent ignore per CONTEXT.md). * Creates a pending challenge. If TOTP mode and no totpSecret, auto-generates one. * Returns reply text with passcode prompt. * * Phase 68 (GUARD-03, GUARD-04, GUARD-10). DO NOT REMOVE. */ export async function handleKillCommand(opts: { command: "kill" | "unkill"; senderJid: string; chatId: string; account: Record; config: Record; runtime?: Record; }): Promise<{ reply: string; configUpdate?: { totpSecret: string } } | null> { const { command, senderJid, chatId, account, config } = opts; const killCfg = resolveKillConfig(config); // Check allowList — silently ignore non-allowed senders if (!isKillAllowed(senderJid, killCfg.allowList)) { log.debug("kill-system: sender not in allowList, ignoring", { senderJid }); return null; } // Check for existing non-expired challenge — reuse (do not reset attempts) const existing = pendingChallenges.get(senderJid); if (existing && Date.now() - existing.createdAt < killCfg.challengeExpirySeconds * 1000) { // Reuse existing challenge, just return prompt const verb = command === "kill" ? "stop" : "restart"; return { reply: buildPasscodePrompt(killCfg, verb), }; } // Create new challenge const challenge: KillChallenge = { command, createdAt: Date.now(), expiryMs: killCfg.challengeExpirySeconds * 1000, attempts: 0, lockedUntil: null, authenticated: false, }; pendingChallenges.set(senderJid, challenge); // TOTP auto-generation if no secret configured let configUpdate: { totpSecret: string } | undefined; if (killCfg.passcodeMode === "totp" && !killCfg.totpSecret) { const accountId = account?.session ?? account?.accountId ?? "guardian"; const generated = generateTotpSecret(accountId); // SECURITY: Do not log base32 or URI log.info("kill-system: auto-generated TOTP secret for first use", { accountId }); configUpdate = { totpSecret: generated.base32 }; // Return the otpauth:// URI in the reply so the user can add to their authenticator const verb = command === "kill" ? "stop" : "restart"; return { reply: `TOTP authentication not yet configured.\n\n` + `Scan this QR / paste this URI in your authenticator app:\n${generated.uri}\n\n` + `Then send /kill again to ${verb} the session.`, configUpdate, }; } const verb = command === "kill" ? "stop" : "restart"; return { reply: buildPasscodePrompt(killCfg, verb) }; } // --------------------------------------------------------------------------- // handleKillPendingResponse // --------------------------------------------------------------------------- /** * Handles a pending kill response (passcode attempt or session selection). * * Flow: * 1. If not authenticated: verify passcode * 2. If authenticated + pendingSessionList: handle numeric session selection * 3. On correct passcode with single session: execute immediately * 4. On correct passcode with multiple sessions: return numbered list * * Phase 68 (GUARD-03, GUARD-04, GUARD-10). DO NOT REMOVE. */ export async function handleKillPendingResponse(opts: { senderJid: string; text: string; chatId: string; account: Record; config: Record; runtime?: Record; }): Promise<{ reply: string; handled: boolean }> { const { senderJid, text, chatId, account, config } = opts; const challenge = pendingChallenges.get(senderJid); if (!challenge) { return { reply: "", handled: false }; } const killCfg = resolveKillConfig(config); // ---- Phase 1: Passcode verification ---- if (!challenge.authenticated) { const result = verifyKillPasscode(senderJid, text.trim(), killCfg); if (!result.success) { let reply = ""; if (result.reason === "no_challenge" || result.reason === "expired") { pendingChallenges.delete(senderJid); reply = "Challenge expired. Send /kill again."; } else if (result.reason === "locked") { const secondsLeft = challenge.lockedUntil ? Math.ceil((challenge.lockedUntil - Date.now()) / 1000) : killCfg.cooldownSeconds; reply = `Too many incorrect attempts. Try again in ${secondsLeft}s.`; } else { const remaining = killCfg.maxAttempts - challenge.attempts; reply = remaining > 0 ? `Incorrect passcode. ${remaining} attempt(s) remaining.` : `Too many incorrect attempts. Locked for ${killCfg.cooldownSeconds}s.`; } sendKillReply(config, chatId, account, reply); return { reply, handled: true }; } // Passcode correct — mark authenticated challenge.authenticated = true; // Get sessions for the account const sessionResult = await getAccountSessions(account, config, challenge.command); if (sessionResult.error) { const msg = "Failed to list sessions. Try again."; pendingChallenges.delete(senderJid); sendKillReply(config, chatId, account, msg); return { reply: msg, handled: true }; } const sessions = sessionResult.sessions; if (sessions.length === 0) { const msg = challenge.command === "kill" ? "No active sessions to stop." : "No stopped sessions to restart."; pendingChallenges.delete(senderJid); sendKillReply(config, chatId, account, msg); return { reply: msg, handled: true }; } if (sessions.length === 1) { // Single session — execute immediately const reply = await executeKillAction( challenge.command, sessions[0], account, config ); pendingChallenges.delete(senderJid); sendKillReply(config, chatId, account, reply); return { reply, handled: true }; } // Multiple sessions — return numbered list challenge.pendingSessionList = sessions; const list = sessions.map((s, i) => `${i + 1}. ${s}`).join("\n"); const verb = challenge.command === "kill" ? "stop" : "restart"; const reply = `Which session to ${verb}?\n\n${list}\n\nReply with the number.`; sendKillReply(config, chatId, account, reply); return { reply, handled: true }; } // ---- Phase 2: Session selection (already authenticated) ---- if (challenge.pendingSessionList && challenge.pendingSessionList.length > 0) { const input = text.trim(); const index = parseInt(input, 10); if (isNaN(index) || index < 1 || index > challenge.pendingSessionList.length) { const reply = `Invalid selection. Reply with a number between 1 and ${challenge.pendingSessionList.length}.`; sendKillReply(config, chatId, account, reply); return { reply, handled: true }; } const session = challenge.pendingSessionList[index - 1]; const reply = await executeKillAction(challenge.command, session, account, config); pendingChallenges.delete(senderJid); sendKillReply(config, chatId, account, reply); return { reply, handled: true }; } return { reply: "", handled: false }; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** Fire-and-forget reply via WAHA. Used by handleKillPendingResponse to avoid repeating the pattern 7 times. DO NOT REMOVE. */ function sendKillReply(config: Record, chatId: string, account: Record, text: string): void { sendWahaText({ cfg: config, to: chatId, text, accountId: typeof account?.session === 'string' ? account.session : undefined, bypassPolicy: true }) .catch((err: Error) => log.warn("kill-system: reply send failed", { error: String(err) })); } /** Resolve killSystem config with defaults. */ function resolveKillConfig(config: Record): KillSystemConfig { const defaults: KillSystemConfig = { enabled: false, passcodeMode: "totp", allowList: [], maxAttempts: 3, cooldownSeconds: 300, challengeExpirySeconds: 120, }; const guardianCfg = (config as Record)?.guardianConfig; if (typeof guardianCfg !== 'object' || guardianCfg === null || Array.isArray(guardianCfg)) { log.warn("kill-system: guardianConfig is not an object, using defaults"); return { ...defaults }; } const killCfg = (guardianCfg as Record)?.killSystem ?? {}; return { enabled: killCfg.enabled ?? false, passcodeMode: killCfg.passcodeMode ?? "totp", staticPasscode: killCfg.staticPasscode, totpSecret: killCfg.totpSecret, allowList: killCfg.allowList ?? [], maxAttempts: killCfg.maxAttempts ?? 3, cooldownSeconds: killCfg.cooldownSeconds ?? 300, challengeExpirySeconds: killCfg.challengeExpirySeconds ?? 120, }; } /** Build passcode prompt text. */ function buildPasscodePrompt(killCfg: KillSystemConfig, verb: string): string { if (killCfg.passcodeMode === "static") { return `Enter the 6-digit passcode to ${verb} the session.`; } return `Enter your TOTP code to ${verb} the session.`; } /** * Get sessions relevant for the kill command. * kill → returns active (non-stopped) sessions * unkill → returns guardian-stopped sessions */ async function getAccountSessions( account: Record, config: Record, command: "kill" | "unkill" ): Promise<{ sessions: string[]; error?: string }> { const baseUrl = account?.baseUrl ?? config?.baseUrl ?? ""; const apiKey = account?.apiKey ?? config?.apiKey ?? ""; if (!baseUrl || !apiKey) return { sessions: [], error: "Missing baseUrl or apiKey" }; try { const sessions = await callWahaApi({ baseUrl, apiKey, path: "/api/sessions", method: "GET", skipRateLimit: true, }); if (!Array.isArray(sessions)) return { sessions: [], error: "WAHA returned unexpected response format" }; if (command === "kill") { // Return sessions that are not stopped return { sessions: sessions .filter((s: Record) => s?.status !== "STOPPED" && s?.status !== "FAILED") .map((s: Record) => s?.name ?? s?.id) .filter(Boolean) as string[], }; } else { // unkill — return sessions that are stopped (guardian-stopped or STOPPED) return { sessions: sessions .filter((s: Record) => s?.status === "STOPPED" || s?.status === "FAILED") .map((s: Record) => s?.name ?? s?.id) .filter(Boolean) as string[], }; } } catch (err) { log.warn("kill-system: failed to fetch sessions", { error: String(err) }); return { sessions: [], error: String(err) }; } } /** * Execute stop or restart on a session via WAHA API. * Returns confirmation reply text with ISO timestamp. * Phase 68 (GUARD-03, GUARD-04). DO NOT REMOVE. */ async function executeKillAction( command: "kill" | "unkill", session: string, account: Record, config: Record, ): Promise { const baseUrl = account?.baseUrl ?? config?.baseUrl ?? ""; const apiKey = account?.apiKey ?? config?.apiKey ?? ""; const ts = new Date().toISOString(); // Phase 69 (GUARD-08): Read alertWebhookUrl from guardianConfig for manual-kill/unkill alerts. // DO NOT REMOVE — required for GUARD-08 manual kill alerting. const alertWebhookUrl: string | undefined = config?.guardianConfig?.alertWebhookUrl; if (command === "kill") { try { await callWahaApi({ baseUrl, apiKey, path: `/api/sessions/${encodeURIComponent(session)}/stop`, method: "POST", skipRateLimit: true, }); markGuardianStopped(session); log.warn("kill-system: session stopped by operator", { session, ts }); // Phase 69 (GUARD-08): Fire alert on manual kill. DO NOT REMOVE. if (alertWebhookUrl) { sendGuardianAlert({ alertWebhookUrl, event: "manual-kill", session, detail: { operator: "whatsapp" } }); } return `Session "${session}" stopped at ${ts}.`; } catch (err) { log.warn("kill-system: stop API call failed", { session, error: String(err) }); return `Failed to stop session "${session}": ${String(err)}`; } } else { try { await callWahaApi({ baseUrl, apiKey, path: `/api/sessions/${encodeURIComponent(session)}/restart`, method: "POST", skipRateLimit: true, }); clearGuardianStopped(session); log.info("kill-system: session restarted by operator", { session, ts }); // Phase 69 (GUARD-08): Fire alert on manual unkill. DO NOT REMOVE. if (alertWebhookUrl) { sendGuardianAlert({ alertWebhookUrl, event: "manual-unkill", session, detail: { operator: "whatsapp" } }); } return `Session "${session}" restarted at ${ts}.`; } catch (err) { log.warn("kill-system: restart API call failed", { session, error: String(err) }); return `Failed to restart session "${session}": ${String(err)}`; } } }