diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 8d6c16b5..69a85a0b 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -54,6 +54,8 @@ | 变量 | 用途 | 默认值/行为 | | --- | --- | --- | | `ASTRBOT_DESKTOP_CLIENT` | 标记桌面客户端环境 | 打包态启动后端时写入 `1` | +| `ASTRBOT_DESKTOP_MANAGED` | 标记后端由桌面进程托管 | 桌面端启动后端时写入 `1`(包括开发态与打包态);桌面免密会话必须同时具备此标记 | +| `ASTRBOT_DESKTOP_SESSION_SECRET` | 桌面原生层与托管后端之间的内部会话密钥 | 每次桌面进程启动时随机生成 256 位值,仅通过子进程环境和本机回环请求传递,不落盘且不应由用户设置 | | `ASTRBOT_INSTALLATION_SOURCE` | 标记 AstrBot 匿名指标中的安装来源 | 打包态启动后端时写入 `desktop` | | `ASTRBOT_BACKEND_STARTUP_HEARTBEAT_PATH` | 桌面端写给后端启动器的 heartbeat 文件路径 | 打包态默认写到 `ASTRBOT_ROOT/data/backend-startup-heartbeat.json` | diff --git a/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs b/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs index 45dc0ab7..9d92f228 100644 --- a/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs +++ b/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; +import { runInNewContext } from 'node:vm'; const bootstrapPath = new URL('../../src-tauri/src/bridge_bootstrap.js', import.meta.url); const chatTransportContractPath = new URL( @@ -8,6 +9,88 @@ const chatTransportContractPath = new URL( import.meta.url, ); +const flushAsyncWork = () => new Promise((resolve) => setImmediate(resolve)); + +function runBootstrap(source, authResults) { + const values = new Map(); + const invocations = []; + const intervals = []; + const localStorage = { + getItem(key) { + return values.has(key) ? values.get(key) : null; + }, + setItem(key, value) { + values.set(String(key), String(value)); + }, + removeItem(key) { + values.delete(String(key)); + }, + clear() { + values.clear(); + }, + }; + const location = { + href: 'http://127.0.0.1:6185/#/auth/login', + origin: 'http://127.0.0.1:6185', + hash: '#/auth/login', + assign() {}, + replace() {}, + toString() { + return this.href; + }, + }; + const window = { + __TAURI_INTERNALS__: { + async invoke(command, payload = {}) { + invocations.push({ command, payload }); + if (command === 'desktop_bridge_get_auth_token') { + const authResult = authResults.shift(); + if (authResult instanceof Error) { + throw authResult; + } + return authResult ?? { + ok: false, + reason: 'No desktop auth response.', + }; + } + if (command === 'plugin:event|listen') { + throw new Error('event bridge is not configured in this test'); + } + return { ok: true, reason: null }; + }, + }, + localStorage, + location, + open: () => null, + setInterval(handler, delay) { + intervals.push({ handler, delay }); + return intervals.length; + }, + }; + class MockElement {} + class MockAnchor extends MockElement {} + const document = { addEventListener() {} }; + const quietConsole = { warn() {}, error() {}, log() {} }; + + runInNewContext( + source + .replace('{TRAY_RESTART_BACKEND_EVENT}', 'astrbot://tray-restart-backend') + .replace('{CHAT_TRANSPORT_MODE_STORAGE_KEY}', 'chat_transport_mode') + .replace('{CHAT_TRANSPORT_MODE_WEBSOCKET}', 'websocket'), + { + window, + document, + URL, + Element: MockElement, + HTMLAnchorElement: MockAnchor, + console: quietConsole, + process: { env: { NODE_ENV: 'production' } }, + }, + ); + + return { window, localStorage, invocations, intervals }; +} + test('bridge bootstrap defines astrbotAppUpdater methods', async () => { const source = await readFile(bootstrapPath, 'utf8'); @@ -18,6 +101,88 @@ test('bridge bootstrap defines astrbotAppUpdater methods', async () => { assert.match(source, /installAppUpdate:\s*\(\)\s*=>/); }); +test('bridge bootstrap owns desktop passwordless authentication lifecycle', async () => { + const source = await readFile(bootstrapPath, 'utf8'); + + assert.match(source, /GET_AUTH_TOKEN:\s*'desktop_bridge_get_auth_token'/); + assert.match(source, /refreshAuthSession:\s*refreshDesktopAuthSession/); + assert.match(source, /localStorage\?\.setItem\(TOKEN_STORAGE_KEY, token\)/); + assert.match(source, /localStorage\?\.setItem\(USER_STORAGE_KEY, username\)/); + assert.match(source, /void refreshDesktopAuthSession\(\);/); + assert.match( + source, + /window\.setInterval\(refreshDesktopAuthSession, DESKTOP_AUTH_REFRESH_INTERVAL_MS\)/, + ); +}); + +test('bridge bootstrap automatically authenticates and reacquires a removed token', async () => { + const source = await readFile(bootstrapPath, 'utf8'); + const runtime = runBootstrap(source, [ + { ok: true, token: 'first-jwt', username: 'astrbot' }, + { ok: true, token: 'second-jwt', username: 'astrbot' }, + ]); + + await flushAsyncWork(); + await flushAsyncWork(); + assert.equal(runtime.localStorage.getItem('token'), 'first-jwt'); + assert.equal(runtime.localStorage.getItem('user'), 'astrbot'); + assert.equal(runtime.window.location.hash, '/welcome'); + assert.equal(runtime.intervals.length, 1); + assert.equal(runtime.intervals[0].delay, 6 * 60 * 60 * 1000); + + runtime.localStorage.removeItem('token'); + await flushAsyncWork(); + await flushAsyncWork(); + assert.equal(runtime.localStorage.getItem('token'), 'second-jwt'); + assert.ok( + runtime.invocations.filter( + ({ command }) => command === 'desktop_bridge_get_auth_token', + ).length >= 2, + ); +}); + +test('bridge bootstrap preserves password login fallback for older backends', async () => { + const source = await readFile(bootstrapPath, 'utf8'); + const runtime = runBootstrap(source, [ + { ok: false, reason: 'Desktop passwordless authentication is unavailable.' }, + ]); + + await flushAsyncWork(); + await flushAsyncWork(); + assert.equal(runtime.localStorage.getItem('token'), null); + assert.equal(runtime.window.location.hash, '#/auth/login'); +}); + +test('bridge bootstrap handles rejected desktop authentication bridge calls', async () => { + const source = await readFile(bootstrapPath, 'utf8'); + const runtime = runBootstrap(source, [new Error('desktop auth bridge unavailable')]); + + const result = await runtime.window.astrbotDesktop.refreshAuthSession(); + + assert.equal(result?.ok, false); + assert.equal(result?.reason, 'Error: desktop auth bridge unavailable'); + assert.equal(runtime.localStorage.getItem('token'), null); + assert.equal(runtime.window.location.hash, '#/auth/login'); +}); + +test('bridge bootstrap normalizes unexpected desktop authentication refresh errors', async () => { + const source = await readFile(bootstrapPath, 'utf8'); + const invalidAuthResult = { ok: true, username: 'astrbot' }; + Object.defineProperty(invalidAuthResult, 'token', { + get() { + throw new Error('unexpected token access failure'); + }, + }); + const runtime = runBootstrap(source, [invalidAuthResult]); + + const result = await runtime.window.astrbotDesktop.refreshAuthSession(); + + assert.equal(result?.ok, false); + assert.equal(result?.reason, 'Unable to refresh desktop authentication.'); + assert.equal(runtime.localStorage.getItem('token'), null); + assert.equal(runtime.window.location.hash, '#/auth/login'); +}); + test('bridge bootstrap transport placeholders are backed by the shared contract', async () => { const [source, rawContract] = await Promise.all([ readFile(bootstrapPath, 'utf8'), diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8a78bbdf..030a4014 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -61,6 +61,7 @@ name = "astrbot-desktop-tauri" version = "4.27.2" dependencies = [ "chrono", + "getrandom 0.3.4", "home", "semver", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 858fb46d..1bd3d74e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ tauri-build = { version = "2.0", features = [] } [dependencies] chrono = { version = "0.4", features = ["clock"] } +getrandom = "0.3" home = "0.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src-tauri/src/app_constants.rs b/src-tauri/src/app_constants.rs index fc8ec743..19cc5f6a 100644 --- a/src-tauri/src/app_constants.rs +++ b/src-tauri/src/app_constants.rs @@ -5,6 +5,7 @@ pub(crate) const ASTRBOT_ROOT_ENV: &str = "ASTRBOT_ROOT"; pub(crate) const BACKEND_TIMEOUT_ENV: &str = "ASTRBOT_BACKEND_TIMEOUT_MS"; pub(crate) const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS: u64 = 15 * 60 * 1000; pub(crate) const GRACEFUL_RESTART_REQUEST_TIMEOUT_MS: u64 = 2_500; +pub(crate) const DESKTOP_AUTH_REQUEST_TIMEOUT_MS: u64 = 2_500; pub(crate) const GRACEFUL_RESTART_START_TIME_TIMEOUT_MS: u64 = 1_800; pub(crate) const GRACEFUL_RESTART_POLL_INTERVAL_MS: u64 = 350; pub(crate) const GRACEFUL_STOP_TIMEOUT_MS: u64 = 10_000; diff --git a/src-tauri/src/app_runtime.rs b/src-tauri/src/app_runtime.rs index 7dfe2ffd..0894b5c3 100644 --- a/src-tauri/src/app_runtime.rs +++ b/src-tauri/src/app_runtime.rs @@ -254,6 +254,7 @@ pub(crate) fn run() { .invoke_handler(tauri::generate_handler![ crate::bridge::commands::desktop_bridge_is_desktop_runtime, crate::bridge::commands::desktop_bridge_get_backend_state, + crate::bridge::commands::desktop_bridge_get_auth_token, crate::bridge::commands::desktop_bridge_set_auth_token, crate::bridge::commands::desktop_bridge_set_shell_locale, crate::bridge::commands::desktop_bridge_get_app_update_channel, diff --git a/src-tauri/src/app_types.rs b/src-tauri/src/app_types.rs index fac9f58a..6f187ea5 100644 --- a/src-tauri/src/app_types.rs +++ b/src-tauri/src/app_types.rs @@ -10,7 +10,7 @@ use std::{ }; use tauri::menu::{CheckMenuItem, MenuItem}; -use crate::{backend, exit_state, DEFAULT_BACKEND_URL}; +use crate::{backend, desktop_auth::DesktopSessionSecret, exit_state, DEFAULT_BACKEND_URL}; #[derive(Clone)] pub(crate) struct TrayMenuState { @@ -45,6 +45,7 @@ pub(crate) struct BackendState { pub(crate) child: Mutex>, pub(crate) backend_url: String, pub(crate) restart_auth_token: Mutex>, + pub(crate) desktop_session_secret: DesktopSessionSecret, pub(crate) startup_loading_mode: Mutex>, pub(crate) log_rotator_stop: Mutex>>, pub(crate) exit_state: Mutex, @@ -67,6 +68,15 @@ pub(crate) struct BackendBridgeResult { pub(crate) reason: Option, } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DesktopAuthBridgeResult { + pub(crate) ok: bool, + pub(crate) token: Option, + pub(crate) username: Option, + pub(crate) reason: Option, +} + pub(crate) struct AtomicFlagGuard<'a> { flag: &'a AtomicBool, } @@ -100,6 +110,8 @@ impl Default for BackendState { DEFAULT_BACKEND_URL, ), restart_auth_token: Mutex::new(None), + desktop_session_secret: DesktopSessionSecret::generate() + .expect("failed to generate secure desktop session secret"), startup_loading_mode: Mutex::new(None), log_rotator_stop: Mutex::new(None), exit_state: Mutex::new(exit_state::ExitStateMachine::default()), diff --git a/src-tauri/src/backend/http.rs b/src-tauri/src/backend/http.rs index 05010bec..7669a1d0 100644 --- a/src-tauri/src/backend/http.rs +++ b/src-tauri/src/backend/http.rs @@ -6,7 +6,18 @@ use std::{ use url::Url; -use crate::{backend::http_response, BackendState, GRACEFUL_RESTART_START_TIME_TIMEOUT_MS}; +use crate::{ + backend::http_response, + desktop_auth::{DesktopAuthSession, DESKTOP_SESSION_ENDPOINT, DESKTOP_SESSION_HEADER}, + BackendState, DESKTOP_AUTH_REQUEST_TIMEOUT_MS, GRACEFUL_RESTART_START_TIME_TIMEOUT_MS, +}; + +#[derive(Default)] +struct BackendRequestOptions<'a> { + auth_token: Option<&'a str>, + desktop_session_secret: Option<&'a str>, + require_loopback: bool, +} impl BackendState { pub(crate) fn ping_backend(&self, timeout_ms: u64) -> bool { @@ -37,6 +48,26 @@ impl BackendState { timeout_ms: u64, body: Option<&str>, auth_token: Option<&str>, + ) -> Option> { + self.request_backend_response_bytes_internal( + method, + api_path, + timeout_ms, + body, + BackendRequestOptions { + auth_token, + ..BackendRequestOptions::default() + }, + ) + } + + fn request_backend_response_bytes_internal( + &self, + method: &str, + api_path: &str, + timeout_ms: u64, + body: Option<&str>, + options: BackendRequestOptions<'_>, ) -> Option> { let base = Url::parse(&self.backend_url).ok()?; let request_url = base.join(api_path).ok()?; @@ -48,9 +79,12 @@ impl BackendState { let port = request_url.port_or_known_default().unwrap_or(80); let timeout = Duration::from_millis(timeout_ms.max(50)); let addrs = (host, port).to_socket_addrs().ok()?; - let mut stream = addrs - .into_iter() - .find_map(|address| TcpStream::connect_timeout(&address, timeout).ok())?; + let mut stream = addrs.into_iter().find_map(|address| { + if options.require_loopback && !is_loopback_socket_address(&address) { + return None; + } + TcpStream::connect_timeout(&address, timeout).ok() + })?; let _ = stream.set_read_timeout(Some(timeout)); let _ = stream.set_write_timeout(Some(timeout)); @@ -64,10 +98,16 @@ impl BackendState { } let payload = body.unwrap_or(""); - let authorization_header = auth_token + let authorization_header = options + .auth_token .and_then(sanitize_authorization_token) .map(|token| format!("Authorization: Bearer {token}\r\n")) .unwrap_or_default(); + let desktop_session_header = options + .desktop_session_secret + .and_then(sanitize_desktop_session_secret) + .map(|secret| format!("{DESKTOP_SESSION_HEADER}: {secret}\r\n")) + .unwrap_or_default(); let request = format!( "{method} {request_target} HTTP/1.1\r\n\ Host: {host}\r\n\ @@ -75,6 +115,7 @@ Accept: application/json\r\n\ Accept-Encoding: identity\r\n\ Connection: close\r\n\ {authorization_header}\ +{desktop_session_header}\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ \r\n\ @@ -152,6 +193,53 @@ Content-Length: {}\r\n\ )?; http_response::parse_backend_start_time(&payload) } + + pub(crate) fn request_desktop_auth_session(&self) -> Option { + let response = self.request_backend_response_bytes_internal( + "POST", + DESKTOP_SESSION_ENDPOINT, + DESKTOP_AUTH_REQUEST_TIMEOUT_MS, + Some("{}"), + BackendRequestOptions { + desktop_session_secret: Some(self.desktop_session_secret.as_str()), + require_loopback: true, + ..BackendRequestOptions::default() + }, + )?; + let payload = http_response::parse_http_json_response(&response)?; + parse_desktop_auth_session(&payload) + } +} + +fn is_loopback_socket_address(address: &std::net::SocketAddr) -> bool { + match address.ip() { + std::net::IpAddr::V4(ipv4) => ipv4.is_loopback(), + std::net::IpAddr::V6(ipv6) => { + ipv6.is_loopback() || ipv6.to_ipv4_mapped().is_some_and(|ipv4| ipv4.is_loopback()) + } + } +} + +fn parse_desktop_auth_session(payload: &serde_json::Value) -> Option { + if payload.get("status").and_then(|value| value.as_str()) != Some("ok") { + return None; + } + + let data = payload.get("data")?; + let token = data + .get("token") + .and_then(|value| value.as_str()) + .and_then(sanitize_authorization_token)? + .to_string(); + let username = data.get("username")?.as_str()?.trim(); + if username.is_empty() { + return None; + } + + Some(DesktopAuthSession { + token, + username: username.to_string(), + }) } fn is_complete_http_response(raw: &[u8]) -> bool { @@ -188,6 +276,17 @@ fn sanitize_authorization_token(token: &str) -> Option<&str> { Some(token) } +fn sanitize_desktop_session_secret(value: &str) -> Option<&str> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return None; + } + Some(value) +} + fn read_http_response_bytes(reader: &mut R) -> Option> { let mut response = Vec::new(); let mut chunk = [0u8; 4096]; @@ -227,6 +326,26 @@ mod tests { assert!(is_complete_http_response(raw)); } + #[test] + fn desktop_secret_requests_only_accept_loopback_socket_addresses() { + assert!(is_loopback_socket_address( + &"127.0.0.1:6185".parse().expect("IPv4 address should parse") + )); + assert!(is_loopback_socket_address( + &"[::1]:6185".parse().expect("IPv6 address should parse") + )); + assert!(is_loopback_socket_address( + &"[::ffff:127.0.0.1]:6185" + .parse() + .expect("mapped IPv6 address should parse") + )); + assert!(!is_loopback_socket_address( + &"192.168.1.10:6185" + .parse() + .expect("remote address should parse") + )); + } + #[test] fn sanitize_authorization_token_rejects_crlf() { assert_eq!(sanitize_authorization_token("abc\r\ndef"), None); @@ -237,6 +356,53 @@ mod tests { assert_eq!(sanitize_authorization_token(" token "), Some("token")); } + #[test] + fn sanitize_desktop_session_secret_requires_256_bit_lowercase_hex() { + let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + assert_eq!(sanitize_desktop_session_secret(secret), Some(secret)); + assert_eq!(sanitize_desktop_session_secret(""), None); + assert_eq!(sanitize_desktop_session_secret("abc123"), None); + assert_eq!( + sanitize_desktop_session_secret( + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" + ), + None + ); + assert_eq!( + sanitize_desktop_session_secret( + "0123456789abcdef0123456789abcdef\r\nInjected: true!!!!!!!!!!!!!!!" + ), + None + ); + } + + #[test] + fn parse_desktop_auth_session_requires_successful_complete_payload() { + let payload = serde_json::json!({ + "status": "ok", + "data": { + "token": "desktop-jwt", + "username": "astrbot" + } + }); + + let session = parse_desktop_auth_session(&payload).expect("session should parse"); + assert_eq!(session.token, "desktop-jwt"); + assert_eq!(session.username, "astrbot"); + + assert!(parse_desktop_auth_session(&serde_json::json!({ + "status": "error", + "data": {"token": "desktop-jwt", "username": "astrbot"} + })) + .is_none()); + assert!(parse_desktop_auth_session(&serde_json::json!({ + "status": "ok", + "data": {"token": "desktop-jwt"} + })) + .is_none()); + } + #[test] fn read_http_response_bytes_keeps_partial_data_on_timeout() { struct TimeoutReader { diff --git a/src-tauri/src/backend/launch.rs b/src-tauri/src/backend/launch.rs index 83eb9c3d..882c51fa 100644 --- a/src-tauri/src/backend/launch.rs +++ b/src-tauri/src/backend/launch.rs @@ -16,6 +16,7 @@ use serde::Deserialize; use serde_json::Value; use tauri::AppHandle; +use crate::desktop_auth::{DesktopSessionSecret, DESKTOP_SESSION_SECRET_ENV}; use crate::{ append_desktop_log, backend_path_override, build_debug_command, launch_plan, logging, runtime_paths, BackendState, BACKEND_LOG_MAX_BYTES, DEFAULT_SHELL_LOCALE, LOG_BACKUP_COUNT, @@ -137,10 +138,17 @@ where command.env("PYTHONNOUSERSITE", "1"); } -fn mark_as_desktop_managed(command: &mut Command) { - command.env(ASTRBOT_DESKTOP_CLIENT_ENV, ENABLED_ENV_VALUE); +fn mark_as_desktop_managed( + command: &mut Command, + desktop_session_secret: &DesktopSessionSecret, + packaged_mode: bool, +) { command.env(ASTRBOT_DESKTOP_MANAGED_ENV, ENABLED_ENV_VALUE); - command.env(ASTRBOT_INSTALLATION_SOURCE_ENV, DESKTOP_INSTALLATION_SOURCE); + command.env(DESKTOP_SESSION_SECRET_ENV, desktop_session_secret.as_str()); + if packaged_mode { + command.env(ASTRBOT_DESKTOP_CLIENT_ENV, ENABLED_ENV_VALUE); + command.env(ASTRBOT_INSTALLATION_SOURCE_ENV, DESKTOP_INSTALLATION_SOURCE); + } } fn configure_desktop_dashboard_environment( @@ -443,9 +451,14 @@ impl BackendState { } } + mark_as_desktop_managed( + &mut command, + &self.desktop_session_secret, + plan.packaged_mode, + ); + if plan.packaged_mode { sanitize_packaged_python_environment(&mut command, append_desktop_log); - mark_as_desktop_managed(&mut command); } if let Some(root_dir) = &plan.root_dir { @@ -551,6 +564,7 @@ mod tests { DEFAULT_DASHBOARD_HOST, DEFAULT_DASHBOARD_PORT, DESKTOP_INSTALLATION_SOURCE, ENABLED_ENV_VALUE, }; + use crate::desktop_auth::{DesktopSessionSecret, DESKTOP_SESSION_SECRET_ENV}; static ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); @@ -637,10 +651,12 @@ mod tests { } #[test] - fn mark_as_desktop_managed_sets_desktop_management_markers() { + fn mark_as_desktop_managed_sets_packaged_desktop_environment() { let mut command = Command::new("sh"); + let desktop_session_secret = + DesktopSessionSecret::generate().expect("secret generation should succeed"); - mark_as_desktop_managed(&mut command); + mark_as_desktop_managed(&mut command, &desktop_session_secret, true); assert_eq!( get_command_env_value(&command, ASTRBOT_DESKTOP_CLIENT_ENV), @@ -650,12 +666,42 @@ mod tests { get_command_env_value(&command, ASTRBOT_DESKTOP_MANAGED_ENV), Some(Some(ENABLED_ENV_VALUE.to_string())) ); + assert_eq!( + get_command_env_value(&command, DESKTOP_SESSION_SECRET_ENV), + Some(Some(desktop_session_secret.as_str().to_string())) + ); assert_eq!( get_command_env_value(&command, ASTRBOT_INSTALLATION_SOURCE_ENV), Some(Some(DESKTOP_INSTALLATION_SOURCE.to_string())) ); } + #[test] + fn mark_as_desktop_managed_keeps_development_runtime_unpacked() { + let mut command = Command::new("sh"); + let desktop_session_secret = + DesktopSessionSecret::generate().expect("secret generation should succeed"); + + mark_as_desktop_managed(&mut command, &desktop_session_secret, false); + + assert_eq!( + get_command_env_value(&command, ASTRBOT_DESKTOP_CLIENT_ENV), + None + ); + assert_eq!( + get_command_env_value(&command, ASTRBOT_DESKTOP_MANAGED_ENV), + Some(Some(ENABLED_ENV_VALUE.to_string())) + ); + assert_eq!( + get_command_env_value(&command, DESKTOP_SESSION_SECRET_ENV), + Some(Some(desktop_session_secret.as_str().to_string())) + ); + assert_eq!( + get_command_env_value(&command, ASTRBOT_INSTALLATION_SOURCE_ENV), + None + ); + } + #[test] fn configure_desktop_dashboard_environment_enables_local_setup_without_default_password() { with_clean_dashboard_env(|| { diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index ddf848f4..64d5bb21 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -15,7 +15,8 @@ use crate::bridge::updater_types::{ }; use crate::{ append_desktop_log, restart_backend_flow, runtime_paths, shell_locale, tray, update_channel, - BackendBridgeResult, BackendBridgeState, BackendState, DEFAULT_SHELL_LOCALE, + BackendBridgeResult, BackendBridgeState, BackendState, DesktopAuthBridgeResult, + DEFAULT_SHELL_LOCALE, }; fn resolve_update_channel(app_handle: &AppHandle) -> update_channel::UpdateChannel { @@ -212,6 +213,47 @@ pub(crate) fn desktop_bridge_get_backend_state(app_handle: AppHandle) -> Backend state.bridge_state(&app_handle) } +#[tauri::command] +pub(crate) async fn desktop_bridge_get_auth_token( + app_handle: AppHandle, +) -> DesktopAuthBridgeResult { + let task_result = tauri::async_runtime::spawn_blocking(move || { + let state = app_handle.state::(); + state.request_desktop_auth_session() + }) + .await; + + match task_result { + Ok(Some(session)) => DesktopAuthBridgeResult { + ok: true, + token: Some(session.token), + username: Some(session.username), + reason: None, + }, + Ok(None) => DesktopAuthBridgeResult { + ok: false, + token: None, + username: None, + reason: Some("Desktop passwordless authentication is unavailable.".to_string()), + }, + Err(join_error) => { + let failure_kind = match &join_error { + tauri::Error::JoinError(error) if error.is_panic() => "panicked", + _ => "failed to complete", + }; + append_desktop_log(&format!( + "desktop authentication task {failure_kind}: {join_error}" + )); + DesktopAuthBridgeResult { + ok: false, + token: None, + username: None, + reason: Some("Desktop authentication task failed.".to_string()), + } + } + } +} + #[tauri::command] pub(crate) fn desktop_bridge_set_auth_token( app_handle: AppHandle, diff --git a/src-tauri/src/bridge_bootstrap.js b/src-tauri/src/bridge_bootstrap.js index fe1cdcb2..e202e8ce 100644 --- a/src-tauri/src/bridge_bootstrap.js +++ b/src-tauri/src/bridge_bootstrap.js @@ -19,6 +19,7 @@ const BRIDGE_COMMANDS = Object.freeze({ IS_DESKTOP_RUNTIME: 'desktop_bridge_is_desktop_runtime', GET_BACKEND_STATE: 'desktop_bridge_get_backend_state', + GET_AUTH_TOKEN: 'desktop_bridge_get_auth_token', SET_AUTH_TOKEN: 'desktop_bridge_set_auth_token', SET_SHELL_LOCALE: 'desktop_bridge_set_shell_locale', GET_APP_UPDATE_CHANNEL: 'desktop_bridge_get_app_update_channel', @@ -149,7 +150,9 @@ }; const TOKEN_STORAGE_KEY = 'token'; + const USER_STORAGE_KEY = 'user'; const SHELL_LOCALE_STORAGE_KEY = 'astrbot-locale'; + const DESKTOP_AUTH_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // Values are injected from the shared desktop bridge transport contract. const CHAT_TRANSPORT = Object.freeze({ STORAGE_KEY: '{CHAT_TRANSPORT_MODE_STORAGE_KEY}', @@ -180,6 +183,52 @@ locale: value, }); + let desktopAuthRefreshPromise = null; + const refreshDesktopAuthSession = () => { + if (desktopAuthRefreshPromise) { + return desktopAuthRefreshPromise; + } + + desktopAuthRefreshPromise = (async () => { + const result = await invokeBridge(BRIDGE_COMMANDS.GET_AUTH_TOKEN); + const token = normalizeStoredValue(result?.token); + const username = normalizeStoredValue(result?.username); + if (!result?.ok || !token || !username) { + devWarn( + 'astrbotDesktop: desktop passwordless authentication unavailable', + result?.reason, + ); + return result; + } + + try { + window.localStorage?.setItem(USER_STORAGE_KEY, username); + window.localStorage?.setItem(TOKEN_STORAGE_KEY, token); + window.localStorage?.removeItem('change_pwd_hint'); + window.localStorage?.removeItem('md5_pwd_hint'); + window.localStorage?.removeItem('password_upgrade_required'); + } catch (error) { + devWarn('astrbotDesktop: failed to persist desktop authentication', error); + return { ok: false, reason: 'Unable to persist desktop authentication.' }; + } + + await syncAuthToken(token); + if (/^#\/auth\/(?:login|setup)(?:[/?]|$)/.test(window.location.hash || '')) { + window.location.hash = '/welcome'; + } + return result; + })() + .catch((error) => { + devWarn('astrbotDesktop: failed to refresh desktop authentication', error); + return { ok: false, reason: 'Unable to refresh desktop authentication.' }; + }) + .finally(() => { + desktopAuthRefreshPromise = null; + }); + + return desktopAuthRefreshPromise; + }; + const IS_DEV = (typeof process !== 'undefined' && process.env && @@ -695,6 +744,7 @@ rawRemoveItem(key); if (key === TOKEN_STORAGE_KEY) { void syncAuthToken(null); + void refreshDesktopAuthSession(); } else if (key === SHELL_LOCALE_STORAGE_KEY) { void syncShellLocale(null); } @@ -705,6 +755,7 @@ rawClear(); void syncAuthToken(null); void syncShellLocale(null); + void refreshDesktopAuthSession(); }; } } catch {} @@ -742,6 +793,7 @@ isDesktopRuntime: () => isRuntimeBridgeEnabled(BRIDGE_COMMANDS.IS_DESKTOP_RUNTIME, true), getBackendState: () => invokeBridge(BRIDGE_COMMANDS.GET_BACKEND_STATE), + refreshAuthSession: refreshDesktopAuthSession, restartBackend: async (authToken = null) => { const normalizedToken = typeof authToken === 'string' && authToken ? authToken : getStoredAuthToken(); @@ -782,4 +834,6 @@ ensureDefaultChatTransportMode(); void syncAuthToken(); void syncShellLocale(); + void refreshDesktopAuthSession(); + window.setInterval(refreshDesktopAuthSession, DESKTOP_AUTH_REFRESH_INTERVAL_MS); })(); diff --git a/src-tauri/src/desktop_auth.rs b/src-tauri/src/desktop_auth.rs new file mode 100644 index 00000000..08aa985f --- /dev/null +++ b/src-tauri/src/desktop_auth.rs @@ -0,0 +1,61 @@ +use std::fmt; + +pub(crate) const DESKTOP_SESSION_SECRET_ENV: &str = "ASTRBOT_DESKTOP_SESSION_SECRET"; +pub(crate) const DESKTOP_SESSION_HEADER: &str = "X-AstrBot-Desktop-Session"; +pub(crate) const DESKTOP_SESSION_ENDPOINT: &str = "/api/v1/auth/desktop-session"; + +pub(crate) struct DesktopSessionSecret(String); + +impl DesktopSessionSecret { + pub(crate) fn generate() -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes)?; + + let mut secret = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut secret, "{byte:02x}").expect("writing to a String cannot fail"); + } + Ok(Self(secret)) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for DesktopSessionSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DesktopSessionSecret([redacted])") + } +} + +pub(crate) struct DesktopAuthSession { + pub(crate) token: String, + pub(crate) username: String, +} + +#[cfg(test)] +mod tests { + use super::DesktopSessionSecret; + + #[test] + fn generated_secret_has_256_bits_encoded_as_lowercase_hex() { + let secret = DesktopSessionSecret::generate().expect("secret generation should succeed"); + + assert_eq!(secret.as_str().len(), 64); + assert!(secret + .as_str() + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + } + + #[test] + fn debug_output_does_not_reveal_secret() { + let secret = DesktopSessionSecret::generate().expect("secret generation should succeed"); + let debug_output = format!("{secret:?}"); + + assert_eq!(debug_output, "DesktopSessionSecret([redacted])"); + assert!(!debug_output.contains(secret.as_str())); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d804b587..4e90c999 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,6 +8,7 @@ mod app_types; mod backend; mod bridge; +mod desktop_auth; mod desktop_settings; mod desktop_state; @@ -38,8 +39,8 @@ pub(crate) use app_helpers::{ navigate_main_window_to_backend, }; pub(crate) use app_types::{ - AtomicFlagGuard, BackendBridgeResult, BackendBridgeState, BackendState, LaunchPlan, - RuntimeManifest, TrayMenuState, + AtomicFlagGuard, BackendBridgeResult, BackendBridgeState, BackendState, + DesktopAuthBridgeResult, LaunchPlan, RuntimeManifest, TrayMenuState, }; pub(crate) use desktop_settings::DesktopSettingsCache;