import { randomBytes } from 'crypto'; export interface PairingConfig { enabled: boolean; codeTtl: number; // milliseconds codeLength: number; // number of characters } interface PendingCode { code: string; createdAt: number; expiresAt: number; /** Optional label for the code (e.g. "for alice"). */ label?: string; } export interface ApprovedSender { channel: string; senderId: string; approvedAt: number; /** The code that was used. */ codeUsed: string; } export interface PairingStore { loadApproved(): ApprovedSender[]; saveApproved(sender: ApprovedSender): void; removeApproved(channel: string, senderId: string): void; } /** * Manages DM pairing codes for authenticating unknown senders. * * Flow: * 1. Admin generates a pairing code via gateway API or TUI command. * 2. Unknown sender DMs the bot with the code as their first message. * 3. If the code is valid and not expired, the sender is approved. * 4. Approved senders bypass the allowlist check for subsequent messages. */ export class PairingManager { private config: PairingConfig; private pendingCodes: Map = new Map(); private approvedSenders: Map = new Map(); private store?: PairingStore; constructor(config: PairingConfig, store?: PairingStore) { this.config = config; this.store = store; if (store) { for (const sender of store.loadApproved()) { const key = `${sender.channel}:${sender.senderId}`; this.approvedSenders.set(key, sender); } } } /** Generate a new pairing code. Returns the code string. */ generateCode(label?: string): string { this.cleanup(); const code = randomBytes(Math.ceil(this.config.codeLength / 2)) .toString('hex') .slice(0, this.config.codeLength) .toUpperCase(); const now = Date.now(); this.pendingCodes.set(code, { code, createdAt: now, expiresAt: now + this.config.codeTtl, label, }); return code; } /** * Validate a code for a given channel+sender. * If valid, adds the sender to the approved list and removes the code. * Returns true if the code was valid. */ validateCode(channel: string, senderId: string, code: string): boolean { this.cleanup(); const normalizedCode = code.trim().toUpperCase(); const pending = this.pendingCodes.get(normalizedCode); if (!pending) {return false;} if (Date.now() > pending.expiresAt) { this.pendingCodes.delete(normalizedCode); return false; } // Code is valid — approve the sender const key = `${channel}:${senderId}`; const approved: ApprovedSender = { channel, senderId, approvedAt: Date.now(), codeUsed: normalizedCode, }; this.approvedSenders.set(key, approved); this.store?.saveApproved(approved); // Remove the used code this.pendingCodes.delete(normalizedCode); return true; } /** Check if a sender is already approved. */ isApproved(channel: string, senderId: string): boolean { const key = `${channel}:${senderId}`; return this.approvedSenders.has(key); } /** Revoke approval for a sender. Returns true if the sender was found and removed. */ revokeApproval(channel: string, senderId: string): boolean { const key = `${channel}:${senderId}`; const deleted = this.approvedSenders.delete(key); if (deleted) { this.store?.removeApproved(channel, senderId); } return deleted; } /** List all currently approved senders. */ listApproved(): ApprovedSender[] { return Array.from(this.approvedSenders.values()); } /** List all pending (non-expired) codes. */ listPendingCodes(): Array<{ code: string; expiresAt: number; label?: string }> { this.cleanup(); return Array.from(this.pendingCodes.values()).map(p => ({ code: p.code, expiresAt: p.expiresAt, label: p.label, })); } /** Remove expired codes. */ cleanup(): void { const now = Date.now(); for (const [code, pending] of this.pendingCodes) { if (now > pending.expiresAt) { this.pendingCodes.delete(code); } } } /** Whether pairing is enabled. */ get enabled(): boolean { return this.config.enabled; } }