from __future__ import annotations import asyncio import base64 import hashlib import hmac import os import secrets from collections.abc import Awaitable, Callable from yuxi.channels.exceptions import ChannelAuthenticationError from yuxi.utils.logging_config import logger _AUTH_CHUNK_SIZE = 400 def _read_password_file(filepath: str) -> str | None: if not filepath or not os.path.isfile(filepath): return None try: with open(filepath, encoding="utf-8") as f: content = f.read().strip() return content or None except OSError as e: logger.warning(f"Failed to read password file {filepath}: {e}") return None def resolve_password( password: str = "", password_file: str = "", env_var: str = "", ) -> str: if password_file: file_content = _read_password_file(password_file) if file_content: return file_content if env_var: env_value = os.environ.get(env_var, "") if env_value: return env_value return password async def send_pass( send_fn: Callable[[str], None], server_password: str, ) -> None: send_fn(f"PASS {server_password}") logger.info("IRC PASS command sent") async def nickserv_register( send_fn: Callable[[str], None], password: str, email: str = "", service: str = "NickServ", ) -> None: if email: send_fn(f"PRIVMSG {service} :REGISTER {password} {email}") else: send_fn(f"PRIVMSG {service} :REGISTER {password}") logger.info(f"IRC {service} REGISTER sent") async def sasl_authenticate( send_fn: Callable[[str], None], read_fn: Callable[[], Awaitable[str | None]], sasl_username: str, sasl_password: str, timeout: float = 30.0, ) -> None: deadline = asyncio.get_running_loop().time() + timeout sasl_encoded = await _sasl_read_ack(read_fn, deadline) if not sasl_encoded: raise ChannelAuthenticationError("SASL: server did not ACK sasl capability") send_fn("AUTHENTICATE PLAIN") await _expect_line(read_fn, deadline, "AUTHENTICATE +") auth_str = f"{sasl_username}\x00{sasl_username}\x00{sasl_password}" encoded = base64.b64encode(auth_str.encode()).decode() _send_authenticate_chunks(send_fn, encoded) await _sasl_read_result(read_fn, deadline) logger.info("IRC SASL authentication successful") def _send_authenticate_chunks(send_fn: Callable[[str], None], data: str) -> None: for i in range(0, len(data), _AUTH_CHUNK_SIZE): chunk = data[i : i + _AUTH_CHUNK_SIZE] send_fn(f"AUTHENTICATE {chunk}") if len(data) % _AUTH_CHUNK_SIZE == 0: send_fn("AUTHENTICATE +") async def _sasl_read_ack( read_fn: Callable[[], Awaitable[str | None]], deadline: float, ) -> bool: while asyncio.get_running_loop().time() < deadline: line = await asyncio.wait_for( read_fn(), timeout=max(0.1, deadline - asyncio.get_running_loop().time()), ) if line is None: continue if "ACK" in line and "sasl" in line: return True return False async def _expect_line( read_fn: Callable[[], Awaitable[str | None]], deadline: float, expected: str, ) -> None: while asyncio.get_running_loop().time() < deadline: line = await asyncio.wait_for( read_fn(), timeout=max(0.1, deadline - asyncio.get_running_loop().time()), ) if line is None: continue if expected in line: return raise ChannelAuthenticationError(f"SASL: expected '{expected}' not received") async def _sasl_read_result( read_fn: Callable[[], Awaitable[str | None]], deadline: float, ) -> None: while asyncio.get_running_loop().time() < deadline: line = await asyncio.wait_for( read_fn(), timeout=max(0.1, deadline - asyncio.get_running_loop().time()), ) if line is None: continue if "900" in line or "903" in line: return if "904" in line or "905" in line: raise ChannelAuthenticationError(f"SASL authentication failed: {line}") raise ChannelAuthenticationError("SASL authentication timed out") async def nickserv_identify( send_fn: Callable[[str], None], password: str, service: str = "NickServ", ) -> None: send_fn(f"PRIVMSG {service} :IDENTIFY {password}") logger.info(f"IRC {service} IDENTIFY sent") async def nick_recover( send_fn: Callable[[str], None], nick: str, sasl_password: str, service: str = "NickServ", ) -> None: send_fn(f"PRIVMSG {service} :GHOST {nick} {sasl_password}") await asyncio.sleep(0.5) send_fn(f"NICK {nick}") logger.info(f"IRC nick recovery attempted for {nick} via {service}") async def sasl_external_authenticate( send_fn: Callable[[str], None], read_fn: Callable[[], Awaitable[str | None]], timeout: float = 30.0, ) -> None: deadline = asyncio.get_running_loop().time() + timeout sasl_encoded = await _sasl_read_ack(read_fn, deadline) if not sasl_encoded: raise ChannelAuthenticationError("SASL EXTERNAL: server did not ACK sasl capability") send_fn("AUTHENTICATE EXTERNAL") await _expect_line(read_fn, deadline, "AUTHENTICATE +") send_fn("AUTHENTICATE +") await _sasl_read_result(read_fn, deadline) logger.info("IRC SASL EXTERNAL authentication successful") def get_server_password( config: dict, env_prefix: str = "IRC", ) -> str: server_password = config.get("server_password", "") password_file = config.get("server_password_file", "") if password_file and not server_password: server_password = _read_password_file(password_file) or "" if not server_password: server_password = os.environ.get(f"{env_prefix}_SERVER_PASSWORD", "") return server_password async def sasl_scram_sha256_authenticate( send_fn: Callable[[str], None], read_fn: Callable[[], Awaitable[str | None]], sasl_username: str, sasl_password: str, timeout: float = 30.0, ) -> None: deadline = asyncio.get_running_loop().time() + timeout acked = await _sasl_read_ack(read_fn, deadline) if not acked: raise ChannelAuthenticationError("SASL SCRAM-SHA-256: server did not ACK sasl capability") send_fn("AUTHENTICATE SCRAM-SHA-256") await _expect_line(read_fn, deadline, "AUTHENTICATE +") client_nonce = secrets.token_urlsafe(24) client_first_bare = f"n={_saslname_prep(sasl_username)},r={client_nonce}" gs2_header = "n,," client_first = gs2_header + client_first_bare _send_authenticate_chunks(send_fn, base64.b64encode(client_first.encode()).decode()) server_first_line = await _sasl_read_response(read_fn, deadline) server_first = base64.b64decode(server_first_line).decode() server_attrs = dict(_parse_sasl_attrs(server_first)) r = server_attrs.get("r", "") s = server_attrs.get("s", "") i = int(server_attrs.get("i", "4096")) if not r.startswith(client_nonce): raise ChannelAuthenticationError("SASL SCRAM-SHA-256: server nonce does not match") salt = base64.b64decode(s) iterations = i salted_password = hashlib.pbkdf2_hmac( "sha256", _saslname_prep(sasl_password).encode("utf-8"), salt, iterations, ) client_key = hmac.new(salted_password, b"Client Key", hashlib.sha256).digest() stored_key = hashlib.sha256(client_key).digest() client_final_without_proof = f"c={base64.b64encode(gs2_header.encode()).decode()},r={r}" auth_message = f"{client_first_bare},{server_first},{client_final_without_proof}" server_key = hmac.new(salted_password, b"Server Key", hashlib.sha256).digest() client_signature = hmac.new(stored_key, auth_message.encode(), hashlib.sha256).digest() client_proof = bytes(a ^ b for a, b in zip(client_key, client_signature)) client_final = f"{client_final_without_proof},p={base64.b64encode(client_proof).decode()}" _send_authenticate_chunks(send_fn, base64.b64encode(client_final.encode()).decode()) server_final_line = await _sasl_read_response(read_fn, deadline) server_final = base64.b64decode(server_final_line).decode() server_final_attrs = dict(_parse_sasl_attrs(server_final)) server_signature_b64 = server_final_attrs.get("v", "") if not server_signature_b64: raise ChannelAuthenticationError("SASL SCRAM-SHA-256: server did not send verifier") expected_server_signature = hmac.new(server_key, auth_message.encode(), hashlib.sha256).digest() if not hmac.compare_digest( base64.b64decode(server_signature_b64), expected_server_signature, ): raise ChannelAuthenticationError("SASL SCRAM-SHA-256: server signature verification failed") logger.info("IRC SASL SCRAM-SHA-256 authentication successful") async def _sasl_read_response( read_fn: Callable[[], Awaitable[str | None]], deadline: float, ) -> str: while asyncio.get_running_loop().time() < deadline: line = await asyncio.wait_for( read_fn(), timeout=max(0.1, deadline - asyncio.get_running_loop().time()), ) if line is None: continue if "AUTHENTICATE " in line and "AUTHENTICATE +" not in line: return line.split("AUTHENTICATE ", 1)[1].strip() raise ChannelAuthenticationError("SASL SCRAM-SHA-256: no response from server") def _saslname_prep(name: str) -> str: return name.replace("=", "=3D").replace(",", "=2C") def _parse_sasl_attrs(data: str) -> list[tuple[str, str]]: pairs: list[tuple[str, str]] = [] for item in data.split(","): if "=" in item: key, value = item.split("=", 1) pairs.append((key, value)) return pairs