51 clippy warnings -> 0, with 8 justified #[allow]s (IPC arity, specta wire types, and the 9 test-only await-holding-lock sites). 27 raw lock calls moved to the poison-tolerant helpers - all of them test code; production was already clean. Caught a non-neutral clippy --fix: removing the redundant 'use hostname;' in credentials.rs orphaned its #[cfg(target_os = "linux")] onto SERVICE_NAME, which would have cfg'd the constant out of every non-Linux build. Compiles clean on Linux, so only Windows/macOS CI would have caught it.
974 lines
35 KiB
Rust
974 lines
35 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.
|
|
//!
|
|
//! TRACES: UR-012 | IR-014
|
|
|
|
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(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
|
|
Command::new("secret-tool")
|
|
.arg("search")
|
|
.arg("service")
|
|
.arg("__nonexistent_test__")
|
|
.output()
|
|
.is_ok()
|
|
}
|
|
|
|
#[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::io::Write;
|
|
use std::process::{Command, Stdio};
|
|
|
|
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()
|
|
}
|
|
|
|
/// Load and decrypt the credential map.
|
|
///
|
|
/// A file that is present but **undecryptable** is deliberately reported as
|
|
/// an *empty* credential set rather than as an error. The key never leaves
|
|
/// the device it was derived on (Android Keystore keys are never backed up,
|
|
/// and the file fallback's key is derived from machine identifiers), so a
|
|
/// restored/transferred install gets ciphertext with no key and every read
|
|
/// would fail *permanently*. Surfacing that as an error made session restore
|
|
/// throw instead of falling back to the login screen: an unrecoverable app
|
|
/// rather than a clean logged-out one. The next successful login re-encrypts
|
|
/// the file with the current key, so the state self-heals.
|
|
///
|
|
/// TRACES: UR-012 | IR-014
|
|
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 = match self.decrypt(&encrypted_data) {
|
|
Ok(decrypted) => decrypted,
|
|
Err(e) => {
|
|
warn!(
|
|
"Credentials file at {:?} exists but cannot be decrypted ({}); \
|
|
treating as no stored credentials. This is expected after a \
|
|
backup restore or device transfer - the encryption key does \
|
|
not travel with the data. Signing in again will rewrite it.",
|
|
self.credentials_path, e
|
|
);
|
|
return Ok(serde_json::json!({}));
|
|
}
|
|
};
|
|
|
|
match serde_json::from_str(&decrypted) {
|
|
Ok(value) => Ok(value),
|
|
Err(e) => {
|
|
warn!(
|
|
"Credentials file at {:?} decrypted to invalid JSON ({}); \
|
|
treating as no stored credentials.",
|
|
self.credentials_path, e
|
|
);
|
|
Ok(serde_json::json!({}))
|
|
}
|
|
}
|
|
}
|
|
|
|
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::*;
|
|
|
|
/// Build a store pinned to the encrypted-file backend with an explicit key,
|
|
/// so a test can simulate "same file, different machine key" (which is what
|
|
/// a restored backup looks like).
|
|
fn file_backed_store(credentials_path: PathBuf, encryption_key: [u8; 32]) -> CredentialStore {
|
|
CredentialStore {
|
|
using_keyring: false,
|
|
credentials_path,
|
|
encryption_key,
|
|
}
|
|
}
|
|
|
|
/// A credentials file we cannot decrypt must read as *no credentials stored*,
|
|
/// not as a hard error. This is the restored-backup case: the ciphertext comes
|
|
/// back but the key that encrypted it (Android Keystore / the machine-derived
|
|
/// key) does not, so every read fails forever.
|
|
///
|
|
/// TRACES: UR-012 | IR-014
|
|
#[test]
|
|
fn undecryptable_credentials_file_reads_as_not_found() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join(CREDENTIALS_FILENAME);
|
|
|
|
let original = file_backed_store(path.clone(), [1u8; 32]);
|
|
original.save_to_file("user-1", "token-abc").unwrap();
|
|
|
|
// Restored onto a device whose derived key differs: same bytes, no key.
|
|
let restored = file_backed_store(path.clone(), [2u8; 32]);
|
|
match restored.get_token("user-1") {
|
|
Err(CredentialError::NotFound) => {}
|
|
other => panic!("expected NotFound for undecryptable ciphertext, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Garbage in the file (truncation, partial restore) is the same story.
|
|
///
|
|
/// TRACES: UR-012 | IR-014
|
|
#[test]
|
|
fn corrupt_credentials_file_reads_as_not_found() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join(CREDENTIALS_FILENAME);
|
|
fs::write(&path, "not base64 at all !!!").unwrap();
|
|
|
|
let store = file_backed_store(path, [3u8; 32]);
|
|
match store.get_token("user-1") {
|
|
Err(CredentialError::NotFound) => {}
|
|
other => panic!("expected NotFound for corrupt file, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// …and the logged-out state must be recoverable: signing in again has to be
|
|
/// able to write over the unreadable file rather than failing on load.
|
|
///
|
|
/// TRACES: UR-012 | IR-014
|
|
#[test]
|
|
fn login_after_undecryptable_file_rewrites_it() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join(CREDENTIALS_FILENAME);
|
|
|
|
let original = file_backed_store(path.clone(), [1u8; 32]);
|
|
original.save_to_file("user-1", "token-abc").unwrap();
|
|
|
|
let restored = file_backed_store(path.clone(), [2u8; 32]);
|
|
restored.save_to_file("user-1", "token-fresh").unwrap();
|
|
|
|
assert_eq!(restored.get_from_file("user-1").unwrap(), "token-fresh");
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|