ForcePilot/backend/package/yuxi/channels/adapters/irc/auth.py
Kris 2dd1a075f2 feat(irc): 实现完整的IRC适配器基础功能模块
新增IRC协议相关的全套工具模块,包括:
- 核心协议解析与CTCP处理
- 消息发送缓存与文本 sanitize
- 账号配置管理与运行时状态
- 命令处理与权限控制
- 服务发现与诊断工具
- 多账号网关与配置加载
2026-05-12 00:45:10 +08:00

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