Migrate vault encryption to OS keyring, handle PIN rate limiting, and improve tombstone handling logic.

- Replaced legacy vault key derivation with OS keyring-backed storage for improved security and platform integration.
- Introduced PIN rate limiting with configurable lockout durations to mitigate brute-force attacks.
- Enhanced tombstone services to use unified error-handling logic via `apply_tombstone`.
- Refactored logic for book and series data synchronization, fixing null checks and improving flow consistency.
- Added comprehensive error handling to all database and vault operations in key manager services.
This commit is contained in:
natreex
2026-03-24 23:05:56 -04:00
parent cfd08e3261
commit 25c7f25a0e
11 changed files with 203 additions and 79 deletions

View File

@@ -24,6 +24,10 @@ struct SecureVault {
encryption_keys: HashMap<String, String>,
last_user_id: Option<String>,
pin_hashes: HashMap<String, String>,
#[serde(default)]
pin_failed_attempts: i32,
#[serde(default)]
pin_locked_until: Option<i64>,
}
fn vault_path() -> PathBuf {
@@ -33,7 +37,37 @@ fn vault_path() -> PathBuf {
.join("secure-config.json")
}
fn derive_machine_key() -> [u8; 32] {
const KEYRING_USER: &str = "vault-key";
/// Retrieves (or generates and stores) the vault encryption key via the OS keyring
/// (macOS Keychain, Windows DPAPI, Linux Secret Service).
/// Falls back to the old derivation method if the keyring is unavailable,
/// and attempts to migrate the key into the keyring for next time.
fn get_vault_key() -> [u8; 32] {
let entry = keyring::Entry::new(SERVICE_NAME, KEYRING_USER);
if let Ok(entry) = &entry {
if let Ok(stored) = entry.get_password() {
if let Ok(decoded) = BASE64.decode(stored.trim()) {
if decoded.len() == 32 {
let mut key = [0u8; 32];
key.copy_from_slice(&decoded);
return key;
}
}
}
}
// No key in keyring yet — generate a random one
let mut key = [0u8; 32];
rand::rng().fill_bytes(&mut key);
let encoded = BASE64.encode(key);
if let Ok(entry) = &entry {
let _ = entry.set_password(&encoded);
}
key
}
/// Legacy derivation for migrating vaults created before keyring support.
fn derive_machine_key_legacy() -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(SERVICE_NAME.as_bytes());
if let Ok(name) = hostname::get() {
@@ -76,96 +110,150 @@ fn decrypt_vault(data: &[u8], key: &[u8; 32]) -> AppResult<Vec<u8>> {
Ok(decrypted.to_vec())
}
fn read_vault() -> SecureVault {
fn read_vault() -> AppResult<SecureVault> {
let path = vault_path();
let key = derive_machine_key();
match fs::read_to_string(&path) {
Ok(content) => {
if let Ok(raw) = BASE64.decode(content.trim()) {
if let Ok(decrypted) = decrypt_vault(&raw, &key) {
if let Ok(vault) = serde_json::from_slice::<SecureVault>(&decrypted) {
return vault;
}
}
}
SecureVault::default()
}
Err(_) => SecureVault::default(),
if !path.exists() {
return Ok(SecureVault::default());
}
let content = fs::read_to_string(&path)
.map_err(|e| AppError::Keyring(format!("Failed to read vault: {}", e)))?;
let raw = BASE64.decode(content.trim())
.map_err(|e| AppError::Keyring(format!("Vault corrupted (base64): {}", e)))?;
// Try the new keyring-backed key first
let key = get_vault_key();
if let Ok(decrypted) = decrypt_vault(&raw, &key) {
if let Ok(vault) = serde_json::from_slice::<SecureVault>(&decrypted) {
return Ok(vault);
}
}
// Fallback: try legacy key and migrate if successful
let legacy_key = derive_machine_key_legacy();
let decrypted = decrypt_vault(&raw, &legacy_key)
.map_err(|_| AppError::Keyring("Vault corrupted: unable to decrypt with any key.".to_string()))?;
let vault: SecureVault = serde_json::from_slice(&decrypted)
.map_err(|e| AppError::Keyring(format!("Vault corrupted (json): {}", e)))?;
// Migrate: re-encrypt with the new keyring key
let _ = write_vault_with_key(&vault, &key);
Ok(vault)
}
fn write_vault(vault: &SecureVault) -> AppResult<()> {
fn write_vault_with_key(vault: &SecureVault, key: &[u8; 32]) -> AppResult<()> {
let path = vault_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::Internal(format!("Failed to create vault dir: {}", e)))?;
}
let json = serde_json::to_string(vault)
.map_err(|e| AppError::Internal(format!("Failed to serialize vault: {}", e)))?;
let key = derive_machine_key();
let encrypted = encrypt_vault(json.as_bytes(), &key)?;
let encrypted = encrypt_vault(json.as_bytes(), key)?;
let encoded = BASE64.encode(&encrypted);
fs::write(&path, encoded).map_err(|e| AppError::Internal(format!("Failed to write vault: {}", e)))
}
fn write_vault(vault: &SecureVault) -> AppResult<()> {
let key = get_vault_key();
write_vault_with_key(vault, &key)
}
// ===== Public API (same signatures as before) =====
pub fn get_user_encryption_key(user_id: &str) -> AppResult<String> {
let vault = read_vault();
let vault = read_vault()?;
vault.encryption_keys.get(user_id).cloned()
.ok_or_else(|| AppError::Keyring(format!("No encryption key for user {}", user_id)))
}
pub fn set_user_encryption_key(user_id: &str, encryption_key: &str) -> AppResult<()> {
let mut vault = read_vault();
let mut vault = read_vault()?;
vault.encryption_keys.insert(user_id.to_string(), encryption_key.to_string());
write_vault(&vault)
}
pub fn has_user_encryption_key(user_id: &str) -> bool {
let vault = read_vault();
vault.encryption_keys.contains_key(user_id)
pub fn has_user_encryption_key(user_id: &str) -> AppResult<bool> {
let vault = read_vault()?;
Ok(vault.encryption_keys.contains_key(user_id))
}
pub fn get_token() -> AppResult<Option<String>> {
let vault = read_vault();
let vault = read_vault()?;
Ok(vault.token)
}
pub fn set_token(token: &str) -> AppResult<()> {
let mut vault = read_vault();
let mut vault = read_vault()?;
vault.token = Some(token.to_string());
write_vault(&vault)
}
pub fn remove_token() -> AppResult<()> {
let mut vault = read_vault();
let mut vault = read_vault()?;
vault.token = None;
write_vault(&vault)
}
pub fn set_pin_hash(user_id: &str, pin_hash: &str) -> AppResult<()> {
let mut vault = read_vault();
let mut vault = read_vault()?;
vault.pin_hashes.insert(user_id.to_string(), pin_hash.to_string());
write_vault(&vault)
}
pub fn get_pin_hash(user_id: &str) -> AppResult<Option<String>> {
let vault = read_vault();
let vault = read_vault()?;
Ok(vault.pin_hashes.get(user_id).cloned())
}
pub fn set_last_user_id(user_id: &str) -> AppResult<()> {
let mut vault = read_vault();
let mut vault = read_vault()?;
vault.last_user_id = Some(user_id.to_string());
write_vault(&vault)
}
pub fn get_last_user_id() -> AppResult<Option<String>> {
let vault = read_vault();
let vault = read_vault()?;
Ok(vault.last_user_id)
}
pub fn clear_vault() -> AppResult<()> {
write_vault(&SecureVault::default())
}
const MAX_PIN_ATTEMPTS: i32 = 5;
const PIN_LOCKOUT_SECONDS: i64 = 300; // 5 minutes
pub fn check_pin_rate_limit() -> AppResult<()> {
let vault = read_vault()?;
if let Some(locked_until) = vault.pin_locked_until {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if now < locked_until {
let remaining = locked_until - now;
return Err(AppError::Auth(format!("Too many attempts. Try again in {} seconds.", remaining)));
}
}
Ok(())
}
pub fn record_pin_failure() -> AppResult<()> {
let mut vault = read_vault()?;
vault.pin_failed_attempts += 1;
if vault.pin_failed_attempts >= MAX_PIN_ATTEMPTS {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
vault.pin_locked_until = Some(now + PIN_LOCKOUT_SECONDS);
}
write_vault(&vault)
}
pub fn reset_pin_attempts() -> AppResult<()> {
let mut vault = read_vault()?;
vault.pin_failed_attempts = 0;
vault.pin_locked_until = None;
write_vault(&vault)
}