From 4ffd7e624c772ccdb255dd5162504b1a8bde4a23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 07:18:44 +0000 Subject: [PATCH 1/2] Store app secrets in encrypted blob Co-authored-by: cooper --- Cargo.lock | 104 +++++++ apps/native/src-tauri/Cargo.toml | 2 + apps/native/src-tauri/src/commands/debug.rs | 1 + .../src-tauri/src/shared_types/prefs.rs | 9 +- apps/native/src-tauri/src/storage/mod.rs | 1 + .../src-tauri/src/storage/secret_blob.rs | 176 ++++++++++++ apps/native/src-tauri/src/storage/store.rs | 269 +++++++++++++++++- .../peekaboo-workflow-contract-self-test.mjs | 2 +- 8 files changed, 547 insertions(+), 17 deletions(-) create mode 100644 apps/native/src-tauri/src/storage/secret_blob.rs diff --git a/Cargo.lock b/Cargo.lock index 3e4984589..0a5e255ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -912,6 +947,16 @@ dependencies = [ "phf_codegen 0.11.3", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.60" @@ -1250,6 +1295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1290,6 +1336,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.20.11" @@ -2287,6 +2342,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2977,6 +3042,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3663,10 +3737,12 @@ dependencies = [ name = "nixmac" version = "0.22.0" dependencies = [ + "aes-gcm", "anyhow", "async-openai", "async-trait", "backtrace", + "base64 0.22.1", "chrono", "clap", "cocoa", @@ -4072,6 +4148,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -4560,6 +4642,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -7800,6 +7894,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/apps/native/src-tauri/Cargo.toml b/apps/native/src-tauri/Cargo.toml index a4b9094d0..e38b16c38 100644 --- a/apps/native/src-tauri/Cargo.toml +++ b/apps/native/src-tauri/Cargo.toml @@ -71,6 +71,8 @@ fuzzy-matcher = "0.3.7" rusqlite_migration = "1.2" tauri-plugin-webdriver-automation = "0.1.3" tiktoken-rs = "0.6" +aes-gcm = "0.10.3" +base64 = "0.22.1" [dependencies.tauri-plugin-sql] features = ["sqlite"] # or "postgres", or "mysql" diff --git a/apps/native/src-tauri/src/commands/debug.rs b/apps/native/src-tauri/src/commands/debug.rs index 2f558544f..86c435c2a 100644 --- a/apps/native/src-tauri/src/commands/debug.rs +++ b/apps/native/src-tauri/src/commands/debug.rs @@ -83,6 +83,7 @@ pub async fn developer_clear_tauri_state(app: AppHandle) -> Result<(), String> { } clear_tauri_store(&app, "settings.json")?; + store::delete_secret_blob_file(&app).map_err(|e| e.to_string())?; clear_tauri_store(&app, "evolve-state.json")?; clear_tauri_store(&app, "build-state.json")?; Ok(()) diff --git a/apps/native/src-tauri/src/shared_types/prefs.rs b/apps/native/src-tauri/src/shared_types/prefs.rs index 4e4da630d..efcd7cf83 100644 --- a/apps/native/src-tauri/src/shared_types/prefs.rs +++ b/apps/native/src-tauri/src/shared_types/prefs.rs @@ -10,13 +10,16 @@ pub enum UpdateChannel { Develop, } -/// User interface preferences (synced to settings.json via tauri-plugin-store). +/// User interface preferences. +/// +/// Non-secret fields are synced to settings.json via tauri-plugin-store. API +/// keys are loaded from the encrypted app secrets blob. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct UiPrefs { - /// OpenRouter API key stored in local app preferences. + /// OpenRouter API key stored in encrypted app secrets. pub openrouter_api_key: Option, - /// OpenAI API key stored in local app preferences. + /// OpenAI API key stored in encrypted app secrets. pub openai_api_key: Option, /// Base URL for Ollama-compatible local models. pub ollama_api_base_url: Option, diff --git a/apps/native/src-tauri/src/storage/mod.rs b/apps/native/src-tauri/src/storage/mod.rs index 445666e55..791c6de19 100644 --- a/apps/native/src-tauri/src/storage/mod.rs +++ b/apps/native/src-tauri/src/storage/mod.rs @@ -1,2 +1,3 @@ pub mod credential_store; +mod secret_blob; pub mod store; diff --git a/apps/native/src-tauri/src/storage/secret_blob.rs b/apps/native/src-tauri/src/storage/secret_blob.rs new file mode 100644 index 000000000..96c00f959 --- /dev/null +++ b/apps/native/src-tauri/src/storage/secret_blob.rs @@ -0,0 +1,176 @@ +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + Aes256Gcm, Nonce, +}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +const BLOB_VERSION: u8 = 1; +const CIPHER_NAME: &str = "AES-256-GCM"; +const DATA_KEY_LEN: usize = 32; +const NONCE_LEN: usize = 12; + +pub type SecretMap = BTreeMap; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SecretBlobPayload { + #[serde(default)] + pub secrets: SecretMap, + #[serde(default)] + pub legacy_keychain_migration_complete: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum SecretBlobError { + #[error("data key must be {DATA_KEY_LEN} bytes, got {0}")] + InvalidDataKeyLength(usize), + #[error("invalid data key encoding: {0}")] + InvalidDataKeyEncoding(#[source] base64::DecodeError), + #[error("invalid encrypted secrets blob JSON: {0}")] + InvalidBlobJson(#[source] serde_json::Error), + #[error("unsupported encrypted secrets blob version: {0}")] + UnsupportedVersion(u8), + #[error("unsupported encrypted secrets cipher: {0}")] + UnsupportedCipher(String), + #[error("invalid encrypted secrets nonce encoding: {0}")] + InvalidNonceEncoding(#[source] base64::DecodeError), + #[error("encrypted secrets nonce must be {NONCE_LEN} bytes, got {0}")] + InvalidNonceLength(usize), + #[error("invalid encrypted secrets ciphertext encoding: {0}")] + InvalidCiphertextEncoding(#[source] base64::DecodeError), + #[error("encrypted secrets blob encryption failed")] + Encrypt, + #[error("encrypted secrets blob decryption failed")] + Decrypt, + #[error("invalid decrypted secrets JSON: {0}")] + InvalidPayloadJson(#[source] serde_json::Error), +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EncryptedSecretBlob { + version: u8, + cipher: String, + nonce: String, + ciphertext: String, +} + +pub fn generate_encoded_data_key() -> String { + let key = Aes256Gcm::generate_key(&mut OsRng); + STANDARD.encode(key.as_slice()) +} + +pub fn decode_data_key(encoded: &str) -> Result<[u8; DATA_KEY_LEN], SecretBlobError> { + let bytes = STANDARD + .decode(encoded.trim()) + .map_err(SecretBlobError::InvalidDataKeyEncoding)?; + bytes + .try_into() + .map_err(|bytes: Vec| SecretBlobError::InvalidDataKeyLength(bytes.len())) +} + +pub fn encrypt_payload( + payload: &SecretBlobPayload, + data_key: &[u8; DATA_KEY_LEN], +) -> Result { + let cipher = Aes256Gcm::new_from_slice(data_key) + .map_err(|_| SecretBlobError::InvalidDataKeyLength(data_key.len()))?; + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let plaintext = serde_json::to_vec(payload).map_err(SecretBlobError::InvalidPayloadJson)?; + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_ref()) + .map_err(|_| SecretBlobError::Encrypt)?; + + let blob = EncryptedSecretBlob { + version: BLOB_VERSION, + cipher: CIPHER_NAME.to_string(), + nonce: STANDARD.encode(nonce.as_slice()), + ciphertext: STANDARD.encode(ciphertext), + }; + + serde_json::to_string_pretty(&blob).map_err(SecretBlobError::InvalidBlobJson) +} + +pub fn decrypt_payload( + encrypted: &str, + data_key: &[u8; DATA_KEY_LEN], +) -> Result { + let blob: EncryptedSecretBlob = + serde_json::from_str(encrypted).map_err(SecretBlobError::InvalidBlobJson)?; + if blob.version != BLOB_VERSION { + return Err(SecretBlobError::UnsupportedVersion(blob.version)); + } + if blob.cipher != CIPHER_NAME { + return Err(SecretBlobError::UnsupportedCipher(blob.cipher)); + } + + let nonce_bytes = STANDARD + .decode(blob.nonce) + .map_err(SecretBlobError::InvalidNonceEncoding)?; + if nonce_bytes.len() != NONCE_LEN { + return Err(SecretBlobError::InvalidNonceLength(nonce_bytes.len())); + } + let ciphertext = STANDARD + .decode(blob.ciphertext) + .map_err(SecretBlobError::InvalidCiphertextEncoding)?; + + let cipher = Aes256Gcm::new_from_slice(data_key) + .map_err(|_| SecretBlobError::InvalidDataKeyLength(data_key.len()))?; + let plaintext = cipher + .decrypt(Nonce::from_slice(&nonce_bytes), ciphertext.as_ref()) + .map_err(|_| SecretBlobError::Decrypt)?; + + serde_json::from_slice(&plaintext).map_err(SecretBlobError::InvalidPayloadJson) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypted_payload_round_trips_secrets() { + let encoded_key = generate_encoded_data_key(); + let data_key = decode_data_key(&encoded_key).unwrap(); + let mut payload = SecretBlobPayload { + legacy_keychain_migration_complete: true, + ..SecretBlobPayload::default() + }; + payload + .secrets + .insert("openrouterApiKey".to_string(), "sk-or-secret".to_string()); + payload + .secrets + .insert("vllmApiKey".to_string(), "vllm-secret".to_string()); + + let encrypted = encrypt_payload(&payload, &data_key).unwrap(); + + assert!(!encrypted.contains("sk-or-secret")); + assert!(!encrypted.contains("vllm-secret")); + assert_eq!(decrypt_payload(&encrypted, &data_key).unwrap(), payload); + } + + #[test] + fn decrypt_rejects_wrong_data_key() { + let data_key = decode_data_key(&generate_encoded_data_key()).unwrap(); + let wrong_key = decode_data_key(&generate_encoded_data_key()).unwrap(); + let payload = SecretBlobPayload::default(); + let encrypted = encrypt_payload(&payload, &data_key).unwrap(); + + assert!(matches!( + decrypt_payload(&encrypted, &wrong_key), + Err(SecretBlobError::Decrypt) + )); + } + + #[test] + fn decode_data_key_requires_32_bytes() { + let encoded = STANDARD.encode([1_u8, 2, 3]); + + assert!(matches!( + decode_data_key(&encoded), + Err(SecretBlobError::InvalidDataKeyLength(3)) + )); + } +} diff --git a/apps/native/src-tauri/src/storage/store.rs b/apps/native/src-tauri/src/storage/store.rs index 9abbbc181..f8bd342fc 100644 --- a/apps/native/src-tauri/src/storage/store.rs +++ b/apps/native/src-tauri/src/storage/store.rs @@ -4,16 +4,21 @@ //! This provides a simple key-value interface for preferences. use crate::shared_types; -use crate::storage::credential_store::{ - get_with_lazy_migration, set_with_cleanup, CredentialStoreError, KeychainStore, - SettingsFileStore, +use crate::storage::{ + credential_store::{CredentialStore, CredentialStoreError, KeychainStore, SettingsFileStore}, + secret_blob::{ + decode_data_key, decrypt_payload, encrypt_payload, generate_encoded_data_key, + SecretBlobPayload, + }, }; -use anyhow::Result; +use anyhow::{anyhow, Context, Result}; +use once_cell::sync::Lazy; use serde::{de::DeserializeOwned, Serialize}; -use std::path::PathBuf; -use std::sync::Arc; -use tauri::{AppHandle, Runtime}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager, Runtime}; use tauri_plugin_store::{Store, StoreExt}; const STORE_PATH: &str = "settings.json"; @@ -45,6 +50,18 @@ pub const UPDATE_CHANNEL_KEY: &str = "updateChannel"; pub const DEFAULT_MAX_ITERATIONS: usize = 25; const KEYCHAIN_SERVICE: &str = "com.darkmatter.nixmac"; +const SECRET_DATA_KEY_ACCOUNT: &str = "appSecretsDataKey.v1"; +const SECRET_BLOB_FILE: &str = "app-secrets.v1.json"; +const SECRET_PREF_KEYS: &[&str] = &["openrouterApiKey", "openaiApiKey", "vllmApiKey"]; + +#[derive(Clone)] +struct SecretBlobState { + blob_path: PathBuf, + data_key: [u8; 32], + payload: SecretBlobPayload, +} + +static SECRET_BLOB_CACHE: Lazy>> = Lazy::new(|| Mutex::new(None)); fn e2e_mock_system_enabled() -> bool { cfg!(debug_assertions) && crate::e2e_runtime::enabled("NIXMAC_E2E_MOCK_SYSTEM") @@ -433,7 +450,7 @@ where { // If the env var is set, return it immediately without touching the keychain. // This avoids OS keychain prompts in dev/CI workflows where credentials are - // injected via environment. Migration from settings.json → keychain will + // injected via environment. Migration into the encrypted secrets blob will // happen the first time the app runs without the env var set. if let Some(value) = env_value { return Ok(Some(value)); @@ -445,6 +462,10 @@ fn keychain_store_for(app: &AppHandle, key: &str) -> KeychainStor KeychainStore::new(app.clone(), KEYCHAIN_SERVICE, key) } +fn data_key_store_for(app: &AppHandle) -> KeychainStore { + KeychainStore::new(app.clone(), KEYCHAIN_SERVICE, SECRET_DATA_KEY_ACCOUNT) +} + fn legacy_settings_store(app: &AppHandle, key: &'static str) -> SettingsFileStore { let app_for_get = app.clone(); let app_for_delete = app.clone(); @@ -462,14 +483,214 @@ fn legacy_settings_store(app: &AppHandle, key: &'static str) -> S ) } +fn secret_blob_path(app: &AppHandle) -> Result { + let app_data = app + .path() + .app_data_dir() + .context("failed to resolve app data directory for encrypted secrets")?; + fs::create_dir_all(&app_data)?; + Ok(app_data.join(SECRET_BLOB_FILE)) +} + +fn lock_secret_blob_cache() -> Result>> { + SECRET_BLOB_CACHE + .lock() + .map_err(|_| anyhow!("secret blob cache lock poisoned")) +} + +fn read_or_create_data_key( + key_store: &KeychainStore, + blob_exists: bool, +) -> Result<[u8; 32]> { + match key_store.get().map_err(anyhow::Error::from)? { + Some(encoded) => decode_data_key(&encoded).map_err(anyhow::Error::from), + None if blob_exists => Err(anyhow!( + "encrypted secrets blob exists but the keychain data key is missing" + )), + None => { + let encoded = generate_encoded_data_key(); + key_store.set(&encoded).map_err(anyhow::Error::from)?; + decode_data_key(&encoded).map_err(anyhow::Error::from) + } + } +} + +fn read_secret_blob_state( + app: &AppHandle, + blob_path: &Path, +) -> Result { + let blob_exists = blob_path.exists(); + let data_key = read_or_create_data_key(&data_key_store_for(app), blob_exists)?; + let payload = if blob_exists { + let encrypted = fs::read_to_string(blob_path) + .with_context(|| format!("failed to read encrypted secrets blob at {blob_path:?}"))?; + decrypt_payload(&encrypted, &data_key).map_err(anyhow::Error::from)? + } else { + SecretBlobPayload::default() + }; + + Ok(SecretBlobState { + blob_path: blob_path.to_path_buf(), + data_key, + payload, + }) +} + +fn persist_secret_blob_state(state: &SecretBlobState) -> Result<()> { + if let Some(parent) = state.blob_path.parent() { + fs::create_dir_all(parent)?; + } + let encrypted = + encrypt_payload(&state.payload, &state.data_key).map_err(anyhow::Error::from)?; + let temp_path = state.blob_path.with_extension("json.tmp"); + fs::write(&temp_path, format!("{encrypted}\n")) + .with_context(|| format!("failed to write encrypted secrets blob at {temp_path:?}"))?; + fs::rename(&temp_path, &state.blob_path).with_context(|| { + format!( + "failed to replace encrypted secrets blob at {:?}", + state.blob_path + ) + })?; + Ok(()) +} + +struct LegacyMigration { + payload_changed: bool, + old_keychain_scan_complete: bool, +} + +fn merge_legacy_secrets( + app: &AppHandle, + payload: &mut SecretBlobPayload, +) -> Result { + if payload.legacy_keychain_migration_complete { + return Ok(LegacyMigration { + payload_changed: false, + old_keychain_scan_complete: true, + }); + } + + let mut payload_changed = false; + let mut old_keychain_scan_complete = true; + for &key in SECRET_PREF_KEYS { + if payload.secrets.contains_key(key) { + continue; + } + let old_keychain = keychain_store_for(app, key); + match old_keychain.get() { + Ok(Some(value)) => { + payload.secrets.insert(key.to_string(), value); + payload_changed = true; + } + Ok(None) => {} + Err(err) => { + old_keychain_scan_complete = false; + log::warn!( + "Failed to inspect legacy per-key keychain item {} during secret blob migration: {}", + key, + err + ); + } + } + } + + for &key in SECRET_PREF_KEYS { + if payload.secrets.contains_key(key) { + continue; + } + let Some(value) = get_string_pref_raw(app, key)? else { + continue; + }; + payload.secrets.insert(key.to_string(), value); + payload_changed = true; + } + + Ok(LegacyMigration { + payload_changed, + old_keychain_scan_complete, + }) +} + +fn cleanup_legacy_secret_stores(app: &AppHandle) -> bool { + let mut cleanup_complete = true; + for &key in SECRET_PREF_KEYS { + let legacy = legacy_settings_store(app, key); + if let Err(err) = legacy.delete() { + cleanup_complete = false; + log::warn!( + "Failed to remove legacy plaintext secret {} after blob migration: {}", + key, + err + ); + } + + let old_keychain = keychain_store_for(app, key); + if let Err(err) = old_keychain.delete() { + cleanup_complete = false; + log::warn!( + "Failed to remove legacy per-key keychain item {} after blob migration: {}", + key, + err + ); + } + } + cleanup_complete +} + +fn load_secret_blob_state(app: &AppHandle) -> Result { + let blob_path = secret_blob_path(app)?; + if let Some(cached) = lock_secret_blob_cache()? + .as_ref() + .filter(|state| state.blob_path == blob_path) + .cloned() + { + return Ok(cached); + } + + let mut state = read_secret_blob_state(app, &blob_path)?; + let migration = merge_legacy_secrets(app, &mut state.payload)?; + + if migration.payload_changed { + persist_secret_blob_state(&state)?; + } + + if migration.old_keychain_scan_complete && cleanup_legacy_secret_stores(app) { + if !state.payload.legacy_keychain_migration_complete { + state.payload.legacy_keychain_migration_complete = true; + persist_secret_blob_state(&state)?; + } + } + + *lock_secret_blob_cache()? = Some(state.clone()); + Ok(state) +} + +fn cache_secret_blob_state(state: SecretBlobState) -> Result<()> { + *lock_secret_blob_cache()? = Some(state); + Ok(()) +} + +pub fn clear_secret_blob_cache() -> Result<()> { + *lock_secret_blob_cache()? = None; + Ok(()) +} + +pub fn delete_secret_blob_file(app: &AppHandle) -> Result<()> { + let blob_path = secret_blob_path(app)?; + if blob_path.exists() { + fs::remove_file(&blob_path) + .with_context(|| format!("failed to remove encrypted secrets blob at {blob_path:?}"))?; + } + clear_secret_blob_cache() +} + fn get_secret_pref(app: &AppHandle, key: &'static str) -> Result> { if e2e_mock_system_enabled() { return get_string_pref_raw(app, key); } - let keychain = keychain_store_for(app, key); - let legacy = legacy_settings_store(app, key); - get_with_lazy_migration(&keychain, &legacy).map_err(anyhow::Error::from) + let state = load_secret_blob_state(app)?; + Ok(state.payload.secrets.get(key).cloned()) } fn set_secret_pref(app: &AppHandle, key: &'static str, value: &str) -> Result<()> { @@ -477,9 +698,31 @@ fn set_secret_pref(app: &AppHandle, key: &'static str, value: &st return set_string_pref(app, key, value); } - let keychain = keychain_store_for(app, key); + let mut state = load_secret_blob_state(app)?; + state + .payload + .secrets + .insert(key.to_string(), value.to_string()); + persist_secret_blob_state(&state)?; + cache_secret_blob_state(state)?; + let legacy = legacy_settings_store(app, key); - set_with_cleanup(&keychain, &legacy, value).map_err(anyhow::Error::from) + if let Err(err) = legacy.delete() { + log::warn!( + "Failed to remove legacy plaintext secret {} after blob write: {}", + key, + err + ); + } + let old_keychain = keychain_store_for(app, key); + if let Err(err) = old_keychain.delete() { + log::warn!( + "Failed to remove legacy per-key keychain item {} after blob write: {}", + key, + err + ); + } + Ok(()) } fn get_usize_pref(app: &AppHandle, key: &str) -> Result> { diff --git a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs index 9261d5629..5993e7006 100644 --- a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs +++ b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs @@ -225,7 +225,7 @@ assert.match(nativeMain, /fn e2e_request_webview_boot_probe[\s\S]*window\.localS assert.match(nativeMain, /page-load-finished-plus-1s[\s\S]*page-load-finished-plus-5s[\s\S]*post-build-plus-2s[\s\S]*post-build-plus-10s[\s\S]*watchdog-before-reload/, 'Native app must probe WebView boot state after build, after page-load finish, and immediately before watchdog reload'); assert.match(nativeMain, /let e2e_page_load_boot_probe = e2e_webview_watchdog[\s\S]*if e2e_page_load_boot_probe \{[\s\S]*page-load-finished-plus-1s[\s\S]*page-load-finished-plus-5s/, 'Page-load WebView boot probes must be gated to active E2E watchdog sessions'); assert.match(nativeMain, /run_on_main_thread\(move \|\| \{\n\s+e2e_request_webview_boot_probe\(&reload_window, "watchdog-before-reload"\);[\s\S]*reload_window\.reload\(\)/, 'Watchdog pre-reload WebView boot probe must run on the main thread before requesting reload'); -assert.match(nativeStore, /fn get_secret_pref[\s\S]*if e2e_mock_system_enabled\(\) \{[\s\S]*return get_string_pref_raw\(app, key\);[\s\S]*get_with_lazy_migration/, 'E2E mock-system mode must bypass keychain reads in UI preference secret lookups'); +assert.match(nativeStore, /fn get_secret_pref[\s\S]*if e2e_mock_system_enabled\(\) \{[\s\S]*return get_string_pref_raw\(app, key\);[\s\S]*load_secret_blob_state/, 'E2E mock-system mode must bypass keychain reads in UI preference secret lookups'); assert.match(debugCommands, /pub async fn e2e_log_breadcrumb[\s\S]*client_timestamp_unix_ms[\s\S]*NIXMAC_E2E_DIAGNOSTICS_DIR[\s\S]*nixmac-frontend-breadcrumbs\.jsonl/, 'Debug command must persist client-timestamped frontend boot breadcrumbs into E2E diagnostics'); assert.match(debugCommands, /pub async fn e2e_mark_boot_stage[\s\S]*get_webview_window\("main"\)[\s\S]*set_title\(&title\)[\s\S]*native boot stage marker/, 'Debug command must mirror E2E boot stages into the native window title for Peekaboo/window-list diagnostics'); assert.match(tauriApi, /logBreadcrumb:[\s\S]*clientTimestampUnixMs[\s\S]*invoke\("e2e_log_breadcrumb"/, 'Frontend API must expose timestamped debug breadcrumb logging through Tauri IPC'); From b7c045001c6cd3d27a8c06f7ecf0d535ddb73f89 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 03:39:37 +0000 Subject: [PATCH 2/2] Fix storage build and storybook snapshot stability Co-authored-by: cooper --- apps/native/.storybook/vitest.setup.ts | 19 +- .../src-tauri/src/storage/credential_store.rs | 191 ------------------ .../__snapshots__/file-view.stories.tsx.snap | 2 +- 3 files changed, 19 insertions(+), 193 deletions(-) diff --git a/apps/native/.storybook/vitest.setup.ts b/apps/native/.storybook/vitest.setup.ts index a750c2f1d..00b492014 100644 --- a/apps/native/.storybook/vitest.setup.ts +++ b/apps/native/.storybook/vitest.setup.ts @@ -7,7 +7,15 @@ function normalizeAnimations(html: string): string { return html .replace(/translateY\(([^)]+)\)/g, (_match, val) => { const rounded = Math.round(Number.parseFloat(val)); - const stableOffset = rounded >= 9 && rounded <= 11 ? 10 : rounded; + const snapToGrid = (base: number, step: number) => { + const candidate = base + Math.round((rounded - base) / step) * step; + return Math.abs(rounded - candidate) <= 1 ? candidate : null; + }; + const stableOffset = + (Math.abs(rounded - 10) <= 1 ? 10 : null) ?? + snapToGrid(4, 36) ?? + snapToGrid(8, 36) ?? + rounded; return `translateY(${stableOffset}px)`; }) .replace(/translateX\(([^)]+)\)/g, (_match, val) => { @@ -43,6 +51,15 @@ function normalizeSnapshotRoot(root: Element): string { html = html.replace(/inmemory:\/\/model\/\d+/g, "inmemory://model/N"); // data-keybinding-context values are similarly auto-incremented. html = html.replace(/data-keybinding-context="\d+"/g, 'data-keybinding-context="N"'); + // Monaco includes a platform class (`mac`, `linux`, `windows`) based on the runner. + html = html.replace( + /\bmonaco-editor no-user-select (?:mac|linux|windows)\s+/g, + "monaco-editor no-user-select " + ); + html = html.replace( + /\bmonaco-editor no-user-select\s+/g, + "monaco-editor no-user-select " + ); return html; } diff --git a/apps/native/src-tauri/src/storage/credential_store.rs b/apps/native/src-tauri/src/storage/credential_store.rs index 2e3ec9de8..48522cd05 100644 --- a/apps/native/src-tauri/src/storage/credential_store.rs +++ b/apps/native/src-tauri/src/storage/credential_store.rs @@ -2,9 +2,6 @@ use std::sync::Arc; use tauri::{AppHandle, Runtime}; use tauri_plugin_keyring::KeyringExt; -#[cfg(test)] -use std::sync::Mutex; - #[derive(Debug, thiserror::Error)] pub enum CredentialStoreError { #[error("keychain operation failed: {0}")] @@ -13,8 +10,6 @@ pub enum CredentialStoreError { Storage(String), #[error("legacy settings store is read-only for writes")] LegacyReadOnly, - #[error("failed to remove legacy plaintext credential: {0}")] - LegacyCleanup(String), } pub trait CredentialStore { @@ -119,189 +114,3 @@ impl CredentialStore for SettingsFileStore { (self.deleter)() } } - -#[cfg(test)] -#[derive(Default)] -pub struct InMemoryStore { - value: Mutex>, -} - -#[cfg(test)] -impl InMemoryStore { - pub fn with_value(value: Option) -> Self { - Self { - value: Mutex::new(value), - } - } -} - -#[cfg(test)] -impl CredentialStore for InMemoryStore { - fn get(&self) -> Result, CredentialStoreError> { - self.value - .lock() - .map(|value| value.clone()) - .map_err(|_| CredentialStoreError::Storage("in-memory store lock poisoned".to_string())) - } - - fn set(&self, value: &str) -> Result<(), CredentialStoreError> { - self.value - .lock() - .map(|mut current| { - *current = Some(value.to_string()); - }) - .map_err(|_| CredentialStoreError::Storage("in-memory store lock poisoned".to_string())) - } - - fn delete(&self) -> Result<(), CredentialStoreError> { - self.value - .lock() - .map(|mut current| { - *current = None; - }) - .map_err(|_| CredentialStoreError::Storage("in-memory store lock poisoned".to_string())) - } -} - -pub fn get_with_lazy_migration( - keychain: &K, - legacy: &L, -) -> Result, CredentialStoreError> -where - K: CredentialStore, - L: CredentialStore, -{ - let keychain_get_err = match keychain.get() { - Ok(Some(value)) => { - // Keychain already has the credential. Clean up any stale plaintext - // copy in legacy storage (e.g. from a previous run that wrote to - // keychain but failed to delete the settings.json entry). - match legacy.get() { - Ok(Some(_)) => { - if let Err(err) = legacy.delete() { - log::warn!( - "Failed to clean up stale plaintext credential from settings: {}", - err - ); - } else { - log::info!("Cleaned up stale plaintext credential from settings (already in keychain)"); - } - } - Ok(None) => {} - Err(err) => { - log::warn!("Could not check legacy store during cleanup: {}", err); - } - } - return Ok(Some(value)); - } - Ok(None) => None, - Err(err) => Some(err), - }; - - let Some(legacy_value) = legacy.get()? else { - return match keychain_get_err { - Some(err) => Err(err), - None => Ok(None), - }; - }; - - match keychain.set(&legacy_value) { - Ok(()) => { - if let Err(err) = legacy.delete() { - log::warn!( - "Credential migrated to keychain but failed to clean up plaintext settings value: {}", - err - ); - } - } - Err(err) => { - log::warn!( - "Credential migration to keychain failed, keeping legacy settings value in place: {}", - err - ); - } - } - - Ok(Some(legacy_value)) -} - -pub fn set_with_cleanup( - keychain: &K, - legacy: &L, - value: &str, -) -> Result<(), CredentialStoreError> -where - K: CredentialStore, - L: CredentialStore, -{ - keychain.set(value)?; - legacy - .delete() - .map_err(|err| CredentialStoreError::LegacyCleanup(err.to_string())) -} - -#[cfg(test)] -mod tests { - use super::*; - - struct FailingSetStore; - - impl CredentialStore for FailingSetStore { - fn get(&self) -> Result, CredentialStoreError> { - Ok(None) - } - - fn set(&self, _value: &str) -> Result<(), CredentialStoreError> { - Err(CredentialStoreError::Storage("set failed".to_string())) - } - - fn delete(&self) -> Result<(), CredentialStoreError> { - Ok(()) - } - } - - #[test] - fn migrates_legacy_value_to_keychain_and_cleans_plaintext() { - let keychain = InMemoryStore::default(); - let legacy = InMemoryStore::with_value(Some("legacy-secret".to_string())); - - let value = get_with_lazy_migration(&keychain, &legacy).unwrap(); - - assert_eq!(value.as_deref(), Some("legacy-secret")); - assert_eq!(keychain.get().unwrap().as_deref(), Some("legacy-secret")); - assert_eq!(legacy.get().unwrap(), None); - } - - #[test] - fn returns_legacy_value_if_keychain_migration_write_fails() { - let keychain = FailingSetStore; - let legacy = InMemoryStore::with_value(Some("legacy-secret".to_string())); - - let value = get_with_lazy_migration(&keychain, &legacy).unwrap(); - - assert_eq!(value.as_deref(), Some("legacy-secret")); - assert_eq!(legacy.get().unwrap().as_deref(), Some("legacy-secret")); - } - - #[test] - fn write_goes_to_keychain_and_removes_legacy_plaintext() { - let keychain = InMemoryStore::default(); - let legacy = InMemoryStore::with_value(Some("stale".to_string())); - - set_with_cleanup(&keychain, &legacy, "new-secret").unwrap(); - - assert_eq!(keychain.get().unwrap().as_deref(), Some("new-secret")); - assert_eq!(legacy.get().unwrap(), None); - } - - #[test] - fn write_failure_does_not_remove_legacy_plaintext() { - let keychain = FailingSetStore; - let legacy = InMemoryStore::with_value(Some("legacy-secret".to_string())); - - let result = set_with_cleanup(&keychain, &legacy, "new-secret"); - - assert!(result.is_err()); - assert_eq!(legacy.get().unwrap().as_deref(), Some("legacy-secret")); - } -} diff --git a/apps/native/src/components/widget/summaries/__snapshots__/file-view.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/file-view.stories.tsx.snap index afc8608f2..57364f592 100644 --- a/apps/native/src/components/widget/summaries/__snapshots__/file-view.stories.tsx.snap +++ b/apps/native/src/components/widget/summaries/__snapshots__/file-view.stories.tsx.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`New Json File 1`] = `"
"`; +exports[`New Json File 1`] = `"
"`; exports[`New Nix File 1`] = `"
"`;