1use 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
39const KEY_FILENAME: &str = "credentials.key";
41
42fn 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
89fn 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
103fn 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
130fn 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#[derive(Debug)]
154pub enum CredentialResult {
155 Keyring,
157 EncryptedFile,
159}
160
161#[derive(Debug)]
163pub enum CredentialError {
164 Keyring(String),
166 Encryption(String),
168 Io(String),
170 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
187pub struct CredentialStore {
189 using_keyring: bool,
191 credentials_path: PathBuf,
193 encryption_key: [u8; 32],
196 legacy_key: [u8; 32],
200}
201
202impl CredentialStore {
203 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 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 pub fn is_using_keyring(&self) -> bool {
232 self.using_keyring
233 }
234
235 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 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 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 fn test_keyring_available() -> bool {
290 #[cfg(target_os = "android")]
292 {
293 android_test_keystore_available()
294 }
295
296 #[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 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 use std::process::Command;
331
332 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 let entry = keyring::Entry::new(SERVICE_NAME, "__test__");
346 match entry {
347 Ok(e) => {
348 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 #[cfg(target_os = "android")]
364 {
365 android_keystore::save_token(user_id, token)
366 }
367
368 #[cfg(target_os = "linux")]
369 {
370 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 #[cfg(target_os = "android")]
426 {
427 android_keystore::get_token(user_id)
428 }
429
430 #[cfg(target_os = "linux")]
431 {
432 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 #[cfg(target_os = "android")]
492 {
493 android_keystore::delete_token(user_id)
494 }
495
496 #[cfg(target_os = "linux")]
497 {
498 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 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(()), Err(e) => Err(CredentialError::Keyring(e.to_string())),
534 }
535 }
536 }
537
538 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 fn derive_legacy_encryption_key() -> [u8; 32] {
566 let mut hasher = Sha256::new();
569
570 #[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 #[cfg(target_os = "android")]
580 {
581 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 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 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 hasher.update(b"jellytau-credential-encryption-v1");
607
608 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 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 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 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 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#[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 static SECURE_STORAGE_CLASS: OnceLock<String> = OnceLock::new();
757
758 const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage";
759
760 pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
762 log::info!("Initializing Android SecureStorage...");
763
764 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 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 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 let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string());
800
801 log::info!("Android SecureStorage initialized successfully");
802 Ok(())
803 }
804
805 pub fn test_keystore_available() -> bool {
807 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 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 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 let ctx = ndk_context::android_context();
848 let context = unsafe { JObject::from_raw(ctx.context().cast()) };
849
850 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 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 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 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 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#[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 #[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 #[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 #[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 #[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 #[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 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 legacy_key: [0xABu8; 32],
1136 }
1137 }
1138
1139 #[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 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 #[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 #[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 #[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}