The encrypted-file fallback derived its AES key from the hostname, a hardcoded salt and `$USER`. Two problems, and the second is the one users actually hit. It was never secret. Every input is readable by anyone who can read the ciphertext beside it, so the derivation bought nothing against the threat its name implies. Calling the result "AES-256-GCM encrypted" oversold it. And it was unstable. Renaming the machine, or launching from a context where `$USER` is unset — a systemd user service, some desktop launchers — changed the key and made every stored token undecryptable. `load_credentials_file` reports a failed decrypt as "no stored credentials", so this surfaced as being silently signed out with nothing to explain it. The key is now 32 random bytes persisted beside the credentials file, mode 0600, generated on first use. That is strictly better on both counts: higher entropy, and it does not move when the machine does. It is still obfuscation at rest rather than a secret — the key sits next to what it opens — and the module docs now say so plainly instead of implying otherwise. The keyring remains the only place a token is really protected. The old derivation is kept solely to read a file written by an earlier build; anything it opens is immediately rewritten under the persisted key, so no one is signed out by the upgrade. Verified against aarch64-linux-android as well as the host.
1220 lines
44 KiB
Rust
1220 lines
44 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 **obfuscation at rest, not a secret**: its key sits in a file
|
|
//! beside the ciphertext, so anyone who can read one can read the other. It
|
|
//! exists so headless systems keep working, and the keyring remains the only
|
|
//! place a token is actually protected.
|
|
//!
|
|
//! The key used to be *derived* from the hostname, `$USER` and a hardcoded salt.
|
|
//! That was no more secret — those are readable by anyone who can read the file
|
|
//! — and it was unstable: renaming the machine, or launching from a context
|
|
//! where `$USER` is unset, changed the key and made every stored token
|
|
//! undecryptable. `load_credentials_file` treats a failed decrypt as "no stored
|
|
//! credentials", so that surfaced as being silently signed out rather than as an
|
|
//! error. The key is now random and persisted, and the old derivation is kept
|
|
//! only to migrate a file written before this change.
|
|
//!
|
|
//! 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";
|
|
|
|
/// Key file for the encrypted-file fallback, beside the credentials it opens.
|
|
const KEY_FILENAME: &str = "credentials.key";
|
|
|
|
/// Load the fallback encryption key, creating it on first use.
|
|
///
|
|
/// Random rather than derived. A derived key was no more secret — its inputs
|
|
/// (hostname, `$USER`, a hardcoded salt) are readable by anyone who can read
|
|
/// the ciphertext — and it silently changed when the machine was renamed or
|
|
/// `$USER` was unset, which read to the user as being signed out for no reason.
|
|
///
|
|
/// If the key cannot be persisted the process still gets a usable key for this
|
|
/// run; credentials written under it simply will not be readable next launch,
|
|
/// which is the same outcome as today and better than refusing to store a token.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
fn load_or_create_key(path: &std::path::Path) -> [u8; 32] {
|
|
if let Ok(existing) = fs::read(path) {
|
|
if existing.len() == 32 {
|
|
let mut key = [0u8; 32];
|
|
key.copy_from_slice(&existing);
|
|
return key;
|
|
}
|
|
warn!(
|
|
"Fallback key at {:?} is {} bytes, not 32; replacing it. Credentials \
|
|
written under the old key will need signing in again.",
|
|
path,
|
|
existing.len()
|
|
);
|
|
}
|
|
|
|
let mut key = [0u8; 32];
|
|
if getrandom::getrandom(&mut key).is_err() {
|
|
warn!("No system randomness for the fallback key; deriving one for this run");
|
|
return CredentialStore::derive_legacy_encryption_key();
|
|
}
|
|
|
|
if let Some(parent) = path.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
}
|
|
match fs::write(path, key) {
|
|
Ok(()) => restrict_to_owner(path),
|
|
Err(e) => warn!(
|
|
"Could not persist the fallback key at {:?} ({}); credentials stored \
|
|
this run will not be readable next launch",
|
|
path, e
|
|
),
|
|
}
|
|
key
|
|
}
|
|
|
|
/// Make a key file owner-readable only. Best effort — a filesystem without
|
|
/// Unix permissions is not a reason to fail.
|
|
fn restrict_to_owner(path: &std::path::Path) {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if let Err(e) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
|
|
warn!("Could not restrict permissions on {:?}: {}", path, e);
|
|
}
|
|
}
|
|
#[cfg(not(unix))]
|
|
let _ = path;
|
|
}
|
|
|
|
/// Decrypt `encrypted` with `key`.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
fn decrypt_with(key: &[u8; 32], 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(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()))
|
|
}
|
|
|
|
/// Encrypt `plaintext` with `key`, prepending a fresh random nonce.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
fn encrypt_with(key: &[u8; 32], plaintext: &str) -> Result<String, CredentialError> {
|
|
let cipher =
|
|
Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
|
|
|
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()))?;
|
|
|
|
let mut combined = nonce_bytes.to_vec();
|
|
combined.extend(ciphertext);
|
|
|
|
Ok(BASE64.encode(&combined))
|
|
}
|
|
|
|
/// 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 the file fallback. Random and persisted, so it does
|
|
/// not change when the machine is renamed.
|
|
encryption_key: [u8; 32],
|
|
/// The pre-existing derivation, retained only to read a file written before
|
|
/// the key was persisted. Anything decrypted with it is rewritten under
|
|
/// `encryption_key`.
|
|
legacy_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 = load_or_create_key(&Self::get_key_path());
|
|
let legacy_key = Self::derive_legacy_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,
|
|
legacy_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 ---
|
|
|
|
/// Where the fallback key lives: beside the credentials file, so the two
|
|
/// travel together and a restore that brings one brings the other.
|
|
fn get_key_path() -> PathBuf {
|
|
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
|
|
proj_dirs.data_dir().join(KEY_FILENAME)
|
|
} else {
|
|
PathBuf::from(KEY_FILENAME)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// The key derivation used before keys were persisted.
|
|
///
|
|
/// Kept **only** so a credentials file written by an older build can still
|
|
/// be read once and rewritten under the persisted key. Never used to
|
|
/// encrypt. See the module docs for why it was replaced.
|
|
///
|
|
/// TRACES: UR-012 | IR-014
|
|
fn derive_legacy_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, from_legacy_key) = match self.decrypt_migrating(&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) => {
|
|
// Rewrite under the persisted key so the legacy derivation is
|
|
// never needed again.
|
|
if from_legacy_key {
|
|
if let Err(e) = self.save_credentials_file(&value) {
|
|
warn!(
|
|
"Could not rewrite credentials under the persisted key: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
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> {
|
|
encrypt_with(&self.encryption_key, plaintext)
|
|
}
|
|
|
|
/// Decrypt with the current key, falling back to the legacy derivation.
|
|
///
|
|
/// Returns the plaintext and whether the legacy key was what opened it, so
|
|
/// the caller can rewrite the file under the current key and stop depending
|
|
/// on a derivation that changes when the machine is renamed.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
fn decrypt_migrating(&self, encrypted: &str) -> Result<(String, bool), CredentialError> {
|
|
match decrypt_with(&self.encryption_key, encrypted) {
|
|
Ok(plaintext) => Ok((plaintext, false)),
|
|
Err(current_err) => match decrypt_with(&self.legacy_key, encrypted) {
|
|
Ok(plaintext) => {
|
|
info!("Credentials were written under the legacy derived key; rewriting them");
|
|
Ok((plaintext, true))
|
|
}
|
|
Err(_) => Err(current_err),
|
|
},
|
|
}
|
|
}
|
|
|
|
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 {
|
|
|
|
/// The fallback key must be the same on every launch.
|
|
///
|
|
/// It used to be derived from the hostname, `$USER` and a static salt.
|
|
/// Renaming the machine — or launching from a context where `$USER` is
|
|
/// unset, such as a systemd user service — changed the key, and
|
|
/// `load_credentials_file` reports a failed decrypt as "no stored
|
|
/// credentials". The user was silently signed out with nothing to explain it.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
#[test]
|
|
fn the_fallback_key_is_stable_across_processes() {
|
|
let dir = std::env::temp_dir().join(format!("jellytau-key-{}", std::process::id()));
|
|
let path = dir.join("credentials.key");
|
|
let _ = fs::remove_file(&path);
|
|
|
|
let first = load_or_create_key(&path);
|
|
let second = load_or_create_key(&path);
|
|
assert_eq!(first, second, "the key must not change between launches");
|
|
assert_ne!(first, [0u8; 32], "the key must be real randomness");
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// Two installs must not share a key.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
#[test]
|
|
fn separate_installs_get_separate_keys() {
|
|
let base = std::env::temp_dir().join(format!("jellytau-keys-{}", std::process::id()));
|
|
let a = load_or_create_key(&base.join("a").join("credentials.key"));
|
|
let b = load_or_create_key(&base.join("b").join("credentials.key"));
|
|
assert_ne!(a, b);
|
|
let _ = fs::remove_dir_all(&base);
|
|
}
|
|
|
|
/// A credentials file written under the old derived key must still open.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
#[test]
|
|
fn credentials_written_under_the_legacy_key_still_decrypt() {
|
|
let legacy = CredentialStore::derive_legacy_encryption_key();
|
|
let mut persisted = [0u8; 32];
|
|
getrandom::getrandom(&mut persisted).unwrap();
|
|
assert_ne!(legacy, persisted);
|
|
|
|
let blob = encrypt_with(&legacy, r#"{"user-1":"token-abc"}"#).unwrap();
|
|
|
|
let store = CredentialStore {
|
|
using_keyring: false,
|
|
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
|
|
encryption_key: persisted,
|
|
legacy_key: legacy,
|
|
};
|
|
|
|
let (plaintext, migrated) = store
|
|
.decrypt_migrating(&blob)
|
|
.expect("a file written under the legacy key must still be readable");
|
|
assert_eq!(plaintext, r#"{"user-1":"token-abc"}"#);
|
|
assert!(migrated, "the caller must know to rewrite it");
|
|
}
|
|
|
|
/// The current key is tried first and needs no migration.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
#[test]
|
|
fn credentials_under_the_current_key_are_not_flagged_for_migration() {
|
|
let mut persisted = [0u8; 32];
|
|
getrandom::getrandom(&mut persisted).unwrap();
|
|
let blob = encrypt_with(&persisted, "hello").unwrap();
|
|
|
|
let store = CredentialStore {
|
|
using_keyring: false,
|
|
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
|
|
encryption_key: persisted,
|
|
legacy_key: [7u8; 32],
|
|
};
|
|
|
|
let (plaintext, migrated) = store.decrypt_migrating(&blob).unwrap();
|
|
assert_eq!(plaintext, "hello");
|
|
assert!(!migrated);
|
|
}
|
|
|
|
/// A blob under neither key fails rather than returning something wrong.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
#[test]
|
|
fn an_unreadable_blob_is_an_error() {
|
|
let blob = encrypt_with(&[1u8; 32], "secret").unwrap();
|
|
let store = CredentialStore {
|
|
using_keyring: false,
|
|
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
|
|
encryption_key: [2u8; 32],
|
|
legacy_key: [3u8; 32],
|
|
};
|
|
assert!(store.decrypt_migrating(&blob).is_err());
|
|
}
|
|
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 distinct legacy key, so "same file, different machine key" stays
|
|
// undecryptable rather than being opened by the migration path.
|
|
legacy_key: [0xABu8; 32],
|
|
}
|
|
}
|
|
|
|
/// 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_migrating(&encrypted).unwrap();
|
|
|
|
assert_eq!(plaintext, decrypted);
|
|
}
|
|
|
|
/// The legacy derivation must stay deterministic *within a machine*, or the
|
|
/// one-time migration of an old credentials file cannot read it.
|
|
///
|
|
/// Its instability *across* machine states is exactly why it no longer
|
|
/// encrypts anything — see `the_fallback_key_is_stable_across_processes`.
|
|
///
|
|
/// TRACES: UR-012 | IR-014 | UT-014
|
|
#[test]
|
|
fn test_legacy_derivation_is_deterministic_for_migration() {
|
|
let key1 = CredentialStore::derive_legacy_encryption_key();
|
|
let key2 = CredentialStore::derive_legacy_encryption_key();
|
|
assert_eq!(key1, key2);
|
|
}
|
|
}
|