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}