From 73ba9b215f8818c9cac8ec787d3e8022a1506a9e Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 10 Sep 2026 21:45:43 +0800 Subject: [PATCH 01/10] fix(android): parse Tauri-wrapped minisign keys so in-app APK install can open Android updater called PublicKey::from_base64 on the wrapped tauri.conf pubkey, so install died before installApk. Also keep vault envelope failures from looking like an unconfigured Volcengine ASR provider. Co-authored-by: Cursor --- openless-all/app/src-tauri/Cargo.toml | 3 +- .../app/src-tauri/src/android/updater.rs | 12 +-- .../src-tauri/src/android/updater_logic.rs | 90 +++++++++++++++++++ .../app/src-tauri/src/commands/credentials.rs | 20 ++++- .../src-tauri/src/persistence/credentials.rs | 85 +++++++++++++++--- 5 files changed, 186 insertions(+), 24 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index e886e37a2..13fc610a6 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -33,6 +33,8 @@ serde_json = "1" semver = "1" # OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。 base64 = "0.22" +# Android in-app updater and host tests for Tauri-wrapped minisign pubkey/signature. +minisign-verify = "0.2" sha2 = "0.10" # 讯飞 RTASR/IFASR 签名:signa = Base64(HmacSHA1(MD5(appid + ts), apiKey))。 md-5 = "0.10" @@ -120,7 +122,6 @@ features = ["linux-native-sync-persistent", "crypto-rust"] jni = "0.21" ndk-context = "0.1" tao = "0.35" -minisign-verify = "0.2" [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.5" diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index 24d6f7a8e..94a43bb9d 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -4,13 +4,12 @@ mod android_impl { use std::path::PathBuf; - use minisign_verify::{PublicKey, Signature}; use serde::Deserialize; use tauri::{AppHandle, Emitter}; use crate::android::updater_logic::{ beta_manifest_urls, format_manifest_error, map_abi_to_arch, stable_manifest_urls, - version_is_newer, INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64, + verify_updater_signature, version_is_newer, INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64, }; use crate::commands::{ fetch_latest_beta_release, parse_latest_beta_from_atom, AppUpdateMetadata, @@ -125,14 +124,7 @@ mod android_impl { } fn verify_signature(apk_bytes: &[u8], signature_b64: &str) -> Result<(), String> { - let public_key = PublicKey::from_base64(UPDATER_PUBKEY_B64) - .map_err(|e| format!("parse updater pubkey: {e}"))?; - let signature = Signature::decode(signature_b64.trim()) - .map_err(|e| format!("decode signature: {e}"))?; - public_key - .verify(apk_bytes, &signature, false) - .map_err(|e| format!("signature verify failed: {e}"))?; - Ok(()) + verify_updater_signature(apk_bytes, signature_b64, UPDATER_PUBKEY_B64) } fn updates_cache_dir() -> Result { diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs index 072d7b675..bb90e64c8 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -54,6 +54,52 @@ pub fn format_manifest_error(status: u16, url: &str) -> String { } } +/// Unwrap a Tauri updater blob into minisign text. +/// +/// `plugins.updater.pubkey` and `.sig` files store the minisign file as +/// standard Base64. Desktop `tauri-plugin-updater` base64-decodes first, then +/// calls `PublicKey::decode` / `Signature::decode`. Passing the wrapped pubkey +/// to `PublicKey::from_base64` fails with "Invalid encoding in minisign data" +/// because that API expects the 42-byte key line, not the 114-byte file. +pub fn decode_tauri_minisign_text(encoded: &str) -> Result { + let trimmed = encoded.trim(); + if trimmed.is_empty() { + return Err("empty minisign blob".to_string()); + } + if trimmed.starts_with("untrusted comment:") { + return Ok(trimmed.to_string()); + } + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(trimmed) + .map_err(|e| format!("decode minisign blob: {e}"))?; + String::from_utf8(bytes).map_err(|e| format!("minisign blob is not UTF-8: {e}")) +} + +pub fn parse_updater_public_key(pubkey_b64: &str) -> Result { + let text = decode_tauri_minisign_text(pubkey_b64)?; + minisign_verify::PublicKey::decode(&text).map_err(|e| format!("parse updater pubkey: {e}")) +} + +pub fn parse_updater_signature(signature: &str) -> Result { + let text = decode_tauri_minisign_text(signature)?; + minisign_verify::Signature::decode(&text).map_err(|e| format!("decode signature: {e}")) +} + +/// Verify APK bytes against a Tauri updater signature (prehashed, same as desktop). +pub fn verify_updater_signature( + data: &[u8], + signature: &str, + pubkey_b64: &str, +) -> Result<(), String> { + let public_key = parse_updater_public_key(pubkey_b64)?; + let signature = parse_updater_signature(signature)?; + public_key + .verify(data, &signature, true) + .map_err(|e| format!("signature verify failed: {e}"))?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -152,4 +198,48 @@ mod tests { .expect("plugins.updater.pubkey in tauri.conf.json"); assert_eq!(conf_pubkey, UPDATER_PUBKEY_B64); } + + #[test] + fn updater_pubkey_decodes_to_minisign_file() { + let text = decode_tauri_minisign_text(UPDATER_PUBKEY_B64).expect("decode pubkey"); + assert!( + text.starts_with("untrusted comment: minisign public key:"), + "decoded={text:?}" + ); + let key_line = text.lines().nth(1).expect("minisign pubkey has a key line"); + assert!( + key_line.starts_with("RW"), + "key line should be the minisign RW blob, got {key_line}" + ); + } + + #[test] + fn wrapped_updater_pubkey_is_not_a_raw_42_byte_key() { + let err = minisign_verify::PublicKey::from_base64(UPDATER_PUBKEY_B64) + .expect_err("Tauri-wrapped pubkey must not parse as a raw 42-byte key"); + assert!( + err.to_string().contains("Invalid encoding"), + "unexpected error: {err}" + ); + } + + #[test] + fn updater_pubkey_parses_after_tauri_unwrap() { + parse_updater_public_key(UPDATER_PUBKEY_B64) + .expect("Tauri-wrapped pubkey must parse after unwrap"); + } + + #[test] + fn decode_tauri_minisign_text_passthrough_raw_file() { + let raw = "untrusted comment: minisign public key: ABC\nRWABC"; + assert_eq!(decode_tauri_minisign_text(&format!("{raw}\n")).unwrap(), raw); + assert_eq!(decode_tauri_minisign_text(raw).unwrap(), raw); + } + + #[test] + fn decode_tauri_minisign_text_rejects_garbage() { + assert!(decode_tauri_minisign_text("%%%not-base64%%%").is_err()); + assert!(decode_tauri_minisign_text("").is_err()); + assert!(decode_tauri_minisign_text(" ").is_err()); + } } diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 423aeb8a0..ed4e7b3a7 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -38,6 +38,7 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet Result, > { run_credential_task(|| { + require_readable_vault()?; CredentialsVault::load_metadata().map_err(credential_persistence_error) }) } @@ -57,6 +58,7 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet channel_id: String, ) -> futures_util::future::BoxFuture<'static, Result> { run_credential_task(move || { + require_readable_vault()?; CredentialsVault::channel_has_secrets(kind, &channel_id) .map_err(credential_persistence_error) }) @@ -115,7 +117,10 @@ impl openless_core::CredentialStore for SystemCredentialStore { Result, > { let model_store = self.model_store.clone(); - run_credential_task(move || credentials_status(preferences, model_store.as_deref())) + run_credential_task(move || { + require_readable_vault()?; + credentials_status(preferences, model_store.as_deref()) + }) } fn read( @@ -126,6 +131,7 @@ impl openless_core::CredentialStore for SystemCredentialStore { Result, openless_core::BackendError>, > { run_credential_task(move || { + require_readable_vault()?; read_vault_credential(&key).map(|value| value.map(openless_core::SecretValue::new)) }) } @@ -421,10 +427,20 @@ fn invalid_credential_key(key: &openless_core::CredentialKey) -> openless_core:: fn credential_persistence_error(error: anyhow::Error) -> openless_core::BackendError { openless_core::BackendError::new( openless_core::BackendErrorCode::Persistence, - format!("credential vault operation failed: {error}"), + format!("credential vault operation failed: {error:#}"), ) } +fn require_readable_vault() -> Result<(), openless_core::BackendError> { + match CredentialsVault::last_read_error() { + Some(error) => Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Persistence, + format!("无法读取已保存的凭据:{error}"), + )), + None => Ok(()), + } +} + #[tauri::command] pub async fn get_credentials(core: CoreState<'_>) -> Result { core.get_credentials_status() diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 3b1c019b1..af554e66b 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -99,18 +99,45 @@ fn android_marketplace_legacy_scrubbed() -> &'static Mutex { /// Failed reads remain retryable and must not become a cached empty configuration. /// External Keychain edits take effect on the next app launch. static CREDENTIALS_CACHE: OnceLock>> = OnceLock::new(); +static LAST_VAULT_READ_ERROR: OnceLock>> = OnceLock::new(); +static LAST_VAULT_READ_ERROR_LOGGED: OnceLock>> = OnceLock::new(); fn credentials_cache() -> &'static Mutex> { CREDENTIALS_CACHE.get_or_init(|| Mutex::new(None)) } +fn last_vault_read_error_slot() -> &'static Mutex> { + LAST_VAULT_READ_ERROR.get_or_init(|| Mutex::new(None)) +} + +fn last_vault_read_error_logged_slot() -> &'static Mutex> { + LAST_VAULT_READ_ERROR_LOGGED.get_or_init(|| Mutex::new(None)) +} + fn store_credentials_cache(root: &CredsRoot) { *credentials_cache().lock() = Some(root.clone()); + clear_vault_read_error(); +} + +fn record_vault_read_failure(error: &anyhow::Error) { + let chain = format!("{error:#}"); + *last_vault_read_error_slot().lock() = Some(chain.clone()); + let mut logged = last_vault_read_error_logged_slot().lock(); + if logged.as_deref() != Some(chain.as_str()) { + log::warn!("[vault] credential read failed: {chain}"); + *logged = Some(chain); + } +} + +fn clear_vault_read_error() { + *last_vault_read_error_slot().lock() = None; + *last_vault_read_error_logged_slot().lock() = None; } #[cfg(test)] fn reset_credentials_cache_for_tests() { *credentials_cache().lock() = None; + clear_vault_read_error(); } #[derive(Debug, Serialize, Deserialize, Default, Clone)] @@ -1465,6 +1492,7 @@ fn load_credentials_into_cache_with( match loader() { Ok(root) => { let root = root.unwrap_or_default(); + clear_vault_read_error(); store_credentials_cache(&root); root } @@ -1472,7 +1500,7 @@ fn load_credentials_into_cache_with( // Do not cache the fallback. In particular, a failed legacy-token // scrub must be retried by the next startup/getter call rather than // hidden for the rest of the process. - log::warn!("[vault] credential read failed: {e}"); + record_vault_read_failure(&e); CredsRoot::default() } } @@ -1523,12 +1551,18 @@ fn load_credentials_for_update_raw() -> Result { #[cfg(target_os = "android")] { - let root = match load_android_credentials()? { - Some(root) => root, - None => CredsRoot::default(), - }; - store_credentials_cache(&root); - return Ok(root); + match load_android_credentials() { + Ok(loaded) => { + clear_vault_read_error(); + let root = loaded.unwrap_or_default(); + store_credentials_cache(&root); + return Ok(root); + } + Err(e) => { + record_vault_read_failure(&e); + return Err(e); + } + } } #[cfg(not(target_os = "android"))] @@ -1537,6 +1571,7 @@ fn load_credentials_for_update_raw() -> Result { // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring // entries,避免反复触发 macOS Keychain ACL 弹窗。 remove_legacy_credentials_file_best_effort(); + clear_vault_read_error(); store_credentials_cache(&root); Ok(root) } @@ -1545,11 +1580,15 @@ fn load_credentials_for_update_raw() -> Result { // save_credentials,cache 会被刷新;如果只返回 default root(没 legacy), // 我们这里再显式 cache 一次防御性补一下。 let root = migrate_legacy_sources_for_update()?; + clear_vault_read_error(); store_credentials_cache(&root); Ok(root) } // 错误路径不缓存 —— 同 load_credentials 注释;让下次读重试 keyring。 - Err(e) => Err(e), + Err(e) => { + record_vault_read_failure(&e); + Err(e) + } } } @@ -2121,6 +2160,13 @@ impl CredentialsVault { /// 系统凭据库 service name;macOS 下对应 Keychain service。 pub const SERVICE_NAME: &'static str = "com.openless.app"; + /// Last envelope/keyring read failure, if this process has not successfully + /// loaded credentials since. Distinguishes "vault unreadable" from + /// "user has not configured a provider" (empty default is volcengine). + pub fn last_read_error() -> Option { + last_vault_read_error_slot().lock().clone() + } + pub fn load_metadata() -> Result { let _guard = credentials_lock().lock(); Ok(credential_metadata(&load_credentials_for_update()?)) @@ -2597,7 +2643,7 @@ mod tests { parse_llm_temperature, reset_credentials_cache_for_tests, set_llm_extra_headers_for_provider_in_root, set_llm_temperature_for_provider_in_root, write_account, write_marketplace_github_token, write_omni_account, CredentialAccount, - CredsAsrEntry, CredsLlmEntry, CredsRoot, MarketplaceGithubToken, + CredentialsVault, CredsAsrEntry, CredsLlmEntry, CredsRoot, MarketplaceGithubToken, KEYRING_CHUNK_MAX_UTF16_UNITS, }; use anyhow::anyhow; @@ -3300,11 +3346,24 @@ mod tests { #[test] fn android_startup_failure_does_not_cache_default_or_suppress_retry() { + use anyhow::Context; reset_credentials_cache_for_tests(); - let first = - load_credentials_into_cache_with(|| Err(anyhow!("injected startup scrub failure"))); + let first = load_credentials_into_cache_with(|| { + Err(anyhow!("injected startup scrub failure") + .context("read Android credential envelope")) + }); assert!(lookup_marketplace_github_token(&first).is_none()); assert!(credentials_cache().lock().is_none()); + let first_error = + CredentialsVault::last_read_error().expect("vault error should be recorded"); + assert!( + first_error.contains("injected startup scrub failure"), + "error chain should include the inner cause, got {first_error}" + ); + assert!( + first_error.contains("read Android credential envelope"), + "error chain should include the outer context, got {first_error}" + ); let dir = std::env::temp_dir().join(format!("openless-android-startup-{}", uuid::Uuid::new_v4())); @@ -3314,6 +3373,10 @@ mod tests { assert!(lookup_marketplace_github_token(&second).is_none()); assert!(credentials_cache().lock().is_some()); + assert!( + CredentialsVault::last_read_error().is_none(), + "successful read must clear the last vault error" + ); assert_android_secret_unrecoverable(&path, "gho_legacy_startup_secret"); *credentials_cache().lock() = Some(CredsRoot::default()); let _ = std::fs::remove_dir_all(dir); From 502e4d456ec5f37c4e2af3936a42187ca0f5edf8 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 10 Sep 2026 22:06:09 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(linux-egui):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=A0=B9=E8=AF=81=E4=B9=A6=E6=8C=87=E7=BA=B9=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=B9=B6=E8=AE=A9=20fcitx5=20=E9=97=A8=E7=A6=81=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E5=AE=9E=E9=99=85=E5=AE=89=E8=A3=85=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release Linux egui 在 beta 上因 RemoteInputStatus 缺字段、match 臂类型不一致,以及门禁要求 ensure_fcitx5_ready(?) 而失败;安装调用本身已在监听器之前,错误会展示在 UI 里。 Co-authored-by: Cursor --- openless-all/app/linux-egui/src/main.rs | 37 ++++++++++++++++++- .../scripts/check-linux-public-surface.ps1 | 2 +- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 2aff6bae9..d2e798788 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1645,7 +1645,9 @@ mod linux_app { ui.ctx().copy_text(display); } } - None => ui.label("完整指纹不可用。请勿安装或信任下载的证书。"), + None => { + ui.label("完整指纹不可用。请勿安装或信任下载的证书。"); + } } ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。"); ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。"); @@ -3002,6 +3004,7 @@ mod linux_app { port: 8443, urls: vec!["https://old.example.invalid".into()], urls_stale, + ca_fingerprint_sha256: None, locale: "en".into(), connection_count: 0, active_session_id: None, @@ -3015,6 +3018,38 @@ mod linux_app { } } + #[test] + fn running_remote_status_shows_ca_fingerprint_or_unavailable_warning() { + let mut app = disconnected_app(); + let fingerprint = "ab".repeat(32); + app.remote_access = Some(( + openless_core::RemoteInputStatus { + enabled: true, + running: true, + starting: false, + port: 8443, + urls: vec!["https://phone.example.invalid".into()], + urls_stale: false, + ca_fingerprint_sha256: Some(fingerprint.clone()), + locale: "zh-CN".into(), + connection_count: 0, + active_session_id: None, + }, + "fixture-pin".into(), + )); + let text = rendered_text(|ui| app.remote_ui(ui)); + assert!(text.contains("本机根证书 SHA-256"), "{text}"); + assert!( + text.contains("AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB"), + "{text}" + ); + assert!(!text.contains("完整指纹不可用"), "{text}"); + + app.remote_access.as_mut().unwrap().0.ca_fingerprint_sha256 = None; + let text = rendered_text(|ui| app.remote_ui(ui)); + assert!(text.contains("完整指纹不可用。请勿安装或信任下载的证书。"), "{text}"); + } + #[test] fn continuation_turn_keeps_receiving_output_and_approval() { let mut app = OpenLessEguiApp::new( diff --git a/openless-all/app/scripts/check-linux-public-surface.ps1 b/openless-all/app/scripts/check-linux-public-surface.ps1 index 74be5ed03..3aee13013 100644 --- a/openless-all/app/scripts/check-linux-public-surface.ps1 +++ b/openless-all/app/scripts/check-linux-public-surface.ps1 @@ -55,7 +55,7 @@ if ($backendSource -match 'qa_runtime:\s*None' -or exit 1 } -$installer = $mainSource.IndexOf('ensure_fcitx5_ready(&config)?') +$installer = $mainSource.IndexOf('ensure_fcitx5_ready(&config)') $listener = $mainSource.IndexOf('Fcitx5HotkeyListener::start') if ($installer -lt 0 -or $listener -lt 0 -or $installer -gt $listener) { Write-Error "Linux AppImage fcitx5 installation must run before the hotkey listener" From 1a6dffe62a3c9e8c67d089ad9f245b7408a991b5 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 10 Sep 2026 23:13:09 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(android):=20Keystore=20=E6=9A=82?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E5=8F=AF=E7=94=A8=E6=97=B6=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E8=AE=A9=20Tauri=20setup=20abort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core.start() 会走 configuration_snapshot → load_credentials_for_update;信封读失败若返回 Err,setup hook 直接 SIGABRT。改为记录 last_read_error 并返回未缓存默认值,应用可以启动。 Co-authored-by: Cursor --- .../app/src-tauri/src/mobile_runtime.rs | 15 ++- .../src-tauri/src/persistence/credentials.rs | 98 ++++++++++++++++--- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs index d840a1590..ea1bc6b3c 100644 --- a/openless-all/app/src-tauri/src/mobile_runtime.rs +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -41,7 +41,20 @@ pub fn run() { let core_backend = coordinator.backend(); app.manage(Arc::clone(&core_backend)); coordinator.tauri_host().bind(app.handle().clone()); - let startup = tauri::async_runtime::block_on(core_backend.start())?; + let startup = tauri::async_runtime::block_on(core_backend.start()); + // #region agent log + match &startup { + Ok(snapshot) => log::warn!( + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H1\",\"location\":\"mobile_runtime.rs:setup\",\"message\":\"core start ok\",\"data\":{{\"running\":{}}},\"timestamp\":0}}", + snapshot.backend.running + ), + Err(error) => log::warn!( + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H1\",\"location\":\"mobile_runtime.rs:setup\",\"message\":\"core start failed\",\"data\":{{\"error\":\"{}\"}},\"timestamp\":0}}", + error.to_string().replace('"', "'") + ), + } + // #endregion + let startup = startup?; if !startup.backend.running { return Err("OpenLess Core did not reach the running state".into()); } diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index af554e66b..0d77859df 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -129,6 +129,59 @@ fn record_vault_read_failure(error: &anyhow::Error) { } } +fn agent_debug_ndjson(hypothesis_id: &str, location: &str, message: &str, data: &str) { + // #region agent log + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let line = format!( + "{{\"sessionId\":\"f73b06\",\"hypothesisId\":\"{hypothesis_id}\",\"location\":\"{location}\",\"message\":\"{message}\",\"data\":{data},\"timestamp\":{timestamp}}}" + ); + log::warn!("[agent-dbg] {line}"); + let mut paths = Vec::new(); + paths.push(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../debug-f73b06.log")); + #[cfg(any(target_os = "android", test))] + if let Ok(dir) = super::android_storage::android_log_dir() { + paths.push(dir.join("debug-f73b06.log")); + } + for path in paths { + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + use std::io::Write; + let _ = writeln!(file, "{line}"); + } + } + // #endregion +} + +/// Android setup calls `Core.start()` → `status()` → `configuration_snapshot`, +/// which uses this path. Returning `Err` here aborts the Tauri setup hook. +#[cfg(any(target_os = "android", test))] +fn android_credentials_root_for_update( + loader: impl FnOnce() -> Result>, +) -> Result { + let root = load_credentials_into_cache_with(loader); + // #region agent log + agent_debug_ndjson( + "H2", + "credentials.rs:android_credentials_root_for_update", + "android for-update resolved without surfacing envelope Err", + &format!( + "{{\"cached\":{},\"lastReadError\":{}}}", + credentials_cache().lock().is_some(), + last_vault_read_error_slot() + .lock() + .is_some() + ), + ); + // #endregion + Ok(root) +} + fn clear_vault_read_error() { *last_vault_read_error_slot().lock() = None; *last_vault_read_error_logged_slot().lock() = None; @@ -1501,6 +1554,17 @@ fn load_credentials_into_cache_with( // scrub must be retried by the next startup/getter call rather than // hidden for the rest of the process. record_vault_read_failure(&e); + // #region agent log + agent_debug_ndjson( + "H2", + "credentials.rs:load_credentials_into_cache_with", + "vault loader returned Err; using uncached default", + &format!( + "{{\"hasEnvelopeContext\":{}}}", + format!("{e:#}").contains("read Android credential envelope") + ), + ); + // #endregion CredsRoot::default() } } @@ -1551,18 +1615,7 @@ fn load_credentials_for_update_raw() -> Result { #[cfg(target_os = "android")] { - match load_android_credentials() { - Ok(loaded) => { - clear_vault_read_error(); - let root = loaded.unwrap_or_default(); - store_credentials_cache(&root); - return Ok(root); - } - Err(e) => { - record_vault_read_failure(&e); - return Err(e); - } - } + return android_credentials_root_for_update(load_android_credentials); } #[cfg(not(target_os = "android"))] @@ -2638,7 +2691,7 @@ mod tests { android_persistable_credentials, chunk_json_payload, credentials_cache, get_android_marketplace_token_at, load_android_credentials_from_path, load_android_credentials_from_path_with_crypto, load_credentials_into_cache_with, - lookup_account, lookup_marketplace_github_token, lookup_omni_account, + android_credentials_root_for_update, lookup_account, lookup_marketplace_github_token, lookup_omni_account, omni_extra_headers_json, omni_temperature_string, parse_extra_headers_json, parse_llm_temperature, reset_credentials_cache_for_tests, set_llm_extra_headers_for_provider_in_root, set_llm_temperature_for_provider_in_root, @@ -3382,6 +3435,25 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn android_for_update_path_does_not_fail_startup_on_envelope_error() { + use anyhow::Context; + reset_credentials_cache_for_tests(); + let root = android_credentials_root_for_update(|| { + Err(anyhow!("temporarily unavailable") + .context("Android credential authentication or key operation failed") + .context("read Android credential envelope")) + }) + .expect("startup must receive a default root, not Err"); + assert!(lookup_marketplace_github_token(&root).is_none()); + assert!(credentials_cache().lock().is_none()); + let error = CredentialsVault::last_read_error().expect("vault error should be recorded"); + assert!( + error.contains("temporarily unavailable"), + "error chain should include the Keystore kind, got {error}" + ); + } + #[test] fn parse_llm_temperature_accepts_empty_and_valid_range() { assert_eq!(parse_llm_temperature("").unwrap(), None); From 1ea221fd7febedc932e5994748022edb6e4f7bb6 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 11 Sep 2026 00:17:00 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(android):=20=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E4=BF=A1=E5=B0=81=E6=9A=82=E6=97=B6=E8=AF=BB=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E8=A6=81=E6=8C=A1=E4=BD=8F=202.0=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=8F=A1=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core.start() 在 setup 成功后还会被 event bridge 和 get_startup_snapshot 再调一次;此时 last_read_error 已记下,require_readable_vault 把 Persistence 抛成「暂时无法启动」。握手改为降级空凭据状态继续运行。 Co-authored-by: Cursor --- .../app/crates/openless-core/src/api.rs | 97 ++++++++++++++++++- .../app/src-tauri/src/commands/credentials.rs | 15 ++- 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index e7433ef62..323e9aa86 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3037,11 +3037,29 @@ impl OpenLessBackend { } pub async fn start(&self) -> Result { - let credentials = self - .deps - .credential_store - .status(self.get_preferences()) - .await?; + let preferences = self.get_preferences(); + let credentials = match self.deps.credential_store.status(preferences.clone()).await { + Ok(credentials) => credentials, + Err(error) if error.code == BackendErrorCode::Persistence => { + // Vault unreadable (e.g. Android Keystore temporarily unavailable) + // must not fail the 2.0 handshake. Dictation still gates on read(). + // #region agent log + log::warn!( + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H6\",\"location\":\"api.rs:start\",\"message\":\"start continuing with default credentials after Persistence\",\"data\":{{\"error\":\"{}\"}},\"timestamp\":0}}", + error.to_string().replace('"', "'") + ); + // #endregion + log::warn!("[core] startup credential status unavailable: {error}"); + CredentialsStatus { + pipeline_mode: crate::shared_types::effective_pipeline_mode( + preferences.multimodal_pipeline_enabled, + preferences.pipeline_mode, + ), + ..CredentialsStatus::default() + } + } + Err(error) => return Err(error), + }; let mut state = self.state.write().expect("backend state lock poisoned"); state.credentials = credentials; if state.running { @@ -8588,6 +8606,75 @@ mod tests { ); } + struct PersistenceOnlyCredentialStore; + + impl crate::credentials::CredentialStore for PersistenceOnlyCredentialStore { + fn status( + &self, + _preferences: crate::shared_types::UserPreferences, + ) -> BoxFuture<'static, Result> { + Box::pin(async { + Err(BackendError::new( + BackendErrorCode::Persistence, + "无法读取已保存的凭据:temporarily unavailable", + )) + }) + } + + fn read( + &self, + _key: crate::credentials::CredentialKey, + ) -> BoxFuture<'static, Result, BackendError>> + { + Box::pin(async { Ok(None) }) + } + + fn write( + &self, + _key: crate::credentials::CredentialKey, + _value: crate::credentials::SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + + fn remove( + &self, + _key: crate::credentials::CredentialKey, + ) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + } + + #[tokio::test] + async fn start_survives_persistent_vault_read_failure() { + let data_dir = TestDataDir::new("vault-persistence-start"); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..BackendConfig::default() + }, + BackendDependencies { + host_actions: Arc::new(FakeHost::default()), + text_inserter: Arc::new(FakeInserter), + dictation_engine: Arc::new(FakeEngine), + task_spawner: Arc::new(TokioTaskSpawner), + credential_store: Arc::new(PersistenceOnlyCredentialStore), + services: crate::domains::BackendServices::unsupported(), + local_asr_runtime: None, + marketplace_config: None, + selection_runtime: None, + selection_polisher: None, + qa_runtime: None, + }, + ) + .unwrap(); + let first = backend.start().await.expect("first start must not fail"); + let second = backend.start().await.expect("handshake start must not fail"); + assert!(first.backend.running); + assert!(second.backend.running); + let _ = data_dir; + } + #[tokio::test] async fn front_app_is_captured_without_reading_documents_when_cursor_context_is_disabled() { let data_dir = TestDataDir::new("host-context-privacy"); diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index ed4e7b3a7..209707a1e 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -433,10 +433,17 @@ fn credential_persistence_error(error: anyhow::Error) -> openless_core::BackendE fn require_readable_vault() -> Result<(), openless_core::BackendError> { match CredentialsVault::last_read_error() { - Some(error) => Err(openless_core::BackendError::new( - openless_core::BackendErrorCode::Persistence, - format!("无法读取已保存的凭据:{error}"), - )), + Some(error) => { + // #region agent log + log::warn!( + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H6\",\"location\":\"credentials.rs:require_readable_vault\",\"message\":\"status/read blocked by last vault error\",\"data\":{{\"blocked\":true}},\"timestamp\":0}}" + ); + // #endregion + Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Persistence, + format!("无法读取已保存的凭据:{error}"), + )) + } None => Ok(()), } } From dda5b4f44ffb6896847d2ec2025a6c857d494bbe Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 11 Sep 2026 13:09:39 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(android):=20=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E6=97=B6=E9=87=8D=E8=AF=95=20Keystore?= =?UTF-8?q?=EF=BC=8C=E5=A4=B1=E8=B4=A5=E4=B8=8D=E8=A6=86=E7=9B=96=E7=A9=BA?= =?UTF-8?q?=E4=BF=A1=E5=B0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动时信封暂时读失败后,require_readable_vault 会在真正 load 之前短路,导致添加提供商一直操作失败。改为每次读写都重试 Keystore;读失败返回 Persistence 且不缓存/不写入默认空根;前端展示后端具体错误。 Co-authored-by: Cursor --- .../app/src-tauri/src/commands/credentials.rs | 41 ++++++++--- .../src-tauri/src/persistence/credentials.rs | 70 ++++++++++++------- .../app/src/pages/settings/ChannelList.tsx | 12 +++- 3 files changed, 85 insertions(+), 38 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 209707a1e..7924e0ac5 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -37,10 +37,7 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet 'static, Result, > { - run_credential_task(|| { - require_readable_vault()?; - CredentialsVault::load_metadata().map_err(credential_persistence_error) - }) + run_credential_task(|| after_vault_attempt(CredentialsVault::load_metadata())) } fn save_metadata( @@ -58,9 +55,7 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet channel_id: String, ) -> futures_util::future::BoxFuture<'static, Result> { run_credential_task(move || { - require_readable_vault()?; - CredentialsVault::channel_has_secrets(kind, &channel_id) - .map_err(credential_persistence_error) + after_vault_attempt(CredentialsVault::channel_has_secrets(kind, &channel_id)) }) } } @@ -118,8 +113,7 @@ impl openless_core::CredentialStore for SystemCredentialStore { > { let model_store = self.model_store.clone(); run_credential_task(move || { - require_readable_vault()?; - credentials_status(preferences, model_store.as_deref()) + after_vault_backend(credentials_status(preferences, model_store.as_deref())) }) } @@ -131,8 +125,9 @@ impl openless_core::CredentialStore for SystemCredentialStore { Result, openless_core::BackendError>, > { run_credential_task(move || { - require_readable_vault()?; - read_vault_credential(&key).map(|value| value.map(openless_core::SecretValue::new)) + after_vault_backend( + read_vault_credential(&key).map(|value| value.map(openless_core::SecretValue::new)), + ) }) } @@ -448,6 +443,30 @@ fn require_readable_vault() -> Result<(), openless_core::BackendError> { } } +/// Call after a real vault load/retry. Surfaces the recorded Keystore chain +/// instead of a generic English anyhow mapping, and never short-circuits first. +fn after_vault_attempt( + result: Result, +) -> Result { + after_vault_backend(result.map_err(credential_persistence_error)) +} + +fn after_vault_backend( + result: Result, +) -> Result { + // #region agent log + log::warn!( + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H7\",\"location\":\"credentials.rs:after_vault_backend\",\"message\":\"vault attempt finished\",\"data\":{{\"ok\":{},\"lastReadError\":{}}},\"timestamp\":0}}", + result.is_ok(), + CredentialsVault::last_read_error().is_some() + ); + // #endregion + if CredentialsVault::last_read_error().is_some() { + require_readable_vault()?; + } + result +} + #[tauri::command] pub async fn get_credentials(core: CoreState<'_>) -> Result { core.get_credentials_status() diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 0d77859df..22422388e 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -140,7 +140,9 @@ fn agent_debug_ndjson(hypothesis_id: &str, location: &str, message: &str, data: ); log::warn!("[agent-dbg] {line}"); let mut paths = Vec::new(); - paths.push(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../debug-f73b06.log")); + paths.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../debug-f73b06.log"), + ); #[cfg(any(target_os = "android", test))] if let Ok(dir) = super::android_storage::android_log_dir() { paths.push(dir.join("debug-f73b06.log")); @@ -158,28 +160,40 @@ fn agent_debug_ndjson(hypothesis_id: &str, location: &str, message: &str, data: // #endregion } -/// Android setup calls `Core.start()` → `status()` → `configuration_snapshot`, -/// which uses this path. Returning `Err` here aborts the Tauri setup hook. +/// Mutations must not persist an empty default over an unreadable envelope. +/// Returning `Err` lets Core surface Persistence after a real Keystore retry. #[cfg(any(target_os = "android", test))] fn android_credentials_root_for_update( loader: impl FnOnce() -> Result>, ) -> Result { - let root = load_credentials_into_cache_with(loader); - // #region agent log - agent_debug_ndjson( - "H2", - "credentials.rs:android_credentials_root_for_update", - "android for-update resolved without surfacing envelope Err", - &format!( - "{{\"cached\":{},\"lastReadError\":{}}}", - credentials_cache().lock().is_some(), - last_vault_read_error_slot() - .lock() - .is_some() - ), - ); - // #endregion - Ok(root) + match loader() { + Ok(loaded) => { + let root = loaded.unwrap_or_default(); + clear_vault_read_error(); + store_credentials_cache(&root); + // #region agent log + agent_debug_ndjson( + "H7", + "credentials.rs:android_credentials_root_for_update", + "android for-update loaded envelope", + "{\"ok\":true}", + ); + // #endregion + Ok(root) + } + Err(error) => { + record_vault_read_failure(&error); + // #region agent log + agent_debug_ndjson( + "H7", + "credentials.rs:android_credentials_root_for_update", + "android for-update retried Keystore and still failed", + "{\"ok\":false}", + ); + // #endregion + Err(error) + } + } } fn clear_vault_read_error() { @@ -2688,10 +2702,10 @@ mod tests { #[cfg(not(windows))] use super::load_android_credentials_from_source_with_crypto; use super::{ - android_persistable_credentials, chunk_json_payload, credentials_cache, - get_android_marketplace_token_at, load_android_credentials_from_path, + android_credentials_root_for_update, android_persistable_credentials, chunk_json_payload, + credentials_cache, get_android_marketplace_token_at, load_android_credentials_from_path, load_android_credentials_from_path_with_crypto, load_credentials_into_cache_with, - android_credentials_root_for_update, lookup_account, lookup_marketplace_github_token, lookup_omni_account, + lookup_account, lookup_marketplace_github_token, lookup_omni_account, omni_extra_headers_json, omni_temperature_string, parse_extra_headers_json, parse_llm_temperature, reset_credentials_cache_for_tests, set_llm_extra_headers_for_provider_in_root, set_llm_temperature_for_provider_in_root, @@ -3436,22 +3450,26 @@ mod tests { } #[test] - fn android_for_update_path_does_not_fail_startup_on_envelope_error() { + fn android_for_update_path_retries_and_does_not_cache_on_envelope_error() { use anyhow::Context; reset_credentials_cache_for_tests(); - let root = android_credentials_root_for_update(|| { + let err = android_credentials_root_for_update(|| { Err(anyhow!("temporarily unavailable") .context("Android credential authentication or key operation failed") .context("read Android credential envelope")) }) - .expect("startup must receive a default root, not Err"); - assert!(lookup_marketplace_github_token(&root).is_none()); + .expect_err("mutations must not receive a default root to persist"); assert!(credentials_cache().lock().is_none()); let error = CredentialsVault::last_read_error().expect("vault error should be recorded"); assert!( error.contains("temporarily unavailable"), "error chain should include the Keystore kind, got {error}" ); + let chain = format!("{err:#}"); + assert!( + chain.contains("temporarily unavailable"), + "returned error should include the Keystore kind, got {chain}" + ); } #[test] diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 5807e0b23..590b814cb 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -140,6 +140,12 @@ function modelAccountFor(kind: ChannelKind): string { return kind === 'llm' ? 'ark.model_id' : 'asr.model'; } +function failedOpMessage(error: unknown, fallback: string): string { + const detail = error instanceof Error ? error.message : String(error); + const trimmed = detail.trim(); + return trimmed || fallback; +} + /** * 把后端的错误串压成按钮上放得下的短标签,且要**能指导行动**: * 401 是 key 不对、429 是被限流等会儿再说、超时是网络——用户看到才知道该改什么。 @@ -264,7 +270,11 @@ export function ChannelList({ await refresh(); } catch (error) { console.error('[channels] create failed', error); - emitSaved('failed', t('common.operationFailed')); + const message = failedOpMessage(error, t('common.operationFailed')); + // #region agent log + fetch('http://127.0.0.1:7807/ingest/0e5d9157-0519-49b4-bb72-cb173586e4dc',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'f73b06'},body:JSON.stringify({sessionId:'f73b06',hypothesisId:'H8',location:'ChannelList.tsx:startCreate',message:'createChannel failed',data:{hasDetail:message!==t('common.operationFailed'),prefix:message.slice(0,48)},timestamp:Date.now(),runId:'post-fix'})}).catch(()=>{}); + // #endregion + emitSaved('failed', message); } finally { setCreatingBusy(false); } From 7fbeb34ad25dd348c208fc0baae4a02ff97ab662 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 11 Sep 2026 14:03:28 +0800 Subject: [PATCH 06/10] =?UTF-8?q?debug(android):=20=E6=8A=8A=20Keystore=20?= =?UTF-8?q?=E6=9A=82=E6=97=B6=E4=B8=8D=E5=8F=AF=E7=94=A8=E7=9A=84=E5=85=B7?= =?UTF-8?q?=E4=BD=93=E5=BC=82=E5=B8=B8=E5=90=8D=E5=B8=A6=E5=9B=9E=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加提供商已会重试信封,但 Keystore 仍返回 temporarily unavailable。JNI 原先丢掉 Kotlin 异常与 JNI 失败路径,无法区分 class load、status 3 还是 BackendBusy。 Co-authored-by: Cursor --- .../android/kotlin/OpenLessCredentialVault.kt | 70 +++++++---- openless-all/app/src-tauri/src/android/jni.rs | 114 +++++++++++++++--- .../src/persistence/android_credentials.rs | 8 ++ 3 files changed, 146 insertions(+), 46 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt index e8e8dd1b9..3c4501a77 100644 --- a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt +++ b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt @@ -24,6 +24,14 @@ private fun credentialResponse(status: Byte, payload: ByteArray = byteArrayOf()) return byteArrayOf(status) + payload } +private fun diagnosticResponse(status: Byte, error: Throwable): ByteArray { + if (status != CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) { + return credentialResponse(status) + } + val name = error.javaClass.simpleName.take(96) + return credentialResponse(status, name.toByteArray(Charsets.UTF_8)) +} + internal fun credentialStatusForKeyLoadFailure(error: GeneralSecurityException): Byte { return when (error) { is KeyPermanentlyInvalidatedException -> CREDENTIAL_STATUS_KEY_MISSING @@ -41,18 +49,20 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { OpenLessCredentialCipher.seal(getOrCreateKey(), plaintext, aad), ) } catch (error: KeyPermanentlyInvalidatedException) { - credentialResponse(credentialStatusForKeyLoadFailure(error)) + diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) } catch (error: UnrecoverableKeyException) { // Keystore2 wraps backend-busy and other provider failures in this // broad JCA exception too. Only an absent alias or the explicit // permanent-invalidated exception is safe to treat as data loss. - credentialResponse(credentialStatusForKeyLoadFailure(error)) + diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) } catch (_: IllegalArgumentException) { credentialResponse(CREDENTIAL_STATUS_MALFORMED) - } catch (_: GeneralSecurityException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) - } catch (_: IOException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) + } catch (error: GeneralSecurityException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: IOException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: RuntimeException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) } } @@ -65,19 +75,21 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { OpenLessCredentialCipher.open(key, packet, aad), ) } catch (error: KeyPermanentlyInvalidatedException) { - credentialResponse(credentialStatusForKeyLoadFailure(error)) + diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) } catch (error: UnrecoverableKeyException) { - credentialResponse(credentialStatusForKeyLoadFailure(error)) + diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) } catch (_: AEADBadTagException) { credentialResponse(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) } catch (_: BadPaddingException) { credentialResponse(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) } catch (_: IllegalArgumentException) { credentialResponse(CREDENTIAL_STATUS_MALFORMED) - } catch (_: GeneralSecurityException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) - } catch (_: IOException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) + } catch (error: GeneralSecurityException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: IOException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: RuntimeException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) } } @@ -89,10 +101,12 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { keyStore.deleteEntry(alias) } credentialResponse(CREDENTIAL_STATUS_OK) - } catch (_: GeneralSecurityException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) - } catch (_: IOException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) + } catch (error: GeneralSecurityException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: IOException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: RuntimeException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) } } @@ -103,10 +117,12 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { CREDENTIAL_STATUS_OK, byteArrayOf(if (loadKeyStore().containsAlias(alias)) 1 else 0), ) - } catch (_: GeneralSecurityException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) - } catch (_: IOException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) + } catch (error: GeneralSecurityException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: IOException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: RuntimeException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) } } @@ -116,13 +132,15 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { getOrCreateKey() credentialResponse(CREDENTIAL_STATUS_OK) } catch (error: KeyPermanentlyInvalidatedException) { - credentialResponse(credentialStatusForKeyLoadFailure(error)) + diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) } catch (error: UnrecoverableKeyException) { - credentialResponse(credentialStatusForKeyLoadFailure(error)) - } catch (_: GeneralSecurityException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) - } catch (_: IOException) { - credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) + diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) + } catch (error: GeneralSecurityException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: IOException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: RuntimeException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) } } diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 137d6eee8..ff20a3bcd 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -209,22 +209,58 @@ pub mod android { } } + fn log_keystore_debug(method: &str, kind: &str, detail: &str) { + // #region agent log + let safe: String = detail + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | ':' | '-' | '/') { + ch + } else { + ' ' + } + }) + .take(120) + .collect(); + log::warn!( + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H9\",\"location\":\"jni.rs:keystore\",\"message\":\"keystore call\",\"data\":{{\"method\":\"{method}\",\"kind\":\"{kind}\",\"detail\":\"{safe}\"}},\"timestamp\":0}}" + ); + // #endregion + } + fn keystore_temporarily_unavailable(env: &mut JNIEnv) -> Result { clear_pending_exception(env); Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()) } - fn credential_response(response: Vec) -> Result, String> { + fn credential_response(method: &str, response: Vec) -> Result, String> { let Some((&status, payload)) = response.split_first() else { + log_keystore_debug(method, "empty_response", ""); return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()); }; match status { 0 => Ok(payload.to_vec()), - 1 => Err(KEYSTORE_KEY_MISSING.to_string()), - 2 => Err(KEYSTORE_AUTHENTICATION_FAILED.to_string()), - 3 => Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()), - 4 => Err(KEYSTORE_MALFORMED.to_string()), - _ => Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()), + 1 => { + log_keystore_debug(method, "status", "key_missing"); + Err(KEYSTORE_KEY_MISSING.to_string()) + } + 2 => { + log_keystore_debug(method, "status", "authentication_failed"); + Err(KEYSTORE_AUTHENTICATION_FAILED.to_string()) + } + 3 => { + let detail = String::from_utf8_lossy(payload); + log_keystore_debug(method, "status_temporarily_unavailable", &detail); + Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()) + } + 4 => { + log_keystore_debug(method, "status", "malformed"); + Err(KEYSTORE_MALFORMED.to_string()) + } + _ => { + log_keystore_debug(method, "status_unknown", &status.to_string()); + Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()) + } } } @@ -236,15 +272,24 @@ pub mod android { with_android_env(|env, context| { let class = match load_context_class(env, context, CREDENTIAL_VAULT_CLASS) { Ok(class) => class, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "class_load", &error); + return keystore_temporarily_unavailable(env); + } }; let first_array = match env.byte_array_from_slice(first) { Ok(array) => array, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "jni_array", &error.to_string()); + return keystore_temporarily_unavailable(env); + } }; let second_array = match env.byte_array_from_slice(second) { Ok(array) => array, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "jni_array", &error.to_string()); + return keystore_temporarily_unavailable(env); + } }; let first_object = JObject::from(first_array); let second_object = JObject::from(second_array); @@ -258,21 +303,31 @@ pub mod android { ], ) { Ok(value) => value, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "jni_call", &error.to_string()); + return keystore_temporarily_unavailable(env); + } }; let object = match value.l() { Ok(object) => object, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "jni_object", &error.to_string()); + return keystore_temporarily_unavailable(env); + } }; if object.is_null() { + log_keystore_debug(method, "null_response", ""); return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()); } let array = JByteArray::from(object); let response = match env.convert_byte_array(&array) { Ok(response) => response, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "jni_bytes", &error.to_string()); + return keystore_temporarily_unavailable(env); + } }; - credential_response(response) + credential_response(method, response) }) } @@ -280,25 +335,38 @@ pub mod android { with_android_env(|env, context| { let class = match load_context_class(env, context, CREDENTIAL_VAULT_CLASS) { Ok(class) => class, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "class_load", &error); + return keystore_temporarily_unavailable(env); + } }; let value = match env.call_static_method(class, method, "()[B", &[]) { Ok(value) => value, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "jni_call", &error.to_string()); + return keystore_temporarily_unavailable(env); + } }; let object = match value.l() { Ok(object) => object, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "jni_object", &error.to_string()); + return keystore_temporarily_unavailable(env); + } }; if object.is_null() { + log_keystore_debug(method, "null_response", ""); return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()); } let array = JByteArray::from(object); let response = match env.convert_byte_array(&array) { Ok(response) => response, - Err(_) => return keystore_temporarily_unavailable(env), + Err(error) => { + log_keystore_debug(method, "jni_bytes", &error.to_string()); + return keystore_temporarily_unavailable(env); + } }; - credential_response(response) + credential_response(method, response) }) } @@ -315,14 +383,20 @@ pub mod android { plaintext: &[u8], aad: &[u8], ) -> Result, AndroidKeystoreFailure> { - call_credential_vault_two_arrays("seal", plaintext, aad).map_err(classify_keystore_failure) + call_credential_vault_two_arrays("seal", plaintext, aad).map_err(|error| { + log_keystore_debug("seal", "bridge", &error); + classify_keystore_failure(error) + }) } pub(crate) fn keystore_open( sealed: &[u8], aad: &[u8], ) -> Result, AndroidKeystoreFailure> { - call_credential_vault_two_arrays("open", sealed, aad).map_err(classify_keystore_failure) + call_credential_vault_two_arrays("open", sealed, aad).map_err(|error| { + log_keystore_debug("open", "bridge", &error); + classify_keystore_failure(error) + }) } pub(crate) fn keystore_delete_key() -> Result<(), AndroidKeystoreFailure> { diff --git a/openless-all/app/src-tauri/src/persistence/android_credentials.rs b/openless-all/app/src-tauri/src/persistence/android_credentials.rs index 83530f772..ad9904389 100644 --- a/openless-all/app/src-tauri/src/persistence/android_credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/android_credentials.rs @@ -129,6 +129,14 @@ pub(super) fn read( crypto: &mut impl AndroidCredentialsCrypto, ) -> Result { recover_verified_sanitized_legacy(path)?; + // #region agent log + log::warn!( + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H12\",\"location\":\"android_credentials.rs:read\",\"message\":\"envelope files\",\"data\":{{\"main\":{},\"pending\":{},\"tmp\":{}}},\"timestamp\":0}}", + path.exists(), + verified_v2_temporary_path(path).exists(), + v2_temporary_path(path).exists() + ); + // #endregion recover_verified_v2_temporary(path, crypto)?; let bytes = match fs::read(path) { Ok(bytes) => bytes, From 46ff25135272c6c350df95e31780ecaf2c30e78f Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 11 Sep 2026 14:46:08 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(android):=20=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E7=94=A8=E4=BA=8E=20Cipher=20=E7=9A=84=20Keystore=20=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E6=8C=89=E5=A4=B1=E6=95=88=E4=BF=A1=E5=B0=81=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机 open 稳定抛出 InvalidKeyException(非 UserNotAuthenticated)。继续当成 temporarily unavailable 会让已保存凭据永远读不出来。改为走密钥缺失路径,删除无法解密的信封后可以重新配置提供商。 Co-authored-by: Cursor --- .../android/kotlin/OpenLessCredentialVault.kt | 23 ++++++++++++++++--- .../test/OpenLessCredentialCipherTest.kt | 9 ++++++++ ...roid-credential-keystore-contract.test.mjs | 3 +++ openless-all/app/src-tauri/src/android/jni.rs | 3 ++- .../src/persistence/android_credentials.rs | 5 ++++ 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt index 3c4501a77..e332a8fa2 100644 --- a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt +++ b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt @@ -3,9 +3,11 @@ package com.openless.app import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties +import android.security.keystore.UserNotAuthenticatedException import androidx.annotation.Keep import java.io.IOException import java.security.GeneralSecurityException +import java.security.InvalidKeyException import java.security.KeyStore import java.security.KeyStoreException import java.security.UnrecoverableKeyException @@ -25,10 +27,13 @@ private fun credentialResponse(status: Byte, payload: ByteArray = byteArrayOf()) } private fun diagnosticResponse(status: Byte, error: Throwable): ByteArray { - if (status != CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) { - return credentialResponse(status) + val name = buildString { + append(error.javaClass.simpleName.take(48)) + error.cause?.javaClass?.simpleName?.let { cause -> + append('/') + append(cause.take(48)) + } } - val name = error.javaClass.simpleName.take(96) return credentialResponse(status, name.toByteArray(Charsets.UTF_8)) } @@ -39,6 +44,14 @@ internal fun credentialStatusForKeyLoadFailure(error: GeneralSecurityException): } } +internal fun credentialStatusForCipherKeyFailure(error: InvalidKeyException): Byte { + return when (error) { + is UserNotAuthenticatedException -> CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE + // Cipher cannot use this key, so the envelope cannot be recovered. + else -> CREDENTIAL_STATUS_KEY_MISSING + } +} + /** AndroidKeyStore owner with fixed, secret-free status responses for JNI. */ internal class AndroidKeystoreCredentialVault(private val alias: String) { @Synchronized @@ -55,6 +68,8 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { // broad JCA exception too. Only an absent alias or the explicit // permanent-invalidated exception is safe to treat as data loss. diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) + } catch (error: InvalidKeyException) { + diagnosticResponse(credentialStatusForCipherKeyFailure(error), error) } catch (_: IllegalArgumentException) { credentialResponse(CREDENTIAL_STATUS_MALFORMED) } catch (error: GeneralSecurityException) { @@ -78,6 +93,8 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) } catch (error: UnrecoverableKeyException) { diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) + } catch (error: InvalidKeyException) { + diagnosticResponse(credentialStatusForCipherKeyFailure(error), error) } catch (_: AEADBadTagException) { credentialResponse(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) } catch (_: BadPaddingException) { diff --git a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt index a3d0a9fdd..9d59966c5 100644 --- a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt +++ b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt @@ -2,6 +2,7 @@ package com.openless.app import java.lang.reflect.Modifier import java.security.GeneralSecurityException +import java.security.InvalidKeyException import java.security.UnrecoverableKeyException import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -110,4 +111,12 @@ class OpenLessCredentialCipherTest { credentialStatusForKeyLoadFailure(UnrecoverableKeyException("backend busy")), ) } + + @Test + fun invalidKeyExceptionIsTreatedAsUnrecoverable() { + assertEquals( + CREDENTIAL_STATUS_KEY_MISSING, + credentialStatusForCipherKeyFailure(InvalidKeyException("Keystore operation failed")), + ) + } } diff --git a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs index a2554e9e3..a1b7f1192 100644 --- a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs +++ b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs @@ -79,6 +79,8 @@ if ( for (const pattern of [ /is\s+KeyPermanentlyInvalidatedException\s*->\s*CREDENTIAL_STATUS_KEY_MISSING/, /else\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/, + /fun\s+credentialStatusForCipherKeyFailure/, + /is\s+UserNotAuthenticatedException\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/, ]) { requirePattern(vault, pattern, `Keystore failure classifier is missing ${pattern}`); } @@ -93,6 +95,7 @@ for (const pattern of [ /tamperedCiphertext/, /tamperedAad/, /unrecoverableKeyExceptionRemainsRetryable/, + /invalidKeyExceptionIsTreatedAsUnrecoverable/, ]) { requirePattern(unitTest, pattern, `JVM crypto tests are missing ${pattern}`); } diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index ff20a3bcd..c5f925679 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -241,7 +241,8 @@ pub mod android { match status { 0 => Ok(payload.to_vec()), 1 => { - log_keystore_debug(method, "status", "key_missing"); + let detail = String::from_utf8_lossy(payload); + log_keystore_debug(method, "status_key_missing", &detail); Err(KEYSTORE_KEY_MISSING.to_string()) } 2 => { diff --git a/openless-all/app/src-tauri/src/persistence/android_credentials.rs b/openless-all/app/src-tauri/src/persistence/android_credentials.rs index ad9904389..3e4c0a8c1 100644 --- a/openless-all/app/src-tauri/src/persistence/android_credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/android_credentials.rs @@ -176,6 +176,11 @@ pub(super) fn read( Ok(ReadOutcome::Plaintext(plaintext)) } Err(StoreError::Crypto(CryptoErrorKind::KeyMissingOrInvalidated)) => { + // #region agent log + log::warn!( + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H13\",\"location\":\"android_credentials.rs:read\",\"message\":\"wiping envelope after unusable Keystore key\",\"data\":{{\"hadMain\":true}},\"timestamp\":0}}" + ); + // #endregion // The ciphertext can no longer be recovered. Reset the alias first; // if that is temporarily unavailable, preserve the file for retry. crypto.delete_key().map_err(StoreError::Crypto)?; From 5295e9c8e1d9f91a7b52c32e39d109b8004ff98a Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 11 Sep 2026 15:27:53 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(android):=20=E5=87=AD=E6=8D=AE=20Keys?= =?UTF-8?q?tore=20=E5=86=99=E5=85=A5=E6=94=B9=E8=B5=B0=E4=B8=BB=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E5=B9=B6=E6=8D=A2=E7=94=A8=20v3=20=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清掉失效信封后,增加提供商仍在 seal 上失败(ProviderException/KeyStoreException)。HyperOS 上 Keystore2 从 JNI 后台线程调用常失败;改为主线程执行,并换用未损坏的 v3 别名,失败时删除别名再生成一次。 Co-authored-by: Cursor --- .../android/kotlin/OpenLessCredentialVault.kt | 108 +++++++++++++++--- ...roid-credential-keystore-contract.test.mjs | 2 + 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt index e332a8fa2..197901f4b 100644 --- a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt +++ b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt @@ -1,5 +1,7 @@ package com.openless.app +import android.os.Handler +import android.os.Looper import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties @@ -11,6 +13,9 @@ import java.security.InvalidKeyException import java.security.KeyStore import java.security.KeyStoreException import java.security.UnrecoverableKeyException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import javax.crypto.AEADBadTagException import javax.crypto.BadPaddingException import javax.crypto.KeyGenerator @@ -28,15 +33,39 @@ private fun credentialResponse(status: Byte, payload: ByteArray = byteArrayOf()) private fun diagnosticResponse(status: Byte, error: Throwable): ByteArray { val name = buildString { - append(error.javaClass.simpleName.take(48)) + append(error.javaClass.simpleName.take(40)) error.cause?.javaClass?.simpleName?.let { cause -> append('/') - append(cause.take(48)) + append(cause.take(40)) } + keystoreNumericCode(error)?.let { code -> + append(':') + append(code) + } + append(':') + append(if (Looper.myLooper() == Looper.getMainLooper()) "main" else "bg") } return credentialResponse(status, name.toByteArray(Charsets.UTF_8)) } +private fun keystoreNumericCode(error: Throwable): Int? { + var current: Throwable? = error + while (current != null) { + try { + for (methodName in arrayOf("getNumericErrorCode", "getErrorCode")) { + val method = + current.javaClass.methods.firstOrNull { it.name == methodName && it.parameterCount == 0 } + ?: continue + when (val value = method.invoke(current)) { + is Int -> return value + } + } + } catch (_: Throwable) {} + current = current.cause + } + return null +} + internal fun credentialStatusForKeyLoadFailure(error: GeneralSecurityException): Byte { return when (error) { is KeyPermanentlyInvalidatedException -> CREDENTIAL_STATUS_KEY_MISSING @@ -56,11 +85,20 @@ internal fun credentialStatusForCipherKeyFailure(error: InvalidKeyException): By internal class AndroidKeystoreCredentialVault(private val alias: String) { @Synchronized fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray { + val first = sealOnce(plaintext, aad, recreate = false) + if (first.first() == CREDENTIAL_STATUS_OK || first.first() == CREDENTIAL_STATUS_MALFORMED) { + return first + } + return sealOnce(plaintext, aad, recreate = true) + } + + private fun sealOnce(plaintext: ByteArray, aad: ByteArray, recreate: Boolean): ByteArray { return try { - credentialResponse( - CREDENTIAL_STATUS_OK, - OpenLessCredentialCipher.seal(getOrCreateKey(), plaintext, aad), - ) + if (recreate) { + deleteEntryQuiet() + } + val key = if (recreate) createKey() else getOrCreateKey() + credentialResponse(CREDENTIAL_STATUS_OK, OpenLessCredentialCipher.seal(key, plaintext, aad)) } catch (error: KeyPermanentlyInvalidatedException) { diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) } catch (error: UnrecoverableKeyException) { @@ -175,6 +213,11 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { existingKey()?.let { return it } + return createKey() + } + + @Throws(GeneralSecurityException::class, IOException::class) + private fun createKey(): SecretKey { val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) generator.init( KeyGenParameterSpec.Builder( @@ -190,6 +233,15 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { return generator.generateKey() } + private fun deleteEntryQuiet() { + try { + val keyStore = loadKeyStore() + if (keyStore.containsAlias(alias)) { + keyStore.deleteEntry(alias) + } + } catch (_: Exception) {} + } + @Throws(KeyStoreException::class, IOException::class, GeneralSecurityException::class) private fun loadKeyStore(): KeyStore { return KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } @@ -202,19 +254,49 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { @Keep object OpenLessCredentialVault { - private const val KEY_ALIAS = "com.openless.app.credentials.v2" - private const val MIGRATION_MARKER_ALIAS = "com.openless.app.credentials.v2.migrated" + // v2 alias on this HyperOS device became unusable (InvalidKeyException / + // ProviderException). v3 is a fresh Keystore2 slot after envelope wipe. + private const val KEY_ALIAS = "com.openless.app.credentials.v3" + private const val MIGRATION_MARKER_ALIAS = "com.openless.app.credentials.v3.migrated" private val backend = AndroidKeystoreCredentialVault(KEY_ALIAS) private val migrationMarker = AndroidKeystoreCredentialVault(MIGRATION_MARKER_ALIAS) @JvmStatic - fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray = backend.seal(plaintext, aad) + fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray = runOnMain { backend.seal(plaintext, aad) } + + @JvmStatic fun open(packet: ByteArray, aad: ByteArray): ByteArray = runOnMain { backend.open(packet, aad) } - @JvmStatic fun open(packet: ByteArray, aad: ByteArray): ByteArray = backend.open(packet, aad) + @JvmStatic fun deleteKey(): ByteArray = runOnMain { backend.deleteKey() } - @JvmStatic fun deleteKey(): ByteArray = backend.deleteKey() + @JvmStatic fun migrationComplete(): ByteArray = runOnMain { migrationMarker.keyExists() } - @JvmStatic fun migrationComplete(): ByteArray = migrationMarker.keyExists() + @JvmStatic fun markMigrationComplete(): ByteArray = runOnMain { migrationMarker.ensureKey() } - @JvmStatic fun markMigrationComplete(): ByteArray = migrationMarker.ensureKey() + private fun runOnMain(block: () -> ByteArray): ByteArray { + if (Looper.myLooper() == Looper.getMainLooper()) { + return block() + } + val result = arrayOfNulls(1) + val error = arrayOfNulls(1) + val latch = CountDownLatch(1) + Handler(Looper.getMainLooper()).post { + try { + result[0] = block() + } catch (thrown: Throwable) { + error[0] = thrown + } finally { + latch.countDown() + } + } + if (!latch.await(8, TimeUnit.SECONDS)) { + return diagnosticResponse( + CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, + TimeoutException("keystore-main-timeout"), + ) + } + error[0]?.let { thrown -> + return diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, thrown) + } + return result[0] ?: credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) + } } diff --git a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs index a1b7f1192..de47bf2bf 100644 --- a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs +++ b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs @@ -81,6 +81,8 @@ for (const pattern of [ /else\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/, /fun\s+credentialStatusForCipherKeyFailure/, /is\s+UserNotAuthenticatedException\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/, + /com\.openless\.app\.credentials\.v3/, + /runOnMain/, ]) { requirePattern(vault, pattern, `Keystore failure classifier is missing ${pattern}`); } From ee0761ea8fa8edcda5393acb86e0114c6a974e04 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 11 Sep 2026 16:39:35 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(android):=20KeyMint=20AES-GCM=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E5=9B=9E=E9=80=80=E5=88=B0=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=A7=81=E6=9C=89=E8=BD=AF=E4=BB=B6=E5=AF=86=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HyperOS 上 seal 稳定返回 KeyStoreException 10,主线程与 v3 别名仍无法写信封。Keystore 不可用时改用 UID 私有软件 AES-GCM,避免增加提供商失败。 Co-authored-by: Cursor --- .../android/kotlin/OpenLessCredentialVault.kt | 204 +++++++++++++++++- .../test/OpenLessCredentialCipherTest.kt | 40 ++++ ...roid-credential-keystore-contract.test.mjs | 5 + openless-all/app/src-tauri/src/android/jni.rs | 5 +- .../src/persistence/android_credentials.rs | 13 +- 5 files changed, 259 insertions(+), 8 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt index 197901f4b..d3267b12f 100644 --- a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt +++ b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt @@ -7,11 +7,13 @@ import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties import android.security.keystore.UserNotAuthenticatedException import androidx.annotation.Keep +import java.io.File import java.io.IOException import java.security.GeneralSecurityException import java.security.InvalidKeyException import java.security.KeyStore import java.security.KeyStoreException +import java.security.SecureRandom import java.security.UnrecoverableKeyException import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -20,6 +22,7 @@ import javax.crypto.AEADBadTagException import javax.crypto.BadPaddingException import javax.crypto.KeyGenerator import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec internal const val CREDENTIAL_STATUS_OK: Byte = 0 internal const val CREDENTIAL_STATUS_KEY_MISSING: Byte = 1 @@ -252,6 +255,142 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { } } +/** + * App-private AES-GCM wrapping key used when AndroidKeyStore/KeyMint rejects + * AES-GCM (observed as KeyStoreException numeric 10 on some HyperOS devices). + * The raw key is UID-scoped, same as the envelope file; it is not hardware-backed. + */ +internal class SoftwareAesCredentialStore(private val directory: File) { + fun keyExists(): Boolean { + val file = keyFile() + return file.isFile && file.length() == KEY_BYTES.toLong() + } + + fun isMigrated(): Boolean = migratedFile().isFile || keyExists() + + fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray { + return try { + val key = loadOrCreateKey() + credentialResponse(CREDENTIAL_STATUS_OK, OpenLessCredentialCipher.seal(key, plaintext, aad)) + } catch (_: IllegalArgumentException) { + credentialResponse(CREDENTIAL_STATUS_MALFORMED) + } catch (error: GeneralSecurityException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: IOException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: RuntimeException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } + } + + fun open(packet: ByteArray, aad: ByteArray): ByteArray { + return try { + val key = loadExistingKey() ?: return credentialResponse(CREDENTIAL_STATUS_KEY_MISSING) + credentialResponse( + CREDENTIAL_STATUS_OK, + OpenLessCredentialCipher.open(key, packet, aad), + ) + } catch (_: AEADBadTagException) { + credentialResponse(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) + } catch (_: BadPaddingException) { + credentialResponse(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) + } catch (_: IllegalArgumentException) { + credentialResponse(CREDENTIAL_STATUS_MALFORMED) + } catch (error: GeneralSecurityException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: IOException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: RuntimeException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } + } + + fun deleteKey(): ByteArray { + return try { + deleteIfPresent(keyFile()) + deleteIfPresent(migratedFile()) + credentialResponse(CREDENTIAL_STATUS_OK) + } catch (error: IOException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: RuntimeException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } + } + + fun markMigrated(): ByteArray { + return try { + if (!directory.exists() && !directory.mkdirs() && !directory.isDirectory) { + throw IOException("software-migrated-dir") + } + migratedFile().writeBytes(byteArrayOf(1)) + restrictPrivate(migratedFile()) + credentialResponse(CREDENTIAL_STATUS_OK) + } catch (error: IOException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } catch (error: RuntimeException) { + diagnosticResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, error) + } + } + + private fun keyFile() = File(directory, SOFTWARE_KEY_NAME) + + private fun migratedFile() = File(directory, SOFTWARE_MIGRATED_NAME) + + @Throws(GeneralSecurityException::class, IOException::class) + private fun loadExistingKey(): SecretKey? { + val file = keyFile() + if (!file.isFile) { + return null + } + val raw = file.readBytes() + if (raw.size != KEY_BYTES) { + throw GeneralSecurityException("software-key-size") + } + return SecretKeySpec(raw, "AES") + } + + @Throws(GeneralSecurityException::class, IOException::class) + private fun loadOrCreateKey(): SecretKey { + loadExistingKey()?.let { + return it + } + if (!directory.exists() && !directory.mkdirs() && !directory.isDirectory) { + throw IOException("software-key-dir") + } + val raw = ByteArray(KEY_BYTES) + SecureRandom().nextBytes(raw) + val file = keyFile() + val tmp = File(directory, "$SOFTWARE_KEY_NAME.tmp") + tmp.writeBytes(raw) + if (!tmp.renameTo(file)) { + tmp.delete() + return loadExistingKey() ?: throw IOException("software-key-install") + } + restrictPrivate(file) + return SecretKeySpec(raw, "AES") + } + + @Throws(IOException::class) + private fun deleteIfPresent(file: File) { + if (file.exists() && !file.delete()) { + throw IOException("software-key-delete") + } + } + + private fun restrictPrivate(file: File) { + file.setReadable(false, false) + file.setWritable(false, false) + file.setReadable(true, true) + file.setWritable(true, true) + } + + companion object { + const val SOFTWARE_KEY_NAME = "credentials.sw.key" + const val SOFTWARE_MIGRATED_NAME = "credentials.sw.migrated" + const val KEY_BYTES = 32 + } +} + @Keep object OpenLessCredentialVault { // v2 alias on this HyperOS device became unusable (InvalidKeyException / @@ -262,15 +401,70 @@ object OpenLessCredentialVault { private val migrationMarker = AndroidKeystoreCredentialVault(MIGRATION_MARKER_ALIAS) @JvmStatic - fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray = runOnMain { backend.seal(plaintext, aad) } + fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray = + runOnMain { + val software = softwareStore() + if (software?.keyExists() == true) { + return@runOnMain software.seal(plaintext, aad) + } + val keystore = backend.seal(plaintext, aad) + if ( + keystore.first() == CREDENTIAL_STATUS_OK || + keystore.first() == CREDENTIAL_STATUS_MALFORMED + ) { + return@runOnMain keystore + } + val fallback = software?.seal(plaintext, aad) ?: return@runOnMain keystore + if (fallback.first() == CREDENTIAL_STATUS_OK) fallback else keystore + } + + @JvmStatic + fun open(packet: ByteArray, aad: ByteArray): ByteArray = + runOnMain { + val software = softwareStore() + if (software?.keyExists() == true) { + return@runOnMain software.open(packet, aad) + } + backend.open(packet, aad) + } - @JvmStatic fun open(packet: ByteArray, aad: ByteArray): ByteArray = runOnMain { backend.open(packet, aad) } + @JvmStatic + fun deleteKey(): ByteArray = + runOnMain { + val software = softwareStore()?.deleteKey() ?: credentialResponse(CREDENTIAL_STATUS_OK) + val keystore = backend.deleteKey() + if (software.first() == CREDENTIAL_STATUS_OK) keystore else software + } - @JvmStatic fun deleteKey(): ByteArray = runOnMain { backend.deleteKey() } + @JvmStatic + fun migrationComplete(): ByteArray = + runOnMain { + val software = softwareStore() + if (software?.isMigrated() == true) { + credentialResponse(CREDENTIAL_STATUS_OK, byteArrayOf(1)) + } else { + migrationMarker.keyExists() + } + } - @JvmStatic fun migrationComplete(): ByteArray = runOnMain { migrationMarker.keyExists() } + @JvmStatic + fun markMigrationComplete(): ByteArray = + runOnMain { + val software = softwareStore() + if (software?.keyExists() == true) { + return@runOnMain software.markMigrated() + } + val keystore = migrationMarker.ensureKey() + if (keystore.first() == CREDENTIAL_STATUS_OK) { + return@runOnMain keystore + } + software?.markMigrated() ?: keystore + } - @JvmStatic fun markMigrationComplete(): ByteArray = runOnMain { migrationMarker.ensureKey() } + private fun softwareStore(): SoftwareAesCredentialStore? { + val context = OpenLessAppContext.context ?: return null + return SoftwareAesCredentialStore(File(context.filesDir, "OpenLess")) + } private fun runOnMain(block: () -> ByteArray): ByteArray { if (Looper.myLooper() == Looper.getMainLooper()) { diff --git a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt index 9d59966c5..57ac51eb6 100644 --- a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt +++ b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt @@ -1,5 +1,6 @@ package com.openless.app +import java.io.File import java.lang.reflect.Modifier import java.security.GeneralSecurityException import java.security.InvalidKeyException @@ -119,4 +120,43 @@ class OpenLessCredentialCipherTest { credentialStatusForCipherKeyFailure(InvalidKeyException("Keystore operation failed")), ) } + + @Test + fun softwareAesRoundTripWithoutAndroidKeyStore() { + val dir = File.createTempFile("ol-sw-aes", "dir") + assertTrue(dir.delete()) + assertTrue(dir.mkdirs()) + try { + val store = SoftwareAesCredentialStore(dir) + val plaintext = "credential-secret".toByteArray() + val aad = "format-version-account".toByteArray() + val sealed = store.seal(plaintext, aad) + assertEquals(CREDENTIAL_STATUS_OK, sealed.first()) + val packet = sealed.copyOfRange(1, sealed.size) + val opened = store.open(packet, aad) + assertEquals(CREDENTIAL_STATUS_OK, opened.first()) + assertArrayEquals(plaintext, opened.copyOfRange(1, opened.size)) + assertTrue(File(dir, SoftwareAesCredentialStore.SOFTWARE_KEY_NAME).isFile) + assertEquals(CREDENTIAL_STATUS_OK, store.markMigrated().first()) + assertTrue(store.isMigrated()) + } finally { + dir.deleteRecursively() + } + } + + @Test + fun softwareAesMissingKeyIsReportedAsMissing() { + val dir = File.createTempFile("ol-sw-aes-missing", "dir") + assertTrue(dir.delete()) + assertTrue(dir.mkdirs()) + try { + val store = SoftwareAesCredentialStore(dir) + assertEquals( + CREDENTIAL_STATUS_KEY_MISSING, + store.open(byteArrayOf(12) + ByteArray(12 + 16), "aad".toByteArray()).first(), + ) + } finally { + dir.deleteRecursively() + } + } } diff --git a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs index de47bf2bf..7cc1f0ed9 100644 --- a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs +++ b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs @@ -83,6 +83,9 @@ for (const pattern of [ /is\s+UserNotAuthenticatedException\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/, /com\.openless\.app\.credentials\.v3/, /runOnMain/, + /class SoftwareAesCredentialStore/, + /credentials\.sw\.key/, + /SecretKeySpec/, ]) { requirePattern(vault, pattern, `Keystore failure classifier is missing ${pattern}`); } @@ -98,6 +101,8 @@ for (const pattern of [ /tamperedAad/, /unrecoverableKeyExceptionRemainsRetryable/, /invalidKeyExceptionIsTreatedAsUnrecoverable/, + /softwareAesRoundTripWithoutAndroidKeyStore/, + /softwareAesMissingKeyIsReportedAsMissing/, ]) { requirePattern(unitTest, pattern, `JVM crypto tests are missing ${pattern}`); } diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index c5f925679..907472ee0 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -239,7 +239,10 @@ pub mod android { return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()); }; match status { - 0 => Ok(payload.to_vec()), + 0 => { + log_keystore_debug(method, "status_ok", ""); + Ok(payload.to_vec()) + } 1 => { let detail = String::from_utf8_lossy(payload); log_keystore_debug(method, "status_key_missing", &detail); diff --git a/openless-all/app/src-tauri/src/persistence/android_credentials.rs b/openless-all/app/src-tauri/src/persistence/android_credentials.rs index 3e4c0a8c1..2fa8005e5 100644 --- a/openless-all/app/src-tauri/src/persistence/android_credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/android_credentials.rs @@ -131,10 +131,13 @@ pub(super) fn read( recover_verified_sanitized_legacy(path)?; // #region agent log log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H12\",\"location\":\"android_credentials.rs:read\",\"message\":\"envelope files\",\"data\":{{\"main\":{},\"pending\":{},\"tmp\":{}}},\"timestamp\":0}}", + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H12\",\"location\":\"android_credentials.rs:read\",\"message\":\"envelope files\",\"data\":{{\"main\":{},\"pending\":{},\"tmp\":{},\"software\":{}}},\"timestamp\":0}}", path.exists(), verified_v2_temporary_path(path).exists(), - v2_temporary_path(path).exists() + v2_temporary_path(path).exists(), + path.parent() + .map(|parent| parent.join("credentials.sw.key").is_file()) + .unwrap_or(false) ); // #endregion recover_verified_v2_temporary(path, crypto)?; @@ -560,6 +563,12 @@ impl AndroidCredentialsCrypto for AndroidKeystoreCrypto { ) -> std::result::Result { let packet = crate::android::jni::android::keystore_seal(plaintext, aad) .map_err(map_keystore_failure)?; + // #region agent log + log::warn!( + "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H14\",\"location\":\"android_credentials.rs:seal\",\"message\":\"keystore_seal ok\",\"data\":{{\"packetLen\":{}}},\"timestamp\":0}}", + packet.len() + ); + // #endregion split_packet(&packet) } From 3bae9d9b7e52afd7cf01425dee03561d190d2896 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 13 Sep 2026 00:30:49 +0800 Subject: [PATCH 10/10] fix(android): preserve credentials across key fallback --- .../android/kotlin/OpenLessCredentialVault.kt | 114 ++++++++++++------ ...OpenLessCredentialVaultInstrumentedTest.kt | 44 +++++++ .../test/OpenLessCredentialCipherTest.kt | 52 ++++++++ .../app/crates/openless-core/src/api.rs | 6 - ...roid-credential-keystore-contract.test.mjs | 86 +++++++++++-- openless-all/app/src-tauri/src/android/jni.rs | 64 +++++----- .../app/src-tauri/src/commands/credentials.rs | 22 +--- .../app/src-tauri/src/mobile_runtime.rs | 15 +-- .../src/persistence/android_credentials.rs | 22 ---- .../src-tauri/src/persistence/credentials.rs | 58 --------- .../app/src/pages/settings/ChannelList.tsx | 3 - 11 files changed, 285 insertions(+), 201 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt index d3267b12f..40eb4e075 100644 --- a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt +++ b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt @@ -8,6 +8,7 @@ import android.security.keystore.KeyProperties import android.security.keystore.UserNotAuthenticatedException import androidx.annotation.Keep import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.security.GeneralSecurityException import java.security.InvalidKeyException @@ -34,6 +35,32 @@ private fun credentialResponse(status: Byte, payload: ByteArray = byteArrayOf()) return byteArrayOf(status) + payload } +internal fun credentialOpenWithFallback(vararg attempts: () -> ByteArray): ByteArray { + var temporarilyUnavailable: ByteArray? = null + var malformed: ByteArray? = null + var authenticationFailed: ByteArray? = null + for (attempt in attempts) { + val response = attempt() + when (response.firstOrNull()) { + CREDENTIAL_STATUS_OK -> return response + CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE -> + temporarilyUnavailable = temporarilyUnavailable ?: response + CREDENTIAL_STATUS_MALFORMED -> malformed = malformed ?: response + CREDENTIAL_STATUS_AUTHENTICATION_FAILED -> + authenticationFailed = authenticationFailed ?: response + CREDENTIAL_STATUS_KEY_MISSING -> {} + else -> + temporarilyUnavailable = + temporarilyUnavailable + ?: credentialResponse(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) + } + } + return temporarilyUnavailable + ?: malformed + ?: authenticationFailed + ?: credentialResponse(CREDENTIAL_STATUS_KEY_MISSING) +} + private fun diagnosticResponse(status: Byte, error: Throwable): ByteArray { val name = buildString { append(error.javaClass.simpleName.take(40)) @@ -88,20 +115,11 @@ internal fun credentialStatusForCipherKeyFailure(error: InvalidKeyException): By internal class AndroidKeystoreCredentialVault(private val alias: String) { @Synchronized fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray { - val first = sealOnce(plaintext, aad, recreate = false) - if (first.first() == CREDENTIAL_STATUS_OK || first.first() == CREDENTIAL_STATUS_MALFORMED) { - return first - } - return sealOnce(plaintext, aad, recreate = true) - } - - private fun sealOnce(plaintext: ByteArray, aad: ByteArray, recreate: Boolean): ByteArray { return try { - if (recreate) { - deleteEntryQuiet() - } - val key = if (recreate) createKey() else getOrCreateKey() - credentialResponse(CREDENTIAL_STATUS_OK, OpenLessCredentialCipher.seal(key, plaintext, aad)) + credentialResponse( + CREDENTIAL_STATUS_OK, + OpenLessCredentialCipher.seal(getOrCreateKey(), plaintext, aad), + ) } catch (error: KeyPermanentlyInvalidatedException) { diagnosticResponse(credentialStatusForKeyLoadFailure(error), error) } catch (error: UnrecoverableKeyException) { @@ -236,15 +254,6 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { return generator.generateKey() } - private fun deleteEntryQuiet() { - try { - val keyStore = loadKeyStore() - if (keyStore.containsAlias(alias)) { - keyStore.deleteEntry(alias) - } - } catch (_: Exception) {} - } - @Throws(KeyStoreException::class, IOException::class, GeneralSecurityException::class) private fun loadKeyStore(): KeyStore { return KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } @@ -266,7 +275,7 @@ internal class SoftwareAesCredentialStore(private val directory: File) { return file.isFile && file.length() == KEY_BYTES.toLong() } - fun isMigrated(): Boolean = migratedFile().isFile || keyExists() + fun isMigrated(): Boolean = migratedFile().isFile fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray { return try { @@ -361,12 +370,20 @@ internal class SoftwareAesCredentialStore(private val directory: File) { SecureRandom().nextBytes(raw) val file = keyFile() val tmp = File(directory, "$SOFTWARE_KEY_NAME.tmp") - tmp.writeBytes(raw) - if (!tmp.renameTo(file)) { - tmp.delete() - return loadExistingKey() ?: throw IOException("software-key-install") + try { + FileOutputStream(tmp).use { output -> + output.write(raw) + output.fd.sync() + } + restrictPrivate(tmp) + if (!tmp.renameTo(file)) { + return loadExistingKey() ?: throw IOException("software-key-install") + } + } finally { + if (tmp.exists()) { + tmp.delete() + } } - restrictPrivate(file) return SecretKeySpec(raw, "AES") } @@ -397,8 +414,13 @@ object OpenLessCredentialVault { // ProviderException). v3 is a fresh Keystore2 slot after envelope wipe. private const val KEY_ALIAS = "com.openless.app.credentials.v3" private const val MIGRATION_MARKER_ALIAS = "com.openless.app.credentials.v3.migrated" + private const val LEGACY_KEY_ALIAS = "com.openless.app.credentials.v2" + private const val LEGACY_MIGRATION_MARKER_ALIAS = "com.openless.app.credentials.v2.migrated" private val backend = AndroidKeystoreCredentialVault(KEY_ALIAS) private val migrationMarker = AndroidKeystoreCredentialVault(MIGRATION_MARKER_ALIAS) + private val legacyBackend = AndroidKeystoreCredentialVault(LEGACY_KEY_ALIAS) + private val legacyMigrationMarker = + AndroidKeystoreCredentialVault(LEGACY_MIGRATION_MARKER_ALIAS) @JvmStatic fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray = @@ -422,10 +444,14 @@ object OpenLessCredentialVault { fun open(packet: ByteArray, aad: ByteArray): ByteArray = runOnMain { val software = softwareStore() - if (software?.keyExists() == true) { - return@runOnMain software.open(packet, aad) - } - backend.open(packet, aad) + credentialOpenWithFallback( + { + software?.open(packet, aad) + ?: credentialResponse(CREDENTIAL_STATUS_KEY_MISSING) + }, + { backend.open(packet, aad) }, + { legacyBackend.open(packet, aad) }, + ) } @JvmStatic @@ -433,7 +459,17 @@ object OpenLessCredentialVault { runOnMain { val software = softwareStore()?.deleteKey() ?: credentialResponse(CREDENTIAL_STATUS_OK) val keystore = backend.deleteKey() - if (software.first() == CREDENTIAL_STATUS_OK) keystore else software + val legacy = legacyBackend.deleteKey() + when { + software.first() != CREDENTIAL_STATUS_OK -> software + keystore.first() != CREDENTIAL_STATUS_OK -> keystore + legacy.first() != CREDENTIAL_STATUS_OK -> + credentialResponse( + CREDENTIAL_STATUS_OK, + "legacy-key-cleanup-deferred".toByteArray(Charsets.UTF_8), + ) + else -> keystore + } } @JvmStatic @@ -443,7 +479,17 @@ object OpenLessCredentialVault { if (software?.isMigrated() == true) { credentialResponse(CREDENTIAL_STATUS_OK, byteArrayOf(1)) } else { - migrationMarker.keyExists() + val current = migrationMarker.keyExists() + if ( + current.first() != CREDENTIAL_STATUS_OK || + current.contentEquals( + credentialResponse(CREDENTIAL_STATUS_OK, byteArrayOf(1)) + ) + ) { + current + } else { + legacyMigrationMarker.keyExists() + } } } diff --git a/openless-all/app/android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt b/openless-all/app/android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt index 9f20a28ff..1c911895a 100644 --- a/openless-all/app/android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt +++ b/openless-all/app/android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt @@ -1,6 +1,8 @@ package com.openless.app import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File import java.security.KeyStore import java.util.UUID import org.junit.After @@ -32,6 +34,19 @@ class OpenLessCredentialVaultInstrumentedTest { return response.copyOfRange(1, response.size) } + private fun legacyVault() = + AndroidKeystoreCredentialVault("com.openless.app.credentials.v2") + + private fun softwareStore(): SoftwareAesCredentialStore { + val filesDir = InstrumentationRegistry.getInstrumentation().targetContext.filesDir + return SoftwareAesCredentialStore(File(filesDir, "OpenLess")) + } + + private fun resetFacadeState() { + OpenLessCredentialVault.deleteKey() + legacyVault().deleteKey() + } + @Test fun roundTripUsesNonExportableKey() { val plaintext = "instrumented credential".toByteArray() @@ -66,6 +81,35 @@ class OpenLessCredentialVaultInstrumentedTest { } } + @Test + fun publicFacadeReadsBeta1V2Envelope() { + resetFacadeState() + try { + val plaintext = "beta1 credential".toByteArray() + val aad = "format-version-account".toByteArray() + val packet = payload(legacyVault().seal(plaintext, aad)) + + assertArrayEquals(plaintext, payload(OpenLessCredentialVault.open(packet, aad))) + } finally { + resetFacadeState() + } + } + + @Test + fun softwareKeyCreatedBeforeEnvelopeCommitDoesNotHideV2Envelope() { + resetFacadeState() + try { + val plaintext = "still-v2 credential".toByteArray() + val aad = "format-version-account".toByteArray() + val packet = payload(legacyVault().seal(plaintext, aad)) + payload(softwareStore().seal("discarded candidate".toByteArray(), aad)) + + assertArrayEquals(plaintext, payload(OpenLessCredentialVault.open(packet, aad))) + } finally { + resetFacadeState() + } + } + @Test fun deletedKeyIsReportedAsMissing() { val aad = "format-version-account".toByteArray() diff --git a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt index 57ac51eb6..590b7f273 100644 --- a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt +++ b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt @@ -137,6 +137,7 @@ class OpenLessCredentialCipherTest { assertEquals(CREDENTIAL_STATUS_OK, opened.first()) assertArrayEquals(plaintext, opened.copyOfRange(1, opened.size)) assertTrue(File(dir, SoftwareAesCredentialStore.SOFTWARE_KEY_NAME).isFile) + assertFalse(store.isMigrated()) assertEquals(CREDENTIAL_STATUS_OK, store.markMigrated().first()) assertTrue(store.isMigrated()) } finally { @@ -159,4 +160,55 @@ class OpenLessCredentialCipherTest { dir.deleteRecursively() } } + + @Test + fun openFallbackPrefersSuccessAndNeverDowngradesARecoverableFailureToMissing() { + val success = byteArrayOf(CREDENTIAL_STATUS_OK, 7) + var afterSuccessCalled = false + assertArrayEquals( + success, + credentialOpenWithFallback( + { byteArrayOf(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) }, + { success }, + { + afterSuccessCalled = true + byteArrayOf(CREDENTIAL_STATUS_OK, 8) + }, + ), + ) + assertFalse(afterSuccessCalled) + assertEquals( + CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE, + credentialOpenWithFallback( + { byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) }, + { byteArrayOf(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) }, + { byteArrayOf(CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE) }, + ) + .first(), + ) + assertEquals( + CREDENTIAL_STATUS_AUTHENTICATION_FAILED, + credentialOpenWithFallback( + { byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) }, + { byteArrayOf(CREDENTIAL_STATUS_AUTHENTICATION_FAILED) }, + ) + .first(), + ) + assertEquals( + CREDENTIAL_STATUS_MALFORMED, + credentialOpenWithFallback( + { byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) }, + { byteArrayOf(CREDENTIAL_STATUS_MALFORMED) }, + ) + .first(), + ) + assertEquals( + CREDENTIAL_STATUS_KEY_MISSING, + credentialOpenWithFallback( + { byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) }, + { byteArrayOf(CREDENTIAL_STATUS_KEY_MISSING) }, + ) + .first(), + ) + } } diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 323e9aa86..499436b6a 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3043,12 +3043,6 @@ impl OpenLessBackend { Err(error) if error.code == BackendErrorCode::Persistence => { // Vault unreadable (e.g. Android Keystore temporarily unavailable) // must not fail the 2.0 handshake. Dictation still gates on read(). - // #region agent log - log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H6\",\"location\":\"api.rs:start\",\"message\":\"start continuing with default credentials after Persistence\",\"data\":{{\"error\":\"{}\"}},\"timestamp\":0}}", - error.to_string().replace('"', "'") - ); - // #endregion log::warn!("[core] startup credential status unavailable: {error}"); CredentialsStatus { pipeline_mode: crate::shared_types::effective_pipeline_mode( diff --git a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs index 7cc1f0ed9..23529ef3e 100644 --- a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs +++ b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs @@ -14,6 +14,10 @@ const paths = { rustStore: new URL('../src-tauri/src/persistence/android_credentials.rs', import.meta.url), credentials: new URL('../src-tauri/src/persistence/credentials.rs', import.meta.url), jni: new URL('../src-tauri/src/android/jni.rs', import.meta.url), + credentialCommands: new URL('../src-tauri/src/commands/credentials.rs', import.meta.url), + coreApi: new URL('../crates/openless-core/src/api.rs', import.meta.url), + mobileRuntime: new URL('../src-tauri/src/mobile_runtime.rs', import.meta.url), + channelList: new URL('../src/pages/settings/ChannelList.tsx', import.meta.url), copyScript: new URL('./copy-android-scaffolding.mjs', import.meta.url), ci: new URL('../../../.github/workflows/ci.yml', import.meta.url), }; @@ -37,18 +41,35 @@ function requirePattern(source, pattern, message) { } } -const [cipher, vault, unitTest, instrumentedTest, rustStore, credentials, jni, copyScript, ci] = - await Promise.all([ - requiredSource('pure AES-GCM codec', paths.cipher), - requiredSource('Android Keystore bridge', paths.vault), - requiredSource('JVM cipher tests', paths.unitTest), - requiredSource('Android Keystore instrumentation tests', paths.instrumentedTest), - requiredSource('Rust Android credential store', paths.rustStore), - requiredSource('credentials integration', paths.credentials), - requiredSource('JNI bridge', paths.jni), - requiredSource('Android scaffolding copier', paths.copyScript), - requiredSource('PR CI workflow', paths.ci), - ]); +const [ + cipher, + vault, + unitTest, + instrumentedTest, + rustStore, + credentials, + jni, + credentialCommands, + coreApi, + mobileRuntime, + channelList, + copyScript, + ci, +] = await Promise.all([ + requiredSource('pure AES-GCM codec', paths.cipher), + requiredSource('Android Keystore bridge', paths.vault), + requiredSource('JVM cipher tests', paths.unitTest), + requiredSource('Android Keystore instrumentation tests', paths.instrumentedTest), + requiredSource('Rust Android credential store', paths.rustStore), + requiredSource('credentials integration', paths.credentials), + requiredSource('JNI bridge', paths.jni), + requiredSource('credential commands', paths.credentialCommands), + requiredSource('Core API', paths.coreApi), + requiredSource('mobile runtime', paths.mobileRuntime), + requiredSource('channel list', paths.channelList), + requiredSource('Android scaffolding copier', paths.copyScript), + requiredSource('PR CI workflow', paths.ci), +]); requirePattern(cipher, /AES\/GCM\/NoPadding/, 'cipher must use AES/GCM/NoPadding'); requirePattern(cipher, /NONCE_BYTES\s*=\s*12/, 'cipher must require a 12-byte nonce'); @@ -82,10 +103,13 @@ for (const pattern of [ /fun\s+credentialStatusForCipherKeyFailure/, /is\s+UserNotAuthenticatedException\s*->\s*CREDENTIAL_STATUS_TEMPORARILY_UNAVAILABLE/, /com\.openless\.app\.credentials\.v3/, + /com\.openless\.app\.credentials\.v2/, /runOnMain/, /class SoftwareAesCredentialStore/, /credentials\.sw\.key/, /SecretKeySpec/, + /FileOutputStream/, + /\.fd\.sync\(\)/, ]) { requirePattern(vault, pattern, `Keystore failure classifier is missing ${pattern}`); } @@ -103,10 +127,17 @@ for (const pattern of [ /invalidKeyExceptionIsTreatedAsUnrecoverable/, /softwareAesRoundTripWithoutAndroidKeyStore/, /softwareAesMissingKeyIsReportedAsMissing/, + /openFallbackPrefersSuccessAndNeverDowngradesARecoverableFailureToMissing/, ]) { requirePattern(unitTest, pattern, `JVM crypto tests are missing ${pattern}`); } -for (const pattern of [/assertNull\([^)]*\.encoded/, /deletedKey/, /tamperedCiphertext/]) { +for (const pattern of [ + /assertNull\([^)]*\.encoded/, + /deletedKey/, + /tamperedCiphertext/, + /publicFacadeReadsBeta1V2Envelope/, + /softwareKeyCreatedBeforeEnvelopeCommitDoesNotHideV2Envelope/, +]) { requirePattern( instrumentedTest, pattern, @@ -114,6 +145,35 @@ for (const pattern of [/assertNull\([^)]*\.encoded/, /deletedKey/, /tamperedCiph ); } +for (const pattern of [/recreate\s*=\s*true/, /deleteEntryQuiet/]) { + if (pattern.test(vault)) { + throw new Error(`credential sealing must not destructively rotate a live key: ${pattern}`); + } +} + +const diagnosticSources = [ + vault, + rustStore, + credentials, + jni, + credentialCommands, + coreApi, + mobileRuntime, + channelList, +].join('\n'); +for (const pattern of [ + /#region agent log/, + /\[agent-dbg\]/, + /f73b06/, + /hypothesisId/, + /debug-f73b06\.log/, + /127\.0\.0\.1:7807/, +]) { + if (pattern.test(diagnosticSources)) { + throw new Error(`one-off agent diagnostic must not ship: ${pattern}`); + } +} + for (const pattern of [ /openless-android-credentials/, /version:\s*u32/, diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 907472ee0..ef0f687bc 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -209,8 +209,7 @@ pub mod android { } } - fn log_keystore_debug(method: &str, kind: &str, detail: &str) { - // #region agent log + fn log_keystore_failure(method: &str, kind: &str, detail: &str) { let safe: String = detail .chars() .map(|ch| { @@ -222,10 +221,11 @@ pub mod android { }) .take(120) .collect(); - log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H9\",\"location\":\"jni.rs:keystore\",\"message\":\"keystore call\",\"data\":{{\"method\":\"{method}\",\"kind\":\"{kind}\",\"detail\":\"{safe}\"}},\"timestamp\":0}}" - ); - // #endregion + if safe.is_empty() { + log::warn!("[vault] Android Keystore method={method} status={kind}"); + } else { + log::warn!("[vault] Android Keystore method={method} status={kind} detail={safe}"); + } } fn keystore_temporarily_unavailable(env: &mut JNIEnv) -> Result { @@ -235,34 +235,36 @@ pub mod android { fn credential_response(method: &str, response: Vec) -> Result, String> { let Some((&status, payload)) = response.split_first() else { - log_keystore_debug(method, "empty_response", ""); + log_keystore_failure(method, "empty_response", ""); return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()); }; match status { 0 => { - log_keystore_debug(method, "status_ok", ""); + if method == "deleteKey" && payload == b"legacy-key-cleanup-deferred" { + log_keystore_failure(method, "legacy_cleanup_deferred", ""); + } Ok(payload.to_vec()) } 1 => { let detail = String::from_utf8_lossy(payload); - log_keystore_debug(method, "status_key_missing", &detail); + log_keystore_failure(method, "status_key_missing", &detail); Err(KEYSTORE_KEY_MISSING.to_string()) } 2 => { - log_keystore_debug(method, "status", "authentication_failed"); + log_keystore_failure(method, "authentication_failed", ""); Err(KEYSTORE_AUTHENTICATION_FAILED.to_string()) } 3 => { let detail = String::from_utf8_lossy(payload); - log_keystore_debug(method, "status_temporarily_unavailable", &detail); + log_keystore_failure(method, "status_temporarily_unavailable", &detail); Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()) } 4 => { - log_keystore_debug(method, "status", "malformed"); + log_keystore_failure(method, "malformed", ""); Err(KEYSTORE_MALFORMED.to_string()) } _ => { - log_keystore_debug(method, "status_unknown", &status.to_string()); + log_keystore_failure(method, "status_unknown", &status.to_string()); Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()) } } @@ -277,21 +279,21 @@ pub mod android { let class = match load_context_class(env, context, CREDENTIAL_VAULT_CLASS) { Ok(class) => class, Err(error) => { - log_keystore_debug(method, "class_load", &error); + log_keystore_failure(method, "class_load", &error); return keystore_temporarily_unavailable(env); } }; let first_array = match env.byte_array_from_slice(first) { Ok(array) => array, Err(error) => { - log_keystore_debug(method, "jni_array", &error.to_string()); + log_keystore_failure(method, "jni_array", &error.to_string()); return keystore_temporarily_unavailable(env); } }; let second_array = match env.byte_array_from_slice(second) { Ok(array) => array, Err(error) => { - log_keystore_debug(method, "jni_array", &error.to_string()); + log_keystore_failure(method, "jni_array", &error.to_string()); return keystore_temporarily_unavailable(env); } }; @@ -308,26 +310,26 @@ pub mod android { ) { Ok(value) => value, Err(error) => { - log_keystore_debug(method, "jni_call", &error.to_string()); + log_keystore_failure(method, "jni_call", &error.to_string()); return keystore_temporarily_unavailable(env); } }; let object = match value.l() { Ok(object) => object, Err(error) => { - log_keystore_debug(method, "jni_object", &error.to_string()); + log_keystore_failure(method, "jni_object", &error.to_string()); return keystore_temporarily_unavailable(env); } }; if object.is_null() { - log_keystore_debug(method, "null_response", ""); + log_keystore_failure(method, "null_response", ""); return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()); } let array = JByteArray::from(object); let response = match env.convert_byte_array(&array) { Ok(response) => response, Err(error) => { - log_keystore_debug(method, "jni_bytes", &error.to_string()); + log_keystore_failure(method, "jni_bytes", &error.to_string()); return keystore_temporarily_unavailable(env); } }; @@ -340,33 +342,33 @@ pub mod android { let class = match load_context_class(env, context, CREDENTIAL_VAULT_CLASS) { Ok(class) => class, Err(error) => { - log_keystore_debug(method, "class_load", &error); + log_keystore_failure(method, "class_load", &error); return keystore_temporarily_unavailable(env); } }; let value = match env.call_static_method(class, method, "()[B", &[]) { Ok(value) => value, Err(error) => { - log_keystore_debug(method, "jni_call", &error.to_string()); + log_keystore_failure(method, "jni_call", &error.to_string()); return keystore_temporarily_unavailable(env); } }; let object = match value.l() { Ok(object) => object, Err(error) => { - log_keystore_debug(method, "jni_object", &error.to_string()); + log_keystore_failure(method, "jni_object", &error.to_string()); return keystore_temporarily_unavailable(env); } }; if object.is_null() { - log_keystore_debug(method, "null_response", ""); + log_keystore_failure(method, "null_response", ""); return Err(KEYSTORE_TEMPORARILY_UNAVAILABLE.to_string()); } let array = JByteArray::from(object); let response = match env.convert_byte_array(&array) { Ok(response) => response, Err(error) => { - log_keystore_debug(method, "jni_bytes", &error.to_string()); + log_keystore_failure(method, "jni_bytes", &error.to_string()); return keystore_temporarily_unavailable(env); } }; @@ -387,20 +389,16 @@ pub mod android { plaintext: &[u8], aad: &[u8], ) -> Result, AndroidKeystoreFailure> { - call_credential_vault_two_arrays("seal", plaintext, aad).map_err(|error| { - log_keystore_debug("seal", "bridge", &error); - classify_keystore_failure(error) - }) + call_credential_vault_two_arrays("seal", plaintext, aad) + .map_err(classify_keystore_failure) } pub(crate) fn keystore_open( sealed: &[u8], aad: &[u8], ) -> Result, AndroidKeystoreFailure> { - call_credential_vault_two_arrays("open", sealed, aad).map_err(|error| { - log_keystore_debug("open", "bridge", &error); - classify_keystore_failure(error) - }) + call_credential_vault_two_arrays("open", sealed, aad) + .map_err(classify_keystore_failure) } pub(crate) fn keystore_delete_key() -> Result<(), AndroidKeystoreFailure> { diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 7924e0ac5..bd9ce5194 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -428,17 +428,10 @@ fn credential_persistence_error(error: anyhow::Error) -> openless_core::BackendE fn require_readable_vault() -> Result<(), openless_core::BackendError> { match CredentialsVault::last_read_error() { - Some(error) => { - // #region agent log - log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H6\",\"location\":\"credentials.rs:require_readable_vault\",\"message\":\"status/read blocked by last vault error\",\"data\":{{\"blocked\":true}},\"timestamp\":0}}" - ); - // #endregion - Err(openless_core::BackendError::new( - openless_core::BackendErrorCode::Persistence, - format!("无法读取已保存的凭据:{error}"), - )) - } + Some(error) => Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Persistence, + format!("无法读取已保存的凭据:{error}"), + )), None => Ok(()), } } @@ -454,13 +447,6 @@ fn after_vault_attempt( fn after_vault_backend( result: Result, ) -> Result { - // #region agent log - log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H7\",\"location\":\"credentials.rs:after_vault_backend\",\"message\":\"vault attempt finished\",\"data\":{{\"ok\":{},\"lastReadError\":{}}},\"timestamp\":0}}", - result.is_ok(), - CredentialsVault::last_read_error().is_some() - ); - // #endregion if CredentialsVault::last_read_error().is_some() { require_readable_vault()?; } diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs index ea1bc6b3c..d840a1590 100644 --- a/openless-all/app/src-tauri/src/mobile_runtime.rs +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -41,20 +41,7 @@ pub fn run() { let core_backend = coordinator.backend(); app.manage(Arc::clone(&core_backend)); coordinator.tauri_host().bind(app.handle().clone()); - let startup = tauri::async_runtime::block_on(core_backend.start()); - // #region agent log - match &startup { - Ok(snapshot) => log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H1\",\"location\":\"mobile_runtime.rs:setup\",\"message\":\"core start ok\",\"data\":{{\"running\":{}}},\"timestamp\":0}}", - snapshot.backend.running - ), - Err(error) => log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H1\",\"location\":\"mobile_runtime.rs:setup\",\"message\":\"core start failed\",\"data\":{{\"error\":\"{}\"}},\"timestamp\":0}}", - error.to_string().replace('"', "'") - ), - } - // #endregion - let startup = startup?; + let startup = tauri::async_runtime::block_on(core_backend.start())?; if !startup.backend.running { return Err("OpenLess Core did not reach the running state".into()); } diff --git a/openless-all/app/src-tauri/src/persistence/android_credentials.rs b/openless-all/app/src-tauri/src/persistence/android_credentials.rs index 2fa8005e5..83530f772 100644 --- a/openless-all/app/src-tauri/src/persistence/android_credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/android_credentials.rs @@ -129,17 +129,6 @@ pub(super) fn read( crypto: &mut impl AndroidCredentialsCrypto, ) -> Result { recover_verified_sanitized_legacy(path)?; - // #region agent log - log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H12\",\"location\":\"android_credentials.rs:read\",\"message\":\"envelope files\",\"data\":{{\"main\":{},\"pending\":{},\"tmp\":{},\"software\":{}}},\"timestamp\":0}}", - path.exists(), - verified_v2_temporary_path(path).exists(), - v2_temporary_path(path).exists(), - path.parent() - .map(|parent| parent.join("credentials.sw.key").is_file()) - .unwrap_or(false) - ); - // #endregion recover_verified_v2_temporary(path, crypto)?; let bytes = match fs::read(path) { Ok(bytes) => bytes, @@ -179,11 +168,6 @@ pub(super) fn read( Ok(ReadOutcome::Plaintext(plaintext)) } Err(StoreError::Crypto(CryptoErrorKind::KeyMissingOrInvalidated)) => { - // #region agent log - log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H13\",\"location\":\"android_credentials.rs:read\",\"message\":\"wiping envelope after unusable Keystore key\",\"data\":{{\"hadMain\":true}},\"timestamp\":0}}" - ); - // #endregion // The ciphertext can no longer be recovered. Reset the alias first; // if that is temporarily unavailable, preserve the file for retry. crypto.delete_key().map_err(StoreError::Crypto)?; @@ -563,12 +547,6 @@ impl AndroidCredentialsCrypto for AndroidKeystoreCrypto { ) -> std::result::Result { let packet = crate::android::jni::android::keystore_seal(plaintext, aad) .map_err(map_keystore_failure)?; - // #region agent log - log::warn!( - "[agent-dbg] {{\"sessionId\":\"f73b06\",\"hypothesisId\":\"H14\",\"location\":\"android_credentials.rs:seal\",\"message\":\"keystore_seal ok\",\"data\":{{\"packetLen\":{}}},\"timestamp\":0}}", - packet.len() - ); - // #endregion split_packet(&packet) } diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 22422388e..f6a26fd9a 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -129,37 +129,6 @@ fn record_vault_read_failure(error: &anyhow::Error) { } } -fn agent_debug_ndjson(hypothesis_id: &str, location: &str, message: &str, data: &str) { - // #region agent log - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - let line = format!( - "{{\"sessionId\":\"f73b06\",\"hypothesisId\":\"{hypothesis_id}\",\"location\":\"{location}\",\"message\":\"{message}\",\"data\":{data},\"timestamp\":{timestamp}}}" - ); - log::warn!("[agent-dbg] {line}"); - let mut paths = Vec::new(); - paths.push( - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../debug-f73b06.log"), - ); - #[cfg(any(target_os = "android", test))] - if let Ok(dir) = super::android_storage::android_log_dir() { - paths.push(dir.join("debug-f73b06.log")); - } - for path in paths { - if let Ok(mut file) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - { - use std::io::Write; - let _ = writeln!(file, "{line}"); - } - } - // #endregion -} - /// Mutations must not persist an empty default over an unreadable envelope. /// Returning `Err` lets Core surface Persistence after a real Keystore retry. #[cfg(any(target_os = "android", test))] @@ -171,26 +140,10 @@ fn android_credentials_root_for_update( let root = loaded.unwrap_or_default(); clear_vault_read_error(); store_credentials_cache(&root); - // #region agent log - agent_debug_ndjson( - "H7", - "credentials.rs:android_credentials_root_for_update", - "android for-update loaded envelope", - "{\"ok\":true}", - ); - // #endregion Ok(root) } Err(error) => { record_vault_read_failure(&error); - // #region agent log - agent_debug_ndjson( - "H7", - "credentials.rs:android_credentials_root_for_update", - "android for-update retried Keystore and still failed", - "{\"ok\":false}", - ); - // #endregion Err(error) } } @@ -1568,17 +1521,6 @@ fn load_credentials_into_cache_with( // scrub must be retried by the next startup/getter call rather than // hidden for the rest of the process. record_vault_read_failure(&e); - // #region agent log - agent_debug_ndjson( - "H2", - "credentials.rs:load_credentials_into_cache_with", - "vault loader returned Err; using uncached default", - &format!( - "{{\"hasEnvelopeContext\":{}}}", - format!("{e:#}").contains("read Android credential envelope") - ), - ); - // #endregion CredsRoot::default() } } diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 590b814cb..ed3f6c7ca 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -271,9 +271,6 @@ export function ChannelList({ } catch (error) { console.error('[channels] create failed', error); const message = failedOpMessage(error, t('common.operationFailed')); - // #region agent log - fetch('http://127.0.0.1:7807/ingest/0e5d9157-0519-49b4-bb72-cb173586e4dc',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'f73b06'},body:JSON.stringify({sessionId:'f73b06',hypothesisId:'H8',location:'ChannelList.tsx:startCreate',message:'createChannel failed',data:{hasDetail:message!==t('common.operationFailed'),prefix:message.slice(0,48)},timestamp:Date.now(),runId:'post-fix'})}).catch(()=>{}); - // #endregion emitSaved('failed', message); } finally { setCreatingBusy(false);