First working POC
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user