821 lines
30 KiB
Rust
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);
|
|
}
|
|
}
|