//! 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 **obfuscation at rest, not a secret**: its key sits in a file //! beside the ciphertext, so anyone who can read one can read the other. It //! exists so headless systems keep working, and the keyring remains the only //! place a token is actually protected. //! //! The key used to be *derived* from the hostname, `$USER` and a hardcoded salt. //! That was no more secret — those are readable by anyone who can read the file //! — and it was unstable: renaming the machine, or launching from a context //! where `$USER` is unset, changed the key and made every stored token //! undecryptable. `load_credentials_file` treats a failed decrypt as "no stored //! credentials", so that surfaced as being silently signed out rather than as an //! error. The key is now random and persisted, and the old derivation is kept //! only to migrate a file written before this change. //! //! TRACES: UR-012 | IR-014 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(not(target_os = "android"))] const SERVICE_NAME: &str = "com.dtourolle.jellytau"; const CREDENTIALS_FILENAME: &str = "credentials.enc"; /// Key file for the encrypted-file fallback, beside the credentials it opens. const KEY_FILENAME: &str = "credentials.key"; /// Load the fallback encryption key, creating it on first use. /// /// Random rather than derived. A derived key was no more secret — its inputs /// (hostname, `$USER`, a hardcoded salt) are readable by anyone who can read /// the ciphertext — and it silently changed when the machine was renamed or /// `$USER` was unset, which read to the user as being signed out for no reason. /// /// If the key cannot be persisted the process still gets a usable key for this /// run; credentials written under it simply will not be readable next launch, /// which is the same outcome as today and better than refusing to store a token. /// /// TRACES: UR-012 | IR-014 | UT-014 fn load_or_create_key(path: &std::path::Path) -> [u8; 32] { if let Ok(existing) = fs::read(path) { if existing.len() == 32 { let mut key = [0u8; 32]; key.copy_from_slice(&existing); return key; } warn!( "Fallback key at {:?} is {} bytes, not 32; replacing it. Credentials \ written under the old key will need signing in again.", path, existing.len() ); } let mut key = [0u8; 32]; if getrandom::getrandom(&mut key).is_err() { warn!("No system randomness for the fallback key; deriving one for this run"); return CredentialStore::derive_legacy_encryption_key(); } if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } match fs::write(path, key) { Ok(()) => restrict_to_owner(path), Err(e) => warn!( "Could not persist the fallback key at {:?} ({}); credentials stored \ this run will not be readable next launch", path, e ), } key } /// Make a key file owner-readable only. Best effort — a filesystem without /// Unix permissions is not a reason to fail. fn restrict_to_owner(path: &std::path::Path) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Err(e) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) { warn!("Could not restrict permissions on {:?}: {}", path, e); } } #[cfg(not(unix))] let _ = path; } /// Decrypt `encrypted` with `key`. /// /// TRACES: UR-012 | IR-014 | UT-014 fn decrypt_with(key: &[u8; 32], 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(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())) } /// Encrypt `plaintext` with `key`, prepending a fresh random nonce. /// /// TRACES: UR-012 | IR-014 | UT-014 fn encrypt_with(key: &[u8; 32], plaintext: &str) -> Result { let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?; 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()))?; let mut combined = nonce_bytes.to_vec(); combined.extend(ciphertext); Ok(BASE64.encode(&combined)) } /// 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 the file fallback. Random and persisted, so it does /// not change when the machine is renamed. encryption_key: [u8; 32], /// The pre-existing derivation, retained only to read a file written before /// the key was persisted. Anything decrypted with it is rewritten under /// `encryption_key`. legacy_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 = load_or_create_key(&Self::get_key_path()); let legacy_key = Self::derive_legacy_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, legacy_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 Command::new("secret-tool") .arg("search") .arg("service") .arg("__nonexistent_test__") .output() .is_ok() } #[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::io::Write; use std::process::{Command, Stdio}; 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 --- /// Where the fallback key lives: beside the credentials file, so the two /// travel together and a restore that brings one brings the other. fn get_key_path() -> PathBuf { if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") { proj_dirs.data_dir().join(KEY_FILENAME) } else { PathBuf::from(KEY_FILENAME) } } 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) } } /// The key derivation used before keys were persisted. /// /// Kept **only** so a credentials file written by an older build can still /// be read once and rewritten under the persisted key. Never used to /// encrypt. See the module docs for why it was replaced. /// /// TRACES: UR-012 | IR-014 fn derive_legacy_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() } /// Load and decrypt the credential map. /// /// A file that is present but **undecryptable** is deliberately reported as /// an *empty* credential set rather than as an error. The key never leaves /// the device it was derived on (Android Keystore keys are never backed up, /// and the file fallback's key is derived from machine identifiers), so a /// restored/transferred install gets ciphertext with no key and every read /// would fail *permanently*. Surfacing that as an error made session restore /// throw instead of falling back to the login screen: an unrecoverable app /// rather than a clean logged-out one. The next successful login re-encrypts /// the file with the current key, so the state self-heals. /// /// TRACES: UR-012 | IR-014 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, from_legacy_key) = match self.decrypt_migrating(&encrypted_data) { Ok(decrypted) => decrypted, Err(e) => { warn!( "Credentials file at {:?} exists but cannot be decrypted ({}); \ treating as no stored credentials. This is expected after a \ backup restore or device transfer - the encryption key does \ not travel with the data. Signing in again will rewrite it.", self.credentials_path, e ); return Ok(serde_json::json!({})); } }; match serde_json::from_str(&decrypted) { Ok(value) => { // Rewrite under the persisted key so the legacy derivation is // never needed again. if from_legacy_key { if let Err(e) = self.save_credentials_file(&value) { warn!( "Could not rewrite credentials under the persisted key: {}", e ); } } Ok(value) } Err(e) => { warn!( "Credentials file at {:?} decrypted to invalid JSON ({}); \ treating as no stored credentials.", self.credentials_path, e ); Ok(serde_json::json!({})) } } } 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 { encrypt_with(&self.encryption_key, plaintext) } /// Decrypt with the current key, falling back to the legacy derivation. /// /// Returns the plaintext and whether the legacy key was what opened it, so /// the caller can rewrite the file under the current key and stop depending /// on a derivation that changes when the machine is renamed. /// /// TRACES: UR-012 | IR-014 | UT-014 fn decrypt_migrating(&self, encrypted: &str) -> Result<(String, bool), CredentialError> { match decrypt_with(&self.encryption_key, encrypted) { Ok(plaintext) => Ok((plaintext, false)), Err(current_err) => match decrypt_with(&self.legacy_key, encrypted) { Ok(plaintext) => { info!("Credentials were written under the legacy derived key; rewriting them"); Ok((plaintext, true)) } Err(_) => Err(current_err), }, } } 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 { /// The fallback key must be the same on every launch. /// /// It used to be derived from the hostname, `$USER` and a static salt. /// Renaming the machine — or launching from a context where `$USER` is /// unset, such as a systemd user service — changed the key, and /// `load_credentials_file` reports a failed decrypt as "no stored /// credentials". The user was silently signed out with nothing to explain it. /// /// TRACES: UR-012 | IR-014 | UT-014 #[test] fn the_fallback_key_is_stable_across_processes() { let dir = std::env::temp_dir().join(format!("jellytau-key-{}", std::process::id())); let path = dir.join("credentials.key"); let _ = fs::remove_file(&path); let first = load_or_create_key(&path); let second = load_or_create_key(&path); assert_eq!(first, second, "the key must not change between launches"); assert_ne!(first, [0u8; 32], "the key must be real randomness"); let _ = fs::remove_dir_all(&dir); } /// Two installs must not share a key. /// /// TRACES: UR-012 | IR-014 | UT-014 #[test] fn separate_installs_get_separate_keys() { let base = std::env::temp_dir().join(format!("jellytau-keys-{}", std::process::id())); let a = load_or_create_key(&base.join("a").join("credentials.key")); let b = load_or_create_key(&base.join("b").join("credentials.key")); assert_ne!(a, b); let _ = fs::remove_dir_all(&base); } /// A credentials file written under the old derived key must still open. /// /// TRACES: UR-012 | IR-014 | UT-014 #[test] fn credentials_written_under_the_legacy_key_still_decrypt() { let legacy = CredentialStore::derive_legacy_encryption_key(); let mut persisted = [0u8; 32]; getrandom::getrandom(&mut persisted).unwrap(); assert_ne!(legacy, persisted); let blob = encrypt_with(&legacy, r#"{"user-1":"token-abc"}"#).unwrap(); let store = CredentialStore { using_keyring: false, credentials_path: PathBuf::from("/nonexistent/credentials.enc"), encryption_key: persisted, legacy_key: legacy, }; let (plaintext, migrated) = store .decrypt_migrating(&blob) .expect("a file written under the legacy key must still be readable"); assert_eq!(plaintext, r#"{"user-1":"token-abc"}"#); assert!(migrated, "the caller must know to rewrite it"); } /// The current key is tried first and needs no migration. /// /// TRACES: UR-012 | IR-014 | UT-014 #[test] fn credentials_under_the_current_key_are_not_flagged_for_migration() { let mut persisted = [0u8; 32]; getrandom::getrandom(&mut persisted).unwrap(); let blob = encrypt_with(&persisted, "hello").unwrap(); let store = CredentialStore { using_keyring: false, credentials_path: PathBuf::from("/nonexistent/credentials.enc"), encryption_key: persisted, legacy_key: [7u8; 32], }; let (plaintext, migrated) = store.decrypt_migrating(&blob).unwrap(); assert_eq!(plaintext, "hello"); assert!(!migrated); } /// A blob under neither key fails rather than returning something wrong. /// /// TRACES: UR-012 | IR-014 | UT-014 #[test] fn an_unreadable_blob_is_an_error() { let blob = encrypt_with(&[1u8; 32], "secret").unwrap(); let store = CredentialStore { using_keyring: false, credentials_path: PathBuf::from("/nonexistent/credentials.enc"), encryption_key: [2u8; 32], legacy_key: [3u8; 32], }; assert!(store.decrypt_migrating(&blob).is_err()); } use super::*; /// Build a store pinned to the encrypted-file backend with an explicit key, /// so a test can simulate "same file, different machine key" (which is what /// a restored backup looks like). fn file_backed_store(credentials_path: PathBuf, encryption_key: [u8; 32]) -> CredentialStore { CredentialStore { using_keyring: false, credentials_path, encryption_key, // A distinct legacy key, so "same file, different machine key" stays // undecryptable rather than being opened by the migration path. legacy_key: [0xABu8; 32], } } /// A credentials file we cannot decrypt must read as *no credentials stored*, /// not as a hard error. This is the restored-backup case: the ciphertext comes /// back but the key that encrypted it (Android Keystore / the machine-derived /// key) does not, so every read fails forever. /// /// TRACES: UR-012 | IR-014 #[test] fn undecryptable_credentials_file_reads_as_not_found() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(CREDENTIALS_FILENAME); let original = file_backed_store(path.clone(), [1u8; 32]); original.save_to_file("user-1", "token-abc").unwrap(); // Restored onto a device whose derived key differs: same bytes, no key. let restored = file_backed_store(path.clone(), [2u8; 32]); match restored.get_token("user-1") { Err(CredentialError::NotFound) => {} other => panic!("expected NotFound for undecryptable ciphertext, got {other:?}"), } } /// Garbage in the file (truncation, partial restore) is the same story. /// /// TRACES: UR-012 | IR-014 #[test] fn corrupt_credentials_file_reads_as_not_found() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(CREDENTIALS_FILENAME); fs::write(&path, "not base64 at all !!!").unwrap(); let store = file_backed_store(path, [3u8; 32]); match store.get_token("user-1") { Err(CredentialError::NotFound) => {} other => panic!("expected NotFound for corrupt file, got {other:?}"), } } /// …and the logged-out state must be recoverable: signing in again has to be /// able to write over the unreadable file rather than failing on load. /// /// TRACES: UR-012 | IR-014 #[test] fn login_after_undecryptable_file_rewrites_it() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(CREDENTIALS_FILENAME); let original = file_backed_store(path.clone(), [1u8; 32]); original.save_to_file("user-1", "token-abc").unwrap(); let restored = file_backed_store(path.clone(), [2u8; 32]); restored.save_to_file("user-1", "token-fresh").unwrap(); assert_eq!(restored.get_from_file("user-1").unwrap(), "token-fresh"); } #[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_migrating(&encrypted).unwrap(); assert_eq!(plaintext, decrypted); } /// The legacy derivation must stay deterministic *within a machine*, or the /// one-time migration of an old credentials file cannot read it. /// /// Its instability *across* machine states is exactly why it no longer /// encrypts anything — see `the_fallback_key_is_stable_across_processes`. /// /// TRACES: UR-012 | IR-014 | UT-014 #[test] fn test_legacy_derivation_is_deterministic_for_migration() { let key1 = CredentialStore::derive_legacy_encryption_key(); let key2 = CredentialStore::derive_legacy_encryption_key(); assert_eq!(key1, key2); } }