Skip to main content

#
jellytau_lib/
credentials.rs

    1//! Secure credential storage module
    2//!
    3//! Provides secure storage for access tokens using:
    4//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
    5//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
    6//!
    7//! The fallback is less secure as the encryption key is derived from machine
    8//! identifiers, but provides functionality on headless systems.
    9//!
    10//! TRACES: UR-012 | IR-014
    11
    12use aes_gcm::{
    13    aead::{Aead, KeyInit},
    14    Aes256Gcm, Nonce,
    15};
    16use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
    17use directories::ProjectDirs;
    18use log::{info, warn};
    19use sha2::{Digest, Sha256};
    20use std::fs;
    21use std::path::PathBuf;
    22
    23#[cfg(not(target_os = "android"))]
    24const SERVICE_NAME: &str = "com.dtourolle.jellytau";
    25
    26const CREDENTIALS_FILENAME: &str = "credentials.enc";
    27
    28/// Result of a credential storage operation
    29#[derive(Debug)]
    30pub enum CredentialResult {
    31    /// Operation succeeded using the system keyring
    32    Keyring,
    33    /// Operation succeeded using encrypted file fallback
    34    EncryptedFile,
    35}
    36
    37/// Error types for credential operations
    38#[derive(Debug)]
    39pub enum CredentialError {
    40    /// Keyring operation failed
    41    Keyring(String),
    42    /// Encryption/decryption failed
    43    Encryption(String),
    44    /// File I/O failed
    45    Io(String),
    46    /// Credential not found
    47    NotFound,
    48}
    49
    50impl std::fmt::Display for CredentialError {
    51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    52        match self {
    53            Self::Keyring(msg) => write!(f, "Keyring error: {}", msg),
    54            Self::Encryption(msg) => write!(f, "Encryption error: {}", msg),
    55            Self::Io(msg) => write!(f, "I/O error: {}", msg),
    56            Self::NotFound => write!(f, "Credential not found"),
    57        }
    58    }
    59}
    60
    61impl std::error::Error for CredentialError {}
    62
    63/// Credential storage manager
    64pub struct CredentialStore {
    65    /// Whether we're using keyring (true) or encrypted file (false)
    66    using_keyring: bool,
    67    /// Path to the encrypted credentials file (fallback)
    68    credentials_path: PathBuf,
    69    /// Encryption key for file fallback (derived from machine ID)
    70    encryption_key: [u8; 32],
    71}
    72
    73impl CredentialStore {
    74    /// Create a new credential store, detecting the best available backend
    75    pub fn new() -> Self {
    76        let credentials_path = Self::get_credentials_path();
    77        let encryption_key = Self::derive_encryption_key();
    78
    79        // Test if keyring is available by trying a dummy operation
    80        let using_keyring = Self::test_keyring_available();
    81
    82        if !using_keyring {
    83            warn!(
    84                "[INIT] System keyring unavailable, using encrypted file fallback at {:?}. \
    85                 This is less secure than system keyring storage.",
    86                credentials_path
    87            );
    88        } else {
    89            info!("[INIT] Using system keyring for credential storage");
    90        }
    91
    92        Self {
    93            using_keyring,
    94            credentials_path,
    95            encryption_key,
    96        }
    97    }
    98
    99    /// Check if we're using the secure keyring backend
    100    pub fn is_using_keyring(&self) -> bool {
    101        self.using_keyring
    102    }
    103
    104    /// Save an access token for a user
    105    pub fn save_token(
    106        &self,
    107        user_id: &str,
    108        token: &str,
    109    ) -> Result<CredentialResult, CredentialError> {
    110        if self.using_keyring {
    111            log::debug!("Saving token for user {} to keyring", user_id);
    112            self.save_to_keyring(user_id, token)?;
    113            Ok(CredentialResult::Keyring)
    114        } else {
    115            log::debug!(
    116                "Saving token for user {} to encrypted file at {:?}",
    117                user_id,
    118                self.credentials_path
    119            );
    120            self.save_to_file(user_id, token)?;
    121            log::debug!("Successfully saved token to encrypted file");
    122            Ok(CredentialResult::EncryptedFile)
    123        }
    124    }
    125
    126    /// Get an access token for a user
    127    pub fn get_token(&self, user_id: &str) -> Result<String, CredentialError> {
    128        if self.using_keyring {
    129            log::debug!("Getting token for user {} from keyring", user_id);
    130            self.get_from_keyring(user_id)
    131        } else {
    132            log::debug!(
    133                "Getting token for user {} from encrypted file at {:?}",
    134                user_id,
    135                self.credentials_path
    136            );
    137            let result = self.get_from_file(user_id);
    138            if result.is_ok() {
    139                log::debug!("Successfully retrieved token from encrypted file");
    140            } else {
    141                log::warn!("Failed to retrieve token from encrypted file: {:?}", result);
    142            }
    143            result
    144        }
    145    }
    146
    147    /// Delete an access token for a user
    148    pub fn delete_token(&self, user_id: &str) -> Result<(), CredentialError> {
    149        if self.using_keyring {
    150            self.delete_from_keyring(user_id)
    151        } else {
    152            self.delete_from_file(user_id)
    153        }
    154    }
    155
    156    // --- Keyring backend ---
    157
    158    fn test_keyring_available() -> bool {
    159        // On Android, use Android Keystore via JNI
    160        #[cfg(target_os = "android")]
    161        {
    162            android_test_keystore_available()
    163        }
    164
    165        // On Linux, the keyring test can block indefinitely if Secret Service
    166        // (gnome-keyring/kwallet) is unresponsive. Use a timeout to prevent hanging.
    167        #[cfg(target_os = "linux")]
    168        {
    169            use std::sync::mpsc;
    170            use std::thread;
    171            use std::time::Duration;
    172
    173            let (tx, rx) = mpsc::channel();
    174
    175            thread::spawn(move || {
    176                let result = Self::test_keyring_inner();
    177                let _ = tx.send(result);
    178            });
    179
    180            // Wait up to 2 seconds for keyring response
    181            match rx.recv_timeout(Duration::from_secs(2)) {
    182                Ok(result) => result,
    183                Err(_) => {
    184                    log::warn!("Keyring availability check timed out after 2 seconds");
    185                    false
    186                }
    187            }
    188        }
    189
    190        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
    191        {
    192            Self::test_keyring_inner()
    193        }
    194    }
    195
    196    #[cfg(target_os = "linux")]
    197    fn test_keyring_inner() -> bool {
    198        // On Linux, test if secret-tool is available
    199        use std::process::Command;
    200
    201        // secret-tool doesn't support --version, so we test with a search command
    202        // that will succeed even if no items are found
    203        Command::new("secret-tool")
    204            .arg("search")
    205            .arg("service")
    206            .arg("__nonexistent_test__")
    207            .output()
    208            .is_ok()
    209    }
    210
    211    #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
    212    fn test_keyring_inner() -> bool {
    213        // On macOS/Windows, test using the keyring-rs library
    214        let entry = keyring::Entry::new(SERVICE_NAME, "__test__");
    215        match entry {
    216            Ok(e) => {
    217                // Try to get (will fail with NotFound, which is fine)
    218                // If it fails with a different error, keyring is not available
    219                match e.get_password() {
    220                    Ok(_) => true,
    221                    Err(keyring::Error::NoEntry) => true,
    222                    Err(keyring::Error::NoStorageAccess(_)) => false,
    223                    Err(_) => false,
    224                }
    225            }
    226            Err(_) => false,
    227        }
    228    }
    229
    230    fn save_to_keyring(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
    231        // On Android, use Android Keystore via JNI
    232        #[cfg(target_os = "android")]
    233        {
    234            android_keystore::save_token(user_id, token)
    235        }
    236
    237        #[cfg(target_os = "linux")]
    238        {
    239            // Use secret-tool directly on Linux as a workaround for keyring-rs library issues
    240            // See Technical Debt section in README.md for details
    241            use std::io::Write;
    242            use std::process::{Command, Stdio};
    243
    244            let key = format!("access_token:{}", user_id);
    245            let mut child = Command::new("secret-tool")
    246                .arg("store")
    247                .arg("--label")
    248                .arg(format!("{}@{}", key, SERVICE_NAME))
    249                .arg("service")
    250                .arg(SERVICE_NAME)
    251                .arg("username")
    252                .arg(&key)
    253                .stdin(Stdio::piped())
    254                .stdout(Stdio::null())
    255                .stderr(Stdio::null())
    256                .spawn()
    257                .map_err(|e| {
    258                    CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e))
    259                })?;
    260
    261            if let Some(mut stdin) = child.stdin.take() {
    262                stdin.write_all(token.as_bytes()).map_err(|e| {
    263                    CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e))
    264                })?;
    265            }
    266
    267            let status = child.wait().map_err(|e| {
    268                CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e))
    269            })?;
    270
    271            if status.success() {
    272                Ok(())
    273            } else {
    274                Err(CredentialError::Keyring(format!(
    275                    "secret-tool failed with status: {}",
    276                    status
    277                )))
    278            }
    279        }
    280
    281        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
    282        {
    283            let key = format!("access_token:{}", user_id);
    284            let entry = keyring::Entry::new(SERVICE_NAME, &key)
    285                .map_err(|e| CredentialError::Keyring(e.to_string()))?;
    286            entry
    287                .set_password(token)
    288                .map_err(|e| CredentialError::Keyring(e.to_string()))
    289        }
    290    }
    291
    292    fn get_from_keyring(&self, user_id: &str) -> Result<String, CredentialError> {
    293        // On Android, use Android Keystore via JNI
    294        #[cfg(target_os = "android")]
    295        {
    296            android_keystore::get_token(user_id)
    297        }
    298
    299        #[cfg(target_os = "linux")]
    300        {
    301            // Use secret-tool directly on Linux as a workaround for keyring-rs library issues
    302            // See Technical Debt section in README.md for details
    303            use std::process::Command;
    304
    305            let key = format!("access_token:{}", user_id);
    306            log::debug!(
    307                "Looking up token with service={}, username={}",
    308                SERVICE_NAME,
    309                key
    310            );
    311
    312            let output = Command::new("secret-tool")
    313                .arg("lookup")
    314                .arg("service")
    315                .arg(SERVICE_NAME)
    316                .arg("username")
    317                .arg(&key)
    318                .output()
    319                .map_err(|e| {
    320                    CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
    321                })?;
    322
    323            if output.status.success() {
    324                log::debug!(
    325                    "secret-tool lookup succeeded, token length: {}",
    326                    output.stdout.len()
    327                );
    328                let token = String::from_utf8(output.stdout)
    329                    .map_err(|e| {
    330                        CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e))
    331                    })?
    332                    .trim()
    333                    .to_string();
    334                Ok(token)
    335            } else {
    336                let stderr = String::from_utf8_lossy(&output.stderr);
    337                log::warn!(
    338                    "secret-tool lookup failed with status: {} stderr: {}",
    339                    output.status,
    340                    stderr
    341                );
    342                Err(CredentialError::NotFound)
    343            }
    344        }
    345
    346        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
    347        {
    348            let key = format!("access_token:{}", user_id);
    349            let entry = keyring::Entry::new(SERVICE_NAME, &key)
    350                .map_err(|e| CredentialError::Keyring(e.to_string()))?;
    351            entry.get_password().map_err(|e| match e {
    352                keyring::Error::NoEntry => CredentialError::NotFound,
    353                _ => CredentialError::Keyring(e.to_string()),
    354            })
    355        }
    356    }
    357
    358    fn delete_from_keyring(&self, user_id: &str) -> Result<(), CredentialError> {
    359        // On Android, use Android Keystore via JNI
    360        #[cfg(target_os = "android")]
    361        {
    362            android_keystore::delete_token(user_id)
    363        }
    364
    365        #[cfg(target_os = "linux")]
    366        {
    367            // Use secret-tool directly on Linux as a workaround for keyring-rs library issues
    368            // See Technical Debt section in README.md for details
    369            use std::process::Command;
    370
    371            let key = format!("access_token:{}", user_id);
    372            let status = Command::new("secret-tool")
    373                .arg("clear")
    374                .arg("service")
    375                .arg(SERVICE_NAME)
    376                .arg("username")
    377                .arg(&key)
    378                .status()
    379                .map_err(|e| {
    380                    CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
    381                })?;
    382
    383            // secret-tool clear returns success even if entry doesn't exist
    384            if status.success() {
    385                Ok(())
    386            } else {
    387                Err(CredentialError::Keyring(format!(
    388                    "secret-tool clear failed with status: {}",
    389                    status
    390                )))
    391            }
    392        }
    393
    394        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
    395        {
    396            let key = format!("access_token:{}", user_id);
    397            let entry = keyring::Entry::new(SERVICE_NAME, &key)
    398                .map_err(|e| CredentialError::Keyring(e.to_string()))?;
    399            match entry.delete_credential() {
    400                Ok(_) => Ok(()),
    401                Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
    402                Err(e) => Err(CredentialError::Keyring(e.to_string())),
    403            }
    404        }
    405    }
    406
    407    // --- Encrypted file backend ---
    408
    409    fn get_credentials_path() -> PathBuf {
    410        if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
    411            proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
    412        } else {
    413            PathBuf::from(CREDENTIALS_FILENAME)
    414        }
    415    }
    416
    417    fn derive_encryption_key() -> [u8; 32] {
    418        // Derive a key from machine-specific identifiers
    419        // This is less secure than a true keyring but provides some protection
    420        let mut hasher = Sha256::new();
    421
    422        // Use hostname on Linux (where it's available and stable)
    423        #[cfg(target_os = "linux")]
    424        {
    425            if let Ok(hostname) = hostname::get() {
    426                hasher.update(hostname.to_string_lossy().as_bytes());
    427            }
    428        }
    429
    430        // On Android, read device properties from the filesystem
    431        #[cfg(target_os = "android")]
    432        {
    433            // Try to read Android build properties from /system/build.prop
    434            let build_prop_paths = ["/system/build.prop", "/vendor/build.prop"];
    435
    436            for path in &build_prop_paths {
    437                if let Ok(content) = fs::read_to_string(path) {
    438                    // Extract key properties for device fingerprint
    439                    for line in content.lines() {
    440                        if line.starts_with("ro.build.fingerprint=")
    441                            || line.starts_with("ro.serialno=")
    442                            || line.starts_with("ro.build.id=")
    443                            || line.starts_with("ro.product.model=")
    444                        {
    445                            hasher.update(line.as_bytes());
    446                        }
    447                    }
    448                }
    449            }
    450
    451            // Also use the app data directory path as it's device/install-specific
    452            if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
    453                hasher.update(proj_dirs.data_dir().to_string_lossy().as_bytes());
    454            }
    455        }
    456
    457        // Use a static salt (app-specific)
    458        hasher.update(b"jellytau-credential-encryption-v1");
    459
    460        // Add username for additional entropy (if available)
    461        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
    462            hasher.update(user.as_bytes());
    463        }
    464
    465        hasher.finalize().into()
    466    }
    467
    468    /// Load and decrypt the credential map.
    469    ///
    470    /// A file that is present but **undecryptable** is deliberately reported as
    471    /// an *empty* credential set rather than as an error. The key never leaves
    472    /// the device it was derived on (Android Keystore keys are never backed up,
    473    /// and the file fallback's key is derived from machine identifiers), so a
    474    /// restored/transferred install gets ciphertext with no key and every read
    475    /// would fail *permanently*. Surfacing that as an error made session restore
    476    /// throw instead of falling back to the login screen: an unrecoverable app
    477    /// rather than a clean logged-out one. The next successful login re-encrypts
    478    /// the file with the current key, so the state self-heals.
    479    ///
    480    /// TRACES: UR-012 | IR-014
    481    fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
    482        if !self.credentials_path.exists() {
    483            return Ok(serde_json::json!({}));
    484        }
    485
    486        let encrypted_data = fs::read_to_string(&self.credentials_path)
    487            .map_err(|e| CredentialError::Io(e.to_string()))?;
    488
    489        if encrypted_data.is_empty() {
    490            return Ok(serde_json::json!({}));
    491        }
    492
    493        let decrypted = match self.decrypt(&encrypted_data) {
    494            Ok(decrypted) => decrypted,
    495            Err(e) => {
    496                warn!(
    497                    "Credentials file at {:?} exists but cannot be decrypted ({}); \
    498                     treating as no stored credentials. This is expected after a \
    499                     backup restore or device transfer - the encryption key does \
    500                     not travel with the data. Signing in again will rewrite it.",
    501                    self.credentials_path, e
    502                );
    503                return Ok(serde_json::json!({}));
    504            }
    505        };
    506
    507        match serde_json::from_str(&decrypted) {
    508            Ok(value) => Ok(value),
    509            Err(e) => {
    510                warn!(
    511                    "Credentials file at {:?} decrypted to invalid JSON ({}); \
    512                     treating as no stored credentials.",
    513                    self.credentials_path, e
    514                );
    515                Ok(serde_json::json!({}))
    516            }
    517        }
    518    }
    519
    520    fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
    521        // Ensure parent directory exists
    522        if let Some(parent) = self.credentials_path.parent() {
    523            fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
    524        }
    525
    526        let json =
    527            serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
    528        let encrypted = self.encrypt(&json)?;
    529
    530        fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
    531    }
    532
    533    fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
    534        let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
    535            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
    536
    537        // Generate a random nonce
    538        let mut nonce_bytes = [0u8; 12];
    539        getrandom::getrandom(&mut nonce_bytes)
    540            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
    541        let nonce = Nonce::from_slice(&nonce_bytes);
    542
    543        let ciphertext = cipher
    544            .encrypt(nonce, plaintext.as_bytes())
    545            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
    546
    547        // Prepend nonce to ciphertext and encode as base64
    548        let mut combined = nonce_bytes.to_vec();
    549        combined.extend(ciphertext);
    550
    551        Ok(BASE64.encode(&combined))
    552    }
    553
    554    fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError> {
    555        let combined = BASE64
    556            .decode(encrypted)
    557            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
    558
    559        if combined.len() < 12 {
    560            return Err(CredentialError::Encryption(
    561                "Invalid encrypted data".to_string(),
    562            ));
    563        }
    564
    565        let (nonce_bytes, ciphertext) = combined.split_at(12);
    566        let nonce = Nonce::from_slice(nonce_bytes);
    567
    568        let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
    569            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
    570
    571        let plaintext = cipher
    572            .decrypt(nonce, ciphertext)
    573            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
    574
    575        String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
    576    }
    577
    578    fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
    579        let mut data = self.load_credentials_file()?;
    580        data[user_id] = serde_json::json!(token);
    581        self.save_credentials_file(&data)
    582    }
    583
    584    fn get_from_file(&self, user_id: &str) -> Result<String, CredentialError> {
    585        let data = self.load_credentials_file()?;
    586        data.get(user_id)
    587            .and_then(|v| v.as_str())
    588            .map(|s| s.to_string())
    589            .ok_or(CredentialError::NotFound)
    590    }
    591
    592    fn delete_from_file(&self, user_id: &str) -> Result<(), CredentialError> {
    593        let mut data = self.load_credentials_file()?;
    594        if let Some(obj) = data.as_object_mut() {
    595            obj.remove(user_id);
    596        }
    597        self.save_credentials_file(&data)
    598    }
    599}
    600
    601impl Default for CredentialStore {
    602    fn default() -> Self {
    603        Self::new()
    604    }
    605}
    606
    607// --- Android Keystore integration via JNI ---
    608
    609#[cfg(target_os = "android")]
    610mod android_keystore {
    611    use super::*;
    612    use jni::objects::{JClass, JObject, JString, JValue};
    613    use jni::JNIEnv;
    614    use std::sync::OnceLock;
    615
    616    /// Cached reference to the SecureStorage class
    617    static SECURE_STORAGE_CLASS: OnceLock<String> = OnceLock::new();
    618
    619    const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage";
    620
    621    /// Initialize the SecureStorage singleton from Android context
    622    pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
    623        log::info!("Initializing Android SecureStorage...");
    624
    625        // Get the ClassLoader from the Context
    626        let class_loader = env
    627            .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
    628            .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
    629            .l()
    630            .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
    631
    632        // Load the SecureStorage class
    633        let class_name = env
    634            .new_string(SECURE_STORAGE_CLASS_NAME.replace('/', "."))
    635            .map_err(|e| format!("Failed to create class name string: {}", e))?;
    636
    637        let storage_class_obj = env
    638            .call_method(
    639                &class_loader,
    640                "loadClass",
    641                "(Ljava/lang/String;)Ljava/lang/Class;",
    642                &[JValue::Object(&class_name.into())],
    643            )
    644            .map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
    645            .l()
    646            .map_err(|e| format!("Failed to convert to Class: {}", e))?;
    647
    648        let storage_class = JClass::from(storage_class_obj);
    649
    650        // Call SecureStorage.initialize(context)
    651        env.call_static_method(
    652            &storage_class,
    653            "initialize",
    654            "(Landroid/content/Context;)V",
    655            &[JValue::Object(context)],
    656        )
    657        .map_err(|e| format!("Failed to initialize SecureStorage: {}", e))?;
    658
    659        // Cache the class name for future use
    660        let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string());
    661
    662        log::info!("Android SecureStorage initialized successfully");
    663        Ok(())
    664    }
    665
    666    /// Test if Android Keystore is available
    667    pub fn test_keystore_available() -> bool {
    668        // Get JNI environment
    669        let ctx = ndk_context::android_context();
    670        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
    671
    672        let vm = match vm {
    673            Ok(vm) => vm,
    674            Err(e) => {
    675                log::warn!("Failed to get JavaVM for keystore test: {}", e);
    676                return false;
    677            }
    678        };
    679
    680        let mut env = match vm.attach_current_thread() {
    681            Ok(env) => env,
    682            Err(e) => {
    683                log::warn!("Failed to attach thread for keystore test: {}", e);
    684                return false;
    685            }
    686        };
    687
    688        // Try to get the SecureStorage instance
    689        match get_secure_storage_instance(&mut env) {
    690            Ok(_) => {
    691                log::info!("Android Keystore available via SecureStorage");
    692                true
    693            }
    694            Err(e) => {
    695                log::warn!("Android Keystore not available: {}", e);
    696                false
    697            }
    698        }
    699    }
    700
    701    /// Get the SecureStorage singleton instance
    702    fn get_secure_storage_instance<'a>(env: &mut JNIEnv<'a>) -> Result<JObject<'a>, String> {
    703        let class_name = SECURE_STORAGE_CLASS
    704            .get()
    705            .ok_or_else(|| "SecureStorage not initialized".to_string())?;
    706
    707        // Get the Android context
    708        let ctx = ndk_context::android_context();
    709        let context = unsafe { JObject::from_raw(ctx.context().cast()) };
    710
    711        // Get the ClassLoader from the Context
    712        let class_loader = env
    713            .call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
    714            .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
    715            .l()
    716            .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
    717
    718        // Load the SecureStorage class using the app's classloader
    719        let class_name_jstring = env
    720            .new_string(class_name.replace('/', "."))
    721            .map_err(|e| format!("Failed to create class name string: {}", e))?;
    722
    723        let storage_class_obj = env
    724            .call_method(
    725                &class_loader,
    726                "loadClass",
    727                "(Ljava/lang/String;)Ljava/lang/Class;",
    728                &[JValue::Object(&class_name_jstring.into())],
    729            )
    730            .map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
    731            .l()
    732            .map_err(|e| format!("Failed to convert to Class: {}", e))?;
    733
    734        let storage_class = JClass::from(storage_class_obj);
    735
    736        let instance = env
    737            .call_static_method(
    738                &storage_class,
    739                "getInstance",
    740                "()Lcom/dtourolle/jellytau/security/SecureStorage;",
    741                &[],
    742            )
    743            .map_err(|e| format!("Failed to get SecureStorage instance: {}", e))?
    744            .l()
    745            .map_err(|e| format!("Failed to convert to object: {}", e))?;
    746
    747        Ok(instance)
    748    }
    749
    750    /// Save a token using Android Keystore
    751    pub fn save_token(user_id: &str, token: &str) -> Result<(), CredentialError> {
    752        let ctx = ndk_context::android_context();
    753        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
    754            .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
    755
    756        let mut env = vm
    757            .attach_current_thread()
    758            .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
    759
    760        let instance =
    761            get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
    762
    763        let key = format!("access_token:{}", user_id);
    764        let key_jstring = env
    765            .new_string(&key)
    766            .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
    767        let token_jstring = env.new_string(token).map_err(|e| {
    768            CredentialError::Keyring(format!("Failed to create token string: {}", e))
    769        })?;
    770
    771        let result = env
    772            .call_method(
    773                instance,
    774                "saveToken",
    775                "(Ljava/lang/String;Ljava/lang/String;)Z",
    776                &[
    777                    JValue::Object(&key_jstring.into()),
    778                    JValue::Object(&token_jstring.into()),
    779                ],
    780            )
    781            .map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
    782            .z()
    783            .map_err(|e| {
    784                CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
    785            })?;
    786
    787        if result {
    788            Ok(())
    789        } else {
    790            Err(CredentialError::Keyring(
    791                "saveToken returned false".to_string(),
    792            ))
    793        }
    794    }
    795
    796    /// Get a token from Android Keystore
    797    pub fn get_token(user_id: &str) -> Result<String, CredentialError> {
    798        let ctx = ndk_context::android_context();
    799        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
    800            .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
    801
    802        let mut env = vm
    803            .attach_current_thread()
    804            .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
    805
    806        let instance =
    807            get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
    808
    809        let key = format!("access_token:{}", user_id);
    810        let key_jstring = env
    811            .new_string(&key)
    812            .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
    813
    814        let result = env
    815            .call_method(
    816                instance,
    817                "getToken",
    818                "(Ljava/lang/String;)Ljava/lang/String;",
    819                &[JValue::Object(&key_jstring.into())],
    820            )
    821            .map_err(|e| CredentialError::Keyring(format!("Failed to call getToken: {}", e)))?
    822            .l()
    823            .map_err(|e| CredentialError::Keyring(format!("Failed to get object result: {}", e)))?;
    824
    825        if result.is_null() {
    826            return Err(CredentialError::NotFound);
    827        }
    828
    829        let token_jstring = JString::from(result);
    830        let token: String = env
    831            .get_string(&token_jstring)
    832            .map_err(|e| CredentialError::Keyring(format!("Failed to get string: {}", e)))?
    833            .into();
    834
    835        Ok(token)
    836    }
    837
    838    /// Delete a token from Android Keystore
    839    pub fn delete_token(user_id: &str) -> Result<(), CredentialError> {
    840        let ctx = ndk_context::android_context();
    841        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
    842            .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
    843
    844        let mut env = vm
    845            .attach_current_thread()
    846            .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
    847
    848        let instance =
    849            get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
    850
    851        let key = format!("access_token:{}", user_id);
    852        let key_jstring = env
    853            .new_string(&key)
    854            .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
    855
    856        let result = env
    857            .call_method(
    858                instance,
    859                "deleteToken",
    860                "(Ljava/lang/String;)Z",
    861                &[JValue::Object(&key_jstring.into())],
    862            )
    863            .map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
    864            .z()
    865            .map_err(|e| {
    866                CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
    867            })?;
    868
    869        if result {
    870            Ok(())
    871        } else {
    872            Err(CredentialError::Keyring(
    873                "deleteToken returned false".to_string(),
    874            ))
    875        }
    876    }
    877}
    878
    879// Export Android keystore functions at the module level for easier access
    880#[cfg(target_os = "android")]
    881pub use android_keystore::{
    882    initialize_secure_storage, test_keystore_available as android_test_keystore_available,
    883};
    884
    885#[cfg(test)]
    886mod tests {
    887    use super::*;
    888
    889    /// Build a store pinned to the encrypted-file backend with an explicit key,
    890    /// so a test can simulate "same file, different machine key" (which is what
    891    /// a restored backup looks like).
    892    fn file_backed_store(credentials_path: PathBuf, encryption_key: [u8; 32]) -> CredentialStore {
    893        CredentialStore {
    894            using_keyring: false,
    895            credentials_path,
    896            encryption_key,
    897        }
    898    }
    899
    900    /// A credentials file we cannot decrypt must read as *no credentials stored*,
    901    /// not as a hard error. This is the restored-backup case: the ciphertext comes
    902    /// back but the key that encrypted it (Android Keystore / the machine-derived
    903    /// key) does not, so every read fails forever.
    904    ///
    905    /// TRACES: UR-012 | IR-014
    906    #[test]
    907    fn undecryptable_credentials_file_reads_as_not_found() {
    908        let dir = tempfile::tempdir().unwrap();
    909        let path = dir.path().join(CREDENTIALS_FILENAME);
    910
    911        let original = file_backed_store(path.clone(), [1u8; 32]);
    912        original.save_to_file("user-1", "token-abc").unwrap();
    913
    914        // Restored onto a device whose derived key differs: same bytes, no key.
    915        let restored = file_backed_store(path.clone(), [2u8; 32]);
    916        match restored.get_token("user-1") {
    917            Err(CredentialError::NotFound) => {}
    918            other => panic!("expected NotFound for undecryptable ciphertext, got {other:?}"),
    919        }
    920    }
    921
    922    /// Garbage in the file (truncation, partial restore) is the same story.
    923    ///
    924    /// TRACES: UR-012 | IR-014
    925    #[test]
    926    fn corrupt_credentials_file_reads_as_not_found() {
    927        let dir = tempfile::tempdir().unwrap();
    928        let path = dir.path().join(CREDENTIALS_FILENAME);
    929        fs::write(&path, "not base64 at all !!!").unwrap();
    930
    931        let store = file_backed_store(path, [3u8; 32]);
    932        match store.get_token("user-1") {
    933            Err(CredentialError::NotFound) => {}
    934            other => panic!("expected NotFound for corrupt file, got {other:?}"),
    935        }
    936    }
    937
    938    /// …and the logged-out state must be recoverable: signing in again has to be
    939    /// able to write over the unreadable file rather than failing on load.
    940    ///
    941    /// TRACES: UR-012 | IR-014
    942    #[test]
    943    fn login_after_undecryptable_file_rewrites_it() {
    944        let dir = tempfile::tempdir().unwrap();
    945        let path = dir.path().join(CREDENTIALS_FILENAME);
    946
    947        let original = file_backed_store(path.clone(), [1u8; 32]);
    948        original.save_to_file("user-1", "token-abc").unwrap();
    949
    950        let restored = file_backed_store(path.clone(), [2u8; 32]);
    951        restored.save_to_file("user-1", "token-fresh").unwrap();
    952
    953        assert_eq!(restored.get_from_file("user-1").unwrap(), "token-fresh");
    954    }
    955
    956    #[test]
    957    fn test_encryption_roundtrip() {
    958        let store = CredentialStore::new();
    959        let plaintext = "test-access-token-12345";
    960
    961        let encrypted = store.encrypt(plaintext).unwrap();
    962        let decrypted = store.decrypt(&encrypted).unwrap();
    963
    964        assert_eq!(plaintext, decrypted);
    965    }
    966
    967    #[test]
    968    fn test_derive_encryption_key_is_deterministic() {
    969        let key1 = CredentialStore::derive_encryption_key();
    970        let key2 = CredentialStore::derive_encryption_key();
    971        assert_eq!(key1, key2);
    972    }
    973}