use crate::crypto::{EncryptionContext, ProgressCallback};
use crate::models::{DatabaseSettings, Item, PasswordDatabase, SecurityLevel, SecuritySettings};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose, Engine as _};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use uuid::Uuid;
use zeroize::{Zeroize, Zeroizing};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FileHeader {
magic: String, header_version: u32, security_level: SecurityLevel,
kdf_settings: SecuritySettings,
salt_b64: String,
hmac_b64: String, algorithm: String, }
pub struct DatabaseManager {
pub database: PasswordDatabase,
pub encryption_context: Option<EncryptionContext>,
pub file_path: Option<String>,
pub file_hmac: Option<Vec<u8>>, }
impl DatabaseManager {
pub fn new(name: String, security_level: SecurityLevel) -> Result<Self> {
Self::new_with_progress(name, security_level, None)
}
pub fn new_with_progress(
name: String,
security_level: SecurityLevel,
_progress_callback: Option<ProgressCallback>,
) -> Result<Self> {
let settings = DatabaseSettings {
security_settings: SecuritySettings::recommended(security_level.clone()),
..DatabaseSettings::default()
};
let database = PasswordDatabase {
version: "1.0.0".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
security_level,
items: Vec::new(),
metadata: crate::models::DatabaseMetadata {
name,
description: None,
settings,
custom_fields: std::collections::HashMap::new(),
},
integrity_hash: String::new(),
};
Ok(Self {
database,
encryption_context: None,
file_path: None,
file_hmac: None,
})
}
pub fn load_from_file(file_path: &str, master_password: &str) -> Result<Self> {
Self::load_from_file_with_progress(file_path, master_password, None)
}
pub fn load_from_file_with_progress(
file_path: &str,
master_password: &str,
progress_callback: Option<ProgressCallback>,
) -> Result<Self> {
let file_bytes =
fs::read(file_path).map_err(|e| anyhow!("Failed to read database file: {}", e))?;
if file_bytes.len() < 8 {
return Err(anyhow!("Database file too short"));
}
if &file_bytes[0..4] != b"PMDB" {
return Err(anyhow!("Invalid file magic"));
}
let header_len =
u32::from_le_bytes([file_bytes[4], file_bytes[5], file_bytes[6], file_bytes[7]])
as usize;
if file_bytes.len() < 8 + header_len {
return Err(anyhow!("Corrupted file header"));
}
let header_json = &file_bytes[8..8 + header_len];
let header: FileHeader = serde_json::from_slice(header_json)
.map_err(|e| anyhow!("Failed to parse header: {}", e))?;
if header.magic != "PMDB" {
return Err(anyhow!("Invalid header magic"));
}
if header.header_version != 1 {
return Err(anyhow!("Unsupported header version"));
}
if header.algorithm != "AES-256-GCM" {
return Err(anyhow!("Unsupported algorithm"));
}
let salt = general_purpose::STANDARD
.decode(header.salt_b64.as_bytes())
.map_err(|e| anyhow!("Failed to decode salt: {}", e))?;
let ciphertext = &file_bytes[8 + header_len..];
let encryption_context = EncryptionContext::from_params_with_progress(
master_password,
header.security_level.clone(),
header.kdf_settings.clone(),
salt,
progress_callback.clone(),
)?;
let decrypted_data = Zeroizing::new(encryption_context.decrypt(ciphertext)?);
let expected_hmac = general_purpose::STANDARD
.decode(header.hmac_b64.as_bytes())
.map_err(|e| anyhow!("Failed to decode HMAC: {}", e))?;
let is_valid = encryption_context.verify_hmac(&decrypted_data, &expected_hmac)?;
if !is_valid {
return Err(anyhow!("Database integrity (HMAC) check failed"));
}
let database: PasswordDatabase = serde_json::from_slice(&decrypted_data)
.map_err(|e| anyhow!("Failed to deserialize database: {}", e))?;
if let Some(callback) = &progress_callback {
callback("Database loaded successfully", 1.0);
}
Ok(Self {
database,
encryption_context: Some(encryption_context),
file_path: Some(file_path.to_string()),
file_hmac: Some(expected_hmac),
})
}
fn write_secure_file(file_path: &str, data: &[u8]) -> Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
options.mode(0o600);
}
let mut file = options
.open(file_path)
.map_err(|e| anyhow!("Failed to open file: {}", e))?;
file.write_all(data)
.map_err(|e| anyhow!("Failed to write file: {}", e))?;
Ok(())
}
pub fn save_to_file(&mut self, file_path: &str, master_password: &str) -> Result<()> {
self.save_to_file_with_progress(file_path, master_password, None)
}
pub fn save_to_file_with_progress(
&mut self,
file_path: &str,
master_password: &str,
progress_callback: Option<ProgressCallback>,
) -> Result<()> {
let encryption_context = if let Some(ctx) = &self.encryption_context {
ctx.clone()
} else {
EncryptionContext::new_with_progress(
master_password,
self.database.security_level.clone(),
self.database.metadata.settings.security_settings.clone(),
progress_callback.clone(),
)?
};
self.database.updated_at = Utc::now();
if let Some(callback) = &progress_callback {
callback("Serializing database", 0.3);
}
let json_data = Zeroizing::new(
serde_json::to_vec(&self.database)
.map_err(|e| anyhow!("Failed to serialize database: {}", e))?,
);
let hmac = encryption_context.compute_hmac(&json_data)?;
if let Some(callback) = &progress_callback {
callback("Encrypting database", 0.6);
}
let encrypted_data = encryption_context.encrypt(&json_data)?;
let header = FileHeader {
magic: "PMDB".to_string(),
header_version: 1,
security_level: self.database.security_level.clone(),
kdf_settings: encryption_context.settings.clone(),
salt_b64: general_purpose::STANDARD.encode(&encryption_context.salt),
hmac_b64: general_purpose::STANDARD.encode(&hmac),
algorithm: "AES-256-GCM".to_string(),
};
let header_json = serde_json::to_vec(&header)
.map_err(|e| anyhow!("Failed to serialize header: {}", e))?;
let mut file_bytes = Vec::with_capacity(8 + header_json.len() + encrypted_data.len());
file_bytes.extend_from_slice(b"PMDB");
file_bytes.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
file_bytes.extend_from_slice(&header_json);
file_bytes.extend_from_slice(&encrypted_data);
if let Some(callback) = &progress_callback {
callback("Writing to file", 0.9);
}
Self::write_secure_file(file_path, &file_bytes)?;
if let Some(callback) = &progress_callback {
callback("Database saved successfully", 1.0);
}
self.encryption_context = Some(encryption_context);
self.file_path = Some(file_path.to_string());
self.file_hmac = Some(hmac);
Ok(())
}
pub fn add_item(&mut self, item: Item) -> Result<()> {
if let Some(ctx) = &self.encryption_context {
let mut item = item;
ctx.update_item_integrity(&mut item)?;
self.database.items.push(item);
self.database.updated_at = Utc::now();
Ok(())
} else {
Err(anyhow!("Database not initialized with encryption context"))
}
}
pub fn remove_item(&mut self, item_id: Uuid) -> Result<()> {
self.database.items.retain(|item| item.get_id() != item_id);
self.database.updated_at = Utc::now();
Ok(())
}
pub fn get_item(&self, item_id: Uuid) -> Option<&Item> {
self.database
.items
.iter()
.find(|item| item.get_id() == item_id)
}
#[allow(dead_code)]
pub fn get_item_mut(&mut self, item_id: Uuid) -> Option<&mut Item> {
self.database
.items
.iter_mut()
.find(|item| item.get_id() == item_id)
}
pub fn update_item(&mut self, item_id: Uuid, updated_item: Item) -> Result<()> {
if let Some(ctx) = &self.encryption_context {
let mut updated_item = updated_item;
ctx.update_item_integrity(&mut updated_item)?;
if let Some(index) = self
.database
.items
.iter()
.position(|item| item.get_id() == item_id)
{
self.database.items[index] = updated_item;
self.database.updated_at = Utc::now();
Ok(())
} else {
Err(anyhow!("Item not found"))
}
} else {
Err(anyhow!("Database not initialized with encryption context"))
}
}
#[allow(dead_code)]
fn attachments_dir(&self) -> Result<PathBuf> {
if let Some(path) = &self.file_path {
Ok(PathBuf::from(format!("{path}.att")))
} else {
Err(anyhow!("No file path set for database"))
}
}
#[allow(dead_code)]
pub fn add_attachment(&mut self, item_id: Uuid, file_name: &str, data: &[u8]) -> Result<Uuid> {
let ctx = self
.encryption_context
.as_ref()
.ok_or_else(|| anyhow!("Database not initialized with encryption context"))?;
let attachment_id = Uuid::new_v4();
let encrypted = ctx.encrypt(data)?;
let dir = self.attachments_dir()?.join(item_id.to_string());
fs::create_dir_all(&dir)
.map_err(|e| anyhow!("Failed to create attachment directory: {e}"))?;
let file_path = dir.join(attachment_id.to_string());
Self::write_secure_file(file_path.to_str().unwrap(), &encrypted)?;
if let Some(item) = self
.database
.items
.iter_mut()
.find(|i| i.get_id() == item_id)
{
item.get_base_mut()
.attachments
.push(crate::models::AttachmentMetadata {
id: attachment_id,
file_name: file_name.to_string(),
mime_type: None,
size: data.len() as u64,
});
self.database.updated_at = Utc::now();
Ok(attachment_id)
} else {
Err(anyhow!("Item not found"))
}
}
#[allow(dead_code)]
pub fn get_attachment(&self, item_id: Uuid, attachment_id: Uuid) -> Result<Vec<u8>> {
let ctx = self
.encryption_context
.as_ref()
.ok_or_else(|| anyhow!("Database not initialized with encryption context"))?;
let file_path = self
.attachments_dir()?
.join(item_id.to_string())
.join(attachment_id.to_string());
let data = fs::read(&file_path).map_err(|e| anyhow!("Failed to read attachment: {e}"))?;
ctx.decrypt(&data)
}
#[allow(dead_code)]
pub fn remove_attachment(&mut self, item_id: Uuid, attachment_id: Uuid) -> Result<()> {
let dir = self
.attachments_dir()?
.join(item_id.to_string())
.join(attachment_id.to_string());
fs::remove_file(&dir).map_err(|e| anyhow!("Failed to remove attachment: {e}"))?;
if let Some(item) = self
.database
.items
.iter_mut()
.find(|i| i.get_id() == item_id)
{
item.get_base_mut()
.attachments
.retain(|a| a.id != attachment_id);
self.database.updated_at = Utc::now();
Ok(())
} else {
Err(anyhow!("Item not found"))
}
}
pub fn search_items(&self, query: &str) -> Vec<&Item> {
let query_lower = query.to_lowercase();
self.database
.items
.iter()
.filter(|item| {
let name = item.get_name().to_lowercase();
name.contains(&query_lower)
})
.collect()
}
pub fn get_items_by_type(&self, item_type: &crate::models::ItemType) -> Vec<&Item> {
self.database
.items
.iter()
.filter(|item| {
std::mem::discriminant(item.get_type()) == std::mem::discriminant(item_type)
})
.collect()
}
#[allow(dead_code)]
pub fn get_items_in_folder(&self, folder_id: Uuid) -> Vec<&Item> {
self.database
.items
.iter()
.filter(|item| {
if let Some(id) = item.get_base().folder_id {
id == folder_id
} else {
false
}
})
.collect()
}
pub fn verify_integrity(&self) -> Result<bool> {
if let Some(ctx) = &self.encryption_context {
for item in &self.database.items {
if !ctx.verify_item_integrity(item)? {
return Ok(false);
}
}
let json_data = Zeroizing::new(
serde_json::to_vec(&self.database)
.map_err(|e| anyhow!("Failed to serialize database: {}", e))?,
);
if let Some(file_hmac) = &self.file_hmac {
ctx.verify_hmac(&json_data, file_hmac)
} else {
Ok(true)
}
} else {
Err(anyhow!("Database not initialized with encryption context"))
}
}
pub fn get_statistics(&self) -> DatabaseStatistics {
let mut stats = DatabaseStatistics {
total_items: self.database.items.len(),
credentials: 0,
folders: 0,
keys: 0,
urls: 0,
notes: 0,
secure_notes: 0,
};
for item in &self.database.items {
match item {
Item::Credential(_) => stats.credentials += 1,
Item::Folder(_) => stats.folders += 1,
Item::Key(_) => stats.keys += 1,
Item::Url(_) => stats.urls += 1,
Item::Note(_) => stats.notes += 1,
Item::SecureNote(_) => stats.secure_notes += 1,
}
}
stats
}
pub fn export_to_json(&self, file_path: &str) -> Result<()> {
let json_data = serde_json::to_string_pretty(&self.database)
.map_err(|e| anyhow!("Failed to serialize database: {}", e))?;
Self::write_secure_file(file_path, json_data.as_bytes())
.map_err(|e| anyhow!("Failed to write JSON file: {}", e))?;
Ok(())
}
pub fn import_from_json(&mut self, file_path: &str) -> Result<()> {
let json_data = fs::read_to_string(file_path)
.map_err(|e| anyhow!("Failed to read JSON file: {}", e))?;
let database: PasswordDatabase = serde_json::from_str(&json_data)
.map_err(|e| anyhow!("Failed to deserialize JSON: {}", e))?;
self.database = database;
Ok(())
}
pub fn change_master_password(&mut self, new_password: &str) -> Result<()> {
let settings = self.database.metadata.settings.security_settings.clone();
let new_encryption_context =
EncryptionContext::new(new_password, self.database.security_level.clone(), settings)?;
for item in &mut self.database.items {
new_encryption_context.update_item_integrity(item)?;
}
self.encryption_context = Some(new_encryption_context);
self.file_hmac = None;
self.database.updated_at = Utc::now();
Ok(())
}
fn zeroize_items(items: &mut [Item]) {
for item in items.iter_mut() {
{
let base = item.get_base_mut();
base.name.zeroize();
for tag in &mut base.tags {
tag.zeroize();
}
for attachment in &mut base.attachments {
attachment.file_name.zeroize();
if let Some(mime) = &mut attachment.mime_type {
mime.zeroize();
}
attachment.size = 0;
}
}
match item {
Item::Credential(c) => {
c.username.zeroize();
c.password.zeroize();
if let Some(url) = &mut c.url {
url.zeroize();
}
if let Some(notes) = &mut c.notes {
notes.zeroize();
}
if let Some(totp) = &mut c.totp_secret {
totp.zeroize();
}
for history in &mut c.password_history {
history.password.zeroize();
}
}
Item::SecureNote(s) => {
s.encrypted_content.zeroize();
for value in s.additional_metadata.values_mut() {
value.zeroize();
}
}
Item::Key(k) => {
k.key_data.zeroize();
}
Item::Note(n) => {
n.content.zeroize();
}
Item::Url(u) => {
u.url.zeroize();
if let Some(title) = &mut u.title {
title.zeroize();
}
if let Some(favicon) = &mut u.favicon {
favicon.zeroize();
}
if let Some(notes) = &mut u.notes {
notes.zeroize();
}
}
Item::Folder(f) => {
if let Some(desc) = &mut f.description {
desc.zeroize();
}
if let Some(color) = &mut f.color {
color.zeroize();
}
}
}
}
}
pub fn get_metadata(&self) -> &crate::models::DatabaseMetadata {
&self.database.metadata
}
#[allow(dead_code)]
pub fn update_metadata(&mut self, metadata: crate::models::DatabaseMetadata) -> Result<()> {
self.database.metadata = metadata;
self.database.updated_at = Utc::now();
Ok(())
}
#[allow(dead_code)]
pub fn is_locked(&self) -> bool {
self.encryption_context.is_none()
}
pub fn lock(&mut self) {
Self::zeroize_items(&mut self.database.items);
self.database.items.clear();
self.encryption_context = None;
if let Some(hmac) = &mut self.file_hmac {
hmac.zeroize();
}
self.file_hmac = None;
}
pub fn unlock(&mut self, master_password: &str) -> Result<()> {
if let Some(file_path) = self.file_path.clone() {
let new_manager = Self::load_from_file(&file_path, master_password)?;
self.database = new_manager.database;
self.encryption_context = new_manager.encryption_context;
self.file_hmac = new_manager.file_hmac;
self.file_path = new_manager.file_path;
Ok(())
} else {
Err(anyhow!("No file path set for database"))
}
}
}
#[derive(Debug, Clone)]
pub struct DatabaseStatistics {
pub total_items: usize,
pub credentials: usize,
pub folders: usize,
pub keys: usize,
pub urls: usize,
pub notes: usize,
pub secure_notes: usize,
}
impl std::fmt::Display for DatabaseStatistics {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Database Statistics:")?;
writeln!(f, " Total Items: {}", self.total_items)?;
writeln!(f, " Credentials: {}", self.credentials)?;
writeln!(f, " Folders: {}", self.folders)?;
writeln!(f, " Keys: {}", self.keys)?;
writeln!(f, " URLs: {}", self.urls)?;
writeln!(f, " Notes: {}", self.notes)?;
write!(f, " Secure Notes: {}", self.secure_notes)
}
}