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 **obfuscation at rest, not a secret**: its key sits in a file
8//! beside the ciphertext, so anyone who can read one can read the other. It
9//! exists so headless systems keep working, and the keyring remains the only
10//! place a token is actually protected.
11//!
12//! The key used to be *derived* from the hostname, `$USER` and a hardcoded salt.
13//! That was no more secret — those are readable by anyone who can read the file
14//! — and it was unstable: renaming the machine, or launching from a context
15//! where `$USER` is unset, changed the key and made every stored token
16//! undecryptable. `load_credentials_file` treats a failed decrypt as "no stored
17//! credentials", so that surfaced as being silently signed out rather than as an
18//! error. The key is now random and persisted, and the old derivation is kept
19//! only to migrate a file written before this change.
20//!
21//! TRACES: UR-012 | IR-014
22
23use aes_gcm::{
24    aead::{Aead, KeyInit},
25    Aes256Gcm, Nonce,
26};
27use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
28use directories::ProjectDirs;
29use log::{info, warn};
30use sha2::{Digest, Sha256};
31use std::fs;
32use std::path::PathBuf;
33
34#[cfg(not(target_os = "android"))]
35const SERVICE_NAME: &str = "com.dtourolle.jellytau";
36
37const CREDENTIALS_FILENAME: &str = "credentials.enc";
38
39/// Key file for the encrypted-file fallback, beside the credentials it opens.
40const KEY_FILENAME: &str = "credentials.key";
41
42/// Load the fallback encryption key, creating it on first use.
43///
44/// Random rather than derived. A derived key was no more secret — its inputs
45/// (hostname, `$USER`, a hardcoded salt) are readable by anyone who can read
46/// the ciphertext — and it silently changed when the machine was renamed or
47/// `$USER` was unset, which read to the user as being signed out for no reason.
48///
49/// If the key cannot be persisted the process still gets a usable key for this
50/// run; credentials written under it simply will not be readable next launch,
51/// which is the same outcome as today and better than refusing to store a token.
52///
53/// TRACES: UR-012 | IR-014 | UT-014
54fn load_or_create_key(path: &std::path::Path) -> [u8; 32] {
55    if let Ok(existing) = fs::read(path) {
56        if existing.len() == 32 {
57            let mut key = [0u8; 32];
58            key.copy_from_slice(&existing);
59            return key;
60        }
61        warn!(
62            "Fallback key at {:?} is {} bytes, not 32; replacing it. Credentials \
63             written under the old key will need signing in again.",
64            path,
65            existing.len()
66        );
67    }
68
69    let mut key = [0u8; 32];
70    if getrandom::getrandom(&mut key).is_err() {
71        warn!("No system randomness for the fallback key; deriving one for this run");
72        return CredentialStore::derive_legacy_encryption_key();
73    }
74
75    if let Some(parent) = path.parent() {
76        let _ = fs::create_dir_all(parent);
77    }
78    match fs::write(path, key) {
79        Ok(()) => restrict_to_owner(path),
80        Err(e) => warn!(
81            "Could not persist the fallback key at {:?} ({}); credentials stored \
82             this run will not be readable next launch",
83            path, e
84        ),
85    }
86    key
87}
88
89/// Make a key file owner-readable only. Best effort — a filesystem without
90/// Unix permissions is not a reason to fail.
91fn restrict_to_owner(path: &std::path::Path) {
92    #[cfg(unix)]
93    {
94        use std::os::unix::fs::PermissionsExt;
95        if let Err(e) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
96            warn!("Could not restrict permissions on {:?}: {}", path, e);
97        }
98    }
99    #[cfg(not(unix))]
100    let _ = path;
101}
102
103/// Decrypt `encrypted` with `key`.
104///
105/// TRACES: UR-012 | IR-014 | UT-014
106fn decrypt_with(key: &[u8; 32], encrypted: &str) -> Result<String, CredentialError> {
107    let combined = BASE64
108        .decode(encrypted)
109        .map_err(|e| CredentialError::Encryption(e.to_string()))?;
110
111    if combined.len() < 12 {
112        return Err(CredentialError::Encryption(
113            "Invalid encrypted data".to_string(),
114        ));
115    }
116
117    let (nonce_bytes, ciphertext) = combined.split_at(12);
118    let nonce = Nonce::from_slice(nonce_bytes);
119
120    let cipher =
121        Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
122
123    let plaintext = cipher
124        .decrypt(nonce, ciphertext)
125        .map_err(|e| CredentialError::Encryption(e.to_string()))?;
126
127    String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
128}
129
130/// Encrypt `plaintext` with `key`, prepending a fresh random nonce.
131///
132/// TRACES: UR-012 | IR-014 | UT-014
133fn encrypt_with(key: &[u8; 32], plaintext: &str) -> Result<String, CredentialError> {
134    let cipher =
135        Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
136
137    let mut nonce_bytes = [0u8; 12];
138    getrandom::getrandom(&mut nonce_bytes)
139        .map_err(|e| CredentialError::Encryption(e.to_string()))?;
140    let nonce = Nonce::from_slice(&nonce_bytes);
141
142    let ciphertext = cipher
143        .encrypt(nonce, plaintext.as_bytes())
144        .map_err(|e| CredentialError::Encryption(e.to_string()))?;
145
146    let mut combined = nonce_bytes.to_vec();
147    combined.extend(ciphertext);
148
149    Ok(BASE64.encode(&combined))
150}
151
152/// Result of a credential storage operation
153#[derive(Debug)]
154pub enum CredentialResult {
155    /// Operation succeeded using the system keyring
156    Keyring,
157    /// Operation succeeded using encrypted file fallback
158    EncryptedFile,
159}
160
161/// Error types for credential operations
162#[derive(Debug)]
163pub enum CredentialError {
164    /// Keyring operation failed
165    Keyring(String),
166    /// Encryption/decryption failed
167    Encryption(String),
168    /// File I/O failed
169    Io(String),
170    /// Credential not found
171    NotFound,
172}
173
174impl std::fmt::Display for CredentialError {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self {
177            Self::Keyring(msg) => write!(f, "Keyring error: {}", msg),
178            Self::Encryption(msg) => write!(f, "Encryption error: {}", msg),
179            Self::Io(msg) => write!(f, "I/O error: {}", msg),
180            Self::NotFound => write!(f, "Credential not found"),
181        }
182    }
183}
184
185impl std::error::Error for CredentialError {}
186
187/// Credential storage manager
188pub struct CredentialStore {
189    /// Whether we're using keyring (true) or encrypted file (false)
190    using_keyring: bool,
191    /// Path to the encrypted credentials file (fallback)
192    credentials_path: PathBuf,
193    /// Encryption key for the file fallback. Random and persisted, so it does
194    /// not change when the machine is renamed.
195    encryption_key: [u8; 32],
196    /// The pre-existing derivation, retained only to read a file written before
197    /// the key was persisted. Anything decrypted with it is rewritten under
198    /// `encryption_key`.
199    legacy_key: [u8; 32],
200}
201
202impl CredentialStore {
203    /// Create a new credential store, detecting the best available backend
204    pub fn new() -> Self {
205        let credentials_path = Self::get_credentials_path();
206        let encryption_key = load_or_create_key(&Self::get_key_path());
207        let legacy_key = Self::derive_legacy_encryption_key();
208
209        // Test if keyring is available by trying a dummy operation
210        let using_keyring = Self::test_keyring_available();
211
212        if !using_keyring {
213            warn!(
214                "[INIT] System keyring unavailable, using encrypted file fallback at {:?}. \
215                 This is less secure than system keyring storage.",
216                credentials_path
217            );
218        } else {
219            info!("[INIT] Using system keyring for credential storage");
220        }
221
222        Self {
223            using_keyring,
224            credentials_path,
225            encryption_key,
226            legacy_key,
227        }
228    }
229
230    /// Check if we're using the secure keyring backend
231    pub fn is_using_keyring(&self) -> bool {
232        self.using_keyring
233    }
234
235    /// Save an access token for a user
236    pub fn save_token(
237        &self,
238        user_id: &str,
239        token: &str,
240    ) -> Result<CredentialResult, CredentialError> {
241        if self.using_keyring {
242            log::debug!("Saving token for user {} to keyring", user_id);
243            self.save_to_keyring(user_id, token)?;
244            Ok(CredentialResult::Keyring)
245        } else {
246            log::debug!(
247                "Saving token for user {} to encrypted file at {:?}",
248                user_id,
249                self.credentials_path
250            );
251            self.save_to_file(user_id, token)?;
252            log::debug!("Successfully saved token to encrypted file");
253            Ok(CredentialResult::EncryptedFile)
254        }
255    }
256
257    /// Get an access token for a user
258    pub fn get_token(&self, user_id: &str) -> Result<String, CredentialError> {
259        if self.using_keyring {
260            log::debug!("Getting token for user {} from keyring", user_id);
261            self.get_from_keyring(user_id)
262        } else {
263            log::debug!(
264                "Getting token for user {} from encrypted file at {:?}",
265                user_id,
266                self.credentials_path
267            );
268            let result = self.get_from_file(user_id);
269            if result.is_ok() {
270                log::debug!("Successfully retrieved token from encrypted file");
271            } else {
272                log::warn!("Failed to retrieve token from encrypted file: {:?}", result);
273            }
274            result
275        }
276    }
277
278    /// Delete an access token for a user
279    pub fn delete_token(&self, user_id: &str) -> Result<(), CredentialError> {
280        if self.using_keyring {
281            self.delete_from_keyring(user_id)
282        } else {
283            self.delete_from_file(user_id)
284        }
285    }
286
287    // --- Keyring backend ---
288
289    fn test_keyring_available() -> bool {
290        // On Android, use Android Keystore via JNI
291        #[cfg(target_os = "android")]
292        {
293            android_test_keystore_available()
294        }
295
296        // On Linux, the keyring test can block indefinitely if Secret Service
297        // (gnome-keyring/kwallet) is unresponsive. Use a timeout to prevent hanging.
298        #[cfg(target_os = "linux")]
299        {
300            use std::sync::mpsc;
301            use std::thread;
302            use std::time::Duration;
303
304            let (tx, rx) = mpsc::channel();
305
306            thread::spawn(move || {
307                let result = Self::test_keyring_inner();
308                let _ = tx.send(result);
309            });
310
311            // Wait up to 2 seconds for keyring response
312            match rx.recv_timeout(Duration::from_secs(2)) {
313                Ok(result) => result,
314                Err(_) => {
315                    log::warn!("Keyring availability check timed out after 2 seconds");
316                    false
317                }
318            }
319        }
320
321        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
322        {
323            Self::test_keyring_inner()
324        }
325    }
326
327    #[cfg(target_os = "linux")]
328    fn test_keyring_inner() -> bool {
329        // On Linux, test if secret-tool is available
330        use std::process::Command;
331
332        // secret-tool doesn't support --version, so we test with a search command
333        // that will succeed even if no items are found
334        Command::new("secret-tool")
335            .arg("search")
336            .arg("service")
337            .arg("__nonexistent_test__")
338            .output()
339            .is_ok()
340    }
341
342    #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
343    fn test_keyring_inner() -> bool {
344        // On macOS/Windows, test using the keyring-rs library
345        let entry = keyring::Entry::new(SERVICE_NAME, "__test__");
346        match entry {
347            Ok(e) => {
348                // Try to get (will fail with NotFound, which is fine)
349                // If it fails with a different error, keyring is not available
350                match e.get_password() {
351                    Ok(_) => true,
352                    Err(keyring::Error::NoEntry) => true,
353                    Err(keyring::Error::NoStorageAccess(_)) => false,
354                    Err(_) => false,
355                }
356            }
357            Err(_) => false,
358        }
359    }
360
361    fn save_to_keyring(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
362        // On Android, use Android Keystore via JNI
363        #[cfg(target_os = "android")]
364        {
365            android_keystore::save_token(user_id, token)
366        }
367
368        #[cfg(target_os = "linux")]
369        {
370            // Use secret-tool directly on Linux as a workaround for keyring-rs library issues
371            // See Technical Debt section in README.md for details
372            use std::io::Write;
373            use std::process::{Command, Stdio};
374
375            let key = format!("access_token:{}", user_id);
376            let mut child = Command::new("secret-tool")
377                .arg("store")
378                .arg("--label")
379                .arg(format!("{}@{}", key, SERVICE_NAME))
380                .arg("service")
381                .arg(SERVICE_NAME)
382                .arg("username")
383                .arg(&key)
384                .stdin(Stdio::piped())
385                .stdout(Stdio::null())
386                .stderr(Stdio::null())
387                .spawn()
388                .map_err(|e| {
389                    CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e))
390                })?;
391
392            if let Some(mut stdin) = child.stdin.take() {
393                stdin.write_all(token.as_bytes()).map_err(|e| {
394                    CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e))
395                })?;
396            }
397
398            let status = child.wait().map_err(|e| {
399                CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e))
400            })?;
401
402            if status.success() {
403                Ok(())
404            } else {
405                Err(CredentialError::Keyring(format!(
406                    "secret-tool failed with status: {}",
407                    status
408                )))
409            }
410        }
411
412        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
413        {
414            let key = format!("access_token:{}", user_id);
415            let entry = keyring::Entry::new(SERVICE_NAME, &key)
416                .map_err(|e| CredentialError::Keyring(e.to_string()))?;
417            entry
418                .set_password(token)
419                .map_err(|e| CredentialError::Keyring(e.to_string()))
420        }
421    }
422
423    fn get_from_keyring(&self, user_id: &str) -> Result<String, CredentialError> {
424        // On Android, use Android Keystore via JNI
425        #[cfg(target_os = "android")]
426        {
427            android_keystore::get_token(user_id)
428        }
429
430        #[cfg(target_os = "linux")]
431        {
432            // Use secret-tool directly on Linux as a workaround for keyring-rs library issues
433            // See Technical Debt section in README.md for details
434            use std::process::Command;
435
436            let key = format!("access_token:{}", user_id);
437            log::debug!(
438                "Looking up token with service={}, username={}",
439                SERVICE_NAME,
440                key
441            );
442
443            let output = Command::new("secret-tool")
444                .arg("lookup")
445                .arg("service")
446                .arg(SERVICE_NAME)
447                .arg("username")
448                .arg(&key)
449                .output()
450                .map_err(|e| {
451                    CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
452                })?;
453
454            if output.status.success() {
455                log::debug!(
456                    "secret-tool lookup succeeded, token length: {}",
457                    output.stdout.len()
458                );
459                let token = String::from_utf8(output.stdout)
460                    .map_err(|e| {
461                        CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e))
462                    })?
463                    .trim()
464                    .to_string();
465                Ok(token)
466            } else {
467                let stderr = String::from_utf8_lossy(&output.stderr);
468                log::warn!(
469                    "secret-tool lookup failed with status: {} stderr: {}",
470                    output.status,
471                    stderr
472                );
473                Err(CredentialError::NotFound)
474            }
475        }
476
477        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
478        {
479            let key = format!("access_token:{}", user_id);
480            let entry = keyring::Entry::new(SERVICE_NAME, &key)
481                .map_err(|e| CredentialError::Keyring(e.to_string()))?;
482            entry.get_password().map_err(|e| match e {
483                keyring::Error::NoEntry => CredentialError::NotFound,
484                _ => CredentialError::Keyring(e.to_string()),
485            })
486        }
487    }
488
489    fn delete_from_keyring(&self, user_id: &str) -> Result<(), CredentialError> {
490        // On Android, use Android Keystore via JNI
491        #[cfg(target_os = "android")]
492        {
493            android_keystore::delete_token(user_id)
494        }
495
496        #[cfg(target_os = "linux")]
497        {
498            // Use secret-tool directly on Linux as a workaround for keyring-rs library issues
499            // See Technical Debt section in README.md for details
500            use std::process::Command;
501
502            let key = format!("access_token:{}", user_id);
503            let status = Command::new("secret-tool")
504                .arg("clear")
505                .arg("service")
506                .arg(SERVICE_NAME)
507                .arg("username")
508                .arg(&key)
509                .status()
510                .map_err(|e| {
511                    CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
512                })?;
513
514            // secret-tool clear returns success even if entry doesn't exist
515            if status.success() {
516                Ok(())
517            } else {
518                Err(CredentialError::Keyring(format!(
519                    "secret-tool clear failed with status: {}",
520                    status
521                )))
522            }
523        }
524
525        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
526        {
527            let key = format!("access_token:{}", user_id);
528            let entry = keyring::Entry::new(SERVICE_NAME, &key)
529                .map_err(|e| CredentialError::Keyring(e.to_string()))?;
530            match entry.delete_credential() {
531                Ok(_) => Ok(()),
532                Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
533                Err(e) => Err(CredentialError::Keyring(e.to_string())),
534            }
535        }
536    }
537
538    // --- Encrypted file backend ---
539
540    /// Where the fallback key lives: beside the credentials file, so the two
541    /// travel together and a restore that brings one brings the other.
542    fn get_key_path() -> PathBuf {
543        if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
544            proj_dirs.data_dir().join(KEY_FILENAME)
545        } else {
546            PathBuf::from(KEY_FILENAME)
547        }
548    }
549
550    fn get_credentials_path() -> PathBuf {
551        if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
552            proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
553        } else {
554            PathBuf::from(CREDENTIALS_FILENAME)
555        }
556    }
557
558    /// The key derivation used before keys were persisted.
559    ///
560    /// Kept **only** so a credentials file written by an older build can still
561    /// be read once and rewritten under the persisted key. Never used to
562    /// encrypt. See the module docs for why it was replaced.
563    ///
564    /// TRACES: UR-012 | IR-014
565    fn derive_legacy_encryption_key() -> [u8; 32] {
566        // Derive a key from machine-specific identifiers
567        // This is less secure than a true keyring but provides some protection
568        let mut hasher = Sha256::new();
569
570        // Use hostname on Linux (where it's available and stable)
571        #[cfg(target_os = "linux")]
572        {
573            if let Ok(hostname) = hostname::get() {
574                hasher.update(hostname.to_string_lossy().as_bytes());
575            }
576        }
577
578        // On Android, read device properties from the filesystem
579        #[cfg(target_os = "android")]
580        {
581            // Try to read Android build properties from /system/build.prop
582            let build_prop_paths = ["/system/build.prop", "/vendor/build.prop"];
583
584            for path in &build_prop_paths {
585                if let Ok(content) = fs::read_to_string(path) {
586                    // Extract key properties for device fingerprint
587                    for line in content.lines() {
588                        if line.starts_with("ro.build.fingerprint=")
589                            || line.starts_with("ro.serialno=")
590                            || line.starts_with("ro.build.id=")
591                            || line.starts_with("ro.product.model=")
592                        {
593                            hasher.update(line.as_bytes());
594                        }
595                    }
596                }
597            }
598
599            // Also use the app data directory path as it's device/install-specific
600            if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
601                hasher.update(proj_dirs.data_dir().to_string_lossy().as_bytes());
602            }
603        }
604
605        // Use a static salt (app-specific)
606        hasher.update(b"jellytau-credential-encryption-v1");
607
608        // Add username for additional entropy (if available)
609        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
610            hasher.update(user.as_bytes());
611        }
612
613        hasher.finalize().into()
614    }
615
616    /// Load and decrypt the credential map.
617    ///
618    /// A file that is present but **undecryptable** is deliberately reported as
619    /// an *empty* credential set rather than as an error. The key never leaves
620    /// the device it was derived on (Android Keystore keys are never backed up,
621    /// and the file fallback's key is derived from machine identifiers), so a
622    /// restored/transferred install gets ciphertext with no key and every read
623    /// would fail *permanently*. Surfacing that as an error made session restore
624    /// throw instead of falling back to the login screen: an unrecoverable app
625    /// rather than a clean logged-out one. The next successful login re-encrypts
626    /// the file with the current key, so the state self-heals.
627    ///
628    /// TRACES: UR-012 | IR-014
629    fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
630        if !self.credentials_path.exists() {
631            return Ok(serde_json::json!({}));
632        }
633
634        let encrypted_data = fs::read_to_string(&self.credentials_path)
635            .map_err(|e| CredentialError::Io(e.to_string()))?;
636
637        if encrypted_data.is_empty() {
638            return Ok(serde_json::json!({}));
639        }
640
641        let (decrypted, from_legacy_key) = match self.decrypt_migrating(&encrypted_data) {
642            Ok(decrypted) => decrypted,
643            Err(e) => {
644                warn!(
645                    "Credentials file at {:?} exists but cannot be decrypted ({}); \
646                     treating as no stored credentials. This is expected after a \
647                     backup restore or device transfer - the encryption key does \
648                     not travel with the data. Signing in again will rewrite it.",
649                    self.credentials_path, e
650                );
651                return Ok(serde_json::json!({}));
652            }
653        };
654
655        match serde_json::from_str(&decrypted) {
656            Ok(value) => {
657                // Rewrite under the persisted key so the legacy derivation is
658                // never needed again.
659                if from_legacy_key {
660                    if let Err(e) = self.save_credentials_file(&value) {
661                        warn!(
662                            "Could not rewrite credentials under the persisted key: {}",
663                            e
664                        );
665                    }
666                }
667                Ok(value)
668            }
669            Err(e) => {
670                warn!(
671                    "Credentials file at {:?} decrypted to invalid JSON ({}); \
672                     treating as no stored credentials.",
673                    self.credentials_path, e
674                );
675                Ok(serde_json::json!({}))
676            }
677        }
678    }
679
680    fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
681        // Ensure parent directory exists
682        if let Some(parent) = self.credentials_path.parent() {
683            fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
684        }
685
686        let json =
687            serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
688        let encrypted = self.encrypt(&json)?;
689
690        fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
691    }
692
693    fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
694        encrypt_with(&self.encryption_key, plaintext)
695    }
696
697    /// Decrypt with the current key, falling back to the legacy derivation.
698    ///
699    /// Returns the plaintext and whether the legacy key was what opened it, so
700    /// the caller can rewrite the file under the current key and stop depending
701    /// on a derivation that changes when the machine is renamed.
702    ///
703    /// TRACES: UR-012 | IR-014 | UT-014
704    fn decrypt_migrating(&self, encrypted: &str) -> Result<(String, bool), CredentialError> {
705        match decrypt_with(&self.encryption_key, encrypted) {
706            Ok(plaintext) => Ok((plaintext, false)),
707            Err(current_err) => match decrypt_with(&self.legacy_key, encrypted) {
708                Ok(plaintext) => {
709                    info!("Credentials were written under the legacy derived key; rewriting them");
710                    Ok((plaintext, true))
711                }
712                Err(_) => Err(current_err),
713            },
714        }
715    }
716
717    fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
718        let mut data = self.load_credentials_file()?;
719        data[user_id] = serde_json::json!(token);
720        self.save_credentials_file(&data)
721    }
722
723    fn get_from_file(&self, user_id: &str) -> Result<String, CredentialError> {
724        let data = self.load_credentials_file()?;
725        data.get(user_id)
726            .and_then(|v| v.as_str())
727            .map(|s| s.to_string())
728            .ok_or(CredentialError::NotFound)
729    }
730
731    fn delete_from_file(&self, user_id: &str) -> Result<(), CredentialError> {
732        let mut data = self.load_credentials_file()?;
733        if let Some(obj) = data.as_object_mut() {
734            obj.remove(user_id);
735        }
736        self.save_credentials_file(&data)
737    }
738}
739
740impl Default for CredentialStore {
741    fn default() -> Self {
742        Self::new()
743    }
744}
745
746// --- Android Keystore integration via JNI ---
747
748#[cfg(target_os = "android")]
749mod android_keystore {
750    use super::*;
751    use jni::objects::{JClass, JObject, JString, JValue};
752    use jni::JNIEnv;
753    use std::sync::OnceLock;
754
755    /// Cached reference to the SecureStorage class
756    static SECURE_STORAGE_CLASS: OnceLock<String> = OnceLock::new();
757
758    const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage";
759
760    /// Initialize the SecureStorage singleton from Android context
761    pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
762        log::info!("Initializing Android SecureStorage...");
763
764        // Get the ClassLoader from the Context
765        let class_loader = env
766            .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
767            .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
768            .l()
769            .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
770
771        // Load the SecureStorage class
772        let class_name = env
773            .new_string(SECURE_STORAGE_CLASS_NAME.replace('/', "."))
774            .map_err(|e| format!("Failed to create class name string: {}", e))?;
775
776        let storage_class_obj = env
777            .call_method(
778                &class_loader,
779                "loadClass",
780                "(Ljava/lang/String;)Ljava/lang/Class;",
781                &[JValue::Object(&class_name.into())],
782            )
783            .map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
784            .l()
785            .map_err(|e| format!("Failed to convert to Class: {}", e))?;
786
787        let storage_class = JClass::from(storage_class_obj);
788
789        // Call SecureStorage.initialize(context)
790        env.call_static_method(
791            &storage_class,
792            "initialize",
793            "(Landroid/content/Context;)V",
794            &[JValue::Object(context)],
795        )
796        .map_err(|e| format!("Failed to initialize SecureStorage: {}", e))?;
797
798        // Cache the class name for future use
799        let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string());
800
801        log::info!("Android SecureStorage initialized successfully");
802        Ok(())
803    }
804
805    /// Test if Android Keystore is available
806    pub fn test_keystore_available() -> bool {
807        // Get JNI environment
808        let ctx = ndk_context::android_context();
809        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
810
811        let vm = match vm {
812            Ok(vm) => vm,
813            Err(e) => {
814                log::warn!("Failed to get JavaVM for keystore test: {}", e);
815                return false;
816            }
817        };
818
819        let mut env = match vm.attach_current_thread() {
820            Ok(env) => env,
821            Err(e) => {
822                log::warn!("Failed to attach thread for keystore test: {}", e);
823                return false;
824            }
825        };
826
827        // Try to get the SecureStorage instance
828        match get_secure_storage_instance(&mut env) {
829            Ok(_) => {
830                log::info!("Android Keystore available via SecureStorage");
831                true
832            }
833            Err(e) => {
834                log::warn!("Android Keystore not available: {}", e);
835                false
836            }
837        }
838    }
839
840    /// Get the SecureStorage singleton instance
841    fn get_secure_storage_instance<'a>(env: &mut JNIEnv<'a>) -> Result<JObject<'a>, String> {
842        let class_name = SECURE_STORAGE_CLASS
843            .get()
844            .ok_or_else(|| "SecureStorage not initialized".to_string())?;
845
846        // Get the Android context
847        let ctx = ndk_context::android_context();
848        let context = unsafe { JObject::from_raw(ctx.context().cast()) };
849
850        // Get the ClassLoader from the Context
851        let class_loader = env
852            .call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
853            .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
854            .l()
855            .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
856
857        // Load the SecureStorage class using the app's classloader
858        let class_name_jstring = env
859            .new_string(class_name.replace('/', "."))
860            .map_err(|e| format!("Failed to create class name string: {}", e))?;
861
862        let storage_class_obj = env
863            .call_method(
864                &class_loader,
865                "loadClass",
866                "(Ljava/lang/String;)Ljava/lang/Class;",
867                &[JValue::Object(&class_name_jstring.into())],
868            )
869            .map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
870            .l()
871            .map_err(|e| format!("Failed to convert to Class: {}", e))?;
872
873        let storage_class = JClass::from(storage_class_obj);
874
875        let instance = env
876            .call_static_method(
877                &storage_class,
878                "getInstance",
879                "()Lcom/dtourolle/jellytau/security/SecureStorage;",
880                &[],
881            )
882            .map_err(|e| format!("Failed to get SecureStorage instance: {}", e))?
883            .l()
884            .map_err(|e| format!("Failed to convert to object: {}", e))?;
885
886        Ok(instance)
887    }
888
889    /// Save a token using Android Keystore
890    pub fn save_token(user_id: &str, token: &str) -> Result<(), CredentialError> {
891        let ctx = ndk_context::android_context();
892        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
893            .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
894
895        let mut env = vm
896            .attach_current_thread()
897            .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
898
899        let instance =
900            get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
901
902        let key = format!("access_token:{}", user_id);
903        let key_jstring = env
904            .new_string(&key)
905            .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
906        let token_jstring = env.new_string(token).map_err(|e| {
907            CredentialError::Keyring(format!("Failed to create token string: {}", e))
908        })?;
909
910        let result = env
911            .call_method(
912                instance,
913                "saveToken",
914                "(Ljava/lang/String;Ljava/lang/String;)Z",
915                &[
916                    JValue::Object(&key_jstring.into()),
917                    JValue::Object(&token_jstring.into()),
918                ],
919            )
920            .map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
921            .z()
922            .map_err(|e| {
923                CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
924            })?;
925
926        if result {
927            Ok(())
928        } else {
929            Err(CredentialError::Keyring(
930                "saveToken returned false".to_string(),
931            ))
932        }
933    }
934
935    /// Get a token from Android Keystore
936    pub fn get_token(user_id: &str) -> Result<String, CredentialError> {
937        let ctx = ndk_context::android_context();
938        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
939            .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
940
941        let mut env = vm
942            .attach_current_thread()
943            .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
944
945        let instance =
946            get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
947
948        let key = format!("access_token:{}", user_id);
949        let key_jstring = env
950            .new_string(&key)
951            .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
952
953        let result = env
954            .call_method(
955                instance,
956                "getToken",
957                "(Ljava/lang/String;)Ljava/lang/String;",
958                &[JValue::Object(&key_jstring.into())],
959            )
960            .map_err(|e| CredentialError::Keyring(format!("Failed to call getToken: {}", e)))?
961            .l()
962            .map_err(|e| CredentialError::Keyring(format!("Failed to get object result: {}", e)))?;
963
964        if result.is_null() {
965            return Err(CredentialError::NotFound);
966        }
967
968        let token_jstring = JString::from(result);
969        let token: String = env
970            .get_string(&token_jstring)
971            .map_err(|e| CredentialError::Keyring(format!("Failed to get string: {}", e)))?
972            .into();
973
974        Ok(token)
975    }
976
977    /// Delete a token from Android Keystore
978    pub fn delete_token(user_id: &str) -> Result<(), CredentialError> {
979        let ctx = ndk_context::android_context();
980        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
981            .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
982
983        let mut env = vm
984            .attach_current_thread()
985            .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
986
987        let instance =
988            get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
989
990        let key = format!("access_token:{}", user_id);
991        let key_jstring = env
992            .new_string(&key)
993            .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
994
995        let result = env
996            .call_method(
997                instance,
998                "deleteToken",
999                "(Ljava/lang/String;)Z",
1000                &[JValue::Object(&key_jstring.into())],
1001            )
1002            .map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
1003            .z()
1004            .map_err(|e| {
1005                CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
1006            })?;
1007
1008        if result {
1009            Ok(())
1010        } else {
1011            Err(CredentialError::Keyring(
1012                "deleteToken returned false".to_string(),
1013            ))
1014        }
1015    }
1016}
1017
1018// Export Android keystore functions at the module level for easier access
1019#[cfg(target_os = "android")]
1020pub use android_keystore::{
1021    initialize_secure_storage, test_keystore_available as android_test_keystore_available,
1022};
1023
1024#[cfg(test)]
1025mod tests {
1026
1027    /// The fallback key must be the same on every launch.
1028    ///
1029    /// It used to be derived from the hostname, `$USER` and a static salt.
1030    /// Renaming the machine — or launching from a context where `$USER` is
1031    /// unset, such as a systemd user service — changed the key, and
1032    /// `load_credentials_file` reports a failed decrypt as "no stored
1033    /// credentials". The user was silently signed out with nothing to explain it.
1034    ///
1035    /// TRACES: UR-012 | IR-014 | UT-014
1036    #[test]
1037    fn the_fallback_key_is_stable_across_processes() {
1038        let dir = std::env::temp_dir().join(format!("jellytau-key-{}", std::process::id()));
1039        let path = dir.join("credentials.key");
1040        let _ = fs::remove_file(&path);
1041
1042        let first = load_or_create_key(&path);
1043        let second = load_or_create_key(&path);
1044        assert_eq!(first, second, "the key must not change between launches");
1045        assert_ne!(first, [0u8; 32], "the key must be real randomness");
1046
1047        let _ = fs::remove_dir_all(&dir);
1048    }
1049
1050    /// Two installs must not share a key.
1051    ///
1052    /// TRACES: UR-012 | IR-014 | UT-014
1053    #[test]
1054    fn separate_installs_get_separate_keys() {
1055        let base = std::env::temp_dir().join(format!("jellytau-keys-{}", std::process::id()));
1056        let a = load_or_create_key(&base.join("a").join("credentials.key"));
1057        let b = load_or_create_key(&base.join("b").join("credentials.key"));
1058        assert_ne!(a, b);
1059        let _ = fs::remove_dir_all(&base);
1060    }
1061
1062    /// A credentials file written under the old derived key must still open.
1063    ///
1064    /// TRACES: UR-012 | IR-014 | UT-014
1065    #[test]
1066    fn credentials_written_under_the_legacy_key_still_decrypt() {
1067        let legacy = CredentialStore::derive_legacy_encryption_key();
1068        let mut persisted = [0u8; 32];
1069        getrandom::getrandom(&mut persisted).unwrap();
1070        assert_ne!(legacy, persisted);
1071
1072        let blob = encrypt_with(&legacy, r#"{"user-1":"token-abc"}"#).unwrap();
1073
1074        let store = CredentialStore {
1075            using_keyring: false,
1076            credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
1077            encryption_key: persisted,
1078            legacy_key: legacy,
1079        };
1080
1081        let (plaintext, migrated) = store
1082            .decrypt_migrating(&blob)
1083            .expect("a file written under the legacy key must still be readable");
1084        assert_eq!(plaintext, r#"{"user-1":"token-abc"}"#);
1085        assert!(migrated, "the caller must know to rewrite it");
1086    }
1087
1088    /// The current key is tried first and needs no migration.
1089    ///
1090    /// TRACES: UR-012 | IR-014 | UT-014
1091    #[test]
1092    fn credentials_under_the_current_key_are_not_flagged_for_migration() {
1093        let mut persisted = [0u8; 32];
1094        getrandom::getrandom(&mut persisted).unwrap();
1095        let blob = encrypt_with(&persisted, "hello").unwrap();
1096
1097        let store = CredentialStore {
1098            using_keyring: false,
1099            credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
1100            encryption_key: persisted,
1101            legacy_key: [7u8; 32],
1102        };
1103
1104        let (plaintext, migrated) = store.decrypt_migrating(&blob).unwrap();
1105        assert_eq!(plaintext, "hello");
1106        assert!(!migrated);
1107    }
1108
1109    /// A blob under neither key fails rather than returning something wrong.
1110    ///
1111    /// TRACES: UR-012 | IR-014 | UT-014
1112    #[test]
1113    fn an_unreadable_blob_is_an_error() {
1114        let blob = encrypt_with(&[1u8; 32], "secret").unwrap();
1115        let store = CredentialStore {
1116            using_keyring: false,
1117            credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
1118            encryption_key: [2u8; 32],
1119            legacy_key: [3u8; 32],
1120        };
1121        assert!(store.decrypt_migrating(&blob).is_err());
1122    }
1123    use super::*;
1124
1125    /// Build a store pinned to the encrypted-file backend with an explicit key,
1126    /// so a test can simulate "same file, different machine key" (which is what
1127    /// a restored backup looks like).
1128    fn file_backed_store(credentials_path: PathBuf, encryption_key: [u8; 32]) -> CredentialStore {
1129        CredentialStore {
1130            using_keyring: false,
1131            credentials_path,
1132            encryption_key,
1133            // A distinct legacy key, so "same file, different machine key" stays
1134            // undecryptable rather than being opened by the migration path.
1135            legacy_key: [0xABu8; 32],
1136        }
1137    }
1138
1139    /// A credentials file we cannot decrypt must read as *no credentials stored*,
1140    /// not as a hard error. This is the restored-backup case: the ciphertext comes
1141    /// back but the key that encrypted it (Android Keystore / the machine-derived
1142    /// key) does not, so every read fails forever.
1143    ///
1144    /// TRACES: UR-012 | IR-014
1145    #[test]
1146    fn undecryptable_credentials_file_reads_as_not_found() {
1147        let dir = tempfile::tempdir().unwrap();
1148        let path = dir.path().join(CREDENTIALS_FILENAME);
1149
1150        let original = file_backed_store(path.clone(), [1u8; 32]);
1151        original.save_to_file("user-1", "token-abc").unwrap();
1152
1153        // Restored onto a device whose derived key differs: same bytes, no key.
1154        let restored = file_backed_store(path.clone(), [2u8; 32]);
1155        match restored.get_token("user-1") {
1156            Err(CredentialError::NotFound) => {}
1157            other => panic!("expected NotFound for undecryptable ciphertext, got {other:?}"),
1158        }
1159    }
1160
1161    /// Garbage in the file (truncation, partial restore) is the same story.
1162    ///
1163    /// TRACES: UR-012 | IR-014
1164    #[test]
1165    fn corrupt_credentials_file_reads_as_not_found() {
1166        let dir = tempfile::tempdir().unwrap();
1167        let path = dir.path().join(CREDENTIALS_FILENAME);
1168        fs::write(&path, "not base64 at all !!!").unwrap();
1169
1170        let store = file_backed_store(path, [3u8; 32]);
1171        match store.get_token("user-1") {
1172            Err(CredentialError::NotFound) => {}
1173            other => panic!("expected NotFound for corrupt file, got {other:?}"),
1174        }
1175    }
1176
1177    /// …and the logged-out state must be recoverable: signing in again has to be
1178    /// able to write over the unreadable file rather than failing on load.
1179    ///
1180    /// TRACES: UR-012 | IR-014
1181    #[test]
1182    fn login_after_undecryptable_file_rewrites_it() {
1183        let dir = tempfile::tempdir().unwrap();
1184        let path = dir.path().join(CREDENTIALS_FILENAME);
1185
1186        let original = file_backed_store(path.clone(), [1u8; 32]);
1187        original.save_to_file("user-1", "token-abc").unwrap();
1188
1189        let restored = file_backed_store(path.clone(), [2u8; 32]);
1190        restored.save_to_file("user-1", "token-fresh").unwrap();
1191
1192        assert_eq!(restored.get_from_file("user-1").unwrap(), "token-fresh");
1193    }
1194
1195    #[test]
1196    fn test_encryption_roundtrip() {
1197        let store = CredentialStore::new();
1198        let plaintext = "test-access-token-12345";
1199
1200        let encrypted = store.encrypt(plaintext).unwrap();
1201        let (decrypted, _) = store.decrypt_migrating(&encrypted).unwrap();
1202
1203        assert_eq!(plaintext, decrypted);
1204    }
1205
1206    /// The legacy derivation must stay deterministic *within a machine*, or the
1207    /// one-time migration of an old credentials file cannot read it.
1208    ///
1209    /// Its instability *across* machine states is exactly why it no longer
1210    /// encrypts anything — see `the_fallback_key_is_stable_across_processes`.
1211    ///
1212    /// TRACES: UR-012 | IR-014 | UT-014
1213    #[test]
1214    fn test_legacy_derivation_is_deterministic_for_migration() {
1215        let key1 = CredentialStore::derive_legacy_encryption_key();
1216        let key2 = CredentialStore::derive_legacy_encryption_key();
1217        assert_eq!(key1, key2);
1218    }
1219}