diff --git a/README.md b/README.md index f991c5f..8139e29 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Tauri 打包目标当前配置为 NSIS 安装包,产物位于 `src-tauri/targe %APPDATA%\DeepSeekMonitorWindows\config.json ``` -其中包含 API Key 和用量 Token。**请不要提交该文件,也不要把截图、日志或配置文件中的密钥内容公开。** +其中包含 API Key 和用量 Token,凭据已通过 Windows DPAPI 加密存储,不在文件中明文保留。**请不要提交该文件,也不要把截图、日志或配置文件中的密钥内容公开。** WebView2 登录缓存通常位于: @@ -113,31 +113,18 @@ DeepSeekMonitorWindows/ │ ├── main.tsx # 主界面、设置页、详情页和 Tauri 调用 │ └── styles.css # Windows 桌面 UI 样式 ├── src-tauri/ # Tauri + Rust 后端 -│ ├── src/lib.rs # API 调用、配置存储、托盘、网页登录同步 -│ ├── tauri.conf.json # Tauri 窗口、打包和安全配置 +│ ├── src/lib.rs # API 调用、配置存储、凭据加密、托盘、网页登录同步 +│ ├── tauri.conf.json # Tauri 窗口、打包和安全配置(CSP 已启用) │ ├── Cargo.toml # Rust 依赖与包信息 │ └── capabilities/ # Tauri 权限配置 +│ ├── default.json # 主窗口权限 +│ └── login-sync.json # 登录窗口最小权限 ├── public/assets/ # DeepSeek 图标与静态资源 ├── scripts/ # Windows 开发脚本 ├── package.json # 前端依赖与脚本 └── README.md # 项目说明 ``` -## 不应提交的文件 - -仓库已通过 `.gitignore` 忽略以下内容: - -- `node_modules/` -- `dist/` -- `src-tauri/target/` -- `.env`, `.env.local`, `.env.*.local` -- `.npmrc` -- `*.log`, `*.err.log`, `*.out.log` -- `test-output/` -- 根目录临时截图 `dashboard-mvp.png`, `settings-mvp.png`, `detail-mvp.png` -- WebView2 缓存和本地运行配置 -- IDE 配置和系统临时文件 - ## 依赖 前端运行依赖: @@ -168,6 +155,25 @@ Rust 后端依赖: 完整发布记录见 GitHub Releases。 +### v1.2.0 + +由 [KerryChia](https://github.com/KerryChia) 开发。 + +**安全加固:** + +- 启用 CSP 内容安全策略,阻止内联脚本注入和外部资源加载。 +- API Key 与用量 Token 改为 Windows DPAPI 加密存储,不再明文写入 config.json。 +- 关闭 `withGlobalTauri`,阻止外部网页访问 Tauri IPC。 +- 登录同步注入脚本增加域名白名单守卫,仅拦截发往 DeepSeek 域名的请求。 +- 移除注入脚本中的 `__TAURI__` 辅助通道。 +- 登录窗口添加 `on_navigation` 导航限制,仅允许 `platform.deepseek.com` 和 `api.deepseek.com`。 +- 拆分 capabilities 权限:`login-sync` 窗口仅保留最小权限,不再开放窗口操作和事件 API。 +- API Key 输入增加长度上限(256 字符)和格式警告;用量 Token 增加长度上限(4096 字符)。 + +**功能改进:** + +- 主窗口改为可拉伸调整大小(最小 340×500,最大 600×1200),拉高窗口即可完整查看图表内容无需滚动。 + ### v1.1.0 - 支持缓存命中、缓存未命中与输出 Token 的明细显示。 @@ -193,4 +199,4 @@ Rust 后端依赖: 本项目仅用于学习和研究目的。请遵守 DeepSeek 的使用条款,合理使用相关接口,避免频繁请求。 -DeepSeek 平台页面结构、登录状态、WebView2 缓存和内部用量接口都可能变化,本项目不保证长期可用。**API Key 和用量 Token 属于敏感凭据,使用者需自行承担本机存储、账号安全、网络请求和数据展示带来的风险。** +DeepSeek 平台页面结构、登录状态、WebView2 缓存和内部用量接口都可能变化,本项目不保证长期可用。**API Key 和用量 Token 已通过 Windows DPAPI 加密存储在本机,但使用者仍需自行承担账号安全、网络请求和数据展示带来的风险。** diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 19bc3e0..a952662 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,17 +1,10 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "enables the default permissions", + "description": "permissions for the main window only", "windows": [ - "main", - "login-sync" + "main" ], - "remote": { - "urls": [ - "https://platform.deepseek.com", - "https://platform.deepseek.com/*" - ] - }, "permissions": [ "core:default", "core:window:allow-start-dragging", @@ -20,7 +13,6 @@ "core:window:allow-close", "core:window:allow-set-focus", "core:event:allow-emit", - "core:event:allow-listen", - "core:webview:allow-internal-toggle-devtools" + "core:event:allow-listen" ] } diff --git a/src-tauri/capabilities/login-sync.json b/src-tauri/capabilities/login-sync.json new file mode 100644 index 0000000..3b4e3fb --- /dev/null +++ b/src-tauri/capabilities/login-sync.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "login-sync", + "description": "minimal permissions for the login-sync window - no Tauri API access, navigation only to DeepSeek", + "windows": [ + "login-sync" + ], + "permissions": [ + "core:default" + ] +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fcee6d3..2c4faef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,7 +21,140 @@ pub fn run() { Emitter, Manager, PhysicalPosition, Position, WebviewWindow, }; - #[derive(Debug, Default, Deserialize, Serialize)] + // --- DPAPI (Windows Data Protection API) for encrypting credentials at rest --- + #[repr(C)] + struct DataBlob { + cb_data: u32, + pb_data: *mut u8, + } + + #[link(name = "crypt32")] + extern "system" { + fn CryptProtectData( + pdata_in: *const DataBlob, + sz_data_descr: *const u16, + p_optional_entropy: *const DataBlob, + pv_reserved: *mut core::ffi::c_void, + p_prompt_struct: *const core::ffi::c_void, + dw_flags: u32, + pdata_out: *mut DataBlob, + ) -> i32; + + fn CryptUnprotectData( + pdata_in: *const DataBlob, + p_sz_data_descr: *mut *mut u16, + p_optional_entropy: *const DataBlob, + pv_reserved: *mut core::ffi::c_void, + p_prompt_struct: *const core::ffi::c_void, + dw_flags: u32, + pdata_out: *mut DataBlob, + ) -> i32; + } + + #[link(name = "kernel32")] + extern "system" { + fn LocalFree(h_mem: isize) -> isize; + } + + fn dpapi_encrypt(plain: &[u8]) -> Result, String> { + let data_in = DataBlob { + cb_data: plain.len() as u32, + pb_data: plain.as_ptr() as *mut u8, + }; + let mut data_out = DataBlob { + cb_data: 0, + pb_data: std::ptr::null_mut(), + }; + let result = unsafe { + CryptProtectData( + &data_in, + std::ptr::null(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null(), + 0, + &mut data_out, + ) + }; + if result == 0 { + return Err("DPAPI 加密失败".to_string()); + } + let encrypted = unsafe { + std::slice::from_raw_parts(data_out.pb_data, data_out.cb_data as usize).to_vec() + }; + unsafe { + LocalFree(data_out.pb_data as isize); + } + Ok(encrypted) + } + + fn dpapi_decrypt(encrypted: &[u8]) -> Result, String> { + let data_in = DataBlob { + cb_data: encrypted.len() as u32, + pb_data: encrypted.as_ptr() as *mut u8, + }; + let mut data_out = DataBlob { + cb_data: 0, + pb_data: std::ptr::null_mut(), + }; + let result = unsafe { + CryptUnprotectData( + &data_in, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null(), + 0, + &mut data_out, + ) + }; + if result == 0 { + return Err("DPAPI 解密失败,凭据可能由其他 Windows 用户或系统加密".to_string()); + } + let decrypted = unsafe { + std::slice::from_raw_parts(data_out.pb_data, data_out.cb_data as usize).to_vec() + }; + unsafe { + LocalFree(data_out.pb_data as isize); + } + Ok(decrypted) + } + + fn hex_encode(data: &[u8]) -> String { + data.iter().map(|b| format!("{:02x}", b)).collect() + } + + fn hex_decode(hex: &str) -> Result, String> { + if hex.len() % 2 != 0 { + return Err("十六进制编码长度无效".to_string()); + } + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| format!("十六进制解码失败:{e}"))) + .collect() + } + + fn encrypt_credential(plain: &str) -> String { + match dpapi_encrypt(plain.as_bytes()) { + Ok(encrypted) => format!("enc1:{}", hex_encode(&encrypted)), + Err(_) => { + log::warn!("DPAPI 加密失败,将明文保存凭据"); + plain.to_string() + } + } + } + + fn decrypt_credential(stored: &str) -> Result { + if let Some(hex) = stored.strip_prefix("enc1:") { + let encrypted = hex_decode(hex)?; + let decrypted = dpapi_decrypt(&encrypted)?; + String::from_utf8(decrypted).map_err(|e| format!("解密凭据失败:{e}")) + } else { + Ok(stored.to_string()) + } + } + + #[derive(Clone, Debug, Default, Deserialize, Serialize)] struct StoredConfig { api_key: Option, #[serde(default)] @@ -63,6 +196,12 @@ pub fn run() { let text = fs::read_to_string(&path).map_err(|error| error.to_string())?; let mut config: StoredConfig = serde_json::from_str(&text).map_err(|error| error.to_string())?; + if let Some(ref key) = config.api_key { + config.api_key = Some(decrypt_credential(key)?); + } + if let Some(ref token) = config.usage_token { + config.usage_token = Some(decrypt_credential(token)?); + } config.refresh_interval_seconds = normalize_refresh_interval_seconds(config.refresh_interval_seconds); Ok(config) @@ -76,12 +215,17 @@ pub fn run() { } fn write_stored_config(config: &StoredConfig) -> Result<(), String> { + let encrypted_config = StoredConfig { + api_key: config.api_key.as_ref().map(|k| encrypt_credential(k)), + usage_token: config.usage_token.as_ref().map(|t| encrypt_credential(t)), + ..config.clone() + }; let path = config_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - let text = serde_json::to_string_pretty(config).map_err(|error| error.to_string())?; + let text = serde_json::to_string_pretty(&encrypted_config).map_err(|error| error.to_string())?; fs::write(path, text).map_err(|error| error.to_string()) } @@ -175,6 +319,12 @@ pub fn run() { if value.is_empty() { return Err("API Key 不能为空".to_string()); } + if value.len() > 256 { + return Err("API Key 长度超出限制".to_string()); + } + if !value.starts_with("sk-") { + log::warn!("API Key 格式异常:不以 sk- 开头"); + } let mut config = read_stored_config()?; config.api_key = Some(value); @@ -320,6 +470,9 @@ pub fn run() { if value.is_empty() { return Err("用量 Token 不能为空".to_string()); } + if value.len() > 4096 { + return Err("用量 Token 长度超出限制".to_string()); + } let mut config = read_stored_config()?; config.usage_token = Some(value); write_stored_config(&config)?; @@ -506,7 +659,14 @@ pub fn run() { if (window.__dsm_token_hook__) return; window.__dsm_token_hook__ = true; var done = false; - var pending = false; + var ALLOWED_HOSTS = ['platform.deepseek.com', 'api.deepseek.com']; + + function isAllowedHost(url) { + try { + var u = new URL(url, window.location.href); + return ALLOWED_HOSTS.indexOf(u.hostname) !== -1; + } catch (e) { return false; } + } function deliver(token) { if (done) return; @@ -516,18 +676,7 @@ pub fn run() { var now = new Date(); var y = now.getFullYear(); var m = now.getMonth() + 1; - // 主通道:写入 document.title,原生侧 window.title() 读取。 - // 外部网站窗口默认不注入 __TAURI__,此通道不依赖它,最可靠。 try { document.title = 'DSM_USAGE_TOKEN:' + y + ':' + m + ':' + token; } catch (e) {} - // 辅通道:若本窗口恰好可用 __TAURI__,直接上报更快 - try { - if (!pending && window.__TAURI__ && window.__TAURI__.core) { - pending = true; - window.__TAURI__.core.invoke('usage_token_captured', { - token: token, month: m, year: y - }).then(function() { done = true; }).catch(function() { pending = false; }); - } - } catch (e) {} } function fromAuth(value) { @@ -540,19 +689,24 @@ pub fn run() { if (typeof origFetch === 'function') { window.fetch = function(input, init) { try { - var headers = (init && init.headers) || (input && input.headers); - if (headers) { - if (typeof Headers !== 'undefined' && headers instanceof Headers) { - fromAuth(headers.get('authorization')); - } else if (Array.isArray(headers)) { - for (var i = 0; i < headers.length; i++) { - if (headers[i] && String(headers[i][0]).toLowerCase() === 'authorization') { - fromAuth(headers[i][1]); + var requestUrl = (typeof input === 'string') ? input : + (input && typeof input.url === 'string') ? input.url : ''; + if (init && typeof init.url === 'string') requestUrl = init.url; + if (isAllowedHost(requestUrl)) { + var headers = (init && init.headers) || (input && input.headers); + if (headers) { + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + fromAuth(headers.get('authorization')); + } else if (Array.isArray(headers)) { + for (var i = 0; i < headers.length; i++) { + if (headers[i] && String(headers[i][0]).toLowerCase() === 'authorization') { + fromAuth(headers[i][1]); + } + } + } else if (typeof headers === 'object') { + for (var k in headers) { + if (k.toLowerCase() === 'authorization') fromAuth(headers[k]); } - } - } else if (typeof headers === 'object') { - for (var k in headers) { - if (k.toLowerCase() === 'authorization') fromAuth(headers[k]); } } } @@ -564,10 +718,18 @@ pub fn run() { var origSet = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function(name, value) { try { - if (name && String(name).toLowerCase() === 'authorization') fromAuth(value); + if (name && String(name).toLowerCase() === 'authorization' && isAllowedHost(this._dsmUrl || '')) { + fromAuth(value); + } } catch (e) {} return origSet.apply(this, arguments); }; + + var origOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url) { + try { this._dsmUrl = url; } catch (e) {} + return origOpen.apply(this, arguments); + }; })(); "#; @@ -606,6 +768,10 @@ pub fn run() { .center() .visible(true) .initialization_script(USAGE_SYNC_POLL_JS) + .on_navigation(|url| { + url.host_str() + .is_some_and(|host| host == "platform.deepseek.com" || host == "api.deepseek.com") + }) .on_page_load(|window, payload| { if matches!(payload.event(), PageLoadEvent::Finished) && payload @@ -613,7 +779,6 @@ pub fn run() { .host_str() .is_some_and(|host| host == "platform.deepseek.com") { - // 双保险:万一 initialization_script 未注入,页面加载完再装一次 hook let _ = window.eval(USAGE_SYNC_POLL_JS); } }) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1ae87f1..f7145b1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,15 +10,18 @@ "beforeBuildCommand": "powershell -ExecutionPolicy Bypass -File scripts/build.ps1" }, "app": { - "withGlobalTauri": true, + "withGlobalTauri": false, "windows": [ { "label": "main", "title": "DeepSeek Monitor Windows", - "width": 356, - "height": 600, - "resizable": false, - "fullscreen": false, + "width": 380, + "height": 680, + "minWidth": 340, + "minHeight": 500, + "maxWidth": 600, + "maxHeight": 1200, + "resizable": true, "decorations": false, "transparent": true, "shadow": false, @@ -26,7 +29,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https://asset.localhost https://tauri.localhost data:; script-src 'self'" } }, "bundle": { diff --git a/src/styles.css b/src/styles.css index 991c184..011dd36 100644 --- a/src/styles.css +++ b/src/styles.css @@ -59,11 +59,23 @@ button { } .panel { - width: min(356px, 100vw); - height: min(600px, 100vh); + width: 100%; + height: 100%; border-radius: 22px; padding: 0 16px 16px; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: rgba(var(--fg), 0.28) transparent; +} + +.panel::-webkit-scrollbar { + width: 5px; +} + +.panel::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(var(--fg), 0.28); } .dashboard-panel { @@ -487,10 +499,11 @@ button { } .settings-panel { - width: min(420px, 100vw); - height: min(620px, 100vh); + width: 100%; + height: 100%; border-radius: 22px; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } .settings-inner {