//! Secure credential storage module //! //! Provides secure storage for access tokens using: //! - Primary: System keyring (Secret Service on Linux, Keychain on macOS) //! - Fallback: AES-256-GCM encrypted file when keyring unavailable //! //! The fallback is less secure as the encryption key is derived from machine //! identifiers, but provides functionality on headless systems. use aes_gcm::{ aead::{Aead, KeyInit}, Aes256Gcm, Nonce, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use directories::ProjectDirs; use log::{info, warn}; use sha2::{Digest, Sha256}; use std::fs; use std::path::PathBuf; #[cfg(target_os = "linux")] use hostname; #[cfg(not(target_os = "android"))] const SERVICE_NAME: &str = "com.dtourolle.jellytau"; const CREDENTIALS_FILENAME: &str = "credentials.enc"; /// Result of a credential storage operation #[derive(Debug)] pub enum CredentialResult { /// Operation succeeded using the system keyring Keyring, /// Operation succeeded using encrypted file fallback EncryptedFile, } /// Error types for credential operations #[derive(Debug)] pub enum CredentialError { /// Keyring operation failed Keyring(String), /// Encryption/decryption failed Encryption(String), /// File I/O failed Io(String), /// Credential not found NotFound, } impl std::fmt::Display for CredentialError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Keyring(msg) => write!(f, "Keyring error: {}", msg), Self::Encryption(msg) => write!(f, "Encryption error: {}", msg), Self::Io(msg) => write!(f, "I/O error: {}", msg), Self::NotFound => write!(f, "Credential not found"), } } } impl std::error::Error for CredentialError {} /// Credential storage manager pub struct CredentialStore { /// Whether we're using keyring (true) or encrypted file (false) using_keyring: bool, /// Path to the encrypted credentials file (fallback) credentials_path: PathBuf, /// Encryption key for file fallback (derived from machine ID) encryption_key: [u8; 32], } impl CredentialStore { /// Create a new credential store, detecting the best available backend pub fn new() -> Self { let credentials_path = Self::get_credentials_path(); let encryption_key = Self::derive_encryption_key(); // Test if keyring is available by trying a dummy operation let using_keyring = Self::test_keyring_available(); if !using_keyring { warn!( "[INIT] System keyring unavailable, using encrypted file fallback at {:?}. \ This is less secure than system keyring storage.", credentials_path ); } else { info!("[INIT] Using system keyring for credential storage"); } Self { using_keyring, credentials_path, encryption_key, } } /// Check if we're using the secure keyring backend pub fn is_using_keyring(&self) -> bool { self.using_keyring } /// Save an access token for a user pub fn save_token(&self, user_id: &str, token: &str) -> Result { if self.using_keyring { log::debug!("Saving token for user {} to keyring", user_id); self.save_to_keyring(user_id, token)?; Ok(CredentialResult::Keyring) } else { log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path); self.save_to_file(user_id, token)?; log::debug!("Successfully saved token to encrypted file"); Ok(CredentialResult::EncryptedFile) } } /// Get an access token for a user pub fn get_token(&self, user_id: &str) -> Result { if self.using_keyring { log::debug!("Getting token for user {} from keyring", user_id); self.get_from_keyring(user_id) } else { log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path); let result = self.get_from_file(user_id); if result.is_ok() { log::debug!("Successfully retrieved token from encrypted file"); } else { log::warn!("Failed to retrieve token from encrypted file: {:?}", result); } result } } /// Delete an access token for a user pub fn delete_token(&self, user_id: &str) -> Result<(), CredentialError> { if self.using_keyring { self.delete_from_keyring(user_id) } else { self.delete_from_file(user_id) } } // --- Keyring backend --- fn test_keyring_available() -> bool { // On Android, use Android Keystore via JNI #[cfg(target_os = "android")] { android_test_keystore_available() } // On Linux, the keyring test can block indefinitely if Secret Service // (gnome-keyring/kwallet) is unresponsive. Use a timeout to prevent hanging. #[cfg(target_os = "linux")] { use std::sync::mpsc; use std::thread; use std::time::Duration; let (tx, rx) = mpsc::channel(); thread::spawn(move || { let result = Self::test_keyring_inner(); let _ = tx.send(result); }); // Wait up to 2 seconds for keyring response match rx.recv_timeout(Duration::from_secs(2)) { Ok(result) => result, Err(_) => { log::warn!("Keyring availability check timed out after 2 seconds"); false } } } #[cfg(all(not(target_os = "linux"), not(target_os = "android")))] { Self::test_keyring_inner() } } #[cfg(target_os = "linux")] fn test_keyring_inner() -> bool { // On Linux, test if secret-tool is available use std::process::Command; // secret-tool doesn't support --version, so we test with a search command // that will succeed even if no items are found match Command::new("secret-tool") .arg("search") .arg("service") .arg("__nonexistent_test__") .output() { Ok(_) => true, // If command runs (even with no results), secret-tool is available Err(_) => false, // Command not found or can't execute } } #[cfg(all(not(target_os = "android"), not(target_os = "linux")))] fn test_keyring_inner() -> bool { // On macOS/Windows, test using the keyring-rs library let entry = keyring::Entry::new(SERVICE_NAME, "__test__"); match entry { Ok(e) => { // Try to get (will fail with NotFound, which is fine) // If it fails with a different error, keyring is not available match e.get_password() { Ok(_) => true, Err(keyring::Error::NoEntry) => true, Err(keyring::Error::NoStorageAccess(_)) => false, Err(_) => false, } } Err(_) => false, } } fn save_to_keyring(&self, user_id: &str, token: &str) -> Result<(), CredentialError> { // On Android, use Android Keystore via JNI #[cfg(target_os = "android")] { android_keystore::save_token(user_id, token) } #[cfg(target_os = "linux")] { // Use secret-tool directly on Linux as a workaround for keyring-rs library issues // See Technical Debt section in README.md for details use std::process::{Command, Stdio}; use std::io::Write; let key = format!("access_token:{}", user_id); let mut child = Command::new("secret-tool") .arg("store") .arg("--label") .arg(format!("{}@{}", key, SERVICE_NAME)) .arg("service") .arg(SERVICE_NAME) .arg("username") .arg(&key) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(token.as_bytes()) .map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?; } let status = child.wait() .map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?; if status.success() { Ok(()) } else { Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status))) } } #[cfg(all(not(target_os = "linux"), not(target_os = "android")))] { let key = format!("access_token:{}", user_id); let entry = keyring::Entry::new(SERVICE_NAME, &key) .map_err(|e| CredentialError::Keyring(e.to_string()))?; entry .set_password(token) .map_err(|e| CredentialError::Keyring(e.to_string())) } } fn get_from_keyring(&self, user_id: &str) -> Result { // On Android, use Android Keystore via JNI #[cfg(target_os = "android")] { android_keystore::get_token(user_id) } #[cfg(target_os = "linux")] { // Use secret-tool directly on Linux as a workaround for keyring-rs library issues // See Technical Debt section in README.md for details use std::process::Command; let key = format!("access_token:{}", user_id); log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key); let output = Command::new("secret-tool") .arg("lookup") .arg("service") .arg(SERVICE_NAME) .arg("username") .arg(&key) .output() .map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?; if output.status.success() { log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len()); let token = String::from_utf8(output.stdout) .map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))? .trim() .to_string(); Ok(token) } else { let stderr = String::from_utf8_lossy(&output.stderr); log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr); Err(CredentialError::NotFound) } } #[cfg(all(not(target_os = "linux"), not(target_os = "android")))] { let key = format!("access_token:{}", user_id); let entry = keyring::Entry::new(SERVICE_NAME, &key) .map_err(|e| CredentialError::Keyring(e.to_string()))?; entry.get_password().map_err(|e| match e { keyring::Error::NoEntry => CredentialError::NotFound, _ => CredentialError::Keyring(e.to_string()), }) } } fn delete_from_keyring(&self, user_id: &str) -> Result<(), CredentialError> { // On Android, use Android Keystore via JNI #[cfg(target_os = "android")] { android_keystore::delete_token(user_id) } #[cfg(target_os = "linux")] { // Use secret-tool directly on Linux as a workaround for keyring-rs library issues // See Technical Debt section in README.md for details use std::process::Command; let key = format!("access_token:{}", user_id); let status = Command::new("secret-tool") .arg("clear") .arg("service") .arg(SERVICE_NAME) .arg("username") .arg(&key) .status() .map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?; // secret-tool clear returns success even if entry doesn't exist if status.success() { Ok(()) } else { Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status))) } } #[cfg(all(not(target_os = "linux"), not(target_os = "android")))] { let key = format!("access_token:{}", user_id); let entry = keyring::Entry::new(SERVICE_NAME, &key) .map_err(|e| CredentialError::Keyring(e.to_string()))?; match entry.delete_credential() { Ok(_) => Ok(()), Err(keyring::Error::NoEntry) => Ok(()), // Already deleted Err(e) => Err(CredentialError::Keyring(e.to_string())), } } } // --- Encrypted file backend --- fn get_credentials_path() -> PathBuf { if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") { proj_dirs.data_dir().join(CREDENTIALS_FILENAME) } else { PathBuf::from(CREDENTIALS_FILENAME) } } fn derive_encryption_key() -> [u8; 32] { // Derive a key from machine-specific identifiers // This is less secure than a true keyring but provides some protection let mut hasher = Sha256::new(); // Use hostname on Linux (where it's available and stable) #[cfg(target_os = "linux")] { if let Ok(hostname) = hostname::get() { hasher.update(hostname.to_string_lossy().as_bytes()); } } // On Android, read device properties from the filesystem #[cfg(target_os = "android")] { // Try to read Android build properties from /system/build.prop let build_prop_paths = [ "/system/build.prop", "/vendor/build.prop", ]; for path in &build_prop_paths { if let Ok(content) = fs::read_to_string(path) { // Extract key properties for device fingerprint for line in content.lines() { if line.starts_with("ro.build.fingerprint=") || line.starts_with("ro.serialno=") || line.starts_with("ro.build.id=") || line.starts_with("ro.product.model=") { hasher.update(line.as_bytes()); } } } } // Also use the app data directory path as it's device/install-specific if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") { hasher.update(proj_dirs.data_dir().to_string_lossy().as_bytes()); } } // Use a static salt (app-specific) hasher.update(b"jellytau-credential-encryption-v1"); // Add username for additional entropy (if available) if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) { hasher.update(user.as_bytes()); } hasher.finalize().into() } fn load_credentials_file(&self) -> Result { if !self.credentials_path.exists() { return Ok(serde_json::json!({})); } let encrypted_data = fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?; if encrypted_data.is_empty() { return Ok(serde_json::json!({})); } let decrypted = self.decrypt(&encrypted_data)?; serde_json::from_str(&decrypted).map_err(|e| CredentialError::Encryption(e.to_string())) } fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> { // Ensure parent directory exists if let Some(parent) = self.credentials_path.parent() { fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?; } let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?; let encrypted = self.encrypt(&json)?; fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string())) } fn encrypt(&self, plaintext: &str) -> Result { let cipher = Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?; // Generate a random nonce let mut nonce_bytes = [0u8; 12]; getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?; let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = cipher .encrypt(nonce, plaintext.as_bytes()) .map_err(|e| CredentialError::Encryption(e.to_string()))?; // Prepend nonce to ciphertext and encode as base64 let mut combined = nonce_bytes.to_vec(); combined.extend(ciphertext); Ok(BASE64.encode(&combined)) } fn decrypt(&self, encrypted: &str) -> Result { let combined = BASE64 .decode(encrypted) .map_err(|e| CredentialError::Encryption(e.to_string()))?; if combined.len() < 12 { return Err(CredentialError::Encryption("Invalid encrypted data".to_string())); } let (nonce_bytes, ciphertext) = combined.split_at(12); let nonce = Nonce::from_slice(nonce_bytes); let cipher = Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?; let plaintext = cipher .decrypt(nonce, ciphertext) .map_err(|e| CredentialError::Encryption(e.to_string()))?; String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string())) } fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> { let mut data = self.load_credentials_file()?; data[user_id] = serde_json::json!(token); self.save_credentials_file(&data) } fn get_from_file(&self, user_id: &str) -> Result { let data = self.load_credentials_file()?; data.get(user_id) .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or(CredentialError::NotFound) } fn delete_from_file(&self, user_id: &str) -> Result<(), CredentialError> { let mut data = self.load_credentials_file()?; if let Some(obj) = data.as_object_mut() { obj.remove(user_id); } self.save_credentials_file(&data) } } impl Default for CredentialStore { fn default() -> Self { Self::new() } } // --- Android Keystore integration via JNI --- #[cfg(target_os = "android")] mod android_keystore { use super::*; use jni::objects::{JClass, JObject, JString, JValue}; use jni::JNIEnv; use std::sync::OnceLock; /// Cached reference to the SecureStorage class static SECURE_STORAGE_CLASS: OnceLock = OnceLock::new(); const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage"; /// Initialize the SecureStorage singleton from Android context pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> { log::info!("Initializing Android SecureStorage..."); // Get the ClassLoader from the Context let class_loader = env .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]) .map_err(|e| format!("Failed to get ClassLoader: {}", e))? .l() .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?; // Load the SecureStorage class let class_name = env .new_string(SECURE_STORAGE_CLASS_NAME.replace('/', ".")) .map_err(|e| format!("Failed to create class name string: {}", e))?; let storage_class_obj = env .call_method( &class_loader, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;", &[JValue::Object(&class_name.into())], ) .map_err(|e| format!("Failed to load SecureStorage class: {}", e))? .l() .map_err(|e| format!("Failed to convert to Class: {}", e))?; let storage_class = JClass::from(storage_class_obj); // Call SecureStorage.initialize(context) env.call_static_method( &storage_class, "initialize", "(Landroid/content/Context;)V", &[JValue::Object(context)], ) .map_err(|e| format!("Failed to initialize SecureStorage: {}", e))?; // Cache the class name for future use let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string()); log::info!("Android SecureStorage initialized successfully"); Ok(()) } /// Test if Android Keystore is available pub fn test_keystore_available() -> bool { // Get JNI environment let ctx = ndk_context::android_context(); let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }; let vm = match vm { Ok(vm) => vm, Err(e) => { log::warn!("Failed to get JavaVM for keystore test: {}", e); return false; } }; let mut env = match vm.attach_current_thread() { Ok(env) => env, Err(e) => { log::warn!("Failed to attach thread for keystore test: {}", e); return false; } }; // Try to get the SecureStorage instance match get_secure_storage_instance(&mut env) { Ok(_) => { log::info!("Android Keystore available via SecureStorage"); true } Err(e) => { log::warn!("Android Keystore not available: {}", e); false } } } /// Get the SecureStorage singleton instance fn get_secure_storage_instance<'a>(env: &mut JNIEnv<'a>) -> Result, String> { let class_name = SECURE_STORAGE_CLASS .get() .ok_or_else(|| "SecureStorage not initialized".to_string())?; // Get the Android context let ctx = ndk_context::android_context(); let context = unsafe { JObject::from_raw(ctx.context().cast()) }; // Get the ClassLoader from the Context let class_loader = env .call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]) .map_err(|e| format!("Failed to get ClassLoader: {}", e))? .l() .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?; // Load the SecureStorage class using the app's classloader let class_name_jstring = env .new_string(class_name.replace('/', ".")) .map_err(|e| format!("Failed to create class name string: {}", e))?; let storage_class_obj = env .call_method( &class_loader, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;", &[JValue::Object(&class_name_jstring.into())], ) .map_err(|e| format!("Failed to load SecureStorage class: {}", e))? .l() .map_err(|e| format!("Failed to convert to Class: {}", e))?; let storage_class = JClass::from(storage_class_obj); let instance = env .call_static_method( &storage_class, "getInstance", "()Lcom/dtourolle/jellytau/security/SecureStorage;", &[], ) .map_err(|e| format!("Failed to get SecureStorage instance: {}", e))? .l() .map_err(|e| format!("Failed to convert to object: {}", e))?; Ok(instance) } /// Save a token using Android Keystore pub fn save_token(user_id: &str, token: &str) -> Result<(), CredentialError> { let ctx = ndk_context::android_context(); let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) } .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?; let mut env = vm .attach_current_thread() .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?; let instance = get_secure_storage_instance(&mut env) .map_err(|e| CredentialError::Keyring(e))?; let key = format!("access_token:{}", user_id); let key_jstring = env .new_string(&key) .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?; let token_jstring = env .new_string(token) .map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?; let result = env .call_method( instance, "saveToken", "(Ljava/lang/String;Ljava/lang/String;)Z", &[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())], ) .map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))? .z() .map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?; if result { Ok(()) } else { Err(CredentialError::Keyring("saveToken returned false".to_string())) } } /// Get a token from Android Keystore pub fn get_token(user_id: &str) -> Result { let ctx = ndk_context::android_context(); let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) } .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?; let mut env = vm .attach_current_thread() .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?; let instance = get_secure_storage_instance(&mut env) .map_err(|e| CredentialError::Keyring(e))?; let key = format!("access_token:{}", user_id); let key_jstring = env .new_string(&key) .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?; let result = env .call_method( instance, "getToken", "(Ljava/lang/String;)Ljava/lang/String;", &[JValue::Object(&key_jstring.into())], ) .map_err(|e| CredentialError::Keyring(format!("Failed to call getToken: {}", e)))? .l() .map_err(|e| CredentialError::Keyring(format!("Failed to get object result: {}", e)))?; if result.is_null() { return Err(CredentialError::NotFound); } let token_jstring = JString::from(result); let token: String = env .get_string(&token_jstring) .map_err(|e| CredentialError::Keyring(format!("Failed to get string: {}", e)))? .into(); Ok(token) } /// Delete a token from Android Keystore pub fn delete_token(user_id: &str) -> Result<(), CredentialError> { let ctx = ndk_context::android_context(); let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) } .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?; let mut env = vm .attach_current_thread() .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?; let instance = get_secure_storage_instance(&mut env) .map_err(|e| CredentialError::Keyring(e))?; let key = format!("access_token:{}", user_id); let key_jstring = env .new_string(&key) .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?; let result = env .call_method( instance, "deleteToken", "(Ljava/lang/String;)Z", &[JValue::Object(&key_jstring.into())], ) .map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))? .z() .map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?; if result { Ok(()) } else { Err(CredentialError::Keyring("deleteToken returned false".to_string())) } } } // Export Android keystore functions at the module level for easier access #[cfg(target_os = "android")] pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available}; #[cfg(test)] mod tests { use super::*; #[test] fn test_encryption_roundtrip() { let store = CredentialStore::new(); let plaintext = "test-access-token-12345"; let encrypted = store.encrypt(plaintext).unwrap(); let decrypted = store.decrypt(&encrypted).unwrap(); assert_eq!(plaintext, decrypted); } #[test] fn test_derive_encryption_key_is_deterministic() { let key1 = CredentialStore::derive_encryption_key(); let key2 = CredentialStore::derive_encryption_key(); assert_eq!(key1, key2); } }