1use 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#[derive(Debug)]
30pub enum CredentialResult {
31 Keyring,
33 EncryptedFile,
35}
36
37#[derive(Debug)]
39pub enum CredentialError {
40 Keyring(String),
42 Encryption(String),
44 Io(String),
46 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
63pub struct CredentialStore {
65 using_keyring: bool,
67 credentials_path: PathBuf,
69 encryption_key: [u8; 32],
71}
72
73impl CredentialStore {
74 pub fn new() -> Self {
76 let credentials_path = Self::get_credentials_path();
77 let encryption_key = Self::derive_encryption_key();
78
79 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 pub fn is_using_keyring(&self) -> bool {
101 self.using_keyring
102 }
103
104 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 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 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 fn test_keyring_available() -> bool {
159 #[cfg(target_os = "android")]
161 {
162 android_test_keystore_available()
163 }
164
165 #[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 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 use std::process::Command;
200
201 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 let entry = keyring::Entry::new(SERVICE_NAME, "__test__");
215 match entry {
216 Ok(e) => {
217 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 #[cfg(target_os = "android")]
233 {
234 android_keystore::save_token(user_id, token)
235 }
236
237 #[cfg(target_os = "linux")]
238 {
239 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 #[cfg(target_os = "android")]
295 {
296 android_keystore::get_token(user_id)
297 }
298
299 #[cfg(target_os = "linux")]
300 {
301 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 #[cfg(target_os = "android")]
361 {
362 android_keystore::delete_token(user_id)
363 }
364
365 #[cfg(target_os = "linux")]
366 {
367 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 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(()), Err(e) => Err(CredentialError::Keyring(e.to_string())),
403 }
404 }
405 }
406
407 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 let mut hasher = Sha256::new();
421
422 #[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 #[cfg(target_os = "android")]
432 {
433 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 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 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 hasher.update(b"jellytau-credential-encryption-v1");
459
460 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 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 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 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 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#[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 static SECURE_STORAGE_CLASS: OnceLock<String> = OnceLock::new();
618
619 const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage";
620
621 pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
623 log::info!("Initializing Android SecureStorage...");
624
625 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 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 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 let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string());
661
662 log::info!("Android SecureStorage initialized successfully");
663 Ok(())
664 }
665
666 pub fn test_keystore_available() -> bool {
668 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 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 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 let ctx = ndk_context::android_context();
709 let context = unsafe { JObject::from_raw(ctx.context().cast()) };
710
711 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 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 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 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 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#[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 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 #[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 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 #[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 #[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}