From 897f3f5910b8c9c5f9c16763c70fd80727a7ed97 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Wed, 19 Nov 2025 12:25:45 +0800 Subject: [PATCH] feat(security): implement secure storage for sensitive data and migrate existing keys --- src/core/connection.js | 13 ++-- src/modules/login.js | 21 +++--- src/utils/secure-storage.js | 128 ++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 src/utils/secure-storage.js diff --git a/src/core/connection.js b/src/core/connection.js index 902cc86..2dece5f 100644 --- a/src/core/connection.js +++ b/src/core/connection.js @@ -2,6 +2,7 @@ // 提供 API 基础地址规范化、请求封装、配置缓存以及统一数据加载能力 import { STATUS_UPDATE_INTERVAL_MS, DEFAULT_API_PORT } from '../utils/constants.js'; +import { secureStorage } from '../utils/secure-storage.js'; export const connectionModule = { // 规范化基础地址,移除尾部斜杠与 /v0/management @@ -28,16 +29,18 @@ export const connectionModule = { setApiBase(newBase) { this.apiBase = this.normalizeBase(newBase); this.apiUrl = this.computeApiUrl(this.apiBase); - localStorage.setItem('apiBase', this.apiBase); - localStorage.setItem('apiUrl', this.apiUrl); // 兼容旧字段 + secureStorage.setItem('apiBase', this.apiBase); + secureStorage.setItem('apiUrl', this.apiUrl); // 兼容旧字段 this.updateLoginConnectionInfo(); }, // 加载设置(简化版,仅加载内部状态) loadSettings() { - const savedBase = localStorage.getItem('apiBase'); - const savedUrl = localStorage.getItem('apiUrl'); - const savedKey = localStorage.getItem('managementKey'); + secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']); + + const savedBase = secureStorage.getItem('apiBase'); + const savedUrl = secureStorage.getItem('apiUrl'); + const savedKey = secureStorage.getItem('managementKey'); if (savedBase) { this.setApiBase(savedBase); diff --git a/src/modules/login.js b/src/modules/login.js index ec6c496..67e0336 100644 --- a/src/modules/login.js +++ b/src/modules/login.js @@ -1,7 +1,12 @@ +import { secureStorage } from '../utils/secure-storage.js'; + export const loginModule = { async checkLoginStatus() { - const savedBase = localStorage.getItem('apiBase'); - const savedKey = localStorage.getItem('managementKey'); + // 将旧的明文缓存迁移为加密格式 + secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']); + + const savedBase = secureStorage.getItem('apiBase'); + const savedKey = secureStorage.getItem('managementKey'); const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true'; if (savedBase && savedKey && wasLoggedIn) { @@ -75,7 +80,7 @@ export const loginModule = { try { this.setApiBase(apiBase); this.managementKey = managementKey; - localStorage.setItem('managementKey', this.managementKey); + secureStorage.setItem('managementKey', this.managementKey); await this.testConnection(); @@ -97,7 +102,7 @@ export const loginModule = { this.stopStatusUpdateTimer(); localStorage.removeItem('isLoggedIn'); - localStorage.removeItem('managementKey'); + secureStorage.removeItem('managementKey'); this.showLoginPage(); }, @@ -127,7 +132,7 @@ export const loginModule = { this.hideLoginError(); this.managementKey = managementKey; - localStorage.setItem('managementKey', this.managementKey); + secureStorage.setItem('managementKey', this.managementKey); await this.login(this.apiBase, this.managementKey); } catch (error) { @@ -186,8 +191,8 @@ export const loginModule = { }, loadLoginSettings() { - const savedBase = localStorage.getItem('apiBase'); - const savedKey = localStorage.getItem('managementKey'); + const savedBase = secureStorage.getItem('apiBase'); + const savedKey = secureStorage.getItem('managementKey'); const loginKeyInput = document.getElementById('login-management-key'); const apiBaseInput = document.getElementById('login-api-base'); @@ -216,7 +221,7 @@ export const loginModule = { const saveKey = (val) => { if (val.trim()) { this.managementKey = val; - localStorage.setItem('managementKey', this.managementKey); + secureStorage.setItem('managementKey', this.managementKey); } }; const saveKeyDebounced = this.debounce(saveKey, 500); diff --git a/src/utils/secure-storage.js b/src/utils/secure-storage.js new file mode 100644 index 0000000..a7c7fcf --- /dev/null +++ b/src/utils/secure-storage.js @@ -0,0 +1,128 @@ +// 简单的浏览器端加密存储封装 +// 仅用于避免本地缓存中明文暴露敏感值,无法替代服务端安全控制 + +const ENC_PREFIX = 'enc::v1::'; +const SECRET_SALT = 'cli-proxy-api-webui::secure-storage'; + +const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; +const decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null; + +let cachedKeyBytes = null; + +function encodeText(text) { + if (encoder) return encoder.encode(text); + const result = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) { + result[i] = text.charCodeAt(i) & 0xff; + } + return result; +} + +function decodeText(bytes) { + if (decoder) return decoder.decode(bytes); + let result = ''; + for (let i = 0; i < bytes.length; i++) { + result += String.fromCharCode(bytes[i]); + } + return result; +} + +function getKeyBytes() { + if (cachedKeyBytes) return cachedKeyBytes; + try { + const host = typeof window !== 'undefined' ? window.location.host : 'unknown-host'; + const ua = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown-ua'; + cachedKeyBytes = encodeText(`${SECRET_SALT}|${host}|${ua}`); + } catch (error) { + console.warn('Secure storage fallback to plain text:', error); + cachedKeyBytes = encodeText(SECRET_SALT); + } + return cachedKeyBytes; +} + +function xorBytes(data, keyBytes) { + const result = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) { + result[i] = data[i] ^ keyBytes[i % keyBytes.length]; + } + return result; +} + +function toBase64(bytes) { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function fromBase64(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function encode(value) { + if (value === null || value === undefined) return value; + try { + const keyBytes = getKeyBytes(); + const encrypted = xorBytes(encodeText(String(value)), keyBytes); + return `${ENC_PREFIX}${toBase64(encrypted)}`; + } catch (error) { + console.warn('Secure storage encode fallback:', error); + return String(value); + } +} + +function decode(payload) { + if (payload === null || payload === undefined) return payload; + if (!payload.startsWith(ENC_PREFIX)) { + return payload; + } + try { + const encodedBody = payload.slice(ENC_PREFIX.length); + const encrypted = fromBase64(encodedBody); + const decrypted = xorBytes(encrypted, getKeyBytes()); + return decodeText(decrypted); + } catch (error) { + console.warn('Secure storage decode fallback:', error); + return payload; + } +} + +export const secureStorage = { + setItem(key, value, { encrypt = true } = {}) { + if (typeof localStorage === 'undefined') return; + if (value === null || value === undefined) { + localStorage.removeItem(key); + return; + } + const storedValue = encrypt ? encode(value) : String(value); + localStorage.setItem(key, storedValue); + }, + + getItem(key, { decrypt = true } = {}) { + if (typeof localStorage === 'undefined') return null; + const raw = localStorage.getItem(key); + if (raw === null) return null; + return decrypt ? decode(raw) : raw; + }, + + removeItem(key) { + if (typeof localStorage === 'undefined') return; + localStorage.removeItem(key); + }, + + migratePlaintextKeys(keys = []) { + if (typeof localStorage === 'undefined') return; + keys.forEach((key) => { + const raw = localStorage.getItem(key); + if (raw && !String(raw).startsWith(ENC_PREFIX)) { + this.setItem(key, raw, { encrypt: true }); + } + }); + } +};