import logging import re import secrets import time from yuxi.channel.extensions.irc.config import DmPolicy, GroupPolicy, IrcConfig from yuxi.channel.extensions.irc.normalize import build_irc_allowlist_candidates from yuxi.channel.extensions.irc.types import ResolvedIrcAccount logger = logging.getLogger(__name__) _PAIRING_CODES: dict[str, tuple[str, float]] = {} _CODE_TTL_SECONDS = 300 _ID_LABEL = "ircNick" def build_irc_mention_regex(nick: str) -> re.Pattern: escaped = re.escape(nick) return re.compile(rf"\b{escaped}\b[:,]?", re.IGNORECASE) def is_mentioned(nick: str, text: str) -> bool: pattern = build_irc_mention_regex(nick) return bool(pattern.search(text)) def resolve_dm_policy(account: ResolvedIrcAccount) -> DmPolicy: return account.config.dm_policy def resolve_group_policy(account: ResolvedIrcAccount) -> GroupPolicy: return account.config.group_policy def match_irc_group( target: str, groups: dict[str, object], channels: list[str], account_nick: str, ) -> object | None: normalized = target.lower() for channel in channels: if channel.lower() == normalized: key = channel if key in groups: return (key, groups[key]) return (key, None) for group_key, group_cfg in groups.items(): if group_key.lower() == normalized: return (group_key, group_cfg) if group_key == "*": return (group_key, group_cfg) return None def check_allowlist_match( nick: str, user: str | None, host: str | None, allowlist: list[str], allow_name_matching: bool = False, ) -> bool: if "*" in allowlist: return True candidates = build_irc_allowlist_candidates(nick, user, host, allow_name_matching) allowlist_lower = {entry.lower() for entry in allowlist} for candidate in candidates: if candidate in allowlist_lower: return True return False def resolve_irc_require_mention( target: str, groups: dict[str, object], channels: list[str], ) -> bool: normalized = target.lower() for channel in channels: if channel.lower() == normalized: match = groups.get(channel) if match is not None and hasattr(match, "require_mention"): return match.require_mention break wildcard = groups.get("*") if wildcard is not None and hasattr(wildcard, "require_mention"): return wildcard.require_mention return True def collect_irc_security_warnings(account: ResolvedIrcAccount) -> list[str]: warnings = [] if not account.tls: warnings.append( "IRC TLS is disabled — traffic and credentials are transmitted in plaintext" ) nickserv = account.config.nickserv if nickserv.register_nick: if not account.nickserv_password: warnings.append( "NickServ registration is enabled but no NickServ password is configured" ) else: warnings.append( "NickServ registration is enabled — disable 'register' after initial registration" ) if account.config.group_policy == GroupPolicy.OPEN: warnings.append( "Group policy is set to 'open' — all channels can receive messages (mention-gated). " "Consider setting to 'allowlist'." ) for entry in account.config.allow_from: if "!" not in entry and "@" not in entry and entry != "*": warnings.append( f"Allowlist entry '{entry}' is a bare nick without !user@host — " "it could be spoofed. Use full nick!user@host format for security." ) return warnings def collect_mutable_allowlist_warnings(cfg: IrcConfig) -> list[str]: warnings = [] for account_cfg in cfg.accounts.values(): for entry in account_cfg.allow_from: if "!" not in entry and "@" not in entry and entry != "*": warnings.append( f"Account '{account_cfg.name or 'unknown'}': mutable allowlist entry '{entry}' " "(bare nick, could be spoofed)" ) return warnings async def generate_irc_pairing_code(peer_id: str) -> str: code = f"{secrets.randbelow(1_000_000):06d}" _PAIRING_CODES[peer_id] = (code, time.monotonic()) logger.info("IRC pairing code generated for peer %s", peer_id) return code async def verify_irc_pairing_code(peer_id: str, code: str) -> bool: stored = _PAIRING_CODES.get(peer_id) if stored is None: return False stored_code, created_at = stored if time.monotonic() - created_at > _CODE_TTL_SECONDS: _PAIRING_CODES.pop(peer_id, None) return False if not secrets.compare_digest(stored_code, code): return False _PAIRING_CODES.pop(peer_id, None) logger.info("IRC pairing code verified for peer %s", peer_id) return True def normalize_irc_allow_entry(entry: str) -> str: stripped = entry.strip() for prefix in ("irc:", "IRC:"): if stripped.startswith(prefix): stripped = stripped[len(prefix):] if "!" in stripped or "@" in stripped: return stripped.lower() return stripped