diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7baae9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# 运行时数据(聊天记录/账户/服务器状态) +data/ +server_data/ +ChaosCryptChat/data/ +ChaosCryptChat/server_data/ + +# 密钥文件(严禁上传!) +*.key + +# Python 缓存 +__pycache__/ +*.pyc + +# 媒体缓存 +media/ + +# 系统文件 +Thumbs.db +.DS_Store +desktop.ini diff --git a/ChaosCryptChat/qtmain.py b/ChaosCryptChat/qtmain.py new file mode 100644 index 0000000..6fafea7 --- /dev/null +++ b/ChaosCryptChat/qtmain.py @@ -0,0 +1,4147 @@ +# -*- coding: utf-8 -*- +""" +🔐 混沌加密聊天 v6 - 单文件版 (PyQt5 客户端 + 全部后端) +============================================================ +· 后端: 混沌加密/每用户密钥认证/账户SQLite/历史SQLite/媒体持久存储 +· 界面: PyQt5 现代圆角UI, 自适应消息气泡 +· 两种群: 🔗端到端(P2P) + ☁️中央服务器(解决NAT) +· 中央服务器单独运行 server.py +运行: python dvs_enc.py +""" +import os +os.environ.setdefault('PYGAME_HIDE_SUPPORT_PROMPT', '1') +import io +import sys +import time +import queue +import wave +import json +import base64 +import struct +import socket +import hashlib +import hmac +import threading +import tempfile +import sqlite3 +import shutil +from datetime import datetime + +# ---------------- 路径 ---------------- +APP_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(APP_DIR, 'data') +try: + os.makedirs(DATA_DIR, exist_ok=True) +except Exception: + DATA_DIR = APP_DIR + +USERS_DB = os.path.join(DATA_DIR, 'users.db') +HISTORY_DB = os.path.join(DATA_DIR, 'chat_history.db') +SESSION_FILE = os.path.join(DATA_DIR, 'session.dat') +SERVER_DB = os.path.join(DATA_DIR, 'server_state.db') + +# 媒体持久目录: 文档/ChaosChat/media (用户可在 GUI 清理, 清理后数据消失) +MEDIA_DIR = os.path.join(os.path.expanduser('~'), 'Documents', 'ChaosChat', 'media') +try: + os.makedirs(MEDIA_DIR, exist_ok=True) +except Exception: + MEDIA_DIR = os.path.join(DATA_DIR, 'media') + try: + os.makedirs(MEDIA_DIR, exist_ok=True) + except Exception: + pass + +# ---------------- 可选依赖 ---------------- +try: + import tkinter as tk + from tkinter import scrolledtext, messagebox, simpledialog, filedialog + GUI_AVAILABLE = True +except Exception: + GUI_AVAILABLE = False + +try: + import pygame + PYGAME_AVAILABLE = True +except Exception: + PYGAME_AVAILABLE = False + +try: + from PIL import Image, ImageTk + PIL_AVAILABLE = True +except Exception: + PIL_AVAILABLE = False + +# 录音后端: 优先 sounddevice, 其次 pyaudio +RECORDER = None +try: + import sounddevice as sd + import numpy as np + RECORDER = 'sounddevice' +except Exception: + RECORDER = None +if RECORDER is None: + try: + import pyaudio + RECORDER = 'pyaudio' + except Exception: + RECORDER = None + +try: + import qrcode + QR_AVAILABLE = True +except Exception: + QR_AVAILABLE = False + + +# ==================== 混沌加密核心 ==================== +class ChaosCryptCore: + def __init__(self, debug=False): + self.debug = debug + self.special_encrypt = {'+': '.', '/': "'", '=': ''} + self.special_decrypt = {'.': '+', "'": '/'} + self.flip_pattern = [1, 1, 0, 0, 1, 0, 0, 0, 1, 0] + + def _generate_random_alphabet(self, lowercase=False): + chars = list("abcdefghijklmnopqrstuvwxyz" if lowercase else "ABCDEFGHIJKLMNOPQRSTUVWXYZ") + n = len(chars) + for i in range(n-1, 0, -1): + j = int.from_bytes(os.urandom(1), 'big') % (i+1) + chars[i], chars[j] = chars[j], chars[i] + return ''.join(chars) + + def _generate_random_digit_mapping(self): + digits = list("0123456789") + pool = list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + n = len(pool) + for i in range(n-1, 0, -1): + j = int.from_bytes(os.urandom(1), 'big') % (i+1) + pool[i], pool[j] = pool[j], pool[i] + return {d: pool[i] for i, d in enumerate(digits)} + + def _generate_random_equal_mapping(self): + chars = list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + n = len(chars) + for i in range(n-1, 0, -1): + j = int.from_bytes(os.urandom(1), 'big') % (i+1) + chars[i], chars[j] = chars[j], chars[i] + return {'0': chars[0], '1': chars[1], '2': chars[2], '3': chars[3]} + + def _generate_random_flip_pattern(self): + return [int.from_bytes(os.urandom(1), 'big') % 2 for _ in range(10)] + + def _generate_short_key(self, length=512): + chars = "0123456789abcdef" + return ''.join(chars[int.from_bytes(os.urandom(1), 'big') % 16] for _ in range(length)) + + def _build_maps(self, upper_mapping, lower_mapping, digit_mapping, equal_mapping): + upper_orig = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + lower_orig = "abcdefghijklmnopqrstuvwxyz" + enc = {} + dec = {} + for i in range(26): + enc[upper_orig[i]] = upper_mapping[i] + enc[lower_orig[i]] = lower_mapping[i] + dec[upper_mapping[i]] = upper_orig[i] + dec[lower_mapping[i]] = lower_orig[i] + digit_rev = {v: k for k, v in digit_mapping.items()} + equal_rev = {v: k for k, v in equal_mapping.items()} + return enc, dec, digit_rev, equal_rev + + def _ensure_printable(self, text): + res = [] + for c in text: + v = ord(c) + if v == 124: + res.append('|') + elif v < 32 or v > 126: + v = v % 95 + 32 + res.append(chr(v)) + else: + res.append(c) + return ''.join(res) + + def _mix_keys(self, user_password, short_key, target_length): + mixed = [] + max_len = max(len(user_password), len(short_key)) + for i in range(max_len): + if i < len(user_password): + mixed.append(ord(user_password[i])) + if i < len(short_key): + mixed.append(ord(short_key[i])) + for i in range(len(mixed)): + if i % 3 == 0: + mixed[i] = (mixed[i] << 1) & 0xFF + elif i % 3 == 1: + mixed[i] = (mixed[i] >> 1) & 0xFF + else: + mixed[i] = mixed[i] ^ 0x5A + for i in range(0, len(mixed)-3, 4): + mixed[i], mixed[i+3] = mixed[i+3], mixed[i] + mixed[i+1], mixed[i+2] = mixed[i+2], mixed[i+1] + mixed.reverse() + res = [] + for x in mixed: + if x < 32 or x > 126: + x = x % 95 + 32 + res.append(chr(x)) + res_str = ''.join(res) + if len(res_str) < target_length: + base = res_str + final = base + it = 0 + while len(final) < target_length: + if it % 3 == 0: + chunk = base[::-1] + elif it % 3 == 1: + chunk = base[::-1] + chunk = ''.join([chr((ord(c) + it) % 95 + 32) for c in chunk]) + else: + chunk = base[::-1] + chunk = ''.join([chr((ord(c) ^ it) % 95 + 32) for c in chunk]) + final += chunk + it += 1 + res_str = final[:target_length] + return self._ensure_printable(res_str) + + def _derive_final_key(self, user_password, short_key, text_length): + mixed = self._mix_keys(user_password, short_key, text_length * 2) + hex_key = ''.join([f"{ord(c):02x}" for c in mixed]) + rev_hex = hex_key[::-1] + final = [] + for i in range(0, len(rev_hex), 2): + if i+1 < len(rev_hex): + byte = rev_hex[i:i+2] + try: + val = int(byte, 16) + if val < 32 or val > 126: + val = val % 95 + 32 + final.append(chr(val)) + except Exception: + final.append('x') + final_str = ''.join(final) + if len(final_str) < text_length: + base = final_str + it = 0 + while len(final_str) < text_length: + if it % 3 == 0: + chunk = base[::-1] + elif it % 3 == 1: + chunk = base[::-1] + chunk = ''.join([chr((ord(c) + it) % 95 + 32) for c in chunk]) + else: + chunk = base[::-1] + chunk = ''.join([chr((ord(c) ^ it) % 95 + 32) for c in chunk]) + final_str += chunk + it += 1 + final_str = final_str[:text_length] + return self._ensure_printable(final_str) + + def _apply_flip(self, text, flip_pattern): + res = [] + for i, ch in enumerate(text): + idx = i % len(flip_pattern) + if ch.isalpha() and flip_pattern[idx] == 1: + res.append(ch.swapcase()) + else: + res.append(ch) + return ''.join(res) + + def _xor_encrypt_to_hex(self, text, key): + key_len = len(key) + res = [] + for i, ch in enumerate(text): + res.append(f"{ord(ch) ^ ord(key[i % key_len]):02x}") + return ''.join(res) + + def _xor_decrypt_to_string(self, hex_text, key): + try: + key_len = len(key) + res = [] + for i in range(0, len(hex_text), 2): + if i+1 < len(hex_text): + byte = hex_text[i:i+2] + val = int(byte, 16) ^ ord(key[(i//2) % key_len]) + if val < 32 or val > 126: + val = val % 95 + 32 + res.append(chr(val)) + return ''.join(res) + except Exception: + return None + + def _substitute_letters(self, text, enc_map): + return ''.join([enc_map.get(c, c) for c in text]) + + def _inverse_substitute(self, text, dec_map): + return ''.join([dec_map.get(c, c) for c in text]) + + def encrypt(self, plaintext, session_key, params): + b64 = base64.b64encode(plaintext.encode('utf-8')).decode('ascii') + processed = b64 + for old, new in self.special_encrypt.items(): + processed = processed.replace(old, new) + processed = processed.rstrip('=') + enc_map, _, _, _ = self._build_maps( + params['upper_mapping'], + params['lower_mapping'], + params['digit_mapping'], + params['equal_mapping'] + ) + sub = self._substitute_letters(processed, enc_map) + flipped = self._apply_flip(sub, params['flip_pattern']) + rev = flipped[::-1] + eq_count = b64.count('=') + eq_char = params['equal_mapping'][str(eq_count)] + with_equal = f"{rev}|{eq_char}" + final_key = self._derive_final_key( + session_key[:16], + params['short_key_fragment'], + len(with_equal) + ) + return self._xor_encrypt_to_hex(with_equal, final_key) + + def decrypt(self, ciphertext, session_key, params): + try: + final_key = self._derive_final_key( + session_key[:16], + params['short_key_fragment'], + len(ciphertext) // 2 + ) + decrypted = self._xor_decrypt_to_string(ciphertext, final_key) + if decrypted is None: + return None + if '|' in decrypted: + main, eq_char = decrypted.split('|') + eq_rev = {v: k for k, v in params['equal_mapping'].items()} + eq_count = int(eq_rev.get(eq_char, '0')) + else: + main = decrypted + eq_count = 0 + rev = main[::-1] + flipped = self._apply_flip(rev, params['flip_pattern']) + _, dec_map, _, _ = self._build_maps( + params['upper_mapping'], + params['lower_mapping'], + params['digit_mapping'], + params['equal_mapping'] + ) + sub = self._inverse_substitute(flipped, dec_map) + for old, new in self.special_decrypt.items(): + sub = sub.replace(old, new) + b64_str = sub + '=' * eq_count + return base64.b64decode(b64_str.encode('ascii')).decode('utf-8') + except Exception: + return None + + +# ==================== 会话密钥 (共享加密) ==================== +class SecureSession: + """基于 ChaosCryptCore 的共享会话加密 (群聊同密钥)""" + + def __init__(self): + self.core = ChaosCryptCore() + self.session_key = None + self.params = None + + def generate(self): + self.session_key = os.urandom(32).hex() + self.params = { + 'upper_mapping': self.core._generate_random_alphabet(False), + 'lower_mapping': self.core._generate_random_alphabet(True), + 'digit_mapping': self.core._generate_random_digit_mapping(), + 'equal_mapping': self.core._generate_random_equal_mapping(), + 'flip_pattern': self.core._generate_random_flip_pattern(), + 'short_key_fragment': self.core._generate_short_key(32), + 'generated_at': datetime.now().isoformat() + } + return self.session_key + + def export_key(self): + data = json.dumps({'session_key': self.session_key, 'params': self.params}, ensure_ascii=False) + return base64.urlsafe_b64encode(data.encode('utf-8')).decode('ascii') + + def import_key(self, key_str): + try: + raw = base64.urlsafe_b64decode(key_str.encode('ascii')) + data = json.loads(raw.decode('utf-8')) + self.session_key = data['session_key'] + self.params = data['params'] + return True + except Exception: + return False + + def has_key(self): + return self.session_key is not None and self.params is not None + + def encrypt(self, plaintext): + if not self.has_key(): + raise ValueError("会话密钥未建立") + nonce = os.urandom(16).hex() + msg_key = hashlib.sha256((self.session_key + nonce).encode('utf-8')).hexdigest() + cipher = self.core.encrypt(plaintext, msg_key, self.params) + hmac = hashlib.sha256((cipher + self.session_key).encode('utf-8')).hexdigest()[:16] + return json.dumps({ + 'nonce': nonce, 'ciphertext': cipher, 'hmac': hmac, 'ts': int(time.time()) + }, ensure_ascii=False) + + def decrypt(self, packet_json): + try: + pkt = json.loads(packet_json) + hmac_calc = hashlib.sha256((pkt['ciphertext'] + self.session_key).encode('utf-8')).hexdigest()[:16] + if hmac_calc != pkt['hmac']: + return None + msg_key = hashlib.sha256((self.session_key + pkt['nonce']).encode('utf-8')).hexdigest() + return self.core.decrypt(pkt['ciphertext'], msg_key, self.params) + except Exception: + return None + + # ---- 二进制数据加密 (文件/图片/语音) ---- + def _keystream(self, nonce, length): + return hashlib.shake_256((self.session_key + nonce).encode('utf-8')).digest(length) + + @staticmethod + def _fast_xor(a, b, chunk=1 << 20): + out = bytearray() + n = len(a) + for i in range(0, n, chunk): + size = min(chunk, n - i) + x = int.from_bytes(a[i:i+size], 'big') ^ int.from_bytes(b[i:i+size], 'big') + out += x.to_bytes(size, 'big') + return bytes(out) + + def encrypt_bytes(self, data): + if not self.has_key(): + raise ValueError("会话密钥未建立") + nonce = os.urandom(16).hex() + ks = self._keystream(nonce, len(data)) + cipher = self._fast_xor(data, ks) + hmac = hashlib.sha256( + b'BIN|' + self.session_key.encode('utf-8') + b'|' + + nonce.encode('utf-8') + b'|' + cipher).hexdigest() + return json.dumps({'alg': 'xor', 'nonce': nonce, + 'data': base64.b64encode(cipher).decode('ascii'), + 'hmac': hmac}, ensure_ascii=False) + + def decrypt_bytes(self, packet): + try: + pkt = json.loads(packet) + nonce = pkt['nonce'] + cipher = base64.b64decode(pkt['data']) + hmac_calc = hashlib.sha256( + b'BIN|' + self.session_key.encode('utf-8') + b'|' + + nonce.encode('utf-8') + b'|' + cipher).hexdigest() + if hmac_calc != pkt['hmac']: + return None + ks = self._keystream(nonce, len(cipher)) + return self._fast_xor(cipher, ks) + except Exception: + return None + + def auth_secret(self): + """从会话密钥派生的群认证密钥 (证明是群成员)""" + return hashlib.sha256(b'CHAOS_AUTH|' + self.session_key.encode('utf-8')).hexdigest() + + def user_key_enc(self): + """用于加密下发每用户密钥的密钥 (客户端/服务器各自计算一致)""" + return hashlib.sha256(b'CHAOS_USERKEY_ENC|' + self.session_key.encode('utf-8')).hexdigest() + + +# ==================== 传输协议 (帧封装) ==================== +MAX_FRAME = 256 * 1024 * 1024 + +def send_msg(sock, obj): + payload = json.dumps(obj, ensure_ascii=False).encode('utf-8') + sock.sendall(struct.pack('>I', len(payload)) + payload) + + +def recv_exact(sock, n): + data = b'' + while len(data) < n: + try: + chunk = sock.recv(n - len(data)) + except socket.timeout: + continue + except Exception: + return None + if not chunk: + return None + data += chunk + return data + + +def recv_msg(sock): + header = recv_exact(sock, 4) + if header is None: + return None + length = struct.unpack('>I', header)[0] + if length <= 0 or length > MAX_FRAME: + return None + payload = recv_exact(sock, length) + if payload is None: + return None + try: + return json.loads(payload.decode('utf-8')) + except Exception: + return None + + +# ==================== 通用工具 ==================== +def stream_xor(data, key_bytes): + """shake256 密钥流异或 (用于加密下发每用户密钥)""" + ks = hashlib.shake_256(key_bytes).digest(len(data)) + out = bytearray() + n = len(data) + chunk = 1 << 20 + for i in range(0, n, chunk): + size = min(chunk, n - i) + x = int.from_bytes(data[i:i+size], 'big') ^ int.from_bytes(ks[i:i+size], 'big') + out += x.to_bytes(size, 'big') + return bytes(out) + + +def gen_mid(): + return f"{int(time.time()*1000)}-{os.urandom(4).hex()}" + + +def fmt_time(ts): + return datetime.fromtimestamp(ts).strftime('%H:%M:%S') + + +# ==================== 文件类型识别 ==================== +FILE_ICONS = { + 'image': '🖼️', 'audio': '🎵', 'video': '🎬', + 'archive': '📦', 'exe': '🛠️', 'lnk': '🔗', 'link': '🔗', + 'doc': '📝', 'sheet': '📊', 'slide': '📽️', 'pdf': '📃', + 'code': '💻', 'text': '📄', 'default': '📄', +} + + +def file_icon(ext): + """按扩展名返回可识别图标""" + e = (ext or '').lower().lstrip('.') + if e in ('png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'ico'): + return FILE_ICONS['image'] + if e in ('wav', 'mp3', 'ogg', 'flac', 'aac', 'm4a'): + return FILE_ICONS['audio'] + if e in ('mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv'): + return FILE_ICONS['video'] + if e in ('zip', 'rar', '7z', 'tar', 'gz', 'bz2'): + return FILE_ICONS['archive'] + if e in ('exe', 'msi', 'bat', 'cmd'): + return FILE_ICONS['exe'] + if e == 'lnk': + return FILE_ICONS['lnk'] + if e in ('doc', 'docx', 'rtf', 'odt'): + return FILE_ICONS['doc'] + if e in ('xls', 'xlsx', 'csv', 'ods'): + return FILE_ICONS['sheet'] + if e in ('ppt', 'pptx', 'odp'): + return FILE_ICONS['slide'] + if e == 'pdf': + return FILE_ICONS['pdf'] + if e in ('py', 'js', 'html', 'css', 'c', 'cpp', 'java', 'go', 'rs', 'json', 'xml'): + return FILE_ICONS['code'] + if e in ('txt', 'md', 'log'): + return FILE_ICONS['text'] + return FILE_ICONS['default'] + + +# ==================== 媒体持久存储 ==================== +def save_media(data, ext='.bin'): + """保存媒体文件到 文档/ChaosChat/media, 返回绝对路径""" + fname = f"{int(time.time()*1000)}-{os.urandom(4).hex()}{ext}" + path = os.path.join(MEDIA_DIR, fname) + try: + with open(path, 'wb') as f: + f.write(data) + return path + except Exception: + return '' + + +def delete_media(path): + try: + if path and os.path.exists(path): + os.remove(path) + except Exception: + pass + + +def clear_media_cache(): + """清空媒体缓存目录 (数据不可恢复)""" + if not os.path.isdir(MEDIA_DIR): + return 0 + count = 0 + for name in os.listdir(MEDIA_DIR): + p = os.path.join(MEDIA_DIR, name) + try: + if os.path.isfile(p): + os.remove(p) + count += 1 + except Exception: + pass + return count + + +# ==================== 账户数据库 (SQLite) ==================== +class AccountDB: + """账户 + 会话 + 已保存群""" + + def __init__(self, db_path=USERS_DB): + self.path = db_path + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.lock = threading.Lock() + self._init() + + def _init(self): + with self.lock: + cur = self.conn.cursor() + cur.execute('''CREATE TABLE IF NOT EXISTS accounts( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + nickname TEXT, + salt TEXT NOT NULL, + pass_hash TEXT NOT NULL, + created_at REAL)''') + cur.execute('''CREATE TABLE IF NOT EXISTS sessions( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + history_key TEXT, + created_at REAL)''') + scols = [r[1] for r in cur.execute('PRAGMA table_info(sessions)').fetchall()] + if 'history_key' not in scols: + cur.execute('ALTER TABLE sessions ADD COLUMN history_key TEXT') + cur.execute('''CREATE TABLE IF NOT EXISTS saved_groups( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + group_key TEXT NOT NULL, + host TEXT, port INTEGER, + session_key TEXT, + nickname TEXT, + is_host INTEGER DEFAULT 0, + user_key TEXT, + UNIQUE(account_id, group_key))''') + cols = [r[1] for r in cur.execute('PRAGMA table_info(saved_groups)').fetchall()] + if 'user_key' not in cols: + cur.execute('ALTER TABLE saved_groups ADD COLUMN user_key TEXT') + self.conn.commit() + + # ---- 密码哈希 (PBKDF2) ---- + @staticmethod + def hash_password(password, salt_hex): + salt = bytes.fromhex(salt_hex) + return hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 120000).hex() + + def register(self, username, password): + salt = os.urandom(16).hex() + ph = self.hash_password(password, salt) + try: + with self.lock: + cur = self.conn.cursor() + cur.execute('INSERT INTO accounts(username, nickname, salt, pass_hash, created_at) VALUES(?,?,?,?,?)', + (username, username, salt, ph, time.time())) + self.conn.commit() + return True, 'ok' + except sqlite3.IntegrityError: + return False, '该用户名已被注册' + + def login(self, username, password): + with self.lock: + cur = self.conn.cursor() + row = cur.execute('SELECT * FROM accounts WHERE username=?', (username,)).fetchone() + if row is None: + return None, '用户不存在' + ph = self.hash_password(password, row['salt']) + if ph != row['pass_hash']: + return None, '密码错误' + return row, 'ok' + + def create_session(self, account_id): + token = os.urandom(32).hex() + with self.lock: + cur = self.conn.cursor() + cur.execute('DELETE FROM sessions WHERE account_id=?', (account_id,)) + cur.execute('INSERT INTO sessions(account_id, token, created_at) VALUES(?,?,?)', + (account_id, token, time.time())) + self.conn.commit() + return token + + def validate_session(self, token): + with self.lock: + cur = self.conn.cursor() + row = cur.execute('''SELECT a.* FROM sessions s + JOIN accounts a ON a.id = s.account_id + WHERE s.token=?''', (token,)).fetchone() + return row + + def update_nickname(self, account_id, nickname): + with self.lock: + cur = self.conn.cursor() + cur.execute('UPDATE accounts SET nickname=? WHERE id=?', (nickname, account_id)) + self.conn.commit() + + # ---- 已保存的群 ---- + def save_group(self, account_id, group_key, host, port, session_key, nickname, is_host, user_key=''): + with self.lock: + cur = self.conn.cursor() + cur.execute('''INSERT INTO saved_groups(account_id, group_key, host, port, session_key, nickname, is_host, user_key) + VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(account_id, group_key) DO UPDATE SET + host=excluded.host, port=excluded.port, session_key=excluded.session_key, + nickname=excluded.nickname, is_host=excluded.is_host, + user_key=excluded.user_key''', + (account_id, group_key, host, port, session_key, nickname, + 1 if is_host else 0, user_key or '')) + self.conn.commit() + + def load_groups(self, account_id): + with self.lock: + cur = self.conn.cursor() + return cur.execute('SELECT * FROM saved_groups WHERE account_id=?', (account_id,)).fetchall() + + def remove_group(self, account_id, group_key): + with self.lock: + cur = self.conn.cursor() + cur.execute('DELETE FROM saved_groups WHERE account_id=? AND group_key=?', (account_id, group_key)) + self.conn.commit() + + +# ==================== 历史记录数据库 (SQLite) ==================== +class HistoryDB: + """群聊记录: 文本存内容, 文件/语音只存路径""" + + def __init__(self, db_path=HISTORY_DB): + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.lock = threading.Lock() + self._init() + + def _init(self): + with self.lock: + cur = self.conn.cursor() + cur.execute('''CREATE TABLE IF NOT EXISTS messages( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + group_key TEXT NOT NULL, + mid TEXT, + sender TEXT, + mtype TEXT, + content TEXT, + ts REAL, + is_mine INTEGER DEFAULT 0, + withdrawn INTEGER DEFAULT 0)''') + cur.execute('CREATE INDEX IF NOT EXISTS idx_msg ON messages(account_id, group_key, id)') + self.conn.commit() + + @staticmethod + def serialize_content(msg): + if msg.type in ('text', 'system'): + return str(msg.content) + if isinstance(msg.content, dict): + d = dict(msg.content) + d.pop('raw', None) # 不入库: 数据存文档目录, 库中只记路径 + return json.dumps(d, ensure_ascii=False) + return str(msg.content) + + @staticmethod + def deserialize_content(mtype, content): + if mtype in ('text', 'system'): + return content + try: + return json.loads(content) + except Exception: + return content + + def save(self, account_id, group_key, msg): + with self.lock: + cur = self.conn.cursor() + cur.execute('''INSERT INTO messages(account_id, group_key, mid, sender, mtype, content, ts, is_mine, withdrawn) + VALUES(?,?,?,?,?,?,?,?,?)''', + (account_id, group_key, msg.mid, msg.sender, msg.type, + self.serialize_content(msg), msg.timestamp, + 1 if msg.is_mine else 0, 1 if msg.withdrawn else 0)) + self.conn.commit() + + def load(self, account_id, group_key, limit=500): + with self.lock: + cur = self.conn.cursor() + return cur.execute('''SELECT * FROM ( + SELECT * FROM messages + WHERE account_id=? AND group_key=? + ORDER BY id DESC LIMIT ?) + ORDER BY id ASC''', + (account_id, group_key, limit)).fetchall() + + def delete_by_mid(self, account_id, group_key, mid): + with self.lock: + cur = self.conn.cursor() + cur.execute('DELETE FROM messages WHERE account_id=? AND group_key=? AND mid=?', + (account_id, group_key, mid)) + self.conn.commit() + + def clear_group(self, account_id, group_key): + with self.lock: + cur = self.conn.cursor() + cur.execute('DELETE FROM messages WHERE account_id=? AND group_key=?', (account_id, group_key)) + self.conn.commit() + + +# ==================== 语音管理 ==================== +class VoiceManager: + def __init__(self, on_status=None): + self.on_status = on_status + self.rate = 44100 + self.channels = 1 + self.recording = False + self._thread = None + self._frames = [] + self._result = None + self.last_voice = None + self.last_voice_from = '' + + def _status(self, text): + if self.on_status: + try: + self.on_status(text) + except Exception: + pass + + def _make_wav(self, pcm_bytes): + buf = io.BytesIO() + with wave.open(buf, 'wb') as wf: + wf.setnchannels(self.channels) + wf.setsampwidth(2) + wf.setframerate(self.rate) + wf.writeframes(pcm_bytes) + return buf.getvalue() + + def _record_worker(self): + self._result = None + try: + if RECORDER == 'sounddevice': + self._frames = [] + + def callback(indata, frames, time_info, status): + if self.recording: + self._frames.append(indata.copy()) + + with sd.InputStream(samplerate=self.rate, channels=1, + callback=callback, dtype='float32'): + while self.recording: + time.sleep(0.05) + if self._frames: + audio = np.concatenate(self._frames, axis=0) + pcm = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16).tobytes() + self._result = self._make_wav(pcm) + elif RECORDER == 'pyaudio': + p = pyaudio.PyAudio() + stream = p.open(format=pyaudio.paInt16, channels=1, rate=self.rate, + input=True, frames_per_buffer=1024) + frames = [] + while self.recording: + data = stream.read(1024, exception_on_overflow=False) + frames.append(data) + stream.stop_stream() + stream.close() + p.terminate() + if frames: + self._result = self._make_wav(b''.join(frames)) + if self._result: + self._status(f"✅ 录音完成 ({len(self._result) // 1024} KB)") + except Exception as e: + self._result = None + self._status(f"❌ 录音失败: {e}") + + def start_recording(self): + if RECORDER is None: + self._status("⚠️ 请安装 sounddevice 或 pyaudio 才能录音") + return False + if self.recording: + return False + self.recording = True + self._thread = threading.Thread(target=self._record_worker, daemon=True) + self._thread.start() + self._status("🎤 录音中... 再点一次停止并发送") + return True + + def stop_recording(self): + if not self.recording: + return None + self.recording = False + if self._thread: + self._thread.join(timeout=10) + result = self._result + self._result = None + if result: + self.last_voice = result + self.last_voice_from = '' + return result + + def play(self, wav_bytes, from_user=''): + if not wav_bytes: + return False + if not PYGAME_AVAILABLE: + self._status("⚠️ 未安装 pygame,无法播放语音") + return False + try: + if not pygame.mixer.get_init(): + pygame.mixer.init(frequency=self.rate, size=-16, channels=self.channels) + fd, path = tempfile.mkstemp(suffix='.wav') + with os.fdopen(fd, 'wb') as f: + f.write(wav_bytes) + sound = pygame.mixer.Sound(path) + sound.play() + + def _cleanup(): + for _ in range(300): + if not pygame.mixer.get_busy(): + break + time.sleep(0.1) + try: + os.remove(path) + except Exception: + pass + + threading.Thread(target=_cleanup, daemon=True).start() + self.last_voice = wav_bytes + self.last_voice_from = from_user + if from_user: + self._status(f"🔊 播放来自 {from_user} 的语音") + return True + except Exception as e: + self._status(f"❌ 播放失败: {e}") + return False + + +# ==================== 消息模型 ==================== +class Message: + def __init__(self, mid, group, sender, msg_type, content, timestamp, is_mine=False): + self.mid = mid + self.group = group + self.sender = sender + self.type = msg_type # text / image / voice / audio / file / system + self.content = content # 文本 str, 或 {'filename','size','path','ext'} + self.timestamp = timestamp + self.is_mine = is_mine + self.read = False + self.withdrawn = False + + def is_withdrawable(self, now=None): + now = now or time.time() + return self.is_mine and not self.withdrawn and (now - self.timestamp) <= 120 + + def get_data_bytes(self): + """返回文件/图片/语音的原始字节 (从磁盘路径读取)""" + if isinstance(self.content, dict): + p = self.content.get('path') + if p and os.path.isfile(p): + try: + with open(p, 'rb') as f: + return f.read() + except Exception: + return b'' + return b'' + + + +# ==================== 服务器状态数据库 (踢人标记/群名/公告) ==================== +class ServerDB: + """服务器级持久状态: 被踢账户 + 群名 + 公告 (重启不丢)""" + + def __init__(self, db_path=SERVER_DB): + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.lock = threading.Lock() + self._init() + + def _init(self): + with self.lock: + cur = self.conn.cursor() + cur.execute("""CREATE TABLE IF NOT EXISTS kicked( + server_key TEXT NOT NULL, + account TEXT NOT NULL, + kicked_at REAL, + PRIMARY KEY(server_key, account))""") + cur.execute("""CREATE TABLE IF NOT EXISTS server_info( + server_key TEXT PRIMARY KEY, + group_name TEXT, + announcement TEXT)""") + cur.execute("""CREATE TABLE IF NOT EXISTS user_keys( + server_key TEXT NOT NULL, + account TEXT NOT NULL, + user_key TEXT, + PRIMARY KEY(server_key, account))""") + self.conn.commit() + + def is_kicked(self, server_key, account): + with self.lock: + cur = self.conn.cursor() + row = cur.execute('SELECT 1 FROM kicked WHERE server_key=? AND account=?', + (server_key, account)).fetchone() + return row is not None + + def add_kicked(self, server_key, account): + with self.lock: + cur = self.conn.cursor() + cur.execute('INSERT OR REPLACE INTO kicked(server_key, account, kicked_at) VALUES(?,?,?)', + (server_key, account, time.time())) + self.conn.commit() + + def remove_kicked(self, server_key, account): + with self.lock: + cur = self.conn.cursor() + cur.execute('DELETE FROM kicked WHERE server_key=? AND account=?', (server_key, account)) + self.conn.commit() + + # ---- 每用户密钥 (身份认证) ---- + def get_user_key(self, server_key, account): + with self.lock: + cur = self.conn.cursor() + row = cur.execute('SELECT user_key FROM user_keys WHERE server_key=? AND account=?', + (server_key, account)).fetchone() + return row['user_key'] if row else None + + def set_user_key(self, server_key, account, user_key): + with self.lock: + cur = self.conn.cursor() + cur.execute('INSERT OR REPLACE INTO user_keys(server_key, account, user_key) VALUES(?,?,?)', + (server_key, account, user_key)) + self.conn.commit() + + def get_info(self, server_key): + with self.lock: + cur = self.conn.cursor() + row = cur.execute('SELECT * FROM server_info WHERE server_key=?', (server_key,)).fetchone() + if row: + return row['group_name'] or '', row['announcement'] or '' + return '', '' + + def save_info(self, server_key, group_name, announcement): + with self.lock: + cur = self.conn.cursor() + cur.execute("""INSERT INTO server_info(server_key, group_name, announcement) VALUES(?,?,?) + ON CONFLICT(server_key) DO UPDATE SET + group_name=excluded.group_name, announcement=excluded.announcement""", + (server_key, group_name or '', announcement or '')) + self.conn.commit() + + +# ==================== 多用户服务器 v5 ==================== +class ChatServer: + """多用户聊天服务器: 群主管理(踢人/群名/公告) + 消息广播""" + + def __init__(self, on_status=None, server_key='default', server_db=None, + auth_secret=None, server_secret=None): + self.sock = None + self.running = False + self.clients = {} # cid -> {socket, nickname, account, addr, lock, is_host, pending, user_key} + self.next_id = 1 + self.lock = threading.Lock() + self.on_status = on_status + self.host = '0.0.0.0' + self.port = 8888 + self.host_cid = None # 第一个加入的客户端 = 群主 + self.announcement = '' + self.group_name = '' + self.server_key = server_key + self.server_db = server_db + self.auth_secret = auth_secret + self.user_key_enc = None + if server_secret: + self.auth_secret = hashlib.sha256( + b'CHAOS_AUTH|' + server_secret.encode('utf-8')).hexdigest() + self.user_key_enc = hashlib.sha256( + b'CHAOS_USERKEY_ENC|' + server_secret.encode('utf-8')).hexdigest() + self.mid_owner = {} # mid -> (account, ts) 消息归属, 用于撤回校验 + + def _status(self, text): + if self.on_status: + try: + self.on_status(text) + except Exception: + pass + + def start(self, port=8888): + try: + if self.server_db: + gn, an = self.server_db.get_info(self.server_key) + self.group_name = gn or '' + self.announcement = an or '' + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind((self.host, port)) + self.sock.listen(10) + self.running = True + self.port = port + threading.Thread(target=self._accept_loop, daemon=True).start() + self._status(f"🔄 服务器已启动,端口 {port},等待连接...") + return True + except Exception as e: + self._status(f"❌ 服务器启动失败: {e}") + return False + + def stop(self): + self.running = False + if self.sock: + try: + self.sock.close() + except Exception: + pass + self.sock = None + with self.lock: + socks = [c['socket'] for c in self.clients.values()] + self.clients.clear() + for s in socks: + try: + s.close() + except Exception: + pass + + def _accept_loop(self): + while self.running: + try: + client, addr = self.sock.accept() + with self.lock: + cid = self.next_id + self.next_id += 1 + self.clients[cid] = { + 'socket': client, + 'nickname': f'用户{cid}', + 'account': '', + 'addr': addr, + 'lock': threading.Lock(), + 'is_host': False + } + self._status(f"✅ 新连接: {addr[0]}:{addr[1]}") + threading.Thread(target=self._client_handler, args=(cid,), daemon=True).start() + except Exception: + break + + def _client_handler(self, cid): + client = self.clients[cid]['socket'] + + # 第一步: join (含账户 + 昵称) + try: + msg = recv_msg(client) + if msg and msg.get('type') == 'join': + account = str(msg.get('account', f'用户{cid}'))[:24] + nickname = str(msg.get('nickname') or msg.get('username') or account)[:20] + with self.lock: + if cid in self.clients: + c = self.clients[cid] + c['account'] = account + c['nickname'] = nickname + if self.host_cid is None: + self.host_cid = cid + c['is_host'] = True + # 加入认证 ①: 必须持有群密钥 (证明是群成员) + if self.auth_secret: + expect = hmac.new(self.auth_secret.encode('utf-8'), + account.encode('utf-8'), hashlib.sha256).hexdigest() + if msg.get('auth') != expect: + self._status(f"⚠️ 拒绝未认证的加入请求: {account}") + self._remove_client(cid) + return + # 加入认证 ②: 每用户密钥 (防止成员冒充他人) + user_key = None + if self.server_db: + user_key = self.server_db.get_user_key(self.server_key, account) + if user_key: + expect2 = hmac.new(user_key.encode('utf-8'), + ('JOIN:' + account).encode('utf-8'), + hashlib.sha256).hexdigest() + if msg.get('user_auth') != expect2: + self._status(f"⚠️ 拒绝身份认证失败(冒充): {account}") + self._remove_client(cid) + return + else: + # 新账户: 签发本人密钥并加密下发 + if self.user_key_enc: + user_key = os.urandom(16).hex() + if self.server_db: + self.server_db.set_user_key(self.server_key, account, user_key) + enc = base64.b64encode( + stream_xor(user_key.encode('utf-8'), + bytes.fromhex(self.user_key_enc))).decode('ascii') + self._send_to(cid, {'type': 'user_key_issued', 'data': enc}) + if user_key: + with self.lock: + if cid in self.clients: + self.clients[cid]['user_key'] = user_key + # 被踢过的账户: 需群主同意 + if self.server_db and self.server_db.is_kicked(self.server_key, account): + with self.lock: + if cid in self.clients: + self.clients[cid]['pending'] = True + self._status(f"⏳ {nickname} 曾被移出, 等待群主同意") + self._send_to(cid, {'type': 'need_approval', + 'reason': '你曾被移出本群,需群主同意后才能加入'}) + if self.host_cid is not None: + self._send_to(self.host_cid, {'type': 'approval_request', + 'account': account, 'nickname': nickname}) + if not self._wait_approval(cid): + self._remove_client(cid) + return + with self.lock: + if cid in self.clients: + self.clients[cid]['pending'] = False + self._send_to(cid, {'type': 'approval_granted'}) + self._status(f"✅ {nickname} 经群主同意重新加入") + self._broadcast({'type': 'system', + 'text': f"✅ {nickname} 经群主同意重新加入"}, exclude=cid) + self._broadcast_users() + # 给成员发群信息 + self._send_to(cid, {'type': 'group_info', + 'group_name': self.group_name, + 'announcement': self.announcement}) + self._status(f"👋 {nickname} 加入了聊天室") + self._broadcast({'type': 'system', 'text': f"👋 {nickname} 加入了聊天室"}, exclude=cid) + self._broadcast_users() + else: + self._remove_client(cid) + return + except Exception: + self._remove_client(cid) + return + + # 第二步: 消息循环 + while self.running: + if cid not in self.clients: + break + try: + msg = recv_msg(client) + if msg is None: + break + self._handle_msg(cid, msg) + except Exception: + break + + self._remove_client(cid) + + def _handle_msg(self, cid, msg): + mtype = msg.get('type') + with self.lock: + if cid not in self.clients: + return + c = self.clients[cid] + uname = c['nickname'] + account = c.get('account', '') + is_host = c.get('is_host', False) + user_key = c.get('user_key') + + # 来源认证: 校验每用户签名 + 账户绑定 (服务器只转发真实用户的消息) + secret = user_key or self.auth_secret + if secret and not self._verify_sig(msg, account, secret): + self._status(f"⚠️ 拒绝未认证的消息 (来自 {uname})") + return + + if mtype == 'rename': + new_name = str(msg.get('new_name', uname))[:20] + with self.lock: + if cid in self.clients: + self.clients[cid]['nickname'] = new_name + self._status(f"✏️ {uname} 改名为 {new_name}") + self._broadcast({'type': 'system', 'text': f"✏️ {uname} 改名为 {new_name}"}, exclude=cid) + self._send_to(cid, {'type': 'system', 'text': f"✅ 你已改名为 {new_name}"}) + self._broadcast_users() + + elif mtype == 'set_announcement': + if not is_host: + return + self.announcement = str(msg.get('text', ''))[:500] + if self.server_db: + self.server_db.save_info(self.server_key, self.group_name, self.announcement) + self._broadcast({'type': 'announcement', 'text': self.announcement, + 'ts': int(time.time())}, exclude=None) + self._status("📢 公告已更新") + + elif mtype == 'set_group_name': + if not is_host: + return + self.group_name = str(msg.get('name', ''))[:30] + if self.server_db: + self.server_db.save_info(self.server_key, self.group_name, self.announcement) + self._broadcast({'type': 'group_name', 'name': self.group_name}, exclude=None) + self._status(f"🏷️ 群名更新: {self.group_name}") + + elif mtype == 'kick': + if not is_host: + return + target = str(msg.get('target', msg.get('account', ''))) + kick_cid = None + with self.lock: + for cid2, cc in self.clients.items(): + if cc.get('account') == target and cid2 != cid: + kick_cid = cid2 + break + if kick_cid is not None: + if self.server_db: + self.server_db.add_kicked(self.server_key, target) + self._send_to(kick_cid, {'type': 'kick', 'reason': '你已被群主移出群'}) + self._broadcast({'type': 'system', 'text': f"🚫 {target} 已被群主移出群"}, exclude=None) + self._remove_client(kick_cid) + + elif mtype == 'approve': + if not is_host: + return + target = str(msg.get('target', msg.get('account', ''))) + decision = msg.get('decision', 'deny') + with self.lock: + target_cid = None + for cid2, cc in self.clients.items(): + if cc.get('account') == target and cc.get('pending'): + target_cid = cid2 + break + if target_cid is not None: + with self.lock: + if target_cid in self.clients: + self.clients[target_cid]['approve_decision'] = decision + if decision == 'grant': + self._status(f"✅ 群主同意 {target} 加入") + else: + self._status(f"❌ 群主拒绝 {target} 加入") + + elif mtype == 'withdraw': + # 只有消息原作者才能撤回 + mid = msg.get('mid', '') + owner = self.mid_owner.get(mid) + if owner and owner[0] == account: + msg['from'] = uname + self._broadcast(msg, exclude=cid) + else: + self._status(f"⚠️ 拒绝他人消息的撤回: {uname}") + + else: + # 普通消息 (chat / file / voice 等): 记录归属并转发 + mid = msg.get('mid') + if mid: + self.mid_owner[mid] = (account, time.time()) + self._prune_mid_owner() + msg['from'] = uname + msg['ts'] = msg.get('ts', int(time.time())) + self._broadcast(msg, exclude=cid) + + def _verify_sig(self, msg, account, secret): + """校验消息 HMAC 签名 + 账户绑定 (secret 为每用户密钥或群密钥)""" + try: + sig = msg.get('sig') + if not sig or not secret: + return False + canonical = json.dumps( + {k: v for k, v in msg.items() if k not in ('sig', 'from', 'ts')}, + sort_keys=True, ensure_ascii=False) + expect = hmac.new(secret.encode('utf-8'), + canonical.encode('utf-8'), hashlib.sha256).hexdigest() + return (sig == expect) and (msg.get('account') == account) + except Exception: + return False + + def _prune_mid_owner(self): + now = time.time() + expired = [m for m, (a, t) in self.mid_owner.items() if now - t > 300] + for m in expired: + self.mid_owner.pop(m, None) + + def _wait_approval(self, cid): + """等待群主同意; 返回 True=同意, False=拒绝/断开""" + client = self.clients[cid]['socket'] + try: + client.settimeout(0.5) + except Exception: + pass + try: + while self.running and cid in self.clients: + with self.lock: + decision = self.clients[cid].get('approve_decision') if cid in self.clients else None + if decision == 'grant': + return True + if decision == 'deny': + self._send_to(cid, {'type': 'approval_denied', 'reason': '群主拒绝了你的加入'}) + return False + try: + data = client.recv(1024) + if not data: + return False + except socket.timeout: + continue + except Exception: + return False + return False + finally: + try: + client.settimeout(None) + except Exception: + pass + + def _send_to(self, cid, msg): + with self.lock: + c = self.clients.get(cid) + if c: + try: + with c['lock']: + send_msg(c['socket'], msg) + except Exception: + pass + + def _remove_client(self, cid): + with self.lock: + entry = self.clients.pop(cid, None) + if cid == self.host_cid: + self.host_cid = None + if entry: + try: + entry['socket'].close() + except Exception: + pass + uname = entry['nickname'] + self._status(f"🔌 {uname} 已断开") + self._broadcast({'type': 'system', 'text': f"👋 {uname} 离开了聊天室"}, exclude=cid) + self._broadcast_users() + + def _broadcast_users(self): + with self.lock: + users = [{'name': c['nickname'], 'account': c.get('account', '')} + for c in self.clients.values()] + self._broadcast({'type': 'users', 'users': users}, exclude=None) + + def _broadcast(self, msg, exclude=None): + with self.lock: + targets = [c for cid, c in self.clients.items() if cid != exclude] + for c in targets: + try: + with c['lock']: + send_msg(c['socket'], msg) + except Exception: + pass + + +# ==================== 聊天客户端 v5 ==================== +class ChatClient: + def __init__(self, on_message=None, on_status=None, on_users=None, + on_file=None, on_voice=None, on_system=None, on_withdraw=None, + on_announcement=None, on_group_name=None, on_kick=None, + on_group_info=None, on_need_approval=None, on_approval_granted=None, + on_approval_denied=None, on_approval_request=None, on_user_key_issued=None, + on_read=None): + self.sock = None + self.username = '' + self.running = False + self.connected = False + self.user_key = None + self.user_key_enc = None + self.on_message = on_message + self.on_status = on_status + self.on_users = on_users + self.on_file = on_file + self.on_voice = on_voice + self.on_system = on_system + self.on_withdraw = on_withdraw + self.on_announcement = on_announcement + self.on_group_name = on_group_name + self.on_kick = on_kick + self.on_group_info = on_group_info + self.on_need_approval = on_need_approval + self.on_approval_granted = on_approval_granted + self.on_approval_denied = on_approval_denied + self.on_approval_request = on_approval_request + self.on_user_key_issued = on_user_key_issued + self.on_read = on_read + self.account = '' + self.auth_secret = None + + def _emit_status(self, text): + if self.on_status: + try: + self.on_status(text) + except Exception: + pass + + def connect(self, host, port, username, nickname=None, account=None, + auth_secret=None, user_key=None, user_key_enc=None): + try: + self.sock = socket.create_connection((host, port), timeout=10) + self.sock.settimeout(None) + self.username = username + self.account = account or username + self.auth_secret = auth_secret + self.user_key = user_key + self.user_key_enc = user_key_enc + nick = nickname or username + acc = account or username + join_payload = {'type': 'join', 'username': nick, + 'nickname': nick, 'account': acc} + if auth_secret: + join_payload['auth'] = hmac.new(auth_secret.encode('utf-8'), + acc.encode('utf-8'), + hashlib.sha256).hexdigest() + if user_key: + join_payload['user_auth'] = hmac.new( + user_key.encode('utf-8'), ('JOIN:' + acc).encode('utf-8'), + hashlib.sha256).hexdigest() + send_msg(self.sock, join_payload) + self.running = True + self.connected = True + threading.Thread(target=self._recv_loop, daemon=True).start() + return True + except Exception as e: + self._emit_status(f"❌ 连接失败: {e}") + return False + + def _sign(self, payload): + """为消息附加账户身份 + 每用户 HMAC 签名 (服务器校验, 防冒充)""" + payload['account'] = self.account or self.username + secret = self.user_key or self.auth_secret + if secret: + canonical = json.dumps( + {k: v for k, v in payload.items() if k not in ('sig', 'from', 'ts')}, + sort_keys=True, ensure_ascii=False) + payload['sig'] = hmac.new(secret.encode('utf-8'), + canonical.encode('utf-8'), + hashlib.sha256).hexdigest() + return payload + + def _recv_loop(self): + while self.running: + try: + msg = recv_msg(self.sock) + if msg is None: + break + self._dispatch(msg) + except Exception: + break + self.connected = False + if self.running: + self._emit_status("🔌 连接已断开") + if self.on_system: + try: + self.on_system("🔌 与服务器的连接已断开") + except Exception: + pass + + def _dispatch(self, msg): + t = msg.get('type') + try: + if t == 'chat': + if self.on_message: + self.on_message(msg.get('from', '?'), msg.get('text', ''), msg.get('mid', '')) + elif t == 'system': + if self.on_system: + self.on_system(msg.get('text', '')) + elif t == 'users': + if self.on_users: + self.on_users(msg.get('users', [])) + elif t == 'file': + if self.on_file: + self.on_file(msg) + elif t == 'voice': + if self.on_voice: + self.on_voice(msg) + elif t == 'withdraw': + if self.on_withdraw: + self.on_withdraw(msg.get('mid', '')) + elif t == 'read': + if self.on_read: + self.on_read(msg.get('mid', '')) + elif t == 'announcement': + if self.on_announcement: + self.on_announcement(msg.get('text', '')) + elif t == 'group_name': + if self.on_group_name: + self.on_group_name(msg.get('name', '')) + elif t == 'group_info': + if self.on_group_info: + self.on_group_info(msg.get('group_name', ''), msg.get('announcement', '')) + elif t == 'kick': + if self.on_kick: + self.on_kick(msg.get('reason', '你已被移出群')) + elif t == 'need_approval': + if self.on_need_approval: + self.on_need_approval(msg.get('reason', '等待群主同意')) + elif t == 'approval_granted': + if self.on_approval_granted: + self.on_approval_granted() + elif t == 'approval_denied': + if self.on_approval_denied: + self.on_approval_denied(msg.get('reason', '群主拒绝你加入')) + elif t == 'approval_request': + if self.on_approval_request: + self.on_approval_request(msg.get('account', ''), msg.get('nickname', '')) + elif t == 'user_key_issued': + try: + if self.user_key_enc: + uk = stream_xor(base64.b64decode(msg.get('data', '')), + bytes.fromhex(self.user_key_enc)).decode('utf-8') + self.user_key = uk + if self.on_user_key_issued: + self.on_user_key_issued(uk) + except Exception: + pass + except Exception: + pass + + # ---- 发送 ---- + def send_chat(self, text, mid=None): + if not self.connected: + return False + try: + payload = {'type': 'chat', 'text': text, 'ts': int(time.time())} + if mid: + payload['mid'] = mid + send_msg(self.sock, self._sign(payload)) + return True + except Exception as e: + self._emit_status(f"❌ 发送失败: {e}") + return False + + def send_file(self, filename, data_bytes, mid=None, mime='file'): + if not self.connected: + return False + try: + payload = {'type': 'file', 'filename': filename, 'size': len(data_bytes), + 'data': base64.b64encode(data_bytes).decode('ascii'), + 'mime': mime, 'ts': int(time.time())} + if mid: + payload['mid'] = mid + send_msg(self.sock, self._sign(payload)) + return True + except Exception as e: + self._emit_status(f"❌ 发送文件失败: {e}") + return False + + def send_voice(self, wav_bytes, mid=None): + if not self.connected: + return False + try: + payload = {'type': 'voice', 'data': base64.b64encode(wav_bytes).decode('ascii'), + 'size': len(wav_bytes), 'ts': int(time.time())} + if mid: + payload['mid'] = mid + send_msg(self.sock, self._sign(payload)) + return True + except Exception as e: + self._emit_status(f"❌ 发送语音失败: {e}") + return False + + def send_withdraw(self, mid): + if not self.connected: + return False + try: + send_msg(self.sock, self._sign({'type': 'withdraw', 'mid': mid})) + return True + except Exception: + return False + + def send_rename(self, new_name): + if not self.connected: + return False + try: + send_msg(self.sock, self._sign({'type': 'rename', 'new_name': new_name})) + return True + except Exception: + return False + + def send_read(self, mid): + """发送已读回执""" + if not self.connected: + return False + try: + send_msg(self.sock, self._sign({'type': 'read', 'mid': mid})) + return True + except Exception: + return False + + def send_announcement(self, text): + if not self.connected: + return False + try: + send_msg(self.sock, self._sign({'type': 'set_announcement', 'text': text})) + return True + except Exception: + return False + + def send_set_group_name(self, name): + if not self.connected: + return False + try: + send_msg(self.sock, self._sign({'type': 'set_group_name', 'name': name})) + return True + except Exception: + return False + + def send_kick(self, account): + if not self.connected: + return False + try: + send_msg(self.sock, self._sign({'type': 'kick', 'target': account})) + return True + except Exception: + return False + + def send_approval(self, account, decision='grant'): + if not self.connected: + return False + try: + send_msg(self.sock, self._sign({'type': 'approve', 'target': account, 'decision': decision})) + return True + except Exception: + return False + + def disconnect(self): + self.running = False + self.connected = False + if self.sock: + try: + self.sock.close() + except Exception: + pass + self.sock = None + + + +# ==================== 中央服务器连接 (解决 NAT) ==================== +class ServerConnection: + """连接到中央服务器: 认证 + 群管理 + 消息中继 (密钥永不上传)""" + + def __init__(self, on_status=None, on_groups=None, on_message=None, on_members=None, + on_system=None, on_group_name=None, on_announcement=None, on_group_info=None): + self.sock = None + self.running = False + self.connected = False + self.token = '' + self.username = '' + self.addr = '' + self.on_status = on_status + self.on_groups = on_groups + self.on_message = on_message + self.on_members = on_members + self.on_system = on_system + self.on_group_name = on_group_name + self.on_announcement = on_announcement + self.on_group_info = on_group_info + + def _emit(self, text): + if self.on_status: + try: + self.on_status(text) + except Exception: + pass + + def connect(self, host, port): + try: + self.sock = socket.create_connection((host, port), timeout=10) + self.sock.settimeout(None) + self.addr = f"{host}:{port}" + self.connected = True + return True + except Exception as e: + self._emit(f"❌ 连接中央服务器失败: {e}") + return False + + def register(self, username, password): + send_msg(self.sock, {'type': 'register', 'username': username, 'password': password}) + return self._wait_login() + + def login(self, username, password): + send_msg(self.sock, {'type': 'login', 'username': username, 'password': password}) + return self._wait_login() + + def _wait_login(self): + try: + self.sock.settimeout(6) + msg = recv_msg(self.sock) + except Exception: + return False, '服务器无响应' + finally: + try: + self.sock.settimeout(None) + except Exception: + pass + if msg and msg.get('type') == 'login_ok': + self.token = msg.get('token', '') + self.username = msg.get('username', '') + self.running = True + threading.Thread(target=self._recv_loop, daemon=True).start() + return True, 'ok' + if msg and msg.get('type') == 'error': + return False, msg.get('msg', '认证失败') + return False, '服务器返回异常' + + def _recv_loop(self): + while self.running: + try: + msg = recv_msg(self.sock) + if msg is None: + break + self._dispatch(msg) + except Exception: + break + self.connected = False + if self.running: + self._emit("🔌 与中央服务器断开") + + def _dispatch(self, msg): + t = msg.get('type') + try: + if t == 'groups': + if self.on_groups: + self.on_groups(msg.get('groups', [])) + elif t == 'group_message': + if self.on_message: + self.on_message(msg.get('group_id', ''), msg.get('from', '?'), + msg.get('payload')) + elif t == 'members': + if self.on_members: + self.on_members(msg.get('group_id', ''), msg.get('members', [])) + elif t == 'system': + if self.on_system: + self.on_system(msg.get('group_id', ''), msg.get('text', '')) + elif t == 'group_name': + if self.on_group_name: + self.on_group_name(msg.get('group_id', ''), msg.get('name', '')) + elif t == 'announcement': + if self.on_announcement: + self.on_announcement(msg.get('group_id', ''), msg.get('text', '')) + elif t == 'group_info': + if self.on_group_info: + self.on_group_info(msg.get('group_id', ''), msg.get('name', ''), + msg.get('announcement', '')) + except Exception: + pass + + def create_group(self, group_id, name): + send_msg(self.sock, {'type': 'create_group', 'group_id': group_id, 'name': name}) + + def list_groups(self): + send_msg(self.sock, {'type': 'list_groups'}) + + def join_group(self, group_id): + send_msg(self.sock, {'type': 'join_group', 'group_id': group_id}) + + def leave_group(self, group_id): + send_msg(self.sock, {'type': 'leave_group', 'group_id': group_id}) + + def set_group_name(self, group_id, name): + send_msg(self.sock, {'type': 'set_group_name', 'group_id': group_id, 'name': name}) + + def set_announcement(self, group_id, text): + send_msg(self.sock, {'type': 'set_announcement', 'group_id': group_id, 'text': text}) + + def group_members(self, group_id): + send_msg(self.sock, {'type': 'group_members', 'group_id': group_id}) + + def send_message(self, group_id, payload): + if not self.connected: + return False + try: + send_msg(self.sock, {'type': 'group_message', 'group_id': group_id, + 'payload': payload}) + return True + except Exception: + return False + + def disconnect(self): + self.running = False + self.connected = False + if self.sock: + try: + self.sock.close() + except Exception: + pass + self.sock = None + + +# ==================== 会话文件 (记住登录) ==================== +def save_session(token): + try: + with open(SESSION_FILE, 'w', encoding='utf-8') as f: + f.write(token) + except Exception: + pass + + +def load_session(): + try: + with open(SESSION_FILE, 'r', encoding='utf-8') as f: + return f.read().strip() + except Exception: + return '' + + +def clear_session(): + try: + if os.path.exists(SESSION_FILE): + os.remove(SESSION_FILE) + except Exception: + pass + + +# ==================== 服务器账号记住我 + 群本地缓存 ==================== +SERVER_CRED_FILE = os.path.join(DATA_DIR, 'server_cred.dat') +SERVER_GROUPS_CACHE = os.path.join(DATA_DIR, 'server_groups_cache.json') + + +def _cred_key(pass_hash): + """用本地账号密码哈希派生密钥 (记住服务器账号用)""" + return hashlib.sha256(pass_hash.encode('utf-8') + b'|SERVER_CRED').digest() + + +def save_server_cred(pass_hash, addr, username, password): + """记住服务器账号: 加密存储 (密钥=本地账号密码哈希派生)""" + try: + key = _cred_key(pass_hash) + nonce = os.urandom(16) + k = hashlib.sha256(key + nonce).digest() + ct = stream_xor(f"{addr}\n{username}\n{password}".encode('utf-8'), k) + h = hmac.new(key, nonce + ct, hashlib.sha256).hexdigest() + blob = base64.b64encode(b'CRED' + nonce + h.encode('ascii') + ct).decode('ascii') + with open(SERVER_CRED_FILE, 'w', encoding='utf-8') as f: + f.write(blob) + return True + except Exception: + return False + + +def load_server_cred(pass_hash): + """读取记住的服务器账号; 密钥不符返回 None""" + try: + with open(SERVER_CRED_FILE, 'r', encoding='utf-8') as f: + b64 = f.read().strip() + key = _cred_key(pass_hash) + blob = base64.b64decode(b64) + if not blob.startswith(b'CRED'): + return None + nonce = blob[4:20] + h = blob[20:84] + ct = blob[84:] + if hmac.new(key, nonce + ct, hashlib.sha256).hexdigest() != h.decode('ascii'): + return None + k = hashlib.sha256(key + nonce).digest() + text = stream_xor(ct, k).decode('utf-8') + addr, username, password = text.split('\n', 2) + return (addr, username, password) + except Exception: + return None + + +def save_server_groups_cache(server_addr, groups): + """本地缓存服务器群列表 (离线也能显示)""" + try: + data = {} + if os.path.exists(SERVER_GROUPS_CACHE): + with open(SERVER_GROUPS_CACHE, 'r', encoding='utf-8') as f: + data = json.load(f) + data[server_addr] = groups + with open(SERVER_GROUPS_CACHE, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False) + except Exception: + pass + + +def load_server_groups_cache(server_addr): + try: + if os.path.exists(SERVER_GROUPS_CACHE): + with open(SERVER_GROUPS_CACHE, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get(server_addr, []) + except Exception: + pass + return [] + + +import warnings +warnings.filterwarnings('ignore', message='sipPyTypeDict.*') +# PyQt5 +from PyQt5 import QtCore, QtGui, QtWidgets + +# 复用后端 (dvs_enc.py) +from dvs_enc import ( + AccountDB, HistoryDB, ServerDB, SecureSession, ChatClient, ChatServer, + ServerConnection, VoiceManager, Message, gen_mid, save_media, + delete_media, clear_media_cache, save_session, load_session, clear_session, + MEDIA_DIR +) + +APP_NAME = "🔐 混沌加密聊天" + +DOWNLOAD_DIR = os.path.join(os.path.expanduser('~'), 'Downloads', 'ChaosChat') +try: + os.makedirs(DOWNLOAD_DIR, exist_ok=True) +except Exception: + pass + + +# ==================== 现代化样式 (QSS) ==================== +QSS = """ +QMainWindow, QDialog { background: #ffffff; } +QLabel { color: #2c3e50; } +QListWidget { + background: #ffffff; border: 1px solid #e0e4ea; border-radius: 8px; + padding: 4px; font-size: 13px; +} +QListWidget::item { padding: 6px; border-radius: 6px; margin: 2px; } +QListWidget::item:selected { background: #dbe7ff; color: #1a73e8; } +QListWidget::item:hover { background: #f0f4ff; } +QTextEdit, QLineEdit { + background: #ffffff; border: 1px solid #e0e4ea; border-radius: 8px; padding: 6px; + font-size: 13px; +} +QPushButton { + background: #eef1f6; border: none; border-radius: 8px; padding: 7px 14px; + font-size: 13px; color: #2c3e50; +} +QPushButton:hover { background: #e2e7f0; } +QPushButton:disabled { background: #f0f0f0; color: #aaa; } +QPushButton.primary { background: #4CAF50; color: white; } +QPushButton.primary:hover { background: #43a047; } +QPushButton.info { background: #2196F3; color: white; } +QPushButton.info:hover { background: #1e88e5; } +QPushButton.danger { background: #e74c3c; color: white; } +QPushButton.danger:hover { background: #c0392b; } +QToolBar { background: #ffffff; border-bottom: 1px solid #e0e4ea; spacing: 4px; padding: 4px; } +QMenuBar { background: #ffffff; border-bottom: 1px solid #e0e4ea; } +QMenuBar::item { padding: 6px 10px; border-radius: 6px; } +QMenuBar::item:selected { background: #dbe7ff; } +QMenu { background: #ffffff; border: 1px solid #e0e4ea; border-radius: 8px; padding: 6px; } +QMenu::item { padding: 6px 24px; border-radius: 6px; } +QMenu::item:selected { background: #dbe7ff; color: #1a73e8; } +QScrollBar:vertical { background: transparent; width: 8px; } +QScrollBar::handle:vertical { background: #c5cbd6; border-radius: 4px; min-height: 30px; } +QScrollBar::add-line, QScrollBar::sub-line { height: 0; } +QStatusBar { background: #ffffff; border-top: 1px solid #e0e4ea; } +""" + + +# ==================== 消息项组件 ==================== +class MessageItem(QtWidgets.QWidget): + """单条消息: 我的靠左, 别人靠右; 整条可选中, 右键菜单""" + + def __init__(self, msg, actions, parent=None): + super().__init__(parent) + self.msg = msg + self.mid = msg.mid + self.actions = actions + self._selected = False + self._notify_select = None + self._status_lab = None + self.setCursor(QtCore.Qt.PointingHandCursor) + # 关键: 自定义 QWidget 必须开 WA_StyledBackground, 否则 QSS 背景不绘制 + self.setAttribute(QtCore.Qt.WA_StyledBackground, True) + self.setStyleSheet("MessageItem{background:#ffffff; border-radius:12px;}") + + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(10, 6, 10, 6) + lay.setSpacing(3) + + # 头部: 发送者 + 时间 (我的靠左, 别人靠右; 我的显示已读/未读) + head_row = QtWidgets.QHBoxLayout() + head = QtWidgets.QLabel(f"👤 {msg.sender} {datetime.fromtimestamp(msg.timestamp).strftime('%H:%M:%S')}") + head.setStyleSheet("font-weight:bold; color:%s;" % ('#1a73e8' if msg.is_mine else '#7a5af8')) + if msg.is_mine: + head_row.addWidget(head) + head_row.addStretch() + self._status_lab = QtWidgets.QLabel("已读" if msg.read else "未读") + self._status_lab.setStyleSheet("color:#999; font-size:11px;") + head_row.addWidget(self._status_lab) + else: + head_row.addStretch() + head_row.addWidget(head) + lay.addLayout(head_row) + + # 内容 + if msg.withdrawn: + lab = QtWidgets.QLabel("🚫 此消息已撤回") + lab.setStyleSheet("color:#999; font-style:italic;") + self._align(lay, lab) + elif msg.type == 'text': + lab = QtWidgets.QLabel(msg.content) + lab.setWordWrap(True) + lab.setMaximumWidth(560) + lab.setStyleSheet("background:#f0f2f5; border-radius:14px; padding:10px 14px;") + lab.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) + self._align(lay, lab) + elif msg.type == 'image': + self._add_image(lay) + elif msg.type in ('voice', 'audio'): + self._add_audio(lay) + elif msg.type == 'file': + self._add_file(lay) + else: + lab = QtWidgets.QLabel(str(msg.content)) + lab.setStyleSheet("color:#666;") + self._align(lay, lab) + + # 关键: 按钮不装过滤器且不透明(保证可点击); 其他子控件(文字/气泡)装过滤器 → 点击选中 + self.installEventFilter(self) + for ch in self.findChildren(QtWidgets.QWidget): + if not isinstance(ch, QtWidgets.QPushButton): + ch.installEventFilter(self) + + def _align(self, lay, w): + """我的消息靠左, 别人的靠右""" + if self.msg.is_mine: + lay.addWidget(w, 0, QtCore.Qt.AlignLeft) + else: + lay.addWidget(w, 0, QtCore.Qt.AlignRight) + + def _align_row(self, row, bubble): + if self.msg.is_mine: + row.addWidget(bubble) + row.addStretch() + else: + row.addStretch() + row.addWidget(bubble) + + def mousePressEvent(self, event): + # 点击消息自身背景 → 选中 + if event.button() == QtCore.Qt.LeftButton and self._notify_select: + self._notify_select() + super().mousePressEvent(event) + + def eventFilter(self, obj, event): + # 点击文字/气泡等子控件(按钮除外) → 选中本消息; 按钮保持自身功能 + if event.type() == QtCore.QEvent.MouseButtonPress and event.button() == QtCore.Qt.LeftButton: + if not isinstance(obj, QtWidgets.QPushButton) and self._notify_select: + self._notify_select() + return False + + def set_selected(self, sel): + self._selected = sel + if sel: + self.setStyleSheet("MessageItem{background:#9cc6ff; border-left:6px solid #1a73e8; border-radius:12px;}") + else: + self.setStyleSheet("MessageItem{background:#ffffff; border-radius:12px;}") + + def set_read_status(self, read): + if self._status_lab: + self._status_lab.setText("已读" if read else "未读") + + def _act(self, key, *args): + fn = self.actions.get(key) + if fn: + try: + fn(*args) + except Exception as e: + print(f" [ACT] 操作 {key} 失败: {e}") + + def _add_file(self, lay): + c = self.msg.content if isinstance(self.msg.content, dict) else {} + fname = c.get('filename', '文件') + size = c.get('size', 0) + ext = c.get('ext', os.path.splitext(fname)[1]) + icon = file_icon(ext) + bubble = QtWidgets.QFrame() + bubble.setStyleSheet("QFrame{background:#e8f0fe; border-radius:14px;}") + blay = QtWidgets.QHBoxLayout(bubble) + blay.setContentsMargins(16, 10, 10, 10) + blay.setSpacing(10) + lab = QtWidgets.QLabel(f"{icon} {fname} ({size // 1024} KB)") + lab.setStyleSheet("background:transparent; color:#202124; font-size:13px;") + lab.setMaximumWidth(360) + blay.addWidget(lab) + dl = QtWidgets.QPushButton("💾 下载") + dl.setStyleSheet("background:#d3e0fb; color:#1a73e8; border-radius:8px; padding:5px 12px;") + dl.clicked.connect(lambda: self._act('on_download', self.mid)) + blay.addWidget(dl) + row = QtWidgets.QHBoxLayout() + self._align_row(row, bubble) + lay.addLayout(row) + + def _add_audio(self, lay): + c = self.msg.content if isinstance(self.msg.content, dict) else {} + fname = c.get('filename', '') + label = "🔊 语音" if self.msg.type == 'voice' else f"🎵 音频 {fname}" + size = c.get('size', 0) + bubble = QtWidgets.QFrame() + bubble.setStyleSheet("QFrame{background:#efe9ff; border-radius:14px;}") + blay = QtWidgets.QHBoxLayout(bubble) + blay.setContentsMargins(12, 10, 10, 10) + blay.setSpacing(10) + play = QtWidgets.QPushButton("▶ 播放") + play.setStyleSheet("background:#7a5af8; color:#fff; border-radius:10px; padding:7px 16px;") + play.clicked.connect(lambda: self._act('on_play', self.mid)) + blay.addWidget(play) + lab = QtWidgets.QLabel(f"{label} ({size // 1024} KB)") + lab.setStyleSheet("background:transparent; color:#5a4a9a; font-size:13px;") + blay.addWidget(lab) + dl = QtWidgets.QPushButton("💾") + dl.setStyleSheet("background:#ddd4f7; color:#5a4a9a; border-radius:8px; padding:5px 10px;") + dl.clicked.connect(lambda: self._act('on_download', self.mid)) + blay.addWidget(dl) + row = QtWidgets.QHBoxLayout() + self._align_row(row, bubble) + lay.addLayout(row) + + def _add_image(self, lay): + c = self.msg.content if isinstance(self.msg.content, dict) else {} + data = self.msg.get_data_bytes() + bubble = QtWidgets.QFrame() + bubble.setStyleSheet("QFrame{background:#ffffff; border-radius:14px; border:1px solid #e0e4ea;}") + blay = QtWidgets.QHBoxLayout(bubble) + blay.setContentsMargins(8, 8, 8, 8) + blay.setSpacing(8) + if data: + try: + from PIL import Image, ImageTk + img = Image.open(io.BytesIO(data)) + img.thumbnail((220, 220)) + img = img.convert("RGBA") + qimg = QtGui.QImage(img.tobytes(), img.width, img.height, QtGui.QImage.Format_RGBA8888) + pix = QtGui.QPixmap.fromImage(qimg) + lab = QtWidgets.QLabel() + lab.setPixmap(pix) + lab.setCursor(QtCore.Qt.PointingHandCursor) + lab.mousePressEvent = lambda e: self._act('on_preview', self.mid) + blay.addWidget(lab) + except Exception: + lab = QtWidgets.QLabel("🖼️ 图片(无法预览)") + blay.addWidget(lab) + else: + lab = QtWidgets.QLabel("🖼️ 图片(文件已清理)") + blay.addWidget(lab) + dl = QtWidgets.QPushButton("💾 下载") + dl.setStyleSheet("background:#eef1f6; color:#2c3e50; border-radius:8px; padding:5px 12px;") + dl.clicked.connect(lambda: self._act('on_download', self.mid)) + blay.addWidget(dl) + row = QtWidgets.QHBoxLayout() + self._align_row(row, bubble) + lay.addLayout(row) + + +class MessageList(QtWidgets.QScrollArea): + """自适应消息列表 (QScrollArea + VBox): 每条消息按内容自动调高""" + + def __init__(self, on_menu=None, on_selected=None, parent=None): + super().__init__(parent) + self.setWidgetResizable(True) + self.setFrameShape(QtWidgets.QFrame.NoFrame) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self._container = QtWidgets.QWidget() + self._container.setStyleSheet("background:#ffffff;") + self._layout = QtWidgets.QVBoxLayout(self._container) + self._layout.setContentsMargins(6, 6, 6, 6) + self._layout.setSpacing(6) + self._layout.addStretch(1) # 消息从顶部排列, 底部弹性 + self.setWidget(self._container) + self._items = {} # mid -> MessageItem + self.selected_mid = None + self._on_menu = on_menu + self._on_selected = on_selected + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_right_click) + # 点击空白处 → 取消选中 + self._container.installEventFilter(self) + self.viewport().installEventFilter(self) + self.installEventFilter(self) + + def eventFilter(self, obj, event): + """点击消息区空白处 → 自动取消选中""" + if event.type() == QtCore.QEvent.MouseButtonPress and event.button() == QtCore.Qt.LeftButton: + if obj in (self._container, self.viewport(), self): + self._deselect() + return False + + def _deselect(self): + self.selected_mid = None + for w in self._items.values(): + w.set_selected(False) + if self._on_selected: + self._on_selected(None) + + def _select(self, mid): + self.selected_mid = mid + for m, w in self._items.items(): + w.set_selected(m == mid) + if self._on_selected: + self._on_selected(mid) + + def _on_right_click(self, pos): + item = self._message_at(pos) + if not item: + self._deselect() + return + self._select(item.mid) + if self._on_menu: + self._on_menu(item.mid, self.mapToGlobal(pos)) + + def _message_at(self, pos): + global_pos = self.mapToGlobal(pos) + w = self._container.childAt(self._container.mapFromGlobal(global_pos)) + while w is not None: + if isinstance(w, MessageItem): + return w + w = w.parentWidget() + return None + + def add_message(self, msg, actions): + wid = MessageItem(msg, actions) + wid._notify_select = lambda m=msg.mid: self._select(m) + wid.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + self._layout.insertWidget(self._layout.count() - 1, wid) # 插到 stretch 之前 + self._items[msg.mid] = wid + # 等宽度确定后再滚到底部 + QtCore.QTimer.singleShot(0, self._scroll_bottom) + return wid + + def update_message(self, mid, msg): + """就地更新某条消息 (已读/未读状态)""" + wid = self._items.get(mid) + if wid: + wid.set_read_status(msg.read) + + def _scroll_bottom(self): + bar = self.verticalScrollBar() + bar.setValue(bar.maximum()) + + def remove_message(self, mid): + wid = self._items.pop(mid, None) + if wid: + self._layout.removeWidget(wid) + wid.deleteLater() + if self.selected_mid == mid: + self.selected_mid = None + + def clear_all(self): + for w in self._items.values(): + self._layout.removeWidget(w) + w.deleteLater() + self._items.clear() + self.selected_mid = None + + +# ==================== 登录对话框 ==================== +class LoginDialog(QtWidgets.QDialog): + """本地账号 注册/登录""" + + def __init__(self, db, parent=None): + super().__init__(parent) + self.db = db + self.result = None + self.mode = 'login' + self.setWindowTitle(f"{APP_NAME} - 登录") + self.setFixedSize(380, 420) + self._build() + + def _build(self): + lay = QtWidgets.QVBoxLayout(self) + lay.setSpacing(10) + title = QtWidgets.QLabel("🔐 混沌加密聊天") + title.setAlignment(QtCore.Qt.AlignCenter) + title.setStyleSheet("font-size:20px; font-weight:bold; color:#1a73e8;") + lay.addWidget(title) + + self.mode_lab = QtWidgets.QLabel("本地账号登录") + self.mode_lab.setAlignment(QtCore.Qt.AlignCenter) + self.mode_lab.setStyleSheet("font-size:13px; color:#555;") + lay.addWidget(self.mode_lab) + + self.u = QtWidgets.QLineEdit() + self.u.setPlaceholderText("账户名") + lay.addWidget(self.u) + self.p = QtWidgets.QLineEdit() + self.p.setPlaceholderText("密码") + self.p.setEchoMode(QtWidgets.QLineEdit.Password) + lay.addWidget(self.p) + self.p2 = QtWidgets.QLineEdit() + self.p2.setPlaceholderText("确认密码") + self.p2.setEchoMode(QtWidgets.QLineEdit.Password) + self.p2.setVisible(False) + lay.addWidget(self.p2) + + self.msg = QtWidgets.QLabel("") + self.msg.setStyleSheet("color:#e74c3c;") + lay.addWidget(self.msg) + + row = QtWidgets.QHBoxLayout() + self.btn_main = QtWidgets.QPushButton("登 录") + self.btn_main.setProperty("class", "primary") + self.btn_main.clicked.connect(self._submit) + row.addWidget(self.btn_main) + self.btn_switch = QtWidgets.QPushButton("没有账号?注册") + self.btn_switch.clicked.connect(self._toggle) + row.addWidget(self.btn_switch) + lay.addLayout(row) + + lay.addStretch() + + def _toggle(self): + if self.mode == 'login': + self.mode = 'register' + self.mode_lab.setText("注册本地账号") + self.btn_main.setText("注 册") + self.btn_switch.setText("已有账号?登录") + self.p2.setVisible(True) + else: + self.mode = 'login' + self.mode_lab.setText("本地账号登录") + self.btn_main.setText("登 录") + self.btn_switch.setText("没有账号?注册") + self.p2.setVisible(False) + self.msg.setText("") + + def _submit(self): + user = self.u.text().strip() + pwd = self.p.text() + if not user or not pwd: + self.msg.setText("请输入账户名和密码") + return + if self.mode == 'login': + row, err = self.db.login(user, pwd) + if row is None: + self.msg.setText(err) + return + self.result = row + self.accept() + else: + if len(pwd) < 6: + self.msg.setText("密码至少 6 位") + return + if pwd != self.p2.text(): + self.msg.setText("两次密码不一致") + return + ok, err = self.db.register(user, pwd) + if not ok: + self.msg.setText(err) + return + row, _ = self.db.login(user, pwd) + self.result = row + self.accept() + + +# ==================== 主窗口 ==================== +class MainWindow(QtWidgets.QMainWindow): + def __init__(self, account): + super().__init__() + self.account = dict(account) if account else {'id': 1, 'username': 'user', 'nickname': '用户'} + self.account_db = AccountDB() + self.history = HistoryDB() + self.server_db = ServerDB() + self.voice = VoiceManager(on_status=self._on_status) + + self.groups = {} # gid -> dict + self.order = [] + self.active_gid = None + self.selected_mid = None + self._gid_counter = 0 + + self.server_conn = None + self.server_addr = '' + self.server_username = '' + + self._ui_queue = [] + self._relogin = False + + self.setWindowTitle(f"{APP_NAME} - {self._my_nick()}") + self.resize(1120, 720) + self._build_ui() + + # UI 轮询 + self._timer = QtCore.QTimer(self) + self._timer.timeout.connect(self._poll_ui) + self._timer.start(60) + + # 启动自动加入 (本地P2P群) + QtCore.QTimer.singleShot(500, self._start_auto_join) + # 服务器: 先显示本地缓存, 再记住我自动登录实时同步 + QtCore.QTimer.singleShot(800, self._load_cached_server_groups) + QtCore.QTimer.singleShot(1500, self._auto_connect_server) + + # ---------- 线程安全 ---------- + def _post(self, fn): + self._ui_queue.append(fn) + + def _poll_ui(self): + if self._ui_queue: + tasks = self._ui_queue[:] + self._ui_queue = [] + for fn in tasks: + try: + fn() + except Exception: + pass + + def _my_nick(self): + return self.account.get('nickname') or self.account.get('username', '用户') + + def _on_status(self, text): + self._post(lambda: self.statusBar().showMessage(text, 5000)) + + # ---------- 界面 ---------- + def _build_ui(self): + # 工具栏 + tb = QtWidgets.QToolBar("主工具栏") + tb.setMovable(False) + self.addToolBar(tb) + + def btn(text, fn, cls=''): + b = QtWidgets.QPushButton(text) + if cls: + b.setProperty("class", cls) + b.clicked.connect(fn) + tb.addWidget(b) + return b + + btn("📡 创建群", self._create_group, 'primary') + btn("📥 加入群", self._join_group, 'info') + tb.addSeparator() + btn("☁️ 服务器", self._connect_server) + btn("☁️ 建群", self._create_server_group) + btn("☁️ 加群", self._join_server_group) + tb.addSeparator() + btn("🔑 密钥", self._copy_active_key) + btn("✏️ 改名", self._rename) + btn("📢 公告", self._set_announcement) + btn("🏷️ 群名", self._set_group_name) + btn("🚫 撤回", self._withdraw_selected) + btn("📤 分享", self._share_selected) + + # 菜单 + mb = self.menuBar() + gm = mb.addMenu("群") + gm.addAction("📡 创建群", self._create_group) + gm.addAction("📥 加入群", self._join_group) + gm.addAction("❌ 离开该群", self._leave_active_group) + sm = mb.addMenu("服务器") + sm.addAction("☁️ 连接服务器", self._connect_server) + sm.addAction("☁️ 服务器建群", self._create_server_group) + sm.addAction("☁️ 服务器加群", self._join_server_group) + mm = mb.addMenu("消息") + mm.addAction("🚫 撤回所选", self._withdraw_selected) + mm.addAction("📤 分享所选", self._share_selected) + mm.addAction("💾 下载所选", self._download_selected) + mm.addAction("📋 复制所选", self._copy_selected) + um = mb.addMenu("工具") + um.addAction("🗑️ 清理媒体缓存", self._clear_media_cache) + um.addAction("📂 打开下载目录", self._open_download_dir) + um.addAction("🔓 退出登录", self._logout) + um.addAction("❌ 退出程序", self._quit) + + # 中央区域 + central = QtWidgets.QWidget() + self.setCentralWidget(central) + root = QtWidgets.QHBoxLayout(central) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(8) + + # 左: 群列表 + left = QtWidgets.QVBoxLayout() + left.addWidget(QtWidgets.QLabel("👥 群列表")) + self.group_list = QtWidgets.QListWidget() + self.group_list.setFixedWidth(240) + self.group_list.itemClicked.connect(self._on_group_click) + self.group_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.group_list.customContextMenuRequested.connect(self._on_group_menu) + left.addWidget(self.group_list) + left_w = QtWidgets.QWidget() + left_w.setLayout(left) + root.addWidget(left_w) + + # 中: 公告横幅 + 消息 + 输入 + center = QtWidgets.QVBoxLayout() + self.banner = QtWidgets.QFrame() + self.banner.setStyleSheet("background:#fff3cd; border-radius:8px;") + b_lay = QtWidgets.QHBoxLayout(self.banner) + self.banner_label = QtWidgets.QLabel("") + self.banner_label.setStyleSheet("color:#8a6d3b;") + b_lay.addWidget(self.banner_label, 1) + b_close = QtWidgets.QPushButton("✕ 关闭") + b_close.setFixedWidth(60) + b_close.clicked.connect(self._hide_banner) + b_lay.addWidget(b_close) + self.banner.setVisible(False) + center.addWidget(self.banner) + + self.message_list = MessageList(on_menu=self._show_context_menu, + on_selected=lambda mid: self._on_msg_selected(mid)) + center.addWidget(self.message_list, 1) + + # 输入区: 多行输入 + 发送 + 图片/文件/语音按钮 + in_area = QtWidgets.QVBoxLayout() + in_row = QtWidgets.QHBoxLayout() + self.input = QtWidgets.QTextEdit() + self.input.setFixedHeight(56) + self.input.setPlaceholderText("输入消息... (Ctrl+Enter 发送)") + QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+Return"), self.input, + activated=self._send_text) + send = QtWidgets.QPushButton("发 送") + send.setProperty("class", "primary") + send.setFixedHeight(44) + send.clicked.connect(self._send_text) + in_row.addWidget(self.input, 1) + in_row.addWidget(send) + in_area.addLayout(in_row) + + btn_row = QtWidgets.QHBoxLayout() + self.img_btn = QtWidgets.QPushButton("📷 图片") + self.img_btn.clicked.connect(self._send_image) + self.file_btn = QtWidgets.QPushButton("📁 文件") + self.file_btn.clicked.connect(self._send_file) + self.record_btn = QtWidgets.QPushButton("🎤 录音") + self.record_btn.clicked.connect(self._toggle_record) + self.play_btn = QtWidgets.QPushButton("▶ 播放") + self.play_btn.clicked.connect(self._play_voice) + for b in (self.img_btn, self.file_btn, self.record_btn, self.play_btn): + b.setFixedHeight(28) + btn_row.addWidget(b) + btn_row.addStretch() + in_area.addLayout(btn_row) + center.addLayout(in_area) + center_w = QtWidgets.QWidget() + center_w.setLayout(center) + root.addWidget(center_w, 1) + + # 右: 用户列表 + right = QtWidgets.QVBoxLayout() + right.addWidget(QtWidgets.QLabel("🟢 在线用户")) + self.users_list = QtWidgets.QListWidget() + self.users_list.setFixedWidth(180) + self.users_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.users_list.customContextMenuRequested.connect(self._on_user_menu) + right.addWidget(self.users_list) + right_w = QtWidgets.QWidget() + right_w.setLayout(right) + root.addWidget(right_w) + + self.statusBar().showMessage("就绪") + self._add_system(None, "📌 点击工具栏或菜单开始;消息可点击选中,右键有更多操作") + + # ---------- 公告横幅 ---------- + def _show_banner(self, text): + if not text: + return + self.banner_label.setText(f"📢 群公告: {text}") + self.banner.setVisible(True) + + def _hide_banner(self): + self.banner.setVisible(False) + + # ---------- 群管理 ---------- + def _new_gid(self): + self._gid_counter += 1 + return f"g{self._gid_counter}" + + def _group_connected(self, group): + if group.get('mode') == 'server': + return bool(group.get('server_conn') and group['server_conn'].connected) + return bool(group.get('client') and group['client'].connected) + + def _build_group(self, gid, host, port, nick, is_host, server, client, session): + return { + 'gid': gid, 'host': host, 'port': port, + 'group_key': f"{host}:{port}", + 'name': nick, 'my_username': nick, + 'my_account': self.account.get('username', ''), + 'is_host': is_host, 'server': server, 'client': client, 'session': session, + 'users': [], 'messages': {}, 'order': [], + 'announcement': '', 'group_name': '', 'mode': 'p2p', 'user_key': None + } + + def _register_group(self, gid, group): + if gid in self.groups: + return + for g in list(self.groups.values()): + if g['group_key'] == group['group_key']: + if group.get('client'): + try: + group['client'].disconnect() + except Exception: + pass + if group.get('server'): + try: + group['server'].stop() + except Exception: + pass + return + self.groups[gid] = group + self.order.append(gid) + self._load_history(gid) + self._refresh_groups() + self._switch_group(gid) + + def _refresh_groups(self): + self.group_list.clear() + for i, gid in enumerate(self.order): + g = self.groups[gid] + tag = '[群主]' if g['is_host'] else '' + prefix = '☁️' if g.get('mode') == 'server' else '🔗' + status = '🟢' if self._group_connected(g) else '🔴' + addr = f"{g['host']}:{g['port']}" + u = g.get('unread', 0) + badge = f" 🔴{u}" if u else "" + item = QtWidgets.QListWidgetItem(f"{status}{prefix}{tag} {g['name']} {addr}{badge}") + item.setData(QtCore.Qt.UserRole, gid) + self.group_list.addItem(item) + + def _on_group_click(self, item): + gid = item.data(QtCore.Qt.UserRole) + if gid and gid != self.active_gid: + self._switch_group(gid) + + def _switch_group(self, gid): + self.active_gid = gid + self.selected_mid = None + group = self.groups.get(gid) + if group: + group['unread'] = 0 + for mid, msg in list(group['messages'].items()): + if not msg.is_mine and not msg.read: + msg.read = True + self._send_read(gid, mid) + self._render_group(gid) + self._refresh_groups() + + def _render_group(self, gid): + self.message_list.clear_all() + group = self.groups.get(gid) + self._render_users(gid) + if not group: + return + for mid in group['order']: + msg = group['messages'][mid] + self.message_list.add_message(msg, self._panel_actions()) + if group.get('announcement'): + self._show_banner(group['announcement']) + else: + self._hide_banner() + + def _render_users(self, gid): + self.users_list.clear() + group = self.groups.get(gid) + if not group: + return + for u in group['users']: + name = u.get('name', '?') + mark = ' (我)' if name == group['my_username'] else '' + self.users_list.addItem(f"👤 {name}{mark}") + + def _panel_actions(self): + return { + 'on_download': self._on_download, + 'on_play': self._on_play, + 'on_preview': self._on_preview, + } + + def _add_message(self, gid, mid, sender, mtype, content, ts, is_mine=False, persist=True): + group = self.groups.get(gid) + if not group: + return + if mid and mid in group['messages']: + return + msg = Message(mid, gid, sender, mtype, content, ts, is_mine) + group['messages'][mid] = msg + group['order'].append(mid) + # 已读/未读: 他人消息, 当前群立即已读+回执; 非当前群计未读 + if not is_mine and mtype != 'system': + if gid == self.active_gid: + msg.read = True + self._send_read(gid, mid) + else: + group['unread'] = group.get('unread', 0) + 1 + self._refresh_groups() + if persist: + try: + self.history.save(self.account['id'], group['group_key'], msg) + except Exception: + pass + if gid == self.active_gid: + self.message_list.add_message(msg, self._panel_actions()) + + def _add_local_message(self, gid, mid, mtype, content): + group = self.groups.get(gid) + if group: + self._add_message(gid, mid, group['my_username'], mtype, content, time.time(), is_mine=True) + + def _add_system(self, gid, text): + if gid is None: + if self.active_gid: + gid = self.active_gid + else: + return + self._add_message(gid, gen_mid(), '系统', 'system', text, time.time(), persist=False) + + def _delete_message(self, gid, mid): + group = self.groups.get(gid) + if not group: + return False + msg = group['messages'].pop(mid, None) + if not msg: + return False + if isinstance(msg.content, dict): + delete_media(msg.content.get('path')) + try: + self.history.delete_by_mid(self.account['id'], group['group_key'], mid) + except Exception: + pass + if mid in group['order']: + group['order'].remove(mid) + if gid == self.active_gid: + self.message_list.remove_message(mid) + return True + + def _load_history(self, gid): + group = self.groups.get(gid) + if not group: + return + try: + rows = self.history.load(self.account['id'], group['group_key']) + except Exception: + rows = [] + for row in rows: + mid = row['mid'] + if not mid or mid in group['messages']: + continue + content = HistoryDB.deserialize_content(row['mtype'], row['content']) + msg = Message(mid, gid, row['sender'], row['mtype'], content, row['ts'], bool(row['is_mine'])) + msg.withdrawn = bool(row['withdrawn']) + group['messages'][mid] = msg + group['order'].append(mid) + + + # ---------- 客户端工厂 ---------- + def _make_client(self, gid): + return ChatClient( + on_message=lambda s, t, m, g=gid: self._post(lambda: self._on_chat(g, s, t, m)), + on_status=lambda t, g=gid: self._post(lambda: self._on_status(t)), + on_users=lambda u, g=gid: self._post(lambda: self._on_users(g, u)), + on_file=lambda m, g=gid: self._on_file(g, m), + on_voice=lambda m, g=gid: self._on_voice(g, m), + on_system=lambda t, g=gid: self._post(lambda: self._add_system(g, t)), + on_withdraw=lambda m, g=gid: self._post(lambda: self._on_withdraw(g, m)), + on_announcement=lambda t, g=gid: self._post(lambda: self._on_announcement(g, t)), + on_group_name=lambda n, g=gid: self._post(lambda: self._on_group_name(g, n)), + on_kick=lambda r, g=gid: self._post(lambda: self._on_kick(g, r)), + on_group_info=lambda gn, an, g=gid: self._post(lambda: self._on_group_info(g, gn, an)), + on_need_approval=lambda r, g=gid: self._post(lambda: self._add_system(g, f"⏳ {r}")), + on_approval_granted=lambda g=gid: self._post(lambda: self._add_system(g, "✅ 群主已同意,你已加入")), + on_approval_denied=lambda r, g=gid: self._post(lambda: self._on_approval_denied(g, r)), + on_approval_request=lambda a, n, g=gid: self._post(lambda: self._on_approval_request(g, a, n)), + on_user_key_issued=lambda uk, g=gid: self._post(lambda: self._on_user_key_issued(g, uk)), + on_read=lambda m, g=gid: self._post( + lambda: self._on_read(g, m)), + ) + + # ---------- P2P 建群/加群 ---------- + def _create_group(self): + from PyQt5.QtWidgets import QInputDialog + port, ok = QInputDialog.getInt(self, "端口", "请输入端口号:", value=8888) + if not ok: + return + gid = self._new_gid() + session = SecureSession(); session.generate() + group_key = f"127.0.0.1:{port}" + server = ChatServer(on_status=lambda t, g=gid: self._post(lambda: self._on_status(t)), + server_key=group_key, server_db=self.server_db, + server_secret=session.session_key) + if not server.start(port): + self._on_status("❌ 服务器启动失败(端口被占用?)") + return + client = self._make_client(gid) + saved_uk = self._get_saved_user_key(group_key) + if not client.connect('127.0.0.1', port, self._my_nick(), nickname=self._my_nick(), + account=self.account.get('username', ''), + auth_secret=session.auth_secret(), + user_key=saved_uk, user_key_enc=session.user_key_enc()): + server.stop() + return + group = self._build_group(gid, '127.0.0.1', port, self._my_nick(), True, server, client, session) + group['user_key'] = saved_uk + self._register_group(gid, group) + self._save_group(gid) + self._add_system(gid, "✅ 群已创建(端到端),你是群主") + self._show_key_dialog(gid) + + def _join_group(self): + from PyQt5.QtWidgets import QInputDialog + host, ok1 = QInputDialog.getText(self, "服务器地址", "服务器IP:", text="127.0.0.1") + if not ok1: + return + port, ok2 = QInputDialog.getInt(self, "端口", "端口号:", value=8888) + if not ok2: + return + key, ok3 = QInputDialog.getText(self, "密钥", "会话密钥(创建者分享):") + if not ok3 or not key: + return + session = SecureSession() + if not session.import_key(key): + self._on_status("❌ 密钥无效") + return + gid = self._new_gid() + client = self._make_client(gid) + saved_uk = self._get_saved_user_key(f"{host}:{port}") + if not client.connect(host, port, self._my_nick(), nickname=self._my_nick(), + account=self.account.get('username', ''), + auth_secret=session.auth_secret(), + user_key=saved_uk, user_key_enc=session.user_key_enc()): + return + group = self._build_group(gid, host, port, self._my_nick(), False, None, client, session) + group['user_key'] = saved_uk + self._register_group(gid, group) + self._save_group(gid) + + def _save_group(self, gid): + group = self.groups.get(gid) + if not group: + return + try: + self.account_db.save_group(self.account['id'], group['group_key'], group['host'], + group['port'], group['session'].export_key(), + self._my_nick(), group['is_host'], group.get('user_key', '')) + except Exception: + pass + + def _get_saved_user_key(self, group_key): + try: + for r in self.account_db.load_groups(self.account['id']): + if r['group_key'] == group_key: + return r['user_key'] or None + except Exception: + pass + return None + + # ---------- 发送辅助 ---------- + def _send_chat_to(self, group, enc, mid): + if group.get('mode') == 'server': + return group['server_conn'].send_message(group['group_id'], + {'kind': 'chat', 'text': enc, 'mid': mid, 'ts': int(time.time())}) + return group['client'].send_chat(enc, mid) + + def _send_file_to(self, group, fname, enc_bytes, mid, mime): + if group.get('mode') == 'server': + return group['server_conn'].send_message(group['group_id'], { + 'kind': 'file', 'filename': fname, 'size': len(enc_bytes), + 'data': base64.b64encode(enc_bytes).decode('ascii'), + 'mime': mime, 'mid': mid, 'ts': int(time.time())}) + return group['client'].send_file(fname, enc_bytes, mid, mime=mime) + + def _send_voice_to(self, group, enc_bytes, mid): + if group.get('mode') == 'server': + return group['server_conn'].send_message(group['group_id'], { + 'kind': 'voice', 'data': base64.b64encode(enc_bytes).decode('ascii'), + 'size': len(enc_bytes), 'mid': mid, 'ts': int(time.time())}) + return group['client'].send_voice(enc_bytes, mid) + + def _send_withdraw_to(self, group, mid): + if group.get('mode') == 'server': + return group['server_conn'].send_message(group['group_id'], {'kind': 'withdraw', 'mid': mid}) + return group['client'].send_withdraw(mid) + + # ---------- 发送 ---------- + def _send_text(self): + gid, group = self._current_group() + if group is None: + self._on_status("请先创建或加入一个群") + return + if not self._group_connected(group): + self._on_status("该群未连接") + return + if not group['session'].has_key(): + self._on_status("该群没有会话密钥") + return + text = self.input.toPlainText().strip() + if not text: + return + try: + mid = gen_mid() + enc = group['session'].encrypt(text) + if self._send_chat_to(group, enc, mid): + self._add_local_message(gid, mid, 'text', text) + self.input.clear() + except Exception as e: + self._add_system(gid, f"❌ 加密失败: {e}") + + def _send_image(self): + gid, group = self._current_group() + if group is None or not self._group_connected(group): + return + path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "选择图片", "", "图片 (*.png *.jpg *.jpeg *.gif *.bmp *.webp)") + if not path: + return + try: + with open(path, 'rb') as f: + data = f.read() + fname = os.path.basename(path) + ext = os.path.splitext(fname)[1].lower() + mid = gen_mid() + enc = group['session'].encrypt_bytes(data) + media_path = save_media(data, ext) + content = {'filename': fname, 'size': len(data), 'path': media_path, 'ext': ext} + if self._send_file_to(group, fname, enc.encode('utf-8'), mid, 'image'): + self._add_local_message(gid, mid, 'image', content) + except Exception as e: + self._add_system(gid, f"❌ 图片读取失败: {e}") + + def _send_file(self): + gid, group = self._current_group() + if group is None or not self._group_connected(group): + return + path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "选择文件") + if not path: + return + try: + with open(path, 'rb') as f: + data = f.read() + fname = os.path.basename(path) + ext = os.path.splitext(fname)[1].lower() + mid = gen_mid() + enc = group['session'].encrypt_bytes(data) + media_path = save_media(data, ext) + content = {'filename': fname, 'size': len(data), 'path': media_path, 'ext': ext} + if self._send_file_to(group, fname, enc.encode('utf-8'), mid, 'file'): + self._add_local_message(gid, mid, 'file', content) + except Exception as e: + self._add_system(gid, f"❌ 文件读取失败: {e}\n (可能是文件被占用/权限/或 .lnk 快捷方式问题)") + + # ---------- 语音 ---------- + def _toggle_record(self): + """录音: 点一次开始, 再点停止并发送""" + gid, group = self._current_group() + if self.voice.recording: + wav = self.voice.stop_recording() + self.record_btn.setText("🎤 录音") + if wav: + if group and self._group_connected(group): + mid = gen_mid() + enc = group['session'].encrypt_bytes(wav) + media_path = save_media(wav, '.wav') + content = {'filename': 'voice.wav', 'size': len(wav), + 'path': media_path, 'ext': '.wav'} + if self._send_voice_to(group, enc.encode('utf-8'), mid): + self._add_local_message(gid, mid, 'voice', content) + else: + self._add_system(gid, "未连接,语音未发送") + else: + if group is None or not self._group_connected(group): + self._on_status("请先加入一个已连接的群") + return + if self.voice.start_recording(): + self.record_btn.setText("⏹ 停止录音") + + def _play_voice(self): + if self.voice.last_voice: + self.voice.play(self.voice.last_voice, self.voice.last_voice_from) + else: + self._on_status("没有可播放的语音") + + # ---------- 接收 ---------- + def _on_chat(self, gid, sender, text, mid): + group = self.groups.get(gid) + if not group: + return + if not group['session'].has_key(): + self._add_system(gid, f"⚠️ 无密钥无法解密 {sender} 的消息") + return + plain = group['session'].decrypt(text) + if plain: + self._add_message(gid, mid or gen_mid(), sender, 'text', plain, int(time.time())) + else: + self._add_system(gid, f"⚠️ {sender} 的消息解密失败") + + def _on_users(self, gid, users): + group = self.groups.get(gid) + if group: + group['users'] = list(users) + if gid == self.active_gid: + self._render_users(gid) + + def _on_file(self, gid, msg): + group = self.groups.get(gid) + if not group: + return + fname = os.path.basename(str(msg.get('filename', '文件'))) + ext = os.path.splitext(fname)[1].lower() + mime = msg.get('mime', 'file') + raw = b'' + try: + eb = base64.b64decode(msg.get('data', '')) + if eb: + raw = group['session'].decrypt_bytes(eb.decode('utf-8')) or b'' + except Exception: + raw = b'' + path = save_media(raw, ext) if raw else '' + content = {'filename': fname, 'size': len(raw), 'path': path, 'ext': ext} + if mime == 'image' or ext in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'): + mtype = 'image' + elif mime in ('voice', 'audio') or ext in ('.wav', '.mp3', '.ogg', '.flac'): + mtype = 'audio' + else: + mtype = 'file' + mid = msg.get('mid', gen_mid()) + from_user = msg.get('from', '?') + ts = int(msg.get('ts', time.time())) + self._post(lambda: self._add_message(gid, mid, from_user, mtype, content, ts)) + + def _on_voice(self, gid, msg): + group = self.groups.get(gid) + if not group: + return + raw = b'' + try: + eb = base64.b64decode(msg.get('data', '')) + if eb: + raw = group['session'].decrypt_bytes(eb.decode('utf-8')) or b'' + except Exception: + raw = b'' + path = save_media(raw, '.wav') if raw else '' + content = {'filename': 'voice.wav', 'size': len(raw), 'path': path, 'ext': '.wav'} + mid = msg.get('mid', gen_mid()) + from_user = msg.get('from', '?') + ts = int(msg.get('ts', time.time())) + self._post(lambda: self._add_message(gid, mid, from_user, 'voice', content, ts)) + if raw: + self.voice.play(raw, from_user) + + def _on_withdraw(self, gid, mid): + if self._delete_message(gid, mid): + self._add_system(gid, "🚫 有一条消息已被撤回") + + def _on_announcement(self, gid, text): + group = self.groups.get(gid) + if group: + group['announcement'] = text + if gid == self.active_gid: + self._show_banner(text) + self._add_system(gid, f"📢 群公告更新: {text}") + + def _on_group_name(self, gid, name): + group = self.groups.get(gid) + if group: + group['group_name'] = name or '' + group['name'] = f"{name or group['my_username']}" + self._refresh_groups() + + def _on_group_info(self, gid, gn, an): + if gn: + self._on_group_name(gid, gn) + if an: + self._on_announcement(gid, an) + + def _on_kick(self, gid, reason): + group = self.groups.get(gid) + if group: + try: + group['client'].disconnect() + except Exception: + pass + self._refresh_groups() + self._add_system(gid, f"🚫 {reason}") + + def _on_approval_denied(self, gid, reason): + group = self.groups.get(gid) + if group: + try: + group['client'].disconnect() + except Exception: + pass + self._refresh_groups() + self._add_system(gid, f"❌ {reason}") + + def _on_approval_request(self, gid, account, nickname): + ret = QtWidgets.QMessageBox.question(self, "入群申请", + f"{nickname} ({account}) 曾被移出本群,申请重新加入。是否同意?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + group = self.groups.get(gid) + if group: + group['client'].send_approval(account, 'grant' if ret == QtWidgets.QMessageBox.Yes else 'deny') + + def _on_user_key_issued(self, gid, user_key): + group = self.groups.get(gid) + if group: + group['user_key'] = user_key + self._save_group(gid) + + def _send_read(self, gid, mid): + """发送已读回执 (P2P 或 服务器群); 未连接时跳过""" + group = self.groups.get(gid) + if not group: + return + if group.get('mode') == 'server': + sc = group.get('server_conn') + if sc and sc.connected: + sc.send_message(group['group_id'], {'kind': 'read', 'mid': mid}) + elif group.get('client'): + group['client'].send_read(mid) + + def _on_read(self, gid, mid): + """收到已读回执: 自己的消息标记为已读""" + group = self.groups.get(gid) + if not group: + return + msg = group['messages'].get(mid) + if msg and msg.is_mine: + msg.read = True + if gid == self.active_gid: + self.message_list.update_message(mid, msg) + + def _current_group(self): + gid = self.active_gid + if gid is None or gid not in self.groups: + return None, None + return gid, self.groups[gid] + + + # ---------- 中央服务器 (解决 NAT) ---------- + def _connect_server(self): + from PyQt5.QtWidgets import QInputDialog + addr, ok = QInputDialog.getText(self, "中央服务器", "服务器地址 (如 服务器IP:8666):", text="127.0.0.1:8666") + if not ok or not addr: + return + try: + host, port = addr.rsplit(':', 1) + port = int(port) + except Exception: + self._on_status("地址格式应为 主机:端口") + return + # 后台线程连接, 避免卡死 GUI + self._on_status(f"☁️ 正在连接 {host}:{port} ...") + threading.Thread(target=self._do_manual_connect, args=(host, port), daemon=True).start() + + def _do_manual_connect(self, host, port): + """后台连接服务器, 成功后在主线程弹登录框; 已连接则复用""" + if self.server_conn and self.server_conn.connected: + self._post(lambda: self._server_auth_dialog(self.server_conn, host, port)) + return + conn = ServerConnection( + on_status=self._on_status, + on_groups=lambda gl: self._post(lambda: self._on_server_groups(gl)), + on_message=lambda gid, frm, pl: self._post(lambda: self._on_server_group_message(gid, frm, pl)), + on_members=lambda gid, mems: self._post(lambda: self._on_server_members(gid, mems)), + on_system=lambda gid, t: self._post(lambda: self._on_server_system(gid, t)), + on_group_name=lambda gid, n: self._post(lambda: self._on_server_group_name(gid, n)), + on_announcement=lambda gid, t: self._post(lambda: self._on_server_announcement(gid, t)), + on_group_info=lambda gid, n, a: self._post(lambda: self._on_server_group_info(gid, n, a))) + if not conn.connect(host, port): + self._post(lambda: self._on_status(f"❌ 连接 {host}:{port} 失败")) + return + self._post(lambda: self._server_auth_dialog(conn, host, port)) + + def _server_auth_dialog(self, conn, host, port): + dlg = QtWidgets.QDialog(self) + dlg.setWindowTitle(f"☁️ 服务器账号 - {host}:{port}") + dlg.setFixedSize(380, 330) + lay = QtWidgets.QVBoxLayout(dlg) + lay.addWidget(QtWidgets.QLabel(f"连接到 {host}:{port}")) + u = QtWidgets.QLineEdit(); u.setPlaceholderText("服务器用户名") + p = QtWidgets.QLineEdit(); p.setPlaceholderText("密码"); p.setEchoMode(QtWidgets.QLineEdit.Password) + lay.addWidget(u); lay.addWidget(p) + lab = QtWidgets.QLabel(""); lab.setStyleSheet("color:#e74c3c;") + lay.addWidget(lab) + row = QtWidgets.QHBoxLayout() + b1 = QtWidgets.QPushButton("登录"); b1.setProperty("class", "info") + b2 = QtWidgets.QPushButton("注册(服务器建号)"); b2.setProperty("class", "primary") + b3 = QtWidgets.QPushButton("取消") + row.addWidget(b1); row.addWidget(b2); row.addWidget(b3) + lay.addLayout(row) + lay.addWidget(QtWidgets.QLabel("🔐 登录成功后会自动记住,下次启动自动登录服务器")) + lay.addWidget(QtWidgets.QLabel("如需清除,删除 data\\server_cred.dat")) + + def finish(ok, err, uname, upass): + if not ok: + lab.setText(err) + return + dlg.accept() + self.server_conn = conn + self.server_addr = f"{host}:{port}" + self.server_username = conn.username + self._bind_server_conn() + self._update_title() + # 登录即自动记住服务器账号 + save_server_cred(self._get_pass_hash(), f"{host}:{port}", uname, upass) + self._on_status("🔐 已记住服务器账号,下次启动自动登录") + self._on_status(f"☁️ 已连接中央服务器 {host}:{port} (用户: {conn.username})") + self._add_system(None, f"☁️ 已连接中央服务器 {host}:{port},正在加载你的群...") + conn.list_groups() + + def do_auth(action): + uname = u.text().strip() + upass = p.text() + if not uname or not upass: + lab.setText("请输入用户名和密码") + return + b1.setEnabled(False); b2.setEnabled(False) + lab.setText("连接中... 请稍候") + + def run(): + if action == 'login': + ok, err = conn.login(uname, upass) + else: + ok, err = conn.register(uname, upass) + self._post(lambda: finish(ok, err, uname, upass)) + + threading.Thread(target=run, daemon=True).start() + + b1.clicked.connect(lambda: do_auth('login')) + b2.clicked.connect(lambda: do_auth('register')) + b3.clicked.connect(dlg.reject) + dlg.exec_() + + def _get_pass_hash(self): + """取本地账号密码哈希 (解密服务器凭据用); 缺失时查库兜底""" + ph = self.account.get('pass_hash', '') or '' + if not ph: + try: + cur = self.account_db.conn.cursor() + r = cur.execute("SELECT pass_hash FROM accounts WHERE id=?", (self.account.get('id', -1),)).fetchone() + if r: + ph = r[0] or '' + except Exception: + pass + return ph + + def _auto_connect_server(self): + """记住我的服务器账号: 自动登录 (后台线程)""" + try: + cred = load_server_cred(self._get_pass_hash()) + except Exception: + cred = None + if not cred: + return + addr, username, password = cred + try: + host, port = addr.rsplit(':', 1) + port = int(port) + except Exception: + return + threading.Thread(target=self._do_auto_connect, + args=(host, port, username, password, 3), daemon=True).start() + + def _do_auto_connect(self, host, port, username, password, attempts=3): + for attempt in range(attempts): + if self.server_conn and self.server_conn.connected: + return + conn = ServerConnection( + on_status=self._on_status, + on_groups=lambda gl: self._post(lambda: self._on_server_groups(gl)), + on_message=lambda gid, frm, pl: self._post(lambda: self._on_server_group_message(gid, frm, pl)), + on_members=lambda gid, mems: self._post(lambda: self._on_server_members(gid, mems)), + on_system=lambda gid, t: self._post(lambda: self._on_server_system(gid, t)), + on_group_name=lambda gid, n: self._post(lambda: self._on_server_group_name(gid, n)), + on_announcement=lambda gid, t: self._post(lambda: self._on_server_announcement(gid, t)), + on_group_info=lambda gid, n, a: self._post(lambda: self._on_server_group_info(gid, n, a))) + if not conn.connect(host, port): + if attempt < attempts - 1: + time.sleep(2) + continue + self._post(lambda: self._on_status("☁️ 服务器自动连接失败(重试3次),请手动「☁️服务器」登录")) + return + ok, err = conn.login(username, password) + if ok: + self._post(lambda: self._on_auto_server_logged_in(conn, host, port)) + return + if attempt < attempts - 1: + time.sleep(2) + continue + self._post(lambda: self._on_status(f"☁️ 服务器自动登录失败: {err},请手动「☁️服务器」登录")) + + def _update_title(self): + """窗口标题显示当前身份: 有服务器账号就显示服务器账号""" + if self.server_username: + self.setWindowTitle(f"{APP_NAME} - {self.server_username} ☁️服务器 [{self._my_nick()}]") + else: + self.setWindowTitle(f"{APP_NAME} - {self._my_nick()}") + + def _bind_server_conn(self): + """把已有服务器群绑定到当前连接 + 更新群内身份为服务器账号""" + for g in self.groups.values(): + if g.get('mode') == 'server': + g['server_conn'] = self.server_conn + if self.server_username: + g['my_username'] = self.server_username + self._refresh_groups() + + def _on_auto_server_logged_in(self, conn, host, port): + self.server_conn = conn + self.server_addr = f"{host}:{port}" + self.server_username = conn.username + self._bind_server_conn() + self._update_title() + self._on_status(f"☁️ 已自动登录中央服务器 {host}:{port} (用户: {conn.username})") + self._add_system(None, "☁️ 服务器自动登录成功,正在实时同步群...") + conn.list_groups() + + def _load_cached_server_groups(self): + """启动时从本地缓存显示服务器群 (离线也能看到), 连接后实时同步""" + try: + cred = load_server_cred(self._get_pass_hash()) + except Exception: + cred = None + if not cred: + return + addr = cred[0] + self.server_addr = addr # 先设服务器地址, 供密钥查找/群去重 + groups = load_server_groups_cache(addr) + if groups: + self._apply_groups_to_ui(groups) + self._on_status(f"☁️ 已从本地缓存显示 {len(groups)} 个服务器群") + + def _on_server_groups(self, groups): + """服务器实时返回群列表 → 刷新UI + 写本地缓存""" + self._apply_groups_to_ui(groups) + save_server_groups_cache(self.server_addr, groups) + self._on_status(f"☁️ 已实时同步 {len(groups)} 个服务器群") + + def _apply_groups_to_ui(self, groups): + for g in groups: + gid = g.get('id', '') + gname = g.get('name', '') or gid + owner = g.get('owner', '') + key = self._get_saved_server_key(self.server_addr, gid) + if key: + session = SecureSession() + try: + session.import_key(key) + except Exception: + session = None + if session: + if not self._server_group_exists(gid): + self._add_server_group(gid, gname, owner, session) + ex = self._gid_for_server_group(gid) + if ex: + self.groups[ex]['server_conn'] = self.server_conn + self._on_group_name(ex, gname or gid) + ann = g.get('announcement', '') + if ann: + self.groups[ex]['announcement'] = ann + if ex == self.active_gid: + self._show_banner(ann) + self._refresh_groups() + else: + self._add_system(None, f"☁️ 群 [{gname}] ({gid}) 需要密钥,点「☁️ 加群」输入群ID和密钥") + + def _server_group_exists(self, group_id): + key = f"server@{self.server_addr}@{group_id}" + return any(g['group_key'] == key for g in self.groups.values()) + + def _gid_for_server_group(self, group_id): + for gid, g in self.groups.items(): + if g.get('mode') == 'server' and g.get('group_id') == group_id: + return gid + return None + + def _add_server_group(self, group_id, group_name, owner, session): + gid = self._new_gid() + try: + srv_host = self.server_addr.split(':')[0] + srv_port = int(self.server_addr.split(':')[1]) + except Exception: + srv_host, srv_port = '127.0.0.1', 8666 + group = { + 'gid': gid, 'mode': 'server', + 'group_key': f"server@{self.server_addr}@{group_id}", + 'host': srv_host, 'port': srv_port, 'group_id': group_id, + 'server_conn': self.server_conn, + 'name': group_name or group_id, 'my_username': self.server_username or self._my_nick(), + 'my_account': self.account.get('username', ''), + 'is_host': owner == (self.server_username or self.account.get('username', '')), + 'server': None, 'client': None, 'session': session, + 'users': [], 'messages': {}, 'order': [], + 'announcement': '', 'group_name': group_name or '', 'user_key': None + } + self._register_group(gid, group) + self._save_server_group(gid) + return gid + + def _save_server_group(self, gid): + group = self.groups.get(gid) + if group: + try: + self.account_db.save_group(self.account['id'], group['group_key'], group['host'], + group['port'], group['session'].export_key(), group['name'], 0) + except Exception: + pass + + def _get_saved_server_key(self, server_addr, group_id): + gkey = f"server@{server_addr}@{group_id}" + try: + for r in self.account_db.load_groups(self.account['id']): + if r['group_key'] == gkey: + return r['session_key'] or None + except Exception: + pass + return None + + def _create_server_group(self): + if not self.server_conn or not self.server_conn.connected: + self._on_status("请先连接中央服务器 (☁️ 服务器)") + return + name, ok = QtWidgets.QInputDialog.getText(self, "☁️ 服务器建群", "群名:", text="新群") + if not ok or not name: + return + group_id = f"sg{int(time.time()*1000)}-{os.urandom(3).hex()}" + session = SecureSession(); session.generate() + self.server_conn.create_group(group_id, name) + gid = self._add_server_group(group_id, name, self.server_username, session) + self._add_system(gid, f"✅ 已在服务器创建群 [{name}]") + self._show_server_group_info(gid) # 显示并复制 群ID + 服务器 + 密钥 + self.server_conn.group_members(group_id) + + def _join_server_group(self): + if not self.server_conn or not self.server_conn.connected: + self._on_status("请先连接中央服务器 (☁️ 服务器)") + return + gid2, ok1 = QtWidgets.QInputDialog.getText(self, "☁️ 服务器加群", "群ID (创建者分享):") + if not ok1 or not gid2: + return + key, ok2 = QtWidgets.QInputDialog.getText(self, "☁️ 服务器加群", "群密钥 (创建者分享):") + if not ok2 or not key: + return + session = SecureSession() + if not session.import_key(key): + self._on_status("密钥无效!") + return + self.server_conn.join_group(gid2) + gid = self._add_server_group(gid2, gid2, '', session) + self._add_system(gid, f"✅ 已加入服务器群 [{gid2}]") + self.server_conn.group_members(gid2) + + def _on_server_group_message(self, group_id, sender, payload): + gid = self._gid_for_server_group(group_id) + if not gid or not isinstance(payload, dict): + return + kind = payload.get('kind') + if kind == 'chat': + self._on_chat(gid, sender, payload.get('text', ''), payload.get('mid', '')) + elif kind == 'file': + self._on_file(gid, {'filename': payload.get('filename', '文件'), 'size': payload.get('size', 0), + 'data': payload.get('data', ''), 'mime': payload.get('mime', 'file'), + 'mid': payload.get('mid', ''), 'ts': payload.get('ts', int(time.time())), + 'from': sender}) + elif kind == 'voice': + self._on_voice(gid, {'data': payload.get('data', ''), 'size': payload.get('size', 0), + 'mid': payload.get('mid', ''), 'ts': payload.get('ts', int(time.time())), + 'from': sender}) + elif kind == 'withdraw': + self._on_withdraw(gid, payload.get('mid', '')) + elif kind == 'read': + self._on_read(gid, payload.get('mid', '')) + + def _on_server_system(self, group_id, text): + gid = self._gid_for_server_group(group_id) + if gid: + self._add_system(gid, text) + if '加入' in text or '离开' in text: + self.server_conn.group_members(group_id) + + def _on_server_members(self, group_id, members): + for gid, g in self.groups.items(): + if g.get('mode') == 'server' and g.get('group_id') == group_id: + g['users'] = [{'name': m, 'account': m} for m in members] + if gid == self.active_gid: + self._render_users(gid) + break + + def _on_server_group_name(self, group_id, name): + gid = self._gid_for_server_group(group_id) + if gid: + self._on_group_name(gid, name) + self._add_system(gid, f"🏷️ 群名更新为: {name}") + + def _on_server_group_info(self, group_id, name, announcement): + """服务器推送的群信息(加入时): 同步群名/公告""" + gid = self._gid_for_server_group(group_id) + if not gid: + return + if name: + self._on_group_name(gid, name) + if announcement: + self._on_announcement(gid, announcement) + + def _on_server_announcement(self, group_id, text): + gid = self._gid_for_server_group(group_id) + if gid: + self._on_announcement(gid, text) + + # ---------- 消息操作 ---------- + def _on_download(self, mid): + gid = self.active_gid + group = self.groups.get(gid) + if not group: + return + msg = group['messages'].get(mid) + if not msg: + return + data = msg.get_data_bytes() + if not data: + self._on_status("文件已清理或不存在") + return + c = msg.content if isinstance(msg.content, dict) else {} + path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "保存文件", os.path.join(DOWNLOAD_DIR, c.get('filename', 'download.bin'))) + if not path: + return + try: + with open(path, 'wb') as f: + f.write(data) + self._add_system(gid, f"✅ 已保存: {path}") + except Exception as e: + self._add_system(gid, f"❌ 保存失败: {e}") + + def _on_play(self, mid): + """播放语音: 优先读媒体文件; 文件缺失时回退到内存缓存, 保证点了就有声""" + gid = self.active_gid + group = self.groups.get(gid) + msg = group['messages'].get(mid) if group else None + if not msg: + self._on_status("消息不存在") + return + data = msg.get_data_bytes() + if data: + self.voice.last_voice = data + self.voice.last_voice_from = msg.sender + self.voice.play(data, msg.sender) + elif self.voice.last_voice: + # 媒体文件缺失 → 用最近一次缓存的语音 + self.voice.play(self.voice.last_voice, self.voice.last_voice_from) + self._on_status("⚠️ 媒体文件缺失,已用缓存语音播放") + else: + self._on_status("文件已清理或不存在") + + def _on_preview(self, mid): + gid = self.active_gid + group = self.groups.get(gid) + msg = group['messages'].get(mid) if group else None + if not msg: + return + data = msg.get_data_bytes() + if not data: + return + try: + from PIL import Image + img = Image.open(io.BytesIO(data)) + img.thumbnail((900, 700)) + img = img.convert("RGBA") + qimg = QtGui.QImage(img.tobytes(), img.width, img.height, QtGui.QImage.Format_RGBA8888) + pix = QtGui.QPixmap.fromImage(qimg) + dlg = QtWidgets.QDialog(self) + dlg.setWindowTitle(f"图片预览 - {msg.sender}") + lab = QtWidgets.QLabel() + lab.setPixmap(pix) + lay = QtWidgets.QVBoxLayout(dlg) + lay.addWidget(lab) + close = QtWidgets.QPushButton("关闭") + close.clicked.connect(dlg.accept) + lay.addWidget(close) + dlg.exec_() + except Exception as e: + self._on_status(f"预览失败: {e}") + + def _on_msg_selected(self, mid): + """消息列表选中/取消回调 → 同步到主窗口 (None=取消选中)""" + self.selected_mid = mid + + def _show_context_menu(self, mid, pos): + gid = self.active_gid + group = self.groups.get(gid) + if not group: + return + msg = group['messages'].get(mid) + if not msg: + return + self.selected_mid = mid + menu = QtWidgets.QMenu(self) + if msg.type == 'text': + menu.addAction("📋 复制文字", lambda: self._copy_msg(msg)) + if msg.type in ('image', 'file', 'audio', 'voice'): + menu.addAction("💾 下载", lambda: self._on_download(mid)) + if msg.type == 'image': + menu.addAction("🔍 查看原图", lambda: self._on_preview(mid)) + if msg.type in ('voice', 'audio'): + menu.addAction("▶ 播放", lambda: self._on_play(mid)) + menu.addSeparator() + if msg.is_withdrawable(): + menu.addAction("🚫 撤回", lambda: self._withdraw(mid)) + menu.addAction("📤 分享到...", lambda: self._share(mid)) + menu.exec_(pos) + + def _copy_msg(self, msg): + if msg.type == 'text': + QtWidgets.QApplication.clipboard().setText(msg.content) + self._on_status("📋 已复制文字") + + def _withdraw(self, mid): + gid = self.active_gid + group = self.groups.get(gid) + if not group: + return + msg = group['messages'].get(mid) + if not msg or not msg.is_withdrawable(): + self._on_status("只能撤回自己 2 分钟内的消息") + return + self._send_withdraw_to(group, mid) + self._delete_message(gid, mid) + self._add_system(gid, "🚫 你撤回了一条消息") + + def _share(self, mid): + gid = self.active_gid + group = self.groups.get(gid) + msg = group['messages'].get(mid) if group else None + if not msg or msg.withdrawn: + return + targets = [g for g in self.order if g != gid] + if not targets: + self._on_status("没有其他群可以分享") + return + name, ok = QtWidgets.QInputDialog.getItem(self, "📤 分享到", "选择目标群:", + [self.groups[g]['name'] for g in targets], 0, False) + if not ok: + return + g2 = targets[[self.groups[g]['name'] for g in targets].index(name)] + group2 = self.groups.get(g2) + if not group2 or not self._group_connected(group2): + self._on_status("目标群未连接") + return + mid2 = gen_mid() + if msg.type == 'text': + enc = group2['session'].encrypt(msg.content) + if self._send_chat_to(group2, enc, mid2): + self._add_local_message(g2, mid2, 'text', msg.content) + elif msg.type in ('image', 'file', 'audio'): + c = msg.content + raw = msg.get_data_bytes() + mime = 'image' if msg.type == 'image' else ('file' if msg.type == 'file' else 'audio') + enc = group2['session'].encrypt_bytes(raw) + new_path = save_media(raw, c.get('ext', '')) + content = {'filename': c.get('filename', 'file'), 'size': len(raw), 'path': new_path, 'ext': c.get('ext', '')} + if self._send_file_to(group2, content['filename'], enc.encode('utf-8'), mid2, mime): + self._add_local_message(g2, mid2, msg.type, content) + elif msg.type == 'voice': + raw = msg.get_data_bytes() + enc = group2['session'].encrypt_bytes(raw) + new_path = save_media(raw, '.wav') + content = {'filename': 'voice.wav', 'size': len(raw), 'path': new_path, 'ext': '.wav'} + if self._send_voice_to(group2, enc.encode('utf-8'), mid2): + self._add_local_message(g2, mid2, 'voice', content) + self._switch_group(g2) + self._add_system(g2, f"📤 已分享一条来自 {msg.sender} 的消息") + + + # ---------- 密钥 / 管理 / 工具 ---------- + def _show_key_dialog(self, gid): + group = self.groups.get(gid) + if not group or not group['session'].has_key(): + return + data = group['session'].export_key() + dlg = QtWidgets.QDialog(self) + dlg.setWindowTitle("🔑 会话密钥") + dlg.setFixedSize(460, 200) + lay = QtWidgets.QVBoxLayout(dlg) + lay.addWidget(QtWidgets.QLabel("复制此密钥分享给加入该群的人")) + te = QtWidgets.QTextEdit() + te.setPlainText(data) + te.setReadOnly(True) + lay.addWidget(te) + row = QtWidgets.QHBoxLayout() + b1 = QtWidgets.QPushButton("📋 复制全部") + b1.setProperty("class", "primary") + b1.clicked.connect(lambda: (QtWidgets.QApplication.clipboard().setText(data), dlg.accept())) + b2 = QtWidgets.QPushButton("关闭") + b2.clicked.connect(dlg.reject) + row.addWidget(b1); row.addWidget(b2) + lay.addLayout(row) + dlg.exec_() + + def _show_server_group_info(self, gid): + """服务器建群: 显示并复制 群ID + 服务器地址 + 群密钥 (加入需要)""" + group = self.groups.get(gid) + if not group: + return + gid2 = group.get('group_id', '') + key = group['session'].export_key() if group['session'].has_key() else '' + addr = self.server_addr or '' + + dlg = QtWidgets.QDialog(self) + dlg.setWindowTitle("☁️ 服务器群邀请信息") + dlg.setFixedSize(500, 340) + lay = QtWidgets.QVBoxLayout(dlg) + lay.addWidget(QtWidgets.QLabel("把下面 3 项分享给朋友,让他们点「☁️ 加群」输入后加入")) + + def make_row(label, value, long=False): + lay.addWidget(QtWidgets.QLabel(label)) + row = QtWidgets.QHBoxLayout() + if long: + te = QtWidgets.QTextEdit(value); te.setFixedHeight(56) + row.addWidget(te, 1) + else: + te = QtWidgets.QLineEdit(value); te.setReadOnly(True) + row.addWidget(te, 1) + btn = QtWidgets.QPushButton("📋 复制") + btn.clicked.connect(lambda: (QtWidgets.QApplication.clipboard().setText(value), dlg.accept())) + row.addWidget(btn) + lay.addLayout(row) + + make_row("🆔 群ID:", gid2) + make_row("🌐 服务器:", addr) + make_row("🔑 群密钥:", key, long=True) + lay.addWidget(QtWidgets.QLabel("⚠️ 密钥请私下发给成员,勿在公开渠道泄露")) + close = QtWidgets.QPushButton("关闭") + close.clicked.connect(dlg.reject) + lay.addWidget(close) + dlg.exec_() + + def _copy_active_key(self): + gid, group = self._current_group() + if group and group['session'].has_key(): + QtWidgets.QApplication.clipboard().setText(group['session'].export_key()) + self._on_status(f"🔑 已复制「{group['name']}」的密钥") + else: + self._on_status("请先选择一个群") + + def _rename(self): + nick, ok = QtWidgets.QInputDialog.getText(self, "改名", "新昵称:", text=self._my_nick()) + if not ok or not nick: + return + nick = nick[:20] + try: + self.account_db.update_nickname(self.account['id'], nick) + except Exception: + pass + self.account = dict(self.account) + self.account['nickname'] = nick + for gid in list(self.order): + g = self.groups[gid] + g['my_username'] = nick + if g.get('mode') == 'server': + pass + elif g.get('client'): + g['client'].send_rename(nick) + self._add_system(gid, f"✏️ 你已改名为 {nick}") + self._refresh_groups() + self._render_users(self.active_gid) + self._on_status(f"✅ 已改名为 {nick}") + + def _set_announcement(self): + gid, group = self._current_group() + if not group: + return + if not group['is_host']: + self._on_status("仅群主可设置公告") + return + text, ok = QtWidgets.QInputDialog.getText(self, "群公告", "输入公告内容:", text=group.get('announcement', '')) + if not ok: + return + if group.get('mode') == 'server': + group['server_conn'].set_announcement(group['group_id'], text) + elif group.get('client'): + group['client'].send_announcement(text) + group['announcement'] = text + if gid == self.active_gid: + self._show_banner(text) + + def _set_group_name(self): + gid, group = self._current_group() + if not group: + return + if not group['is_host']: + self._on_status("仅群主可设置群名") + return + cur = group.get('group_name', '') or group.get('group_id', '') + name, ok = QtWidgets.QInputDialog.getText(self, "群名", "输入群名:", text=cur) + if not ok or not name: + return + if group.get('mode') == 'server': + group['server_conn'].set_group_name(group['group_id'], name) + elif group.get('client'): + group['client'].send_set_group_name(name) + self._on_group_name(gid, name) + + def _withdraw_selected(self): + if self.selected_mid: + self._withdraw(self.selected_mid) + else: + self._on_status("请先点击选择一条消息") + + def _share_selected(self): + if self.selected_mid: + self._share(self.selected_mid) + else: + self._on_status("请先点击选择一条消息") + + def _download_selected(self): + if self.selected_mid: + self._on_download(self.selected_mid) + else: + self._on_status("请先点击选择一条消息") + + def _copy_selected(self): + gid = self.active_gid + group = self.groups.get(gid) + msg = group['messages'].get(self.selected_mid) if group and self.selected_mid else None + if msg: + self._copy_msg(msg) + else: + self._on_status("请先点击选择一条消息") + + def _leave_active_group(self): + if self.active_gid: + self._leave_group(self.active_gid) + + def _leave_group(self, gid): + group = self.groups.get(gid) + if not group: + return + if group.get('mode') == 'server': + try: + group['server_conn'].leave_group(group['group_id']) + except Exception: + pass + else: + try: + group['client'].disconnect() + except Exception: + pass + if group['server']: + try: + group['server'].stop() + except Exception: + pass + if gid in self.order: + self.order.remove(gid) + del self.groups[gid] + try: + self.account_db.remove_group(self.account['id'], group['group_key']) + except Exception: + pass + if self.active_gid == gid: + self.active_gid = self.order[-1] if self.order else None + self._render_group(self.active_gid) + self._refresh_groups() + + def _on_group_menu(self, pos): + item = self.group_list.itemAt(pos) + if not item: + return + gid = item.data(QtCore.Qt.UserRole) + menu = QtWidgets.QMenu(self) + menu.addAction("🔑 复制密钥", lambda: self._copy_group_key(gid)) + menu.addAction("💾 导出密钥文件", lambda: self._save_key_file(gid)) + menu.addAction("📤 分享密钥到其他群", lambda: self._share_key_to(gid)) + menu.addAction("📋 群属性", lambda: self._group_properties(gid)) + menu.addSeparator() + menu.addAction("切换到此群", lambda: self._switch_group(gid)) + menu.addAction("❌ 离开该群", lambda: self._leave_group(gid)) + menu.exec_(self.group_list.mapToGlobal(pos)) + + def _copy_group_key(self, gid): + group = self.groups.get(gid) + if group and group['session'].has_key(): + QtWidgets.QApplication.clipboard().setText(group['session'].export_key()) + self._on_status(f"🔑 已复制「{group['name']}」的密钥") + + def _save_key_file(self, gid): + group = self.groups.get(gid) + if not group or not group['session'].has_key(): + return + data = group['session'].export_key() + path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "导出密钥", f"群密钥_{gid}.txt", "文本 (*.txt)") + if not path: + return + try: + with open(path, 'w', encoding='utf-8') as f: + f.write(data) + self._on_status(f"✅ 密钥已导出: {path}") + except Exception as e: + self._on_status(f"❌ 导出失败: {e}") + + def _share_key_to(self, gid): + group = self.groups.get(gid) + if not group or not group['session'].has_key(): + return + data = group['session'].export_key() + targets = [g for g in self.order if g != gid] + if not targets: + self._on_status("没有其他群可以分享") + return + name, ok = QtWidgets.QInputDialog.getItem(self, "📤 分享密钥到", "选择目标群:", + [self.groups[g]['name'] for g in targets], 0, False) + if not ok: + return + g2 = targets[[self.groups[g]['name'] for g in targets].index(name)] + QtWidgets.QApplication.clipboard().setText(data) + self._switch_group(g2) + self._add_system(g2, f"📤 已复制「{group['name']}」的密钥,可粘贴给群成员加入该群") + + def _group_properties(self, gid): + group = self.groups.get(gid) + if not group: + return + mode = group.get('mode', 'p2p') + mode_text = '🔗 端到端 (P2P)' if mode != 'server' else '☁️ 中央服务器' + gid_field = group.get('group_id', '(无)') if mode == 'server' else '(无)' + status = '🟢 在线' if self._group_connected(group) else '🔴 离线' + info = (f"📋 群名: {group.get('group_name', '') or group['name']}\n" + f"🏷️ 类型: {mode_text}\n" + f"🆔 群ID: {gid_field}\n" + f"🌐 地址: {group['host']}:{group['port']}\n" + f"📡 状态: {status}\n" + f"👑 身份: {'是 [群主]' if group['is_host'] else '否 [成员]'}\n" + f"🔑 加密: {'✅ 已加密' if group['session'].has_key() else '❌ 无密钥'}\n" + f"👥 成员: {len(group['users'])} 人") + QtWidgets.QMessageBox.information(self, "📋 群属性", info) + + def _on_user_menu(self, pos): + gid, group = self._current_group() + if not group: + return + item = self.users_list.itemAt(pos) + if not item: + return + if group.get('mode') == 'server': + self._on_status("服务器群由服务器管理成员") + return + if not group['is_host']: + self._on_status("仅群主可管理用户") + return + idx = self.users_list.row(item) + if idx < 0 or idx >= len(group['users']): + return + target = group['users'][idx] + if target.get('account') == self.account.get('username', ''): + return + if QtWidgets.QMessageBox.question(self, "踢人", f"确定将 {target.get('name','')} 移出群吗?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) == QtWidgets.QMessageBox.Yes: + group['client'].send_kick(target.get('account', '')) + + def _clear_media_cache(self): + if QtWidgets.QMessageBox.question(self, "清理媒体缓存", + "将删除「文档/ChaosChat/media」中所有文件/图片/语音,历史记录中的这些文件将无法打开。确定?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) == QtWidgets.QMessageBox.Yes: + n = clear_media_cache() + self._on_status(f"🗑️ 已清理 {n} 个媒体文件") + + def _open_download_dir(self): + try: + os.startfile(DOWNLOAD_DIR) + except Exception: + pass + + # ---------- 自动加入 ---------- + def _start_auto_join(self): + threading.Thread(target=self._auto_join_worker, daemon=True).start() + + def _auto_join_worker(self): + try: + rows = self.account_db.load_groups(self.account['id']) + except Exception: + rows = [] + for r in rows: + if r['group_key'] and str(r['group_key']).startswith('server@'): + continue + self._auto_join_one(r) + + def _auto_join_one(self, r, attempts=3): + host, port = r['host'], r['port'] + key = f"{host}:{port}" + for g in list(self.groups.values()): + if g['group_key'] == key and self._group_connected(g): + return + session = SecureSession() + try: + session.import_key(r['session_key']) + except Exception: + pass + gid = self._new_gid() + for attempt in range(attempts): + server = None + if r['is_host']: + server = ChatServer(on_status=lambda t: self._post(lambda: self._on_status(t)), + server_key=key, server_db=self.server_db, + server_secret=session.session_key) + if not server.start(port): + server = None + if attempt < attempts - 1: + time.sleep(2) + continue + return + client = self._make_client(gid) + saved_uk = r['user_key'] if 'user_key' in r.keys() else None + ok = client.connect(host, port, self._my_nick(), nickname=self._my_nick(), + account=self.account.get('username', ''), + auth_secret=session.auth_secret(), user_key=saved_uk, + user_key_enc=session.user_key_enc()) + if ok: + group = self._build_group(gid, host, port, self._my_nick(), + bool(r['is_host']), server, client, session) + group['user_key'] = saved_uk + self._post(lambda gid=gid, group=group: self._register_group(gid, group)) + return + if server: + try: + server.stop() + except Exception: + pass + if attempt < attempts - 1: + time.sleep(2) + + # ---------- 退出 ---------- + def _logout(self): + clear_session() + self._relogin = True + self._close_all() + + def _quit(self): + self._relogin = False + self._close_all() + + def _close_all(self): + try: + if self.server_conn: + self.server_conn.disconnect() + except Exception: + pass + for gid in list(self.order): + g = self.groups.get(gid) + if g: + try: + if g.get('client'): + g['client'].disconnect() + except Exception: + pass + if g.get('server'): + try: + g['server'].stop() + except Exception: + pass + try: + self.history.conn.close() + except Exception: + pass + try: + self.account_db.conn.close() + except Exception: + pass + self.close() + + def closeEvent(self, event): + self._close_all() + event.accept() + + +# ==================== 主入口 ==================== +def main(): + import sys + app = QtWidgets.QApplication(sys.argv) + app.setStyleSheet(QSS) + db = AccountDB() + + # 自动登录 (记住我) + account = None + token = load_session() + if token: + account = db.validate_session(token) + if account: + print(f"✅ 自动登录: {account['username']}") + + if account is None: + login = LoginDialog(db) + if login.exec_() != QtWidgets.QDialog.Accepted: + return + account = login.result + # 本地登录不强制记住; 保持简单 + token = db.create_session(account['id']) + save_session(token) + + win = MainWindow(account) + win.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/ChaosCryptChat/server.py b/ChaosCryptChat/server.py new file mode 100644 index 0000000..6318737 --- /dev/null +++ b/ChaosCryptChat/server.py @@ -0,0 +1,418 @@ +# -*- coding: utf-8 -*- +""" +☁️ 混沌加密聊天 - 中央服务器 (解决 NAT / 公网穿透) +==================================================== +- 服务器只负责: 用户认证 + 群成员管理 + 消息中继 +- 所有消息内容由客户端加密, 服务器【不持有群密钥】 +- 密钥只在客户端本地, 永不上传到服务器 +- 部署: 放到有公网IP的机器, python server.py [端口] +""" +import os +import sys +import json +import time +import base64 +import struct +import socket +import hashlib +import threading +import sqlite3 + +SERVER_HOST = '0.0.0.0' +SERVER_PORT = 8666 +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'server_data', 'server.db') + +MAX_FRAME = 256 * 1024 * 1024 + + +# ==================== 帧协议 (与客户端一致) ==================== +def send_msg(sock, obj): + payload = json.dumps(obj, ensure_ascii=False).encode('utf-8') + sock.sendall(struct.pack('>I', len(payload)) + payload) + + +def recv_exact(sock, n): + data = b'' + while len(data) < n: + try: + chunk = sock.recv(n - len(data)) + except socket.timeout: + continue + except Exception: + return None + if not chunk: + return None + data += chunk + return data + + +def recv_msg(sock): + header = recv_exact(sock, 4) + if header is None: + return None + length = struct.unpack('>I', header)[0] + if length <= 0 or length > MAX_FRAME: + return None + payload = recv_exact(sock, length) + if payload is None: + return None + try: + return json.loads(payload.decode('utf-8')) + except Exception: + return None + + +# ==================== 服务器数据库 ==================== +class ServerDB: + def __init__(self, db_path=DB_PATH): + os.makedirs(os.path.dirname(db_path), exist_ok=True) + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.lock = threading.Lock() + self._init() + + def _init(self): + with self.lock: + cur = self.conn.cursor() + cur.execute('''CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + salt TEXT NOT NULL, + pass_hash TEXT NOT NULL, + created_at REAL)''') + cur.execute('''CREATE TABLE IF NOT EXISTS groups( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + name TEXT, + announcement TEXT, + created_at REAL)''') + cur.execute('''CREATE TABLE IF NOT EXISTS group_members( + group_id TEXT NOT NULL, + username TEXT NOT NULL, + joined_at REAL, + PRIMARY KEY(group_id, username))''') + self.conn.commit() + + def register(self, username, password): + salt = os.urandom(16).hex() + ph = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), + bytes.fromhex(salt), 120000).hex() + try: + with self.lock: + cur = self.conn.cursor() + cur.execute('INSERT INTO users(username, salt, pass_hash, created_at) VALUES(?,?,?,?)', + (username, salt, ph, time.time())) + self.conn.commit() + return True, 'ok' + except sqlite3.IntegrityError: + return False, '该用户名已被注册' + + def login(self, username, password): + with self.lock: + cur = self.conn.cursor() + row = cur.execute('SELECT * FROM users WHERE username=?', (username,)).fetchone() + if row is None: + return None, '用户不存在' + ph = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), + bytes.fromhex(row['salt']), 120000).hex() + if ph != row['pass_hash']: + return None, '密码错误' + return row, 'ok' + + def create_group(self, group_id, owner, name): + with self.lock: + cur = self.conn.cursor() + cur.execute('INSERT INTO groups(id, owner, name, created_at) VALUES(?,?,?,?)', + (group_id, owner, name, time.time())) + cur.execute('INSERT OR IGNORE INTO group_members(group_id, username, joined_at) VALUES(?,?,?)', + (group_id, owner, time.time())) + self.conn.commit() + + def add_member(self, group_id, username): + with self.lock: + cur = self.conn.cursor() + row = cur.execute('SELECT 1 FROM groups WHERE id=?', (group_id,)).fetchone() + if row is None: + return False + cur.execute('INSERT OR IGNORE INTO group_members(group_id, username, joined_at) VALUES(?,?,?)', + (group_id, username, time.time())) + self.conn.commit() + return True + + def remove_member(self, group_id, username): + with self.lock: + cur = self.conn.cursor() + cur.execute('DELETE FROM group_members WHERE group_id=? AND username=?', + (group_id, username)) + self.conn.commit() + + def get_group(self, group_id): + with self.lock: + cur = self.conn.cursor() + return cur.execute('SELECT id, name, announcement, owner FROM groups WHERE id=?', + (group_id,)).fetchone() + + def list_groups(self, username): + with self.lock: + cur = self.conn.cursor() + rows = cur.execute('''SELECT g.id, g.name, g.owner, g.announcement FROM groups g + JOIN group_members m ON m.group_id = g.id + WHERE m.username=?''', (username,)).fetchall() + return [{'id': r['id'], 'name': r['name'], 'owner': r['owner'], + 'announcement': r['announcement'] or ''} for r in rows] + + def is_owner(self, group_id, username): + with self.lock: + cur = self.conn.cursor() + row = cur.execute('SELECT owner FROM groups WHERE id=?', (group_id,)).fetchone() + return bool(row and row['owner'] == username) + + def set_group_name(self, group_id, name): + with self.lock: + cur = self.conn.cursor() + cur.execute('UPDATE groups SET name=? WHERE id=?', (name, group_id)) + self.conn.commit() + + def set_announcement(self, group_id, text): + with self.lock: + cur = self.conn.cursor() + cur.execute('UPDATE groups SET announcement=? WHERE id=?', (text, group_id)) + self.conn.commit() + + def members(self, group_id): + with self.lock: + cur = self.conn.cursor() + rows = cur.execute('SELECT username FROM group_members WHERE group_id=?', + (group_id,)).fetchall() + return [r['username'] for r in rows] + + +# ==================== 中央服务器 ==================== +class CentralServer: + def __init__(self, host=SERVER_HOST, port=SERVER_PORT, db=None): + self.host = host + self.port = port + self.db = db or ServerDB() + self.sock = None + self.running = False + self.clients = {} # cid -> {socket, username, token, addr, lock} + self.next_id = 1 + self.lock = threading.Lock() + + def start(self): + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind((self.host, self.port)) + self.sock.listen(50) + self.running = True + print(f"☁️ 中央服务器启动: {self.host}:{self.port}") + print(f" 数据库: {DB_PATH}") + threading.Thread(target=self._accept_loop, daemon=True).start() + + def stop(self): + self.running = False + if self.sock: + try: + self.sock.close() + except Exception: + pass + self.sock = None + with self.lock: + socks = [c['socket'] for c in self.clients.values()] + self.clients.clear() + for s in socks: + try: + s.close() + except Exception: + pass + + def _accept_loop(self): + while self.running: + try: + client, addr = self.sock.accept() + with self.lock: + cid = self.next_id + self.next_id += 1 + self.clients[cid] = {'socket': client, 'username': None, + 'token': None, 'addr': addr, + 'lock': threading.Lock()} + print(f" 📶 新连接: {addr[0]}:{addr[1]}") + threading.Thread(target=self._handler, args=(cid,), daemon=True).start() + except Exception: + break + + def _send(self, cid, msg): + with self.lock: + c = self.clients.get(cid) + if c: + try: + with c['lock']: + send_msg(c['socket'], msg) + except Exception: + pass + + def _handler(self, cid): + client = self.clients[cid]['socket'] + # 第一步: 认证 (register 自动登录 或 login) + try: + msg = recv_msg(client) + if msg and msg.get('type') == 'register': + username = str(msg.get('username', ''))[:24] + password = str(msg.get('password', '')) + ok, err = self.db.register(username, password) + if not ok: + self._send(cid, {'type': 'error', 'msg': err}) + self._remove(cid) + return + row, _ = self.db.login(username, password) + self._auth_ok(cid, row) + elif msg and msg.get('type') == 'login': + username = str(msg.get('username', ''))[:24] + password = str(msg.get('password', '')) + row, err = self.db.login(username, password) + if row is None: + self._send(cid, {'type': 'error', 'msg': err}) + self._remove(cid) + return + self._auth_ok(cid, row) + else: + self._remove(cid) + return + except Exception: + self._remove(cid) + return + + # 第二步: 业务消息循环 + while self.running: + if cid not in self.clients: + break + try: + msg = recv_msg(client) + if msg is None: + break + self._handle(cid, msg) + except Exception: + break + self._remove(cid) + + def _auth_ok(self, cid, row): + username = row['username'] + token = os.urandom(16).hex() + with self.lock: + if cid in self.clients: + self.clients[cid]['username'] = username + self.clients[cid]['token'] = token + print(f" 👤 {username} 已登录") + self._send(cid, {'type': 'login_ok', 'token': token, 'username': username}) + + def _handle(self, cid, msg): + with self.lock: + if cid not in self.clients: + return + username = self.clients[cid]['username'] + mtype = msg.get('type') + + if mtype == 'create_group': + group_id = str(msg.get('group_id') or f"g{int(time.time()*1000)}-{os.urandom(3).hex()}") + name = str(msg.get('name', ''))[:40] + self.db.create_group(group_id, username, name or group_id) + print(f" 📡 {username} 创建群 [{name}] ({group_id})") + self._send(cid, {'type': 'group_created', 'group_id': group_id, 'name': name}) + + elif mtype == 'list_groups': + groups = self.db.list_groups(username) + self._send(cid, {'type': 'groups', 'groups': groups}) + + elif mtype == 'join_group': + group_id = str(msg.get('group_id', '')) + if self.db.add_member(group_id, username): + self._send(cid, {'type': 'join_ok', 'group_id': group_id}) + # 推送当前群名/公告给新成员 + ginfo = self.db.get_group(group_id) + self._send(cid, {'type': 'group_info', 'group_id': group_id, + 'name': ginfo['name'] if ginfo else '', + 'announcement': ginfo['announcement'] if ginfo else ''}) + self._broadcast_group(group_id, + {'type': 'system', 'text': f"👋 {username} 加入了群", + 'group_id': group_id}, exclude=cid) + else: + self._send(cid, {'type': 'error', 'msg': '群不存在'}) + + elif mtype == 'set_group_name': + group_id = str(msg.get('group_id', '')) + name = str(msg.get('name', ''))[:40] + if self.db.is_owner(group_id, username): + self.db.set_group_name(group_id, name) + self._broadcast_group(group_id, {'type': 'group_name', 'group_id': group_id, + 'name': name}, exclude=None) + print(f" 🏷️ {username} 修改群名: {name}") + else: + self._send(cid, {'type': 'error', 'msg': '仅群主可修改群名'}) + + elif mtype == 'set_announcement': + group_id = str(msg.get('group_id', '')) + text = str(msg.get('text', ''))[:500] + if self.db.is_owner(group_id, username): + self.db.set_announcement(group_id, text) + self._broadcast_group(group_id, {'type': 'announcement', 'group_id': group_id, + 'text': text}, exclude=None) + print(f" 📢 {username} 设置公告: {text[:30]}") + else: + self._send(cid, {'type': 'error', 'msg': '仅群主可设置公告'}) + + elif mtype == 'leave_group': + group_id = str(msg.get('group_id', '')) + self.db.remove_member(group_id, username) + self._send(cid, {'type': 'leave_ok', 'group_id': group_id}) + self._broadcast_group(group_id, + {'type': 'system', 'text': f"👋 {username} 离开了群", + 'group_id': group_id}, exclude=cid) + + elif mtype == 'group_members': + group_id = str(msg.get('group_id', '')) + self._send(cid, {'type': 'members', 'group_id': group_id, + 'members': self.db.members(group_id)}) + + elif mtype == 'group_message': + group_id = str(msg.get('group_id', '')) + payload = msg.get('payload') + # 转发给同群其他成员 (服务器不解析加密内容) + self._broadcast_group(group_id, + {'type': 'group_message', 'group_id': group_id, + 'from': username, 'payload': payload}, exclude=cid) + + def _broadcast_group(self, group_id, msg, exclude=None): + members = set(self.db.members(group_id)) + with self.lock: + targets = [(cid2, c['socket'], c['lock']) for cid2, c in self.clients.items() + if c.get('username') in members and cid2 != exclude] + for cid2, sock, lk in targets: + try: + with lk: + send_msg(sock, msg) + except Exception: + pass + + def _remove(self, cid): + with self.lock: + entry = self.clients.pop(cid, None) + if entry: + try: + entry['socket'].close() + except Exception: + pass + if entry['username']: + print(f" 🔌 {entry['username']} 断开") + + +if __name__ == '__main__': + port = int(sys.argv[1]) if len(sys.argv) > 1 else SERVER_PORT + srv = CentralServer(port=port) + srv.start() + print("按 Ctrl+C 停止服务器") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + srv.stop() + print("\n☁️ 服务器已停止") diff --git a/README.md b/README.md index d6509f8..e263d3a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -\# 🔐 ChaosCrypt - 混沌加密系统 +\# 🔐 ChaosCrypt - 混沌加密系统(v2.0) @@ -6,6 +6,8 @@ > \*不依赖 NIST、不依赖椭圆曲线、不依赖质数分解——只依赖随机性本身\* +> \*\*v2.0 新增 💬 ChaosCryptChat:基于混沌加密的端到端加密聊天系统(文本/图片/文件/语音)\*\* + \--- @@ -36,9 +38,11 @@ 10\. \[常见问题](#常见问题) -11\. \[项目结构](#项目结构) +11\. \[💬 ChaosCryptChat 混沌加密聊天](#-chaoscryptchat-混沌加密聊天) -12\. \[免责声明](#免责声明) +12\. \[项目结构](#项目结构) + +13\. \[免责声明](#免责声明) @@ -60,6 +64,8 @@ > 这不是对现有加密算法的改进,而是对"安全"这个概念的重新定义。 +\*\*v2.0 新增\*\*:在混沌加密内核之上构建了 \*\*ChaosCryptChat\*\* 端到端加密聊天系统,让混沌加密真正走进实时通信场景。 + \--- @@ -128,6 +134,8 @@ ChaosCrypt 对此表示: | 🐍 \*\*纯 Python\*\* | 仅需 Python 3.6+,零额外依赖 | +| 💬 \*\*加密聊天(v2.0)\*\* | ChaosCryptChat 端到端加密聊天:文本/图片/文件/语音,P2P + 中央服务器双模式 | + \--- @@ -482,6 +490,83 @@ AES 的 10⁷⁷ 在它面前就像一粒沙子对比整个银河系。 +\## 💬 ChaosCryptChat 混沌加密聊天(v2.0 新增) + +\*\*ChaosCryptChat\*\* 是基于 ChaosCrypt 混沌加密内核构建的端到端加密聊天系统。所有聊天内容(文本、图片、文件、语音)在发送前均经混沌加密,服务器与中间节点无法读取任何消息。 + +\### 核心特性 + +| 特性 | 说明 | +|------|------| +| 🔗 \*\*端到端加密\*\* | 所有消息客户端本地加密,服务器只做中继,不持有群密钥 | +| ☁️ \*\*双群模式\*\* | 🔗 端到端 P2P 直连 + ☁️ 中央服务器(解决 NAT 穿透) | +| 🎤 \*\*语音消息\*\* | 录音 → 混沌加密 → 发送 → 解密播放 | +| 🖼️ \*\*图片/文件\*\* | 二进制数据混沌加密传输,支持任意文件类型 | +| 👥 \*\*群管理\*\* | 创建群、加入群、群名、公告、踢人、群主审批 | +| 🚫 \*\*消息撤回\*\* | 2 分钟内可撤回自己的消息 | +| 🔑 \*\*每用户密钥\*\* | 每用户独立密钥认证,防止冒充他人 | +| 📜 \*\*历史记录\*\* | SQLite 本地存储聊天历史(重启不丢) | +| 🛡️ \*\*HMAC 校验\*\* | 每条消息带 HMAC 签名,防篡改、防伪造 | +| 📱 \*\*密钥分享\*\* | 群密钥支持复制 / 二维码 / 文件三种方式分享 | + +\### 两种群模式 + +\*\*① 端到端(P2P)群聊\*\*:群主即服务器,成员直连群主主机,适合局域网或公网 IP 场景。群密钥由群主生成并分享给成员。 + +\*\*② 中央服务器群聊\*\*:运行 `server.py`(公网 IP)解决 NAT 穿透。服务器只负责用户认证、群成员管理、消息中继——\*\*不持有群密钥,无法解密任何消息\*\*。 + +\### 快速启动 + +\#### 启动聊天客户端 + +```bash + +cd ChaosCryptChat + +python qtmain.py + +``` + +\#### 启动中央服务器(可选,公网/NAT 场景) + +```bash + +cd ChaosCryptChat + +python server.py 8666 + +``` + +\#### 安装依赖 + +```bash + +pip install PyQt5 \# 必需,GUI 界面 + +pip install pygame \# 语音播放(可选) + +pip install sounddevice numpy \# 录音(可选,或 pyaudio) + +pip install Pillow \# 图片显示(可选) + +pip install qrcode \# 群密钥二维码(可选) + +``` + +> 除 PyQt5 外均为可选依赖,未安装时对应功能自动禁用。 + +\### 使用流程 + +1\. \*\*注册/登录\*\*:启动后创建本地账户(PBKDF2 密码哈希,支持自动登录) + +2\. \*\*创建群\*\*:点击「📡 创建群」,将群密钥复制/生成二维码分享给好友 + +3\. \*\*加入群\*\*:点击「📥 加入群」,粘贴群密钥或扫码加入 + +4\. \*\*开始聊天\*\*:发送文本/图片/文件/语音;右键消息可撤回、分享、下载;群主可踢人、设公告、改群名 + +\--- + \## 快速开始 @@ -501,7 +586,7 @@ cd chaoscrypt -\# 无需安装依赖,Python 3.6+ 即可 +\# 核心模块零依赖,Python 3.6+ 即可;ChaosCryptChat 需 PyQt5 ``` @@ -813,6 +898,18 @@ A:有。证明如下: A:初级和安全没有半毛钱关系。你长得好看能挡子弹吗?能破就是能破,不能破就是不能破。\*\*你破一个试试?\*\* +\*\*Q:ChaosCryptChat 聊天安全吗?\*\* + +A:端到端加密——消息在客户端用群密钥混沌加密,服务器只转发密文、不持有密钥。即使服务器被入侵也拿不到聊天内容。 + +\*\*Q:聊天密钥怎么分享?\*\* + +A:群主创建群后生成群密钥,可通过复制文本、二维码、密钥文件三种方式分享。请走可信渠道传递,避免被中间人截获。 + +\*\*Q:ChaosCryptChat 需要公网 IP 吗?\*\* + +A:不需要。局域网用 P2P 直连即可;跨网络用中央服务器(server.py)中继,解决 NAT 穿透。 + \--- @@ -831,13 +928,19 @@ chaoscrypt/ ├── README.md # 本文档 -└── SECURITY.md # 安全说明文档 +├── SECURITY.md # 安全说明文档 + +└── ChaosCryptChat/ # 💬 混沌加密聊天(v2.0 新增) + + ├── qtmain.py # PyQt5 聊天客户端(端到端加密) + + └── server.py # ☁️ 中央服务器(NAT 穿透 / 消息中继) ``` -就这么三个文件,不藏任何东西,不依赖任何库。 +核心就三个文件,零依赖;ChaosCryptChat 是 v2.0 新增的加密聊天模块。 @@ -855,6 +958,10 @@ chaoscrypt/ > 生产环境请确保 `.key` 文件和用户密码均安全存储,并考虑强制用户使用强密码策略。 +> \*\*ChaosCryptChat\*\* 服务器不持有群密钥,但会记录用户登录信息与消息中继日志,请谨慎选择信任的服务器。 + +> 聊天密钥由群主分享,请通过可信渠道传递;即使服务器被攻陷也不会泄露历史聊天内容。 + \--- diff --git a/SECURITY.md b/SECURITY.md index 8c2fdf9..566c3bd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -765,4 +765,71 @@ DeriveKey(K\_user, K\_short, n) 包含: ================================================================================ +================================================================================ +十、ChaosCryptChat 聊天模块安全分析(v2.0) +================================================================================ + +本部分分析 ChaosCryptChat(端到端加密聊天系统)的安全模型。 + +10.1 威胁模型 + + 假设: + - 中央服务器可被攻击者完全控制(最坏情况) + - 网络传输可被监听、篡改、重放 + - 客户端本地文件可信(不讨论端侧木马) + +10.2 端到端加密 + + 消息路径: + 明文 → 混沌加密(群密钥 + nonce 派生消息密钥)→ 密文 → 网络 → 密文 → 混沌解密 → 明文 + + 关键属性: + - 群密钥只存在于客户端本地,永不上传服务器 + - 服务器仅中继密文,无法解密任何消息 + - 每条消息使用独立 nonce 派生消息密钥,避免重放 + - HMAC-SHA256 完整性校验:密文被篡改将导致解密失败 + +10.3 中央服务器模型 + + 服务器职责:用户认证 + 群成员管理 + 消息中继 + + 服务器不持有: + - 群密钥(session key) + - 消息明文 + - 每用户密钥(user key,经服务器持有密钥加密后下发) + + 服务器被攻陷的影响: + - 无法解密历史或实时聊天内容 + - 可能泄露:用户名、登录时间、中继日志、群成员关系 + - 可实施:拒绝服务、成员关系观察(元数据泄露) + +10.4 身份认证 + + 用户密码:PBKDF2-HMAC-SHA256,120,000 次迭代 + 随机盐 + 每用户密钥:加入群时签发,用于 HMAC 签名,防止成员冒充他人 + 群密钥认证:加入群需提供群密钥派生的认证凭据,证明是群成员 + +10.5 消息完整性 + + 文本/语音/文件消息均携带 HMAC: + - 文本: HMAC(密文 + 群密钥) + - 二进制: HMAC('BIN|' + 群密钥 + nonce + 密文) + + 篡改检测:接收方重算 HMAC 不匹配 → 拒绝消息 + +10.6 已知限制 + + 1. 端到端(P2P)模式下,群主主机既是聊天者又是服务器,若群主被攻陷则群聊失守 + 2. 服务器虽无法解密内容,但能观察到元数据(谁和谁在何时通信) + 3. 群密钥通过群主手动分享,存在被截获的风险(需可信渠道传递) + 4. 语音播放/录音依赖第三方库(pygame/sounddevice),其安全性不在本系统保证范围 + 5. 消息撤回仅做本地标记 + 服务器广播,已离线成员仍可能看到撤回前的消息 + +10.7 结论 + + ChaosCryptChat 提供端到端加密 + 完整性校验 + 身份认证,服务器无法解密内容。 + 其安全边界符合"服务器不可信"模型,适合对隐私有要求但不涉及合规认证的通信场景。 + 元数据泄露与密钥分享渠道仍需用户自行权衡与管理。 + +================================================================================