新增IRC协议相关的全套工具模块,包括: - 核心协议解析与CTCP处理 - 消息发送缓存与文本 sanitize - 账号配置管理与运行时状态 - 命令处理与权限控制 - 服务发现与诊断工具 - 多账号网关与配置加载
318 lines
9.6 KiB
Python
318 lines
9.6 KiB
Python
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
|