diff --git a/.env.example b/.env.example index 573e8682fb..582b092278 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,9 @@ JWT_TOKEN= OPENHUMAN_CORE_PORT=7788 # [optional] Default: http://127.0.0.1:7788/rpc OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc +# [optional] Pre-seed the core RPC bearer token (Tauri sets this automatically; +# for standalone CLI use only) +# OPENHUMAN_CORE_TOKEN= # [optional] Run mode: child (default, spawns sidecar) | inprocess OPENHUMAN_CORE_RUN_MODE=child # [optional] Override path to openhuman core binary (leave blank for auto-detection) diff --git a/Cargo.lock b/Cargo.lock index e11f792f08..170f8578ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4541,7 +4541,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.53.1" +version = "0.53.3" dependencies = [ "aes-gcm", "anyhow", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 15f2f9b209..704884053e 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.53.1" +version = "0.53.3" dependencies = [ "anyhow", "async-trait", @@ -15,6 +15,7 @@ dependencies = [ "env_logger", "flate2", "futures-util", + "hex", "log", "mac-notification-sys", "nix", @@ -22,6 +23,7 @@ dependencies = [ "objc2", "objc2-app-kit", "parking_lot", + "rand 0.9.2", "reqwest 0.12.28", "rusqlite", "rustls", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 70db67c77d..f8f20c9b1f 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -72,6 +72,8 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rand = "0.9" +hex = "0.4" # Tauri's vendored dev-server proxy (see `vendor/tauri-cef/.../protocol/tauri.rs`) # builds a reqwest 0.13 client that requires a process-wide rustls # `CryptoProvider`. Without one, `ClientBuilder::build()` panics with diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index cb379f15fe..960e4bd8d6 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -5,6 +5,7 @@ description = "Core RPC URL, sidecar restart, dictation hotkey, webview-account, [permission.commands] allow = [ "core_rpc_url", + "core_rpc_token", "restart_core_process", "service_install_direct", "service_start_direct", diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs index 6e295398cc..782f4e6ee2 100644 --- a/app/src-tauri/src/core_process.rs +++ b/app/src-tauri/src/core_process.rs @@ -1,7 +1,9 @@ use std::io::IsTerminal; use std::path::PathBuf; use std::sync::Arc; +use std::sync::LazyLock; +use parking_lot::RwLock; use tokio::net::TcpStream; use tokio::process::{Child, Command}; use tokio::sync::Mutex; @@ -46,6 +48,24 @@ pub enum CoreRunMode { ChildProcess, } +/// Generate a 256-bit cryptographically-random bearer token as a hex string. +/// +/// Uses the same encoding as `openhuman_core::core::auth::generate_token` +/// (`hex::encode`) so the token format never silently diverges between the +/// Tauri-side generator and the core-side validator. +pub fn generate_rpc_token() -> String { + use rand::RngCore as _; + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +static CURRENT_RPC_TOKEN: LazyLock>> = LazyLock::new(|| RwLock::new(None)); + +pub fn current_rpc_token() -> Option { + CURRENT_RPC_TOKEN.read().clone() +} + #[derive(Clone)] pub struct CoreProcessHandle { child: Arc>>, @@ -56,10 +76,20 @@ pub struct CoreProcessHandle { /// Override path set by the auto-updater after staging a new binary. core_bin_override: Arc>>, run_mode: CoreRunMode, + /// Bearer token passed to the core via `OPENHUMAN_CORE_TOKEN` and returned + /// to the frontend so every RPC request can include `Authorization: Bearer`. + rpc_token: Arc, } impl CoreProcessHandle { pub fn new(port: u16, core_bin: Option, run_mode: CoreRunMode) -> Self { + let rpc_token = generate_rpc_token(); + // CURRENT_RPC_TOKEN is intentionally NOT set here. It is published by + // ensure_running() only after the child process that received + // OPENHUMAN_CORE_TOKEN has been successfully spawned. Setting it here + // would advertise a token that the running core (which may be a stale + // process the handle did not spawn) has never seen, causing 401s on + // every subsequent authenticated call. Self { child: Arc::new(Mutex::new(None)), task: Arc::new(Mutex::new(None)), @@ -68,9 +98,15 @@ impl CoreProcessHandle { core_bin, core_bin_override: Arc::new(Mutex::new(None)), run_mode, + rpc_token: Arc::new(rpc_token), } } + /// The bearer token the core process uses to authenticate inbound RPC requests. + pub fn rpc_token(&self) -> &str { + &self.rpc_token + } + pub fn rpc_url(&self) -> String { format!("http://127.0.0.1:{}/rpc", self.port) } @@ -158,10 +194,15 @@ impl CoreProcessHandle { }; apply_core_color_env(&mut cmd); apply_core_no_window(&mut cmd); + cmd.env("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str()); let child = cmd .spawn() .map_err(|e| format!("failed to spawn core process: {e}"))?; *guard = Some(child); + // Publish only after the child that holds OPENHUMAN_CORE_TOKEN + // has been spawned successfully. + *CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string()); + log::debug!("[auth] CURRENT_RPC_TOKEN set after in-process spawn"); } } CoreRunMode::ChildProcess => { @@ -197,11 +238,16 @@ impl CoreProcessHandle { apply_core_color_env(&mut cmd); apply_core_no_window(&mut cmd); + cmd.env("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str()); let child = cmd .spawn() .map_err(|e| format!("failed to spawn core process: {e}"))?; *guard = Some(child); + // Publish only after the child that holds OPENHUMAN_CORE_TOKEN + // has been spawned successfully. + *CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string()); + log::debug!("[auth] CURRENT_RPC_TOKEN set after child process spawn"); } } } diff --git a/app/src-tauri/src/core_process_tests.rs b/app/src-tauri/src/core_process_tests.rs index 27bdf6edcb..b1503ebd24 100644 --- a/app/src-tauri/src/core_process_tests.rs +++ b/app/src-tauri/src/core_process_tests.rs @@ -1,8 +1,8 @@ //! Sibling tests extracted from core_process.rs — see PR #835. use super::{ - default_core_bin, default_core_port, default_core_run_mode, same_executable_path, - CoreProcessHandle, CoreRunMode, + current_rpc_token, default_core_bin, default_core_port, default_core_run_mode, + generate_rpc_token, same_executable_path, CoreProcessHandle, CoreRunMode, }; use std::io::Write; use std::path::PathBuf; @@ -226,6 +226,93 @@ fn ensure_running_returns_ok_when_rpc_port_already_open() { ); } +// --------------------------------------------------------------------------- +// Token generation tests +// --------------------------------------------------------------------------- + +/// `generate_rpc_token` must produce a 64-character lowercase hex string +/// (32 bytes × 2 hex digits = 64 chars), matching the format expected by the +/// core's auth middleware. +#[test] +fn generate_rpc_token_produces_64_hex_chars() { + let token = generate_rpc_token(); + assert_eq!( + token.len(), + 64, + "256-bit token → 64 hex chars, got {token:?}" + ); + assert!( + token.chars().all(|c| c.is_ascii_hexdigit()), + "token must be hex, got {token:?}" + ); + assert!( + token.chars().all(|c| !c.is_uppercase()), + "token must be lowercase hex, got {token:?}" + ); +} + +/// Each call generates a different token (CSPRNG — not a constant). +#[test] +fn generate_rpc_token_is_not_constant() { + assert_ne!( + generate_rpc_token(), + generate_rpc_token(), + "two consecutive tokens must differ" + ); +} + +/// `CoreProcessHandle::new` must produce a non-empty, correctly-formatted +/// bearer token immediately — no file I/O or timing dependency. +#[test] +fn core_process_handle_new_token_is_valid() { + let handle = CoreProcessHandle::new(19001, None, CoreRunMode::ChildProcess); + let token = handle.rpc_token(); + assert_eq!(token.len(), 64, "handle token must be 64 hex chars"); + assert!( + token.chars().all(|c| c.is_ascii_hexdigit()), + "handle token must be hex" + ); +} + +/// `CoreProcessHandle::new()` must NOT publish the token to the global +/// `CURRENT_RPC_TOKEN`. The global is set only after `ensure_running()` +/// successfully spawns the child that received `OPENHUMAN_CORE_TOKEN`. +/// Advertising the token before spawn would cause 401s when the port is +/// already held by a stale process that never received this token. +#[test] +fn new_does_not_publish_global_token() { + // Capture current global state before constructing the handle. + let before = current_rpc_token(); + let handle = CoreProcessHandle::new(19002, None, CoreRunMode::ChildProcess); + let after = current_rpc_token(); + + // The global must not have changed to this handle's token. + assert_ne!( + after.as_deref(), + Some(handle.rpc_token()), + "new() must not publish its token to CURRENT_RPC_TOKEN before ensure_running() spawns" + ); + // Whatever was in the global before must still be there (or still None). + assert_eq!( + before, after, + "new() must leave CURRENT_RPC_TOKEN unchanged" + ); +} + +/// Two handles constructed sequentially must each have a unique token, +/// but neither should update the global until ensure_running() spawns. +#[test] +fn each_handle_has_unique_token() { + let h1 = CoreProcessHandle::new(19003, None, CoreRunMode::ChildProcess); + let h2 = CoreProcessHandle::new(19004, None, CoreRunMode::ChildProcess); + + assert_ne!( + h1.rpc_token(), + h2.rpc_token(), + "each handle must have a unique token" + ); +} + // Tests for logging/diagnostics (grep-friendly patterns) #[test] fn core_bin_resolution_logs_expected_patterns() { diff --git a/app/src-tauri/src/core_rpc.rs b/app/src-tauri/src/core_rpc.rs new file mode 100644 index 0000000000..b49392dc99 --- /dev/null +++ b/app/src-tauri/src/core_rpc.rs @@ -0,0 +1,19 @@ +//! Shared helpers for authenticated calls from the Tauri host to the local core RPC. + +use reqwest::RequestBuilder; + +const CORE_RPC_URL_ENV: &str = "OPENHUMAN_CORE_RPC_URL"; +pub(crate) fn core_rpc_url_value() -> String { + std::env::var(CORE_RPC_URL_ENV).unwrap_or_else(|_| { + format!( + "http://127.0.0.1:{}/rpc", + crate::core_process::default_core_port() + ) + }) +} + +pub(crate) fn apply_auth(builder: RequestBuilder) -> Result { + let token = crate::core_process::current_rpc_token() + .ok_or_else(|| "core RPC token is not initialized".to_string())?; + Ok(builder.header("Authorization", format!("Bearer {token}"))) +} diff --git a/app/src-tauri/src/core_update.rs b/app/src-tauri/src/core_update.rs index 59e4cdb43d..5d8b34a1c8 100644 --- a/app/src-tauri/src/core_update.rs +++ b/app/src-tauri/src/core_update.rs @@ -52,7 +52,7 @@ pub struct CoreUpdateInfo { } /// Query the running core's version via JSON-RPC. -pub async fn query_core_version(rpc_url: &str) -> Result { +pub async fn query_core_version(rpc_url: &str, rpc_token: &str) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .build() @@ -67,6 +67,7 @@ pub async fn query_core_version(rpc_url: &str) -> Result { let resp = client .post(rpc_url) + .header("Authorization", format!("Bearer {rpc_token}")) .json(&body) .send() .await @@ -104,8 +105,8 @@ pub fn is_outdated(running: &str, target: &str) -> bool { } /// Full check: query running version, compare against minimum AND latest GitHub release. -pub async fn check_full(rpc_url: &str) -> Result { - let running = query_core_version(rpc_url).await?; +pub async fn check_full(rpc_url: &str, rpc_token: &str) -> Result { + let running = query_core_version(rpc_url, rpc_token).await?; let minimum = MINIMUM_CORE_VERSION; let outdated = is_outdated(&running, minimum); @@ -393,6 +394,7 @@ pub async fn check_and_update_core( force: bool, ) -> Result<(), String> { let rpc_url = handle.rpc_url(); + let rpc_token = handle.rpc_token().to_string(); log::info!( "[core-update] checking core version at {} (minimum: {}, force: {})", rpc_url, @@ -401,7 +403,7 @@ pub async fn check_and_update_core( ); // Step 1: Query running version. - let running_version = match query_core_version(&rpc_url).await { + let running_version = match query_core_version(&rpc_url, &rpc_token).await { Ok(v) => v, Err(e) => { log::warn!("[core-update] could not query core version: {e}"); diff --git a/app/src-tauri/src/imessage_scanner/mod.rs b/app/src-tauri/src/imessage_scanner/mod.rs index 7ea4ff7b81..5b626db45c 100644 --- a/app/src-tauri/src/imessage_scanner/mod.rs +++ b/app/src-tauri/src/imessage_scanner/mod.rs @@ -179,15 +179,15 @@ fn chat_allowed(chat_id: &str, allowed: &[String]) -> bool { /// - `Err(_)` on transport or parse errors (caller should retry next tick) #[cfg(target_os = "macos")] async fn fetch_imessage_gate() -> anyhow::Result>> { - let url = std::env::var("OPENHUMAN_CORE_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".into()); + let url = crate::core_rpc::core_rpc_url_value(); let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "openhuman.config_get", "params": {} }); - let res = http_client().post(&url).json(&body).send().await?; + let req = crate::core_rpc::apply_auth(http_client().post(&url)).map_err(anyhow::Error::msg)?; + let res = req.json(&body).send().await?; if !res.status().is_success() { anyhow::bail!("config_get http {}", res.status()); } @@ -407,8 +407,7 @@ fn message_body(m: &chatdb::Message) -> String { #[cfg(target_os = "macos")] async fn ingest_group(account_id: &str, key: &str, transcript: String) -> anyhow::Result<()> { let (chat_id, day) = key.split_once(':').unwrap_or((key, "")); - let url = std::env::var("OPENHUMAN_CORE_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".into()); + let url = crate::core_rpc::core_rpc_url_value(); let body = json!({ "jsonrpc": "2.0", @@ -430,7 +429,8 @@ async fn ingest_group(account_id: &str, key: &str, transcript: String) -> anyhow } }); - let res = http_client().post(&url).json(&body).send().await?; + let req = crate::core_rpc::apply_auth(http_client().post(&url)).map_err(anyhow::Error::msg)?; + let res = req.json(&body).send().await?; if !res.status().is_success() { anyhow::bail!("core rpc {}: {}", res.status(), res.text().await?); diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 6918273cf9..89e038da22 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod cdp; mod cef_preflight; mod cef_profile; mod core_process; +mod core_rpc; mod core_update; mod discord_scanner; mod gmail; @@ -77,8 +78,20 @@ fn expand_dictation_shortcuts(shortcut: &str) -> Vec { #[tauri::command] fn core_rpc_url() -> String { - std::env::var("OPENHUMAN_CORE_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string()) + crate::core_rpc::core_rpc_url_value() +} + +/// Tauri command: return the per-process bearer token that must be sent with +/// every core RPC request as `Authorization: Bearer `. +/// +/// The token is generated by the Tauri shell at startup (inside +/// [`CoreProcessHandle::new`]), injected into the core child process via +/// `OPENHUMAN_CORE_TOKEN`, and stored in the handle — available immediately +/// with no file I/O or timing issues. +#[tauri::command] +fn core_rpc_token(state: tauri::State<'_, core_process::CoreProcessHandle>) -> String { + log::debug!("[auth] core_rpc_token: returning token to frontend"); + state.inner().rpc_token().to_string() } #[tauri::command] @@ -289,7 +302,8 @@ async fn check_core_update( state: tauri::State<'_, core_process::CoreProcessHandle>, ) -> Result { let rpc_url = state.inner().rpc_url(); - let info = core_update::check_full(&rpc_url).await?; + let rpc_token = state.inner().rpc_token().to_string(); + let info = core_update::check_full(&rpc_url, &rpc_token).await?; serde_json::to_value(&info).map_err(|e| format!("serialize error: {e}")) } @@ -1286,6 +1300,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ core_rpc_url, + core_rpc_token, overlay_parent_rpc_url, check_core_update, apply_core_update, diff --git a/app/src-tauri/src/slack_scanner/mod.rs b/app/src-tauri/src/slack_scanner/mod.rs index fdd94f4058..e71ee1f5a0 100644 --- a/app/src-tauri/src/slack_scanner/mod.rs +++ b/app/src-tauri/src/slack_scanner/mod.rs @@ -479,14 +479,14 @@ async fn post_memory_doc_ingest(account_id: &str, ingest: &Value) -> Result<(), "params": params, }); - let url = std::env::var("OPENHUMAN_CORE_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string()); + let url = crate::core_rpc::core_rpc_url_value(); let client = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .build() .map_err(|e| format!("http client: {e}"))?; - let resp = client - .post(&url) + let req = crate::core_rpc::apply_auth(client.post(&url)) + .map_err(|e| format!("prepare {url}: {e}"))?; + let resp = req .json(&body) .send() .await diff --git a/app/src-tauri/src/telegram_scanner/mod.rs b/app/src-tauri/src/telegram_scanner/mod.rs index eecee79a32..f8905c85e4 100644 --- a/app/src-tauri/src/telegram_scanner/mod.rs +++ b/app/src-tauri/src/telegram_scanner/mod.rs @@ -397,14 +397,14 @@ async fn post_memory_doc_ingest(account_id: &str, ingest: &Value) -> Result<(), "params": params, }); - let url = std::env::var("OPENHUMAN_CORE_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string()); + let url = crate::core_rpc::core_rpc_url_value(); let client = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .build() .map_err(|e| format!("http client: {e}"))?; - let resp = client - .post(&url) + let req = crate::core_rpc::apply_auth(client.post(&url)) + .map_err(|e| format!("prepare {url}: {e}"))?; + let resp = req .json(&body) .send() .await diff --git a/app/src-tauri/src/whatsapp_scanner/mod.rs b/app/src-tauri/src/whatsapp_scanner/mod.rs index 7fe00d1758..c79b2b2583 100644 --- a/app/src-tauri/src/whatsapp_scanner/mod.rs +++ b/app/src-tauri/src/whatsapp_scanner/mod.rs @@ -773,13 +773,6 @@ fn emit_grouped_whatsapp( } } -/// Resolve the core JSON-RPC URL — same rule as the `core_rpc_url` Tauri -/// command in lib.rs: env var or default loopback port. -fn core_rpc_url_value() -> String { - std::env::var("OPENHUMAN_CORE_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string()) -} - /// Build the `openhuman.memory_doc_ingest` payload for a single /// (chatId, day) group and POST it directly to the core. The shape /// mirrors `persistWhatsappChatDay` on the React side so the memory docs @@ -883,13 +876,14 @@ async fn post_memory_doc_ingest(account_id: &str, ingest: &Value) -> Result<(), "params": params, }); - let url = core_rpc_url_value(); + let url = crate::core_rpc::core_rpc_url_value(); let client = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .build() .map_err(|e| format!("http client: {e}"))?; - let resp = client - .post(&url) + let req = crate::core_rpc::apply_auth(client.post(&url)) + .map_err(|e| format!("prepare {url}: {e}"))?; + let resp = req .json(&body) .send() .await diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 66e1832a26..beec627126 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -1,3 +1,4 @@ +import { invoke, isTauri } from '@tauri-apps/api/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { dispatchLocalAiMethod } from '../../lib/ai/localCoreAiMemory'; @@ -305,4 +306,66 @@ describe('coreRpcClient', () => { const headers = init.headers as Record; expect(headers['Content-Type']).toBe('application/json'); }); + + test('adds bearer token header in Tauri mode', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'core_rpc_url') return 'http://127.0.0.1:7788/rpc'; + if (cmd === 'core_rpc_token') return 'test-local-token'; + throw new Error(`unexpected command: ${cmd}`); + }); + const { callCoreRpc: callFreshCoreRpc } = await import('../coreRpcClient'); + + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }), + } as Response); + + await callFreshCoreRpc({ method: 'openhuman.threads_list' }); + + const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers.Authorization).toBe('Bearer test-local-token'); + }); + + test('fails closed in Tauri mode when core rpc token is unavailable', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'core_rpc_url') return 'http://127.0.0.1:7788/rpc'; + if (cmd === 'core_rpc_token') throw new Error('denied'); + throw new Error(`unexpected command: ${cmd}`); + }); + const { callCoreRpc: callFreshCoreRpc } = await import('../coreRpcClient'); + + await expect(callFreshCoreRpc({ method: 'openhuman.threads_list' })).rejects.toThrow( + 'Core RPC token unavailable in Tauri; local RPC auth cannot be satisfied' + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('caches a missing token result after the first Tauri lookup failure', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'core_rpc_url') return 'http://127.0.0.1:7788/rpc'; + if (cmd === 'core_rpc_token') throw new Error('denied'); + throw new Error(`unexpected command: ${cmd}`); + }); + const { callCoreRpc: callFreshCoreRpc } = await import('../coreRpcClient'); + + await expect(callFreshCoreRpc({ method: 'openhuman.threads_list' })).rejects.toThrow( + 'Core RPC token unavailable in Tauri; local RPC auth cannot be satisfied' + ); + await expect(callFreshCoreRpc({ method: 'openhuman.threads_list' })).rejects.toThrow( + 'Core RPC token unavailable in Tauri; local RPC auth cannot be satisfied' + ); + + const tokenCalls = vi + .mocked(invoke) + .mock.calls.filter(([cmd]) => cmd === 'core_rpc_token').length; + expect(tokenCalls).toBe(1); + expect(fetch).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 08bc06f292..03da77f90d 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -48,6 +48,9 @@ const LEGACY_METHOD_ALIASES: Record = { let nextJsonRpcId = 1; let resolvedCoreRpcUrl: string | null = null; let resolvingCoreRpcUrl: Promise | null = null; +let resolvedCoreRpcToken: string | null = null; +let didResolveCoreRpcToken = false; +let resolvingCoreRpcToken: Promise | null = null; const coreRpcLog = debug('core-rpc'); const coreRpcError = debug('core-rpc:error'); @@ -119,6 +122,39 @@ export async function getCoreRpcUrl(): Promise { return resolvePromise; } +/** + * Returns the per-process RPC bearer token written by the core binary to + * `~/.openhuman/core.token` at startup. The token is fetched once via a + * Tauri command and then cached for the lifetime of the frontend process. + * + * Returns `null` in non-Tauri environments (e.g. Vitest) where the command + * is not available so existing tests remain unaffected. + */ +async function getCoreRpcToken(): Promise { + if (didResolveCoreRpcToken) return resolvedCoreRpcToken; + if (!coreIsTauri()) return null; + if (resolvingCoreRpcToken) return resolvingCoreRpcToken; + + resolvingCoreRpcToken = (async () => { + try { + const token = await invoke('core_rpc_token'); + resolvedCoreRpcToken = token?.trim() || null; + didResolveCoreRpcToken = true; + coreRpcLog('core RPC token loaded'); + return resolvedCoreRpcToken; + } catch (err) { + coreRpcError('failed to load core RPC token', err); + resolvedCoreRpcToken = null; + didResolveCoreRpcToken = true; + return null; + } finally { + resolvingCoreRpcToken = null; + } + })(); + + return resolvingCoreRpcToken; +} + export async function getCoreHttpBaseUrl(): Promise { const rpcUrl = await getCoreRpcUrl(); const url = new URL(rpcUrl); @@ -148,12 +184,21 @@ export async function callCoreRpc({ }; try { - const rpcUrl = await getCoreRpcUrl(); + const [rpcUrl, token] = await Promise.all([getCoreRpcUrl(), getCoreRpcToken()]); coreRpcLog('HTTP request', { id: payload.id, method: payload.method }); + if (coreIsTauri() && !token) { + throw new Error('Core RPC token unavailable in Tauri; local RPC auth cannot be satisfied'); + } + + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + const response = await fetch(rpcUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify(payload), }); diff --git a/src/core/auth.rs b/src/core/auth.rs new file mode 100644 index 0000000000..2e2673c589 --- /dev/null +++ b/src/core/auth.rs @@ -0,0 +1,244 @@ +//! Per-process RPC bearer-token authentication. +//! +//! At server startup, [`init_rpc_token`] generates a 256-bit +//! cryptographically-random token, writes it to +//! `{workspace_dir}/core.token` (owner-read-only on Unix), and stores it in a +//! process-global [`OnceLock`]. The Tauri shell reads that file and includes +//! the token in every request as `Authorization: Bearer `. +//! +//! Endpoints exempt from auth (checked by [`rpc_auth_middleware`]): +//! - `GET /` — public info page +//! - `GET /health` — liveness probe +//! - `GET /auth/telegram` — external browser callback (carries its own token) +//! - `GET /schema` — read-only schema discovery +//! - `GET /events` — SSE stream; browser `EventSource` cannot set headers +//! - `GET /events/webhooks` — webhook SSE; same browser constraint +//! - `GET /ws/dictation` — WebSocket upgrade; browser WS API cannot set headers +//! - `OPTIONS *` — CORS preflight (handled by outer CORS middleware) +//! +//! Only `POST /rpc` carries executable commands and requires the bearer token. + +use std::io::Write as _; +use std::path::Path; +use std::sync::OnceLock; + +use axum::http::{header, Method, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +static RPC_TOKEN: OnceLock = OnceLock::new(); + +/// Paths that bypass bearer-token authentication. +/// +/// Only `/rpc` carries executable commands and must be protected. All other +/// routes are read-only, streaming, or WebSocket upgrades whose clients +/// (browser `EventSource`, browser `WebSocket`) cannot set `Authorization` +/// headers via standard APIs. +const PUBLIC_PATHS: &[&str] = &[ + "/", + "/health", + "/auth/telegram", + "/schema", + "/events", + "/events/webhooks", + "/ws/dictation", +]; + +/// The environment variable the Tauri shell sets before spawning the core. +/// +/// When this variable is present the core uses its value as the RPC token +/// (no file I/O needed). When absent (standalone `openhuman core run`) the +/// core generates a token and writes it to `{workspace_dir}/core.token` so +/// CLI clients can authenticate. +pub const CORE_TOKEN_ENV_VAR: &str = "OPENHUMAN_CORE_TOKEN"; + +/// Initialize the per-process RPC token. +/// +/// **Preferred path — Tauri-spawned core**: reads the token from the +/// `OPENHUMAN_CORE_TOKEN` environment variable set by the Tauri shell. No +/// file is written; the token is always available the instant the process +/// starts. +/// +/// **Fallback — standalone CLI**: generates a fresh 256-bit token, writes it +/// to `{workspace_dir}/core.token` (owner-read-only on Unix) for external +/// callers, and stores it in the process global. +/// +/// # Errors +/// +/// Returns an error only in the fallback path, if the token file cannot be +/// written. +pub fn init_rpc_token(workspace_dir: &Path) -> anyhow::Result<()> { + // Idempotency guard: if the token is already set, do nothing. A second + // call must never write a new token to disk while the process still + // validates the original in-memory value — that would cause clients + // reading core.token to start getting 401s immediately. + if RPC_TOKEN.get().is_some() { + log::debug!("[auth] init_rpc_token: already initialized, skipping"); + return Ok(()); + } + + // Fast path: token pre-seeded by the Tauri shell via env var. + if let Ok(env_token) = std::env::var(CORE_TOKEN_ENV_VAR) { + let env_token = env_token.trim().to_string(); + if !env_token.is_empty() { + let _ = RPC_TOKEN.set(env_token); + log::info!("[auth] core RPC token loaded from environment (Tauri-managed)"); + return Ok(()); + } + } + + // Fallback: standalone CLI — generate and write to file. + let token = generate_token(); + let token_path = workspace_dir.join("core.token"); + write_token_file(&token_path, &token)?; + let _ = RPC_TOKEN.set(token); + log::info!( + "[auth] core RPC token generated and written to {}", + token_path.display() + ); + Ok(()) +} + +/// Returns the active RPC token, if initialized. +pub fn get_rpc_token() -> Option<&'static str> { + RPC_TOKEN.get().map(String::as_str) +} + +/// Axum middleware: enforce `Authorization: Bearer ` on all protected +/// endpoints. +/// +/// Public paths (see [`PUBLIC_PATHS`]) and CORS preflight `OPTIONS` requests +/// bypass this check. All other requests must carry the exact bearer token +/// that was written to `core.token` at startup. +pub async fn rpc_auth_middleware(req: axum::extract::Request, next: Next) -> Response { + let path = req.uri().path().to_string(); + + // CORS preflight and public utility paths bypass auth. + if req.method() == Method::OPTIONS || PUBLIC_PATHS.contains(&path.as_str()) { + return next.run(req).await; + } + + let Some(expected) = get_rpc_token() else { + // Shouldn't happen in production — token is always initialized before + // the router starts serving. Deny to be safe. + log::error!("[auth] RPC token not initialized — denying request to {path}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "ok": false, + "error": "server_error", + "message": "Auth subsystem not initialized" + })), + ) + .into_response(); + }; + + let bearer = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if bearer + .strip_prefix("Bearer ") + // NOTE: Plain string comparison is not timing-safe, but acceptable + // here as the RPC server only binds to localhost by default and + // the token is 256 bits, making brute-force timing attacks + // impractical in this threat model. + .is_some_and(|token| token == expected) + { + log::trace!("[auth] authorized request to {path}"); + next.run(req).await + } else { + log::warn!("[auth] unauthorized request to {path} — missing or wrong bearer token"); + ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "ok": false, + "error": "unauthorized", + "message": "Missing or invalid Authorization header. Supply 'Authorization: Bearer '." + })), + ) + .into_response() + } +} + +/// Generate a 256-bit cryptographically-random token as a lowercase hex string. +/// +/// Uses `rand::rng()` (thread-local, OS-seeded CSPRNG) introduced in rand 0.9. +fn generate_token() -> String { + use rand::RngCore as _; + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Write `token` to `path` with owner-only read+write permissions on Unix. +fn write_token_file(path: &Path, token: &str) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(token.as_bytes())?; + } + + #[cfg(not(unix))] + { + std::fs::write(path, token)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_token_produces_64_hex_chars() { + let t = generate_token(); + assert_eq!(t.len(), 64, "256 bits → 64 hex chars"); + assert!(t.chars().all(|c| c.is_ascii_hexdigit()), "must be hex"); + } + + #[test] + fn generate_token_is_not_constant() { + assert_ne!(generate_token(), generate_token()); + } + + #[test] + fn write_and_read_token_roundtrips() { + let tmp = std::env::temp_dir().join(format!("core-auth-test-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let path = tmp.join("core.token"); + let token = "cafebabe1234567890abcdef0123456789abcdef0123456789abcdef01234567"; + write_token_file(&path, token).unwrap(); + let back = std::fs::read_to_string(&path).unwrap(); + assert_eq!(back, token); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[cfg(unix)] + #[test] + fn token_file_has_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let tmp = std::env::temp_dir().join(format!("core-auth-perms-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let path = tmp.join("core.token"); + write_token_file(&path, "abc").unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "token file must be 0o600"); + std::fs::remove_dir_all(&tmp).ok(); + } +} diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 90d5712b68..65f03ee277 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -379,6 +379,11 @@ async fn dictation_ws_handler(ws: WebSocketUpgrade) -> Response { /// /// Includes routes for health, schema, SSE events, JSON-RPC, and Telegram auth. /// Conditionally attaches Socket.IO if enabled. +/// +/// Middleware order (outermost → innermost): +/// 1. `cors_middleware` — handles `OPTIONS` preflight and adds CORS headers +/// 2. `rpc_auth_middleware` — validates `Authorization: Bearer ` on protected paths +/// 3. `http_request_log_middleware` — logs non-RPC HTTP requests with timing pub fn build_core_http_router(socketio_enabled: bool) -> Router { let router = Router::new() .route("/", get(root_handler)) @@ -391,6 +396,7 @@ pub fn build_core_http_router(socketio_enabled: bool) -> Router { .route("/auth/telegram", get(telegram_auth_handler)) .fallback(not_found_handler) .layer(middleware::from_fn(http_request_log_middleware)) + .layer(middleware::from_fn(crate::core::auth::rpc_auth_middleware)) .layer(middleware::from_fn(cors_middleware)) .with_state(AppState { core_version: env!("CARGO_PKG_VERSION").to_string(), @@ -609,6 +615,15 @@ async fn run_server_inner( // Ensure all controllers are registered before starting. let _ = all::all_registered_controllers(); + // Initialize the per-process RPC bearer token. + // Written to {workspace_dir}/core.token so the Tauri shell can read it. + let token_dir = crate::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".openhuman") + }); + crate::core::auth::init_rpc_token(&token_dir)?; + let (resolved_port, port_source) = match port { Some(p) => (p, "CLI --port"), None => ( diff --git a/src/core/mod.rs b/src/core/mod.rs index baec65d3ab..bd299d9ec9 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -8,6 +8,7 @@ use serde::Serialize; pub mod agent_cli; pub mod all; +pub mod auth; pub mod autocomplete_cli_adapter; pub mod cli; pub mod dispatch; diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index eb15b8794e..c4841bd0a9 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -15,9 +15,13 @@ use futures_util::StreamExt; use serde_json::{json, Value}; use tempfile::tempdir; +use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR}; use openhuman_core::core::jsonrpc::build_core_http_router; use openhuman_core::openhuman::memory::all_memory_tree_registered_controllers; +const TEST_RPC_TOKEN: &str = "json-rpc-e2e-local-token"; +static JSON_RPC_AUTH_INIT: OnceLock<()> = OnceLock::new(); + struct EnvVarGuard { key: &'static str, old: Option, @@ -446,6 +450,7 @@ async fn serve_on_ephemeral( SocketAddr, tokio::task::JoinHandle>, ) { + ensure_test_rpc_auth(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); @@ -468,6 +473,7 @@ async fn post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> let url = format!("{}/rpc", rpc_base.trim_end_matches('/')); let resp = client .post(&url) + .header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}")) .json(&body) .send() .await @@ -491,6 +497,7 @@ async fn read_first_sse_event(events_url: &str) -> Value { .expect("client"); let resp = client .get(events_url) + .header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}")) .send() .await .unwrap_or_else(|e| panic!("GET {events_url}: {e}")); @@ -537,6 +544,7 @@ async fn read_sse_event_by_type(events_url: &str, target_event: &str) -> Value { .expect("client"); let resp = client .get(events_url) + .header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}")) .send() .await .unwrap_or_else(|e| panic!("GET {events_url}: {e}")); @@ -664,6 +672,14 @@ enabled = false toml::from_str(&cfg).expect("config toml must match Config schema"); } +fn ensure_test_rpc_auth() { + std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN); + JSON_RPC_AUTH_INIT.get_or_init(|| { + let token_dir = std::env::temp_dir().join("openhuman-json-rpc-e2e-auth"); + init_rpc_token(&token_dir).expect("init rpc auth token for json_rpc_e2e"); + }); +} + #[tokio::test] async fn json_rpc_protocol_auth_and_agent_hello() { let _env_lock = json_rpc_e2e_env_lock(); @@ -2624,3 +2640,143 @@ async fn skills_uninstall_rpc_e2e() { rpc_join.abort(); } + +// --------------------------------------------------------------------------- +// Auth middleware tests +// --------------------------------------------------------------------------- + +/// POST /rpc without any Authorization header → 401 with error=unauthorized. +#[tokio::test] +async fn rpc_rejects_unauthenticated_request() { + let _env_lock = json_rpc_e2e_env_lock(); + ensure_test_rpc_auth(); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let client = reqwest::Client::new(); + + let resp = client + .post(format!("http://{rpc_addr}/rpc")) + .header("Content-Type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"core.ping","params":{}}"#) + .send() + .await + .expect("request"); + + assert_eq!(resp.status(), 401, "missing Authorization must yield 401"); + let body: Value = resp.json().await.expect("json body"); + assert_eq!( + body["error"], "unauthorized", + "error field must be 'unauthorized'" + ); + + rpc_join.abort(); +} + +/// POST /rpc with a syntactically valid but wrong bearer token → 401. +#[tokio::test] +async fn rpc_rejects_wrong_token() { + let _env_lock = json_rpc_e2e_env_lock(); + ensure_test_rpc_auth(); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let client = reqwest::Client::new(); + + let resp = client + .post(format!("http://{rpc_addr}/rpc")) + .header( + AUTHORIZATION, + "Bearer deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ) + .header("Content-Type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"core.ping","params":{}}"#) + .send() + .await + .expect("request"); + + assert_eq!(resp.status(), 401, "wrong token must yield 401"); + let body: Value = resp.json().await.expect("json body"); + assert_eq!(body["error"], "unauthorized"); + + rpc_join.abort(); +} + +/// Every path in PUBLIC_PATHS must bypass the auth middleware — i.e. never +/// return 401 — even without an Authorization header. Some paths return +/// non-2xx for other reasons (missing query params, no WebSocket upgrade +/// headers) so the assertion is `!= 401`, not `.is_success()`. +#[tokio::test] +async fn public_paths_accessible_without_token() { + let _env_lock = json_rpc_e2e_env_lock(); + ensure_test_rpc_auth(); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let client = reqwest::Client::new(); + let base = format!("http://{rpc_addr}"); + + // Paths that return 200 without any extra params. + for path in ["/", "/health", "/schema", "/events/webhooks"] { + let resp = client + .get(format!("{base}{path}")) + .send() + .await + .unwrap_or_else(|e| panic!("GET {path}: {e}")); + assert!( + resp.status().is_success(), + "public path {path} must return 2xx without auth, got {}", + resp.status() + ); + } + + // Paths that bypass auth but return non-2xx for unrelated reasons + // (missing required query params, no WebSocket upgrade headers, etc.). + // The invariant is that the auth middleware does NOT reject them with 401. + for path in ["/auth/telegram", "/events", "/ws/dictation"] { + let resp = client + .get(format!("{base}{path}")) + .send() + .await + .unwrap_or_else(|e| panic!("GET {path}: {e}")); + assert_ne!( + resp.status(), + StatusCode::UNAUTHORIZED, + "public path {path} must not be auth-gated (got {})", + resp.status() + ); + } + + rpc_join.abort(); +} + +/// Simulate an external process using a guessed token — must be rejected. +#[tokio::test] +async fn external_process_with_guessed_token_is_rejected() { + let _env_lock = json_rpc_e2e_env_lock(); + ensure_test_rpc_auth(); // server validates against TEST_RPC_TOKEN + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let client = reqwest::Client::new(); + + // An attacker process trying a plausible-looking token that isn't the real one. + let attacker_token = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + assert_ne!( + attacker_token, TEST_RPC_TOKEN, + "attacker token must differ from real one" + ); + + let resp = client + .post(format!("http://{rpc_addr}/rpc")) + .header(AUTHORIZATION, format!("Bearer {attacker_token}")) + .header("Content-Type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"core.ping","params":{}}"#) + .send() + .await + .expect("request"); + + assert_eq!( + resp.status(), + 401, + "external process with wrong token must be rejected" + ); + + rpc_join.abort(); +} diff --git a/tests/live_routing_e2e.rs b/tests/live_routing_e2e.rs index a07f28deeb..42a7e6e68a 100644 --- a/tests/live_routing_e2e.rs +++ b/tests/live_routing_e2e.rs @@ -20,9 +20,12 @@ use serde_json::{json, Value}; use tempfile::tempdir; use tokio::time::timeout; +use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR}; use openhuman_core::core::jsonrpc::build_core_http_router; static LIVE_E2E_ENV_LOCK: OnceLock> = OnceLock::new(); +static LIVE_RPC_AUTH_INIT: OnceLock<()> = OnceLock::new(); +const TEST_RPC_TOKEN: &str = "live-routing-e2e-local-token"; struct EnvVarGuard { key: &'static str, @@ -96,6 +99,7 @@ async fn post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> let client = reqwest::Client::new(); let resp = client .post(format!("{rpc_base}/rpc")) + .header("Authorization", format!("Bearer {TEST_RPC_TOKEN}")) .json(&json!({ "jsonrpc": "2.0", "id": id, @@ -115,6 +119,7 @@ async fn read_sse_event_by_types(events_url: &str, target_events: &[&str]) -> Va let client = reqwest::Client::new(); let resp = client .get(events_url) + .header("Authorization", format!("Bearer {TEST_RPC_TOKEN}")) .send() .await .unwrap_or_else(|e| panic!("open SSE stream failed: {e}")); @@ -165,6 +170,7 @@ fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value { } async fn serve_rpc() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + ensure_test_rpc_auth(); let app = build_core_http_router(false); let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) .await @@ -178,6 +184,14 @@ async fn serve_rpc() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { (addr, join) } +fn ensure_test_rpc_auth() { + std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN); + LIVE_RPC_AUTH_INIT.get_or_init(|| { + let token_dir = std::env::temp_dir().join("openhuman-live-routing-e2e-auth"); + init_rpc_token(&token_dir).expect("init rpc auth token for live_routing_e2e"); + }); +} + #[tokio::test] #[ignore = "requires live backend URL + valid token"] async fn live_channel_web_chat_routing_cases_trigger_real_backend() {