jellytau/src-tauri/src/credentials.rs

821 lines
30 KiB
Rust

//! Secure credential storage module
//!
//! Provides secure storage for access tokens using:
//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
//!
//! The fallback is less secure as the encryption key is derived from machine
//! identifiers, but provides functionality on headless systems.
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use directories::ProjectDirs;
use log::{info, warn};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
#[cfg(target_os = "linux")]
use hostname;
#[cfg(not(target_os = "android"))]
const SERVICE_NAME: &str = "com.dtourolle.jellytau";
const CREDENTIALS_FILENAME: &str = "credentials.enc";
/// Result of a credential storage operation
#[derive(Debug)]
pub enum CredentialResult {
/// Operation succeeded using the system keyring
Keyring,
/// Operation succeeded using encrypted file fallback
EncryptedFile,
}
/// Error types for credential operations
#[derive(Debug)]
pub enum CredentialError {
/// Keyring operation failed
Keyring(String),
/// Encryption/decryption failed
Encryption(String),
/// File I/O failed
Io(String),
/// Credential not found
NotFound,
}
impl std::fmt::Display for CredentialError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Keyring(msg) => write!(f, "Keyring error: {}", msg),
Self::Encryption(msg) => write!(f, "Encryption error: {}", msg),
Self::Io(msg) => write!(f, "I/O error: {}", msg),
Self::NotFound => write!(f, "Credential not found"),
}
}
}
impl std::error::Error for CredentialError {}
/// Credential storage manager
pub struct CredentialStore {
/// Whether we're using keyring (true) or encrypted file (false)
using_keyring: bool,
/// Path to the encrypted credentials file (fallback)
credentials_path: PathBuf,
/// Encryption key for file fallback (derived from machine ID)
encryption_key: [u8; 32],
}
impl CredentialStore {
/// Create a new credential store, detecting the best available backend
pub fn new() -> Self {
let credentials_path = Self::get_credentials_path();
let encryption_key = Self::derive_encryption_key();
// Test if keyring is available by trying a dummy operation
let using_keyring = Self::test_keyring_available();
if !using_keyring {
warn!(
"[INIT] System keyring unavailable, using encrypted file fallback at {:?}. \
This is less secure than system keyring storage.",
credentials_path
);
} else {
info!("[INIT] Using system keyring for credential storage");
}
Self {
using_keyring,
credentials_path,
encryption_key,
}
}
/// Check if we're using the secure keyring backend
pub fn is_using_keyring(&self) -> bool {
self.using_keyring
}
/// Save an access token for a user
pub fn save_token(&self, user_id: &str, token: &str) -> Result<CredentialResult, CredentialError> {
if self.using_keyring {
log::debug!("Saving token for user {} to keyring", user_id);
self.save_to_keyring(user_id, token)?;
Ok(CredentialResult::Keyring)
} else {
log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path);
self.save_to_file(user_id, token)?;
log::debug!("Successfully saved token to encrypted file");
Ok(CredentialResult::EncryptedFile)
}
}
/// Get an access token for a user
pub fn get_token(&self, user_id: &str) -> Result<String, CredentialError> {
if self.using_keyring {
log::debug!("Getting token for user {} from keyring", user_id);
self.get_from_keyring(user_id)
} else {
log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path);
let result = self.get_from_file(user_id);
if result.is_ok() {
log::debug!("Successfully retrieved token from encrypted file");
} else {
log::warn!("Failed to retrieve token from encrypted file: {:?}", result);
}
result
}
}
/// Delete an access token for a user
pub fn delete_token(&self, user_id: &str) -> Result<(), CredentialError> {
if self.using_keyring {
self.delete_from_keyring(user_id)
} else {
self.delete_from_file(user_id)
}
}
// --- Keyring backend ---
fn test_keyring_available() -> bool {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_test_keystore_available()
}
// On Linux, the keyring test can block indefinitely if Secret Service
// (gnome-keyring/kwallet) is unresponsive. Use a timeout to prevent hanging.
#[cfg(target_os = "linux")]
{
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = Self::test_keyring_inner();
let _ = tx.send(result);
});
// Wait up to 2 seconds for keyring response
match rx.recv_timeout(Duration::from_secs(2)) {
Ok(result) => result,
Err(_) => {
log::warn!("Keyring availability check timed out after 2 seconds");
false
}
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
Self::test_keyring_inner()
}
}
#[cfg(target_os = "linux")]
fn test_keyring_inner() -> bool {
// On Linux, test if secret-tool is available
use std::process::Command;
// secret-tool doesn't support --version, so we test with a search command
// that will succeed even if no items are found
match Command::new("secret-tool")
.arg("search")
.arg("service")
.arg("__nonexistent_test__")
.output()
{
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Err(_) => false, // Command not found or can't execute
}
}
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
fn test_keyring_inner() -> bool {
// On macOS/Windows, test using the keyring-rs library
let entry = keyring::Entry::new(SERVICE_NAME, "__test__");
match entry {
Ok(e) => {
// Try to get (will fail with NotFound, which is fine)
// If it fails with a different error, keyring is not available
match e.get_password() {
Ok(_) => true,
Err(keyring::Error::NoEntry) => true,
Err(keyring::Error::NoStorageAccess(_)) => false,
Err(_) => false,
}
}
Err(_) => false,
}
}
fn save_to_keyring(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_keystore::save_token(user_id, token)
}
#[cfg(target_os = "linux")]
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::{Command, Stdio};
use std::io::Write;
let key = format!("access_token:{}", user_id);
let mut child = Command::new("secret-tool")
.arg("store")
.arg("--label")
.arg(format!("{}@{}", key, SERVICE_NAME))
.arg("service")
.arg(SERVICE_NAME)
.arg("username")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(token.as_bytes())
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
}
let status = child.wait()
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status)))
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
let key = format!("access_token:{}", user_id);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
entry
.set_password(token)
.map_err(|e| CredentialError::Keyring(e.to_string()))
}
}
fn get_from_keyring(&self, user_id: &str) -> Result<String, CredentialError> {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_keystore::get_token(user_id)
}
#[cfg(target_os = "linux")]
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::Command;
let key = format!("access_token:{}", user_id);
log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key);
let output = Command::new("secret-tool")
.arg("lookup")
.arg("service")
.arg(SERVICE_NAME)
.arg("username")
.arg(&key)
.output()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
if output.status.success() {
log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len());
let token = String::from_utf8(output.stdout)
.map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))?
.trim()
.to_string();
Ok(token)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr);
Err(CredentialError::NotFound)
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
let key = format!("access_token:{}", user_id);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
entry.get_password().map_err(|e| match e {
keyring::Error::NoEntry => CredentialError::NotFound,
_ => CredentialError::Keyring(e.to_string()),
})
}
}
fn delete_from_keyring(&self, user_id: &str) -> Result<(), CredentialError> {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_keystore::delete_token(user_id)
}
#[cfg(target_os = "linux")]
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::Command;
let key = format!("access_token:{}", user_id);
let status = Command::new("secret-tool")
.arg("clear")
.arg("service")
.arg(SERVICE_NAME)
.arg("username")
.arg(&key)
.status()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
// secret-tool clear returns success even if entry doesn't exist
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status)))
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
let key = format!("access_token:{}", user_id);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
match entry.delete_credential() {
Ok(_) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
Err(e) => Err(CredentialError::Keyring(e.to_string())),
}
}
}
// --- Encrypted file backend ---
fn get_credentials_path() -> PathBuf {
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
} else {
PathBuf::from(CREDENTIALS_FILENAME)
}
}
fn derive_encryption_key() -> [u8; 32] {
// Derive a key from machine-specific identifiers
// This is less secure than a true keyring but provides some protection
let mut hasher = Sha256::new();
// Use hostname on Linux (where it's available and stable)
#[cfg(target_os = "linux")]
{
if let Ok(hostname) = hostname::get() {
hasher.update(hostname.to_string_lossy().as_bytes());
}
}
// On Android, read device properties from the filesystem
#[cfg(target_os = "android")]
{
// Try to read Android build properties from /system/build.prop
let build_prop_paths = [
"/system/build.prop",
"/vendor/build.prop",
];
for path in &build_prop_paths {
if let Ok(content) = fs::read_to_string(path) {
// Extract key properties for device fingerprint
for line in content.lines() {
if line.starts_with("ro.build.fingerprint=")
|| line.starts_with("ro.serialno=")
|| line.starts_with("ro.build.id=")
|| line.starts_with("ro.product.model=") {
hasher.update(line.as_bytes());
}
}
}
}
// Also use the app data directory path as it's device/install-specific
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
hasher.update(proj_dirs.data_dir().to_string_lossy().as_bytes());
}
}
// Use a static salt (app-specific)
hasher.update(b"jellytau-credential-encryption-v1");
// Add username for additional entropy (if available)
if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
hasher.update(user.as_bytes());
}
hasher.finalize().into()
}
fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
if !self.credentials_path.exists() {
return Ok(serde_json::json!({}));
}
let encrypted_data =
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
if encrypted_data.is_empty() {
return Ok(serde_json::json!({}));
}
let decrypted = self.decrypt(&encrypted_data)?;
serde_json::from_str(&decrypted).map_err(|e| CredentialError::Encryption(e.to_string()))
}
fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
// Ensure parent directory exists
if let Some(parent) = self.credentials_path.parent() {
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
}
let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let encrypted = self.encrypt(&json)?;
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
}
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Generate a random nonce
let mut nonce_bytes = [0u8; 12];
getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Prepend nonce to ciphertext and encode as base64
let mut combined = nonce_bytes.to_vec();
combined.extend(ciphertext);
Ok(BASE64.encode(&combined))
}
fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError> {
let combined = BASE64
.decode(encrypted)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
if combined.len() < 12 {
return Err(CredentialError::Encryption("Invalid encrypted data".to_string()));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
}
fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
let mut data = self.load_credentials_file()?;
data[user_id] = serde_json::json!(token);
self.save_credentials_file(&data)
}
fn get_from_file(&self, user_id: &str) -> Result<String, CredentialError> {
let data = self.load_credentials_file()?;
data.get(user_id)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or(CredentialError::NotFound)
}
fn delete_from_file(&self, user_id: &str) -> Result<(), CredentialError> {
let mut data = self.load_credentials_file()?;
if let Some(obj) = data.as_object_mut() {
obj.remove(user_id);
}
self.save_credentials_file(&data)
}
}
impl Default for CredentialStore {
fn default() -> Self {
Self::new()
}
}
// --- Android Keystore integration via JNI ---
#[cfg(target_os = "android")]
mod android_keystore {
use super::*;
use jni::objects::{JClass, JObject, JString, JValue};
use jni::JNIEnv;
use std::sync::OnceLock;
/// Cached reference to the SecureStorage class
static SECURE_STORAGE_CLASS: OnceLock<String> = OnceLock::new();
const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage";
/// Initialize the SecureStorage singleton from Android context
pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
log::info!("Initializing Android SecureStorage...");
// Get the ClassLoader from the Context
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the SecureStorage class
let class_name = env
.new_string(SECURE_STORAGE_CLASS_NAME.replace('/', "."))
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let storage_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&class_name.into())],
)
.map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let storage_class = JClass::from(storage_class_obj);
// Call SecureStorage.initialize(context)
env.call_static_method(
&storage_class,
"initialize",
"(Landroid/content/Context;)V",
&[JValue::Object(context)],
)
.map_err(|e| format!("Failed to initialize SecureStorage: {}", e))?;
// Cache the class name for future use
let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string());
log::info!("Android SecureStorage initialized successfully");
Ok(())
}
/// Test if Android Keystore is available
pub fn test_keystore_available() -> bool {
// Get JNI environment
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
let vm = match vm {
Ok(vm) => vm,
Err(e) => {
log::warn!("Failed to get JavaVM for keystore test: {}", e);
return false;
}
};
let mut env = match vm.attach_current_thread() {
Ok(env) => env,
Err(e) => {
log::warn!("Failed to attach thread for keystore test: {}", e);
return false;
}
};
// Try to get the SecureStorage instance
match get_secure_storage_instance(&mut env) {
Ok(_) => {
log::info!("Android Keystore available via SecureStorage");
true
}
Err(e) => {
log::warn!("Android Keystore not available: {}", e);
false
}
}
}
/// Get the SecureStorage singleton instance
fn get_secure_storage_instance<'a>(env: &mut JNIEnv<'a>) -> Result<JObject<'a>, String> {
let class_name = SECURE_STORAGE_CLASS
.get()
.ok_or_else(|| "SecureStorage not initialized".to_string())?;
// Get the Android context
let ctx = ndk_context::android_context();
let context = unsafe { JObject::from_raw(ctx.context().cast()) };
// Get the ClassLoader from the Context
let class_loader = env
.call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the SecureStorage class using the app's classloader
let class_name_jstring = env
.new_string(class_name.replace('/', "."))
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let storage_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&class_name_jstring.into())],
)
.map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let storage_class = JClass::from(storage_class_obj);
let instance = env
.call_static_method(
&storage_class,
"getInstance",
"()Lcom/dtourolle/jellytau/security/SecureStorage;",
&[],
)
.map_err(|e| format!("Failed to get SecureStorage instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
Ok(instance)
}
/// Save a token using Android Keystore
pub fn save_token(user_id: &str, token: &str) -> Result<(), CredentialError> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let token_jstring = env
.new_string(token)
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
let result = env
.call_method(
instance,
"saveToken",
"(Ljava/lang/String;Ljava/lang/String;)Z",
&[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("saveToken returned false".to_string()))
}
}
/// Get a token from Android Keystore
pub fn get_token(user_id: &str) -> Result<String, CredentialError> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let result = env
.call_method(
instance,
"getToken",
"(Ljava/lang/String;)Ljava/lang/String;",
&[JValue::Object(&key_jstring.into())],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call getToken: {}", e)))?
.l()
.map_err(|e| CredentialError::Keyring(format!("Failed to get object result: {}", e)))?;
if result.is_null() {
return Err(CredentialError::NotFound);
}
let token_jstring = JString::from(result);
let token: String = env
.get_string(&token_jstring)
.map_err(|e| CredentialError::Keyring(format!("Failed to get string: {}", e)))?
.into();
Ok(token)
}
/// Delete a token from Android Keystore
pub fn delete_token(user_id: &str) -> Result<(), CredentialError> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let result = env
.call_method(
instance,
"deleteToken",
"(Ljava/lang/String;)Z",
&[JValue::Object(&key_jstring.into())],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("deleteToken returned false".to_string()))
}
}
}
// Export Android keystore functions at the module level for easier access
#[cfg(target_os = "android")]
pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encryption_roundtrip() {
let store = CredentialStore::new();
let plaintext = "test-access-token-12345";
let encrypted = store.encrypt(plaintext).unwrap();
let decrypted = store.decrypt(&encrypted).unwrap();
assert_eq!(plaintext, decrypted);
}
#[test]
fn test_derive_encryption_key_is_deterministic() {
let key1 = CredentialStore::derive_encryption_key();
let key2 = CredentialStore::derive_encryption_key();
assert_eq!(key1, key2);
}
}