该提交新增了完整的 Nostr 去中心化社交网络适配器实现,包含以下核心功能: 1. 基础加密与密钥处理:支持 nsec/npub/hex 格式密钥转换,实现 NIP-04/NIP-17 加解密 2. Relay 管理与健康监控:支持多 Relay 连接、自动重连、健康评分与自动选优 3. 事件与消息处理:实现事件校验、去重、速率限制、消息缓存与状态持久化 4. 个人资料管理:支持发布/导入/合并 Nostr Kind 0 元数据事件 5. 配对机制:实现安全的双向配对通信流程 6. Zap 功能:支持 NIP-57 打赏请求与收据解析 7. NIP-05 验证:实现域名身份验证 8. 配置系统:完整的配置校验与多账户支持 9. 监控与指标:提供连接监控、事件统计与健康快照 10. HTTP API:提供个人资料管理的 RESTful 接口 11. 安装向导:命令行配置向导与初始化流程 所有模块均遵循 Nostr 协议规范,支持多账户、多 Relay 部署,内置安全防护与流量控制机制。
174 lines
6.6 KiB
Python
174 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
import secrets
|
|
import time
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from yuxi.channels.adapters.nostr.crypto import NostrCrypto
|
|
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
|
|
class NostrPairingManager:
|
|
PAIRING_CHALLENGE_PREFIX = "🔐 PairingChallenge::"
|
|
|
|
def __init__(self, crypto: NostrCrypto, relay_manager: RelayManager):
|
|
self._crypto = crypto
|
|
self._relay_manager = relay_manager
|
|
self._pending: dict[str, dict] = {}
|
|
self._on_pairing_request: list[Callable[[str, str], Awaitable[None]]] = []
|
|
self._on_pairing_approved: list[Callable[[str, str], Awaitable[None]]] = []
|
|
self._on_pairing_denied: list[Callable[[str, str], Awaitable[None]]] = []
|
|
|
|
def on_pairing_request(self, handler: Callable[[str, str], Awaitable[None]]) -> None:
|
|
"""注册配对请求回调 (challenge_id, pubkey)"""
|
|
self._on_pairing_request.append(handler)
|
|
|
|
def on_pairing_approved(self, handler: Callable[[str, str], Awaitable[None]]) -> None:
|
|
"""注册配对批准回调 (challenge_id, pubkey)"""
|
|
self._on_pairing_approved.append(handler)
|
|
|
|
def on_pairing_denied(self, handler: Callable[[str, str], Awaitable[None]]) -> None:
|
|
"""注册配对拒绝回调 (challenge_id, pubkey)"""
|
|
self._on_pairing_denied.append(handler)
|
|
|
|
async def _notify_pairing_request(self, challenge_id: str, pubkey: str) -> None:
|
|
for handler in self._on_pairing_request:
|
|
try:
|
|
await handler(challenge_id, pubkey)
|
|
except Exception:
|
|
logger.debug("on_pairing_request handler error", exc_info=True)
|
|
|
|
async def _notify_pairing_approved(self, challenge_id: str, pubkey: str) -> None:
|
|
for handler in self._on_pairing_approved:
|
|
try:
|
|
await handler(challenge_id, pubkey)
|
|
except Exception:
|
|
logger.debug("on_pairing_approved handler error", exc_info=True)
|
|
|
|
async def _notify_pairing_denied(self, challenge_id: str, pubkey: str) -> None:
|
|
for handler in self._on_pairing_denied:
|
|
try:
|
|
await handler(challenge_id, pubkey)
|
|
except Exception:
|
|
logger.debug("on_pairing_denied handler error", exc_info=True)
|
|
|
|
def issue_challenge(self, target_pubkey: str) -> str:
|
|
token = secrets.token_hex(16)
|
|
challenge_id = secrets.token_hex(4)
|
|
full_token = f"{self.PAIRING_CHALLENGE_PREFIX}{challenge_id}::{token}"
|
|
self._pending[challenge_id] = {
|
|
"pubkey": target_pubkey,
|
|
"token": token,
|
|
"status": "pending",
|
|
"created_at": time.monotonic(),
|
|
}
|
|
return full_token
|
|
|
|
def verify_response(self, content: str) -> tuple[bool, str]:
|
|
if not content.startswith(self.PAIRING_CHALLENGE_PREFIX):
|
|
return False, "not a pairing response"
|
|
try:
|
|
body = content[len(self.PAIRING_CHALLENGE_PREFIX) :]
|
|
parts = body.split("::", 1)
|
|
challenge_id = parts[0]
|
|
token = parts[1] if len(parts) > 1 else ""
|
|
except (ValueError, IndexError):
|
|
return False, "invalid pairing format"
|
|
|
|
pending = self._pending.get(challenge_id)
|
|
if not pending:
|
|
return False, f"unknown challenge id: {challenge_id}"
|
|
if pending["token"] != token:
|
|
return False, "token mismatch"
|
|
if pending["status"] != "pending":
|
|
return False, f"challenge already {pending['status']}"
|
|
|
|
pending["status"] = "approved"
|
|
return True, challenge_id
|
|
|
|
async def approve_challenge(self, challenge_id: str) -> tuple[bool, str]:
|
|
"""通过 UI 回调批准配对请求"""
|
|
pending = self._pending.get(challenge_id)
|
|
if not pending:
|
|
return False, f"unknown challenge id: {challenge_id}"
|
|
if pending["status"] != "pending":
|
|
return False, f"challenge already {pending['status']}"
|
|
|
|
pending["status"] = "approved"
|
|
pubkey = pending["pubkey"]
|
|
await self._notify_pairing_approved(challenge_id, pubkey)
|
|
return True, challenge_id
|
|
|
|
def deny_challenge(self, challenge_id: str) -> bool:
|
|
pending = self._pending.get(challenge_id)
|
|
if not pending:
|
|
return False
|
|
pending["status"] = "denied"
|
|
return True
|
|
|
|
async def deny_challenge_async(self, challenge_id: str) -> bool:
|
|
"""通过 UI 回调拒绝配对请求"""
|
|
pending = self._pending.get(challenge_id)
|
|
if not pending:
|
|
return False
|
|
pending["status"] = "denied"
|
|
await self._notify_pairing_denied(challenge_id, pending["pubkey"])
|
|
return True
|
|
|
|
def list_pending(self) -> list[dict]:
|
|
return [
|
|
{
|
|
"id": cid,
|
|
"pubkey": p["pubkey"],
|
|
"status": p["status"],
|
|
"created_at": p.get("created_at", 0),
|
|
}
|
|
for cid, p in self._pending.items()
|
|
if p["status"] == "pending"
|
|
]
|
|
|
|
def list_all(self) -> list[dict]:
|
|
return [
|
|
{
|
|
"id": cid,
|
|
"pubkey": p["pubkey"],
|
|
"status": p["status"],
|
|
"created_at": p.get("created_at", 0),
|
|
}
|
|
for cid, p in self._pending.items()
|
|
]
|
|
|
|
def get_pending(self, challenge_id: str) -> dict | None:
|
|
pending = self._pending.get(challenge_id)
|
|
if not pending:
|
|
return None
|
|
return {
|
|
"id": challenge_id,
|
|
"pubkey": pending["pubkey"],
|
|
"status": pending["status"],
|
|
"created_at": pending.get("created_at", 0),
|
|
}
|
|
|
|
def cleanup_expired(self, ttl_sec: int = 300) -> int:
|
|
now = time.monotonic()
|
|
removed = 0
|
|
for cid in list(self._pending.keys()):
|
|
entry = self._pending[cid]
|
|
if entry["status"] != "pending":
|
|
self._pending.pop(cid, None)
|
|
removed += 1
|
|
elif now - entry.get("created_at", 0) > ttl_sec:
|
|
entry["status"] = "expired"
|
|
self._pending.pop(cid, None)
|
|
removed += 1
|
|
return removed
|
|
|
|
async def send_pairing_challenge(self, target_pubkey: str, chat_type: str = "direct") -> str | None:
|
|
challenge = self.issue_challenge(target_pubkey)
|
|
event = self._crypto.build_and_sign_event(kind=4, content=challenge, tags=[["p", target_pubkey]])
|
|
success_count = await self._relay_manager.broadcast(event)
|
|
if success_count > 0:
|
|
return challenge
|
|
return None
|