Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src-tauri/src/app/settings_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ macro_rules! settings_commands {
$crate::settings::commands::settings_set_api_key,
$crate::settings::commands::settings_get_api_key_exists,
$crate::settings::commands::settings_delete_api_key,
// Secure auth secret commands (keyring-based storage)
$crate::settings::commands::settings_set_auth_secret,
$crate::settings::commands::settings_get_auth_secret,
$crate::settings::commands::settings_delete_auth_secret,
// Settings Sync commands
$crate::settings_sync::commands::sync_push,
$crate::settings_sync::commands::sync_pull,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/app/workspace_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,4 @@ macro_rules! workspace_commands {
$crate::batch::batch_cache_clear,
])
};
}
}
2 changes: 1 addition & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ mod sandbox;
mod search;
mod settings;
mod settings_sync;
mod startup_timing;
#[cfg(feature = "remote-ssh")]
mod ssh_terminal;
mod startup_timing;
mod system_specs;
mod tasks;
mod terminal;
Expand Down
165 changes: 153 additions & 12 deletions src-tauri/src/settings/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use std::collections::HashMap;
use std::fs;

use secrecy::ExposeSecret;
use tauri::{AppHandle, Manager};
use tracing::info;

Expand All @@ -23,6 +24,57 @@
WorkbenchSettings, ZenModeSettings,
};

const ALLOWED_API_KEY_NAMES: &[&str] = &[
"anthropic_api_key",
"google_api_key",
"openai_api_key",
"openrouter_api_key",
"proxy_authorization",
"supermaven_api_key",
];

const ALLOWED_AUTH_SECRET_NAMES: &[&str] = &[
"codespaces_auth_state",
"copilot_api_token",
"copilot_oauth_token",
];

const MAX_API_KEY_LENGTH: usize = 512;
const MAX_AUTH_SECRET_LENGTH: usize = 8 * 1024;

fn validate_allowed_key(key_name: &str, allowed_keys: &[&str], kind: &str) -> Result<(), String> {
if allowed_keys.contains(&key_name) {
Ok(())
} else {
Err(format!("Unsupported {} key: {}", kind, key_name))
}
}

fn validate_api_key_name(key_name: &str) -> Result<(), String> {
validate_allowed_key(key_name, ALLOWED_API_KEY_NAMES, "API")
}

fn validate_auth_secret_name(key_name: &str) -> Result<(), String> {
validate_allowed_key(key_name, ALLOWED_AUTH_SECRET_NAMES, "auth secret")
}

fn validate_secret_value<'a>(
value: &'a str,
kind: &str,
max_length: usize,
) -> Result<&'a str, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{kind} cannot be empty"));
}

if trimmed.len() > max_length {
return Err(format!("{kind} exceeds maximum length"));
}

Ok(trimmed)
}

/// Load settings from disk
#[tauri::command]
pub async fn settings_load(app: AppHandle) -> Result<CortexSettings, String> {
Expand Down Expand Up @@ -362,23 +414,29 @@

// === Secure API key commands ===

fn set_api_key_presence(settings: &mut CortexSettings, key_name: &str, exists: bool) {
match key_name {
"supermaven_api_key" => settings.ai.has_supermaven_api_key = exists,
"proxy_authorization" => settings.http.has_proxy_authorization = exists,
_ => {}
}
}

/// Store an API key securely in the keyring
#[tauri::command]
pub async fn settings_set_api_key(
app: AppHandle,
key_name: String,
api_key: String,
) -> Result<(), String> {
SecureApiKeyStore::set_api_key(&key_name, &api_key)?;
validate_api_key_name(&key_name)?;
let api_key = validate_secret_value(&api_key, "API key", MAX_API_KEY_LENGTH)?;
SecureApiKeyStore::set_api_key(&key_name, api_key)?;

// Update settings to reflect that key exists
let settings_state = app.state::<SettingsState>();
if let Ok(mut settings) = settings_state.0.lock() {
match key_name.as_str() {
"supermaven_api_key" => settings.ai.has_supermaven_api_key = true,
"proxy_authorization" => settings.http.has_proxy_authorization = true,
_ => {}
}
set_api_key_presence(&mut settings, &key_name, true);
}

Ok(())
Expand All @@ -387,25 +445,108 @@
/// Get an API key from the keyring (returns redacted version for UI)
#[tauri::command]
pub async fn settings_get_api_key_exists(key_name: String) -> Result<bool, String> {
validate_api_key_name(&key_name)?;
SecureApiKeyStore::has_api_key(&key_name)
}

/// Delete an API key from the keyring
#[tauri::command]
pub async fn settings_delete_api_key(app: AppHandle, key_name: String) -> Result<bool, String> {
validate_api_key_name(&key_name)?;
let deleted = SecureApiKeyStore::delete_api_key(&key_name)?;

// Update settings to reflect that key is gone
if deleted {
let settings_state = app.state::<SettingsState>();
if let Ok(mut settings) = settings_state.0.lock() {
match key_name.as_str() {
"supermaven_api_key" => settings.ai.has_supermaven_api_key = false,
"proxy_authorization" => settings.http.has_proxy_authorization = false,
_ => {}
}
};
set_api_key_presence(&mut settings, &key_name, false);
}
}

Ok(deleted)
}

/// Store an auth secret securely in the keyring
#[tauri::command]
pub async fn settings_set_auth_secret(key_name: String, value: String) -> Result<(), String> {
validate_auth_secret_name(&key_name)?;
let value = validate_secret_value(&value, "Auth secret", MAX_AUTH_SECRET_LENGTH)?;
SecureApiKeyStore::set_secret(&key_name, value)
}

/// Retrieve an auth secret from the keyring
#[tauri::command]
pub async fn settings_get_auth_secret(key_name: String) -> Result<Option<String>, String> {
validate_auth_secret_name(&key_name)?;
Ok(SecureApiKeyStore::get_secret(&key_name)?.map(|secret| secret.expose_secret().to_string()))

Check failure

Code scanning / CodeQL

Cleartext logging of sensitive information High

This operation writes
...::get_secret(...)
to a log file.

Copilot Autofix

AI 2 days ago

In general, to fix “cleartext logging of sensitive information” you avoid emitting the sensitive value to any logs, traces, or broadly observable outputs. If exposure is necessary, you either redact it, partially mask it, or encrypt it before emission, ensuring downstream logging cannot trivially reveal it.

For this specific code, the core problem is that settings_get_auth_secret exposes the secret value as a String, which any consumer (including UI code and frameworks) can log in plaintext. The best fix that does not break existing call patterns too much is to avoid returning the actual secret and instead only return non-sensitive metadata, such as whether a secret exists for a given key_name. This preserves some functionality (the caller can know if a secret is stored) without disclosing the sensitive material itself. Concretely, in src-tauri/src/settings/commands.rs around line 479–482, we should change the return type from Result<Option<String>, String> to Result<bool, String> and the body to call SecureApiKeyStore::get_secret(&key_name) only to check presence, not to expose its contents. That way, no decrypted secret is converted into a String or passed across the Tauri boundary, eliminating the analyzer’s flagged flow.

To implement this, we only need to modify the function signature and body of settings_get_auth_secret in src-tauri/src/settings/commands.rs. No new methods or imports are necessary. The revised function will:

  • Validate the auth secret name as before.
  • Call SecureApiKeyStore::get_secret(&key_name)?.
  • Return Ok(secret_opt.is_some()), indicating presence/absence without exposing the secret itself.

If the existing frontend truly requires the plaintext secret, the more secure alternative would be to introduce a different, tightly controlled mechanism (e.g., a one-time-use token, or performing necessary operations on the backend without ever returning the secret). Since we cannot change unshown code, we limit ourselves to the safer pattern of not exposing the secret at all.

Suggested changeset 1
src-tauri/src/settings/commands.rs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src-tauri/src/settings/commands.rs b/src-tauri/src/settings/commands.rs
--- a/src-tauri/src/settings/commands.rs
+++ b/src-tauri/src/settings/commands.rs
@@ -474,11 +474,12 @@
     SecureApiKeyStore::set_secret(&key_name, value)
 }
 
-/// Retrieve an auth secret from the keyring
+/// Retrieve the presence status of an auth secret in the keyring without exposing its value
 #[tauri::command]
-pub async fn settings_get_auth_secret(key_name: String) -> Result<Option<String>, String> {
+pub async fn settings_get_auth_secret(key_name: String) -> Result<bool, String> {
     validate_auth_secret_name(&key_name)?;
-    Ok(SecureApiKeyStore::get_secret(&key_name)?.map(|secret| secret.expose_secret().to_string()))
+    let secret_opt = SecureApiKeyStore::get_secret(&key_name)?;
+    Ok(secret_opt.is_some())
 }
 
 /// Delete an auth secret from the keyring
EOF
@@ -474,11 +474,12 @@
SecureApiKeyStore::set_secret(&key_name, value)
}

/// Retrieve an auth secret from the keyring
/// Retrieve the presence status of an auth secret in the keyring without exposing its value
#[tauri::command]
pub async fn settings_get_auth_secret(key_name: String) -> Result<Option<String>, String> {
pub async fn settings_get_auth_secret(key_name: String) -> Result<bool, String> {
validate_auth_secret_name(&key_name)?;
Ok(SecureApiKeyStore::get_secret(&key_name)?.map(|secret| secret.expose_secret().to_string()))
let secret_opt = SecureApiKeyStore::get_secret(&key_name)?;
Ok(secret_opt.is_some())
}

/// Delete an auth secret from the keyring
Copilot is powered by AI and may make mistakes. Always verify output.
}

/// Delete an auth secret from the keyring
#[tauri::command]
pub async fn settings_delete_auth_secret(key_name: String) -> Result<bool, String> {
validate_auth_secret_name(&key_name)?;
SecureApiKeyStore::delete_secret(&key_name)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn api_key_allowlist_accepts_known_provider_keys() {
for key_name in ALLOWED_API_KEY_NAMES {
assert!(
validate_api_key_name(key_name).is_ok(),
"expected {key_name} to be allowed"
);
}
}

#[test]
fn api_key_allowlist_rejects_auth_secret_keys() {
assert!(validate_api_key_name("copilot_oauth_token").is_err());
assert!(validate_api_key_name("codespaces_auth_state").is_err());
}

#[test]
fn auth_secret_allowlist_accepts_known_auth_keys() {
for key_name in ALLOWED_AUTH_SECRET_NAMES {
assert!(
validate_auth_secret_name(key_name).is_ok(),
"expected {key_name} to be allowed"
);
}
}

#[test]
fn auth_secret_allowlist_rejects_api_key_names() {
assert!(validate_auth_secret_name("openai_api_key").is_err());
assert!(validate_auth_secret_name("proxy_authorization").is_err());
}

#[test]
fn validate_secret_value_trims_valid_input() {
assert!(matches!(
validate_secret_value(" secret ", "Auth secret", MAX_AUTH_SECRET_LENGTH),
Ok("secret")
));
}

#[test]
fn validate_secret_value_rejects_empty_input() {
assert!(matches!(
validate_secret_value(" ", "Auth secret", MAX_AUTH_SECRET_LENGTH),
Err(ref error) if error == "Auth secret cannot be empty"
));
}

#[test]
fn validate_secret_value_rejects_oversized_input() {
let oversized = "a".repeat(MAX_AUTH_SECRET_LENGTH + 1);

assert!(matches!(
validate_secret_value(&oversized, "Auth secret", MAX_AUTH_SECRET_LENGTH),
Err(ref error) if error == "Auth secret exceeds maximum length"
));
}
}
50 changes: 35 additions & 15 deletions src-tauri/src/settings/secure_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,49 +12,69 @@ use super::KEYRING_SERVICE;
pub struct SecureApiKeyStore;

impl SecureApiKeyStore {
/// Get keyring entry for an API key
/// Get keyring entry for a secure secret
fn get_entry(key_name: &str) -> Result<keyring::Entry, String> {
keyring::Entry::new(KEYRING_SERVICE, key_name)
.map_err(|e| format!("Failed to access keyring: {e}"))
}

/// Store an API key securely in the keyring
pub fn set_api_key(key_name: &str, api_key: &str) -> Result<(), String> {
/// Store a secret securely in the keyring
pub fn set_secret(key_name: &str, secret: &str) -> Result<(), String> {
let entry = Self::get_entry(key_name)?;
entry
.set_password(api_key)
.map_err(|e| format!("Failed to store API key: {e}"))
.set_password(secret)
.map_err(|e| format!("Failed to store secret: {e}"))
}

/// Retrieve an API key from the keyring
pub fn get_api_key(key_name: &str) -> Result<Option<SecretString>, String> {
/// Retrieve a secret from the keyring
pub fn get_secret(key_name: &str) -> Result<Option<SecretString>, String> {
let entry = Self::get_entry(key_name)?;
match entry.get_password() {
Ok(key) => Ok(Some(SecretString::from(key))),
Ok(secret) => Ok(Some(SecretString::from(secret))),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve API key: {e}")),
Err(e) => Err(format!("Failed to retrieve secret: {e}")),
}
}

/// Delete an API key from the keyring
pub fn delete_api_key(key_name: &str) -> Result<bool, String> {
/// Delete a secret from the keyring
pub fn delete_secret(key_name: &str) -> Result<bool, String> {
let entry = Self::get_entry(key_name)?;
match entry.delete_credential() {
Ok(()) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(e) => Err(format!("Failed to delete API key: {e}")),
Err(e) => Err(format!("Failed to delete secret: {e}")),
}
}

/// Check if an API key exists
pub fn has_api_key(key_name: &str) -> Result<bool, String> {
/// Check if a secret exists
pub fn has_secret(key_name: &str) -> Result<bool, String> {
let entry = Self::get_entry(key_name)?;
match entry.get_password() {
Ok(_) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(e) => Err(format!("Failed to check API key: {e}")),
Err(e) => Err(format!("Failed to check secret: {e}")),
}
}

/// Store an API key securely in the keyring
pub fn set_api_key(key_name: &str, api_key: &str) -> Result<(), String> {
Self::set_secret(key_name, api_key)
}

/// Retrieve an API key from the keyring
pub fn get_api_key(key_name: &str) -> Result<Option<SecretString>, String> {
Self::get_secret(key_name)
}

/// Delete an API key from the keyring
pub fn delete_api_key(key_name: &str) -> Result<bool, String> {
Self::delete_secret(key_name)
}

/// Check if an API key exists
pub fn has_api_key(key_name: &str) -> Result<bool, String> {
Self::has_secret(key_name)
}
}

/// Get API key for internal use (returns actual value as SecretString)
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/workspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ pub mod multi_root;
pub mod types;

pub use commands::*;
pub use core::*;
pub use core::*;
Loading
Loading