diff --git a/Cargo.lock b/Cargo.lock index 6c8409f..808b33f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -681,7 +681,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] @@ -1450,27 +1450,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "native-host" -version = "1.1.0" -dependencies = [ - "aes-gcm", - "arboard", - "argon2", - "chrono", - "clap", - "colored", - "crossterm", - "hex", - "keyring", - "rand_core 0.10.1", - "ratatui", - "rpassword", - "serde", - "serde_json", - "which", -] - [[package]] name = "nix" version = "0.29.0" @@ -1762,7 +1741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -1991,7 +1970,7 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -2003,12 +1982,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "ratatui" version = "0.30.2" diff --git a/crates/cli/src/json/mod.rs b/crates/cli/src/json/mod.rs index 9b57691..9a775f9 100644 --- a/crates/cli/src/json/mod.rs +++ b/crates/cli/src/json/mod.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; -use silicate_core::{SilicateError, find_password_file}; +use silicate_core::Silicate; +use silicate_core::error::SilicateError; #[derive(Debug, Serialize, Deserialize)] pub struct Secret { @@ -10,10 +11,12 @@ pub struct Secret { /// This function reads the secrets from the files in the config directory and returns a vector of Secret structs. pub fn get_secrets(config_dir: &str, websites: Vec) -> Result, SilicateError> { + let silicate = Silicate::new(config_dir.to_string()); let mut secrets = Vec::new(); for website in websites { // (e.g., "github" or "github-tag") - let file_identifier = find_password_file(config_dir, &website.to_string())? + let file_identifier = silicate + .find_password_file(&website.to_string())? .ok_or("unknown".to_string()) .unwrap(); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 66be319..52694f1 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -4,7 +4,7 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); use clap::{Parser, Subcommand}; use colored::*; use rpassword::prompt_password_with_config; -use silicate_core::*; +use silicate_core::Silicate; use std::process; use std::string::ToString; use std::{ @@ -224,10 +224,10 @@ fn write_init_timestamp() -> chrono::DateTime { time_init } -fn get_key() -> Vec { +fn get_key(silicate: &Silicate) -> Vec { let init_cmd = "silicate init".to_string().italic(); - match retrieve_key_from_keyring() { + match silicate.retrieve_key_from_keyring() { Ok(k) => k.try_into().unwrap(), Err(_) => { match fs::exists(config_dir() + "salt.bin") { @@ -235,22 +235,22 @@ fn get_key() -> Vec { if t { let salt = fs::read(config_dir() + "salt.bin").unwrap(); let password = get_password("Enter key password: "); - let key = - match derive_key_from_password(&password, &salt.try_into().unwrap()) { - Ok(s) => s, - Err(e) => { - let msg = - format!("Failed to read salt for key derivation: {}", e) - .to_string() - .red(); - println!("{}", msg); - write_to_logs(&format!( - "Failed to read salt for key derivation: {}", - e - )); - process::exit(1); - } - }; + let key = match silicate + .derive_key_from_password(&password, &salt.try_into().unwrap()) + { + Ok(s) => s, + Err(e) => { + let msg = format!("Failed to read salt for key derivation: {}", e) + .to_string() + .red(); + println!("{}", msg); + write_to_logs(&format!( + "Failed to read salt for key derivation: {}", + e + )); + process::exit(1); + } + }; return key.to_vec(); } else { let msg = format!( @@ -285,6 +285,7 @@ fn get_key() -> Vec { fn main() { let cli = CLI::parse(); let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + let silicate = Silicate::new(config_dir()); if cli.version { let tagline = format!("Silicate -- a simple password manager, built for speed.") @@ -320,10 +321,11 @@ fn main() { password }; - let key = get_key(); + let key = get_key(&silicate); - let (cipher_bytes, nonce_bytes) = - encrypt_passwd(&key.try_into().unwrap(), password).unwrap(); + let (cipher_bytes, nonce_bytes) = silicate + .encrypt_passwd(&key.try_into().unwrap(), password) + .unwrap(); if let Some(tag) = option_tag { fs::write( @@ -357,7 +359,7 @@ fn main() { if input.trim().to_lowercase() == "y" { // 1. Scan the directory using your existing helper to look for a matching name - let passwords = silicate_core::list_passwords(&config_dir()); + let passwords = silicate.list_passwords(); // Find if any entry matches 'website' or starts with 'website-' let target_file = passwords.unwrap().into_iter().find(|filename| { @@ -400,16 +402,17 @@ fn main() { } Command::Show { website, display } => { println!("Retrieving password for: {}", website); - let key = get_key(); + let key = get_key(&silicate); let data = fs::read(format!("{}{}.bin", config_dir(), website)).unwrap(); let (nonce_bytes, cipher_bytes) = data.split_at(12); - let password = silicate_core::decrypt_passwd( - &key.try_into().unwrap(), - cipher_bytes.to_vec(), - nonce_bytes.try_into().unwrap(), - ) - .unwrap(); + let password = silicate + .decrypt_passwd( + &key.try_into().unwrap(), + cipher_bytes.to_vec(), + nonce_bytes.try_into().unwrap(), + ) + .unwrap(); if *display { let msg = format!("Password for {}: {}", website, password.bold()); println!("{}", msg); @@ -509,10 +512,10 @@ fn main() { write_to_logs("Password manager initialized."); // Generating a new key/password derivation and storing it in the keyring - if is_keyring_available() { - let new_key = generate_key(); + if silicate.is_keyring_available() { + let new_key = silicate.generate_key(); - match store_key_in_keyring(&new_key) { + match silicate.store_key_in_keyring(&new_key) { Ok(_) => { write_init_timestamp(); println!("Key stored in keyring successfully.\n{}", welcome_msg); @@ -535,7 +538,7 @@ fn main() { } let password = get_password("Enter a password to derive the encryption key: "); - let (_, salt) = match generate_fallback_key(&password) { + let (_, salt) = match silicate.generate_fallback_key(&password) { Ok((_, s)) => ((), s), Err(e) => { println!( @@ -564,7 +567,7 @@ fn main() { display, tag: option_tag, } => { - if !check_fzf_installed() { + if !silicate.check_fzf_installed() { let msg = "fzf is not installed or not found in PATH. Please install fzf to use the search feature." .to_string() @@ -574,9 +577,9 @@ fn main() { return; } - match silicate_core::search_password(&config_dir(), option_tag) { + match silicate.search_password(option_tag) { Ok(Some(selection)) => { - let key = get_key(); + let key = get_key(&silicate); let data = (if let Some(tag) = option_tag { fs::read(format!("{}{}-{}.bin", config_dir(), selection, tag)) } else { @@ -584,12 +587,13 @@ fn main() { }) .unwrap(); let (nonce_bytes, cipher_bytes) = data.split_at(12); - let password = silicate_core::decrypt_passwd( - &key.try_into().unwrap(), - cipher_bytes.to_vec(), - nonce_bytes.try_into().unwrap(), - ) - .unwrap(); + let password = silicate + .decrypt_passwd( + &key.try_into().unwrap(), + cipher_bytes.to_vec(), + nonce_bytes.try_into().unwrap(), + ) + .unwrap(); if *display { let msg = format!("Password for {}: {}", selection, password.bold()); @@ -654,13 +658,14 @@ fn main() { let length = length.unwrap_or(16); // Default length of 16 if not specified - let password = silicate_core::generate_password(length, symbols); + let password = silicate.generate_password(length, symbols); if let Some(website) = website { - let key = get_key(); + let key = get_key(&silicate); - let (cipher_bytes, nonce_bytes) = - encrypt_passwd(&key.try_into().unwrap(), password.clone()).unwrap(); + let (cipher_bytes, nonce_bytes) = silicate + .encrypt_passwd(&key.try_into().unwrap(), password.clone()) + .unwrap(); if let Some(tag) = tag { fs::write( @@ -767,7 +772,7 @@ fn main() { } } Command::Edit { website } => { - let file_path_option = match find_password_file(&config_dir(), website) { + let file_path_option = match silicate.find_password_file(website) { Ok(path) => path, Err(e) => { println!( @@ -786,7 +791,7 @@ fn main() { }; if let Some(path) = file_path_option { - let key_vec = get_key(); + let key_vec = get_key(&silicate); let key_bytes = key_vec.as_slice(); let key: &[u8; 32] = match key_bytes.try_into() { @@ -828,7 +833,7 @@ fn main() { }; let (nonce_bytes, cipher_bytes) = data.split_at(12); - let old_password = match silicate_core::decrypt_passwd( + let old_password = match silicate.decrypt_passwd( key, cipher_bytes.to_vec(), nonce_bytes.try_into().unwrap(), @@ -931,10 +936,9 @@ fn main() { } } - let (new_cipher_bytes, new_nonce_bytes) = match encrypt_passwd( - key, - new_password, - ) { + let (new_cipher_bytes, new_nonce_bytes) = match silicate + .encrypt_passwd(key, new_password) + { Ok((c, n)) => (c, n), Err(e) => { println!( @@ -980,12 +984,12 @@ fn main() { } Command::Export { file_path, key } => { if *key { - match export_key(file_path) { + match silicate.export_key(file_path) { Ok(()) => println!("Key exported successfully."), Err(e) => eprintln!("Failed to export key: {}", e), } } else { - let passwords = match list_passwords(&config_dir()) { + let passwords = match silicate.list_passwords() { Ok(p) => p, Err(e) => { println!( @@ -1030,7 +1034,7 @@ fn main() { } Command::Import { file_path, key } => { if *key { - match import_key(file_path) { + match silicate.import_key(file_path) { Ok(()) => println!("Key imported successfully."), Err(e) => eprintln!("Failed to import key: {}", e), } @@ -1081,7 +1085,7 @@ fn main() { let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); if input.trim() == "y" || input.trim() == "Y" { - match rename_password_file(&config_dir(), old_website, new_website, tag) { + match silicate.rename_password_file(old_website, new_website, tag) { Ok(()) => println!( "{}", format!( @@ -1104,7 +1108,7 @@ fn main() { } Command::Tag { command } => match command { TagCommand::List {} => { - let tags = match silicate_core::list_tags(&config_dir()) { + let tags = match silicate.list_tags() { Ok(t) => t, Err(e) => { println!( @@ -1129,9 +1133,9 @@ fn main() { } }, Command::List { tag } => { - let websites = - silicate_core::list_passwords(&config_dir()) - .expect("Failed to list passwords."); + let websites = silicate + .list_passwords() + .expect("Failed to list passwords."); if websites.is_empty() { println!("{}", "No passwords stored yet.".yellow()); } else { @@ -1174,7 +1178,7 @@ fn main() { } } Command::Stats {} => { - let stats = match silicate_core::get_stats(&config_dir()) { + let stats = match silicate.get_stats() { Ok(s) => s, Err(e) => { println!( @@ -1214,7 +1218,7 @@ fn main() { } }, None => { - let passwords = match list_passwords(&config_dir()) { + let passwords = match silicate.list_passwords() { Ok(passwords) => passwords, Err(e) => { let msg = format!("Failed to get passwords: {e}").dimmed().red(); @@ -1223,10 +1227,10 @@ fn main() { } }; - let key = get_key().try_into().unwrap(); + let key = get_key(&silicate).try_into().unwrap(); let mut terminal = ratatui::init(); - let mut app = tui::App::new(passwords, key); + let mut app = tui::App::new(passwords, key, silicate.config_dir().to_string()); let result = app.run(&mut terminal); diff --git a/crates/cli/src/tui/mod.rs b/crates/cli/src/tui/mod.rs index 584e69d..d3cabf0 100644 --- a/crates/cli/src/tui/mod.rs +++ b/crates/cli/src/tui/mod.rs @@ -1,25 +1,17 @@ -use std::{ fs, io }; +use std::{fs, io}; -use crossterm::event::{ Event::Key, KeyCode, KeyEvent, KeyEventKind }; +use crossterm::event::{Event::Key, KeyCode, KeyEvent, KeyEventKind}; use ratatui::{ - DefaultTerminal, - Frame, - layout::{ Alignment, Constraint, Layout, Rect }, - style::{ Modifier, Style, Stylize }, - text::{ Line, Span }, + DefaultTerminal, Frame, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Modifier, Style, Stylize}, + text::{Line, Span}, widgets::{ - Block, - Borders, - Clear, - HighlightSpacing, - List, - ListItem, - ListState, - Paragraph, - Wrap, + Block, Borders, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph, Wrap, }, }; -use silicate_core::SilicateError; +use silicate_core::Silicate; +use silicate_core::error::SilicateError; fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { Rect { @@ -41,13 +33,14 @@ pub struct App { entries: Vec, state: ListState, key: [u8; 32], + silicate: Silicate, search_query: String, is_searching: bool, search_target: SearchTarget, } impl App { - pub fn new(entries: Vec, key: [u8; 32]) -> Self { + pub fn new(entries: Vec, key: [u8; 32], config_dir: String) -> Self { let mut state = ListState::default(); state.select(None); App { @@ -55,6 +48,7 @@ impl App { entries, state, key, + silicate: Silicate::new(config_dir), search_query: String::new(), is_searching: false, search_target: SearchTarget::Name, @@ -71,7 +65,11 @@ impl App { let selected = self.state.selected().unwrap_or(0); - let next = if selected >= self.filtered_entries().len() - 1 { 0 } else { selected + 1 }; + let next = if selected >= self.filtered_entries().len() - 1 { + 0 + } else { + selected + 1 + }; self.state.select(Some(next)); } @@ -86,7 +84,11 @@ impl App { let selected = self.state.selected().unwrap_or(0); - let previous = if selected == 0 { self.filtered_entries().len() - 1 } else { selected - 1 }; + let previous = if selected == 0 { + self.filtered_entries().len() - 1 + } else { + selected - 1 + }; self.state.select(Some(previous)); } @@ -95,11 +97,12 @@ impl App { while !self.exit { terminal.draw(|frame| self.draw(frame))?; - let input = crossterm::event - ::read() - .map_err(|e| { - io::Error::new(io::ErrorKind::Other, format!("Failed to read TUI input: {e}")) - })?; + let input = crossterm::event::read().map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!("Failed to read TUI input: {e}"), + ) + })?; if let Key(key_event) = input { self.handle_key(key_event)?; @@ -112,10 +115,9 @@ impl App { fn draw(&mut self, frame: &mut Frame) { let area = frame.area(); - let horizontal_area = Layout::horizontal([ - Constraint::Percentage(25), - Constraint::Percentage(75), - ]).areas(area); + let horizontal_area = + Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(75)]) + .areas(area); let [list_area, view_area] = horizontal_area; @@ -140,7 +142,9 @@ impl App { .block(Block::default().title("Passwords").borders(Borders::ALL)) .highlight_symbol("> ") .highlight_style( - Style::default().fg(ratatui::style::Color::Green).add_modifier(Modifier::REVERSED) + Style::default() + .fg(ratatui::style::Color::Green) + .add_modifier(Modifier::REVERSED), ) .highlight_spacing(HighlightSpacing::Always); @@ -149,39 +153,29 @@ impl App { let details_content = if let Some(selected_idx) = self.state.selected() { if let Some(entry_name) = filtered.get(selected_idx) { match self.get_decrypted_password(entry_name) { - Ok(password) => - vec![ - Line::from( - vec![ - Span::raw("Account/Site: "), - Span::styled( - entry_name, - Style::default() - .fg(ratatui::style::Color::Cyan) - .add_modifier(Modifier::BOLD) - ) - ] + Ok(password) => vec![ + Line::from(vec![ + Span::raw("Account/Site: "), + Span::styled( + entry_name, + Style::default() + .fg(ratatui::style::Color::Cyan) + .add_modifier(Modifier::BOLD), ), - Line::from(""), - Line::from( - vec![ - Span::raw("Password: "), - Span::styled( - password, - Style::default().fg(ratatui::style::Color::Green) - ) - ] - ) - ], - Err(e) => - vec![ - Line::from( - Span::styled( - format!("Decryption Error: {}", e), - Style::default().fg(ratatui::style::Color::Red) - ) - ) - ], + ]), + Line::from(""), + Line::from(vec![ + Span::raw("Password: "), + Span::styled( + password, + Style::default().fg(ratatui::style::Color::Green), + ), + ]), + ], + Err(e) => vec![Line::from(Span::styled( + format!("Decryption Error: {}", e), + Style::default().fg(ratatui::style::Color::Red), + ))], } } else { vec![Line::from("No entry found.")] @@ -203,13 +197,17 @@ impl App { // Construct the visual toggle selectors with custom highlights let name_style = if self.search_target == SearchTarget::Name { - Style::default().fg(ratatui::style::Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(ratatui::style::Color::Green) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(ratatui::style::Color::DarkGray) }; let tag_style = if self.search_target == SearchTarget::Tag { - Style::default().fg(ratatui::style::Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(ratatui::style::Color::Green) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(ratatui::style::Color::DarkGray) }; @@ -228,9 +226,10 @@ impl App { let popup_content = vec![ Line::from(self.search_query.as_str()), Line::from("─".repeat((popup.width as usize).saturating_sub(2))).dim(), - Line::from( - vec![Span::styled(name_icon, name_style), Span::styled(tag_icon, tag_style)] - ) + Line::from(vec![ + Span::styled(name_icon, name_style), + Span::styled(tag_icon, tag_style), + ]), ]; let input = Paragraph::new(popup_content) @@ -240,10 +239,8 @@ impl App { frame.render_widget(input, popup); // Cursor goes inside the box - frame.set_cursor_position(( - popup.x + 1 + (self.search_query.len() as u16), - popup.y + 1, - )); + frame + .set_cursor_position((popup.x + 1 + (self.search_query.len() as u16), popup.y + 1)); } } @@ -268,15 +265,17 @@ impl App { // Read the encrypted file binary chunk let data = fs::read(full_path)?; if data.len() < 12 { - return Err(SilicateError::Plain("Invalid password file format (too short)")); + return Err(SilicateError::Plain( + "Invalid password file format (too short)", + )); } let (nonce_bytes, cipher_bytes) = data.split_at(12); - let decrypted = silicate_core::decrypt_passwd( + let decrypted = self.silicate.decrypt_passwd( &self.key, cipher_bytes.to_vec(), - nonce_bytes.try_into()? + nonce_bytes.try_into()?, )?; Ok(decrypted) @@ -293,16 +292,14 @@ impl App { .filter(|entry| { let entry_lc = entry.to_lowercase(); match self.search_target { - SearchTarget::Name => - entry_lc - .split_once('-') - .map(|(name, _)| name.contains(&query)) - .unwrap_or_else(|| entry_lc.contains(&query)), - SearchTarget::Tag => - entry_lc - .split_once('-') - .map(|(_, tag)| tag.contains(&query)) - .unwrap_or(false), + SearchTarget::Name => entry_lc + .split_once('-') + .map(|(name, _)| name.contains(&query)) + .unwrap_or_else(|| entry_lc.contains(&query)), + SearchTarget::Tag => entry_lc + .split_once('-') + .map(|(_, tag)| tag.contains(&query)) + .unwrap_or(false), } }) .cloned() diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs new file mode 100644 index 0000000..fd378e5 --- /dev/null +++ b/crates/core/src/crypto.rs @@ -0,0 +1,35 @@ +use crate::error::SilicateError; +use aes_gcm::{ + Aes256Gcm, KeyInit, Nonce, + aead::{Aead, OsRng, rand_core::RngCore}, +}; + +/// Encrypts the given plaintext using AES-256-GCM. Returns the ciphertext and the nonce used for encryption. +pub(crate) fn encrypt_passwd( + key_bytes: &[u8; 32], + plaintext: String, +) -> Result<(Vec, [u8; 12]), SilicateError> { + // Changed [u8; 12] to Vec + let cipher = Aes256Gcm::new_from_slice(key_bytes)?; + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes())?; + Ok((ciphertext, nonce_bytes)) +} + +/// Decrypts the given ciphertext using AES-256-GCM. Requires the same key and nonce used for encryption. +pub(crate) fn decrypt_passwd( + key_bytes: &[u8; 32], + ciphertext: Vec, + nonce_bytes: [u8; 12], +) -> Result { + let cipher = Aes256Gcm::new_from_slice(key_bytes)?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let plaintext_bytes = cipher.decrypt(nonce, ciphertext.as_ref())?; + let plaintext = String::from_utf8(plaintext_bytes)?; + Ok(plaintext) +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs new file mode 100644 index 0000000..cb990f5 --- /dev/null +++ b/crates/core/src/error.rs @@ -0,0 +1,114 @@ +#[derive(Debug)] +pub enum SilicateError { + Plain(&'static str), + KeyringError(keyring::Error), + IoError(std::io::Error), + HexError(hex::FromHexError), + SerdeJsonError(serde_json::Error), + Argon2Error(argon2::password_hash::Error), + AesGcmError(aes_gcm::Error), + AesInvalidKeyLengthError(aes_gcm::aes::cipher::InvalidLength), + StdioError(std::io::Error), + Utf8Error(std::string::FromUtf8Error), + Argon2PasswordHashError(argon2::password_hash::Error), + TryFromSliceError(std::array::TryFromSliceError), +} + +impl From<&'static str> for SilicateError { + fn from(err: &'static str) -> SilicateError { + SilicateError::Plain(err) + } +} + +impl From for SilicateError { + fn from(err: keyring::Error) -> SilicateError { + SilicateError::KeyringError(err) + } +} + +impl From for SilicateError { + fn from(err: std::io::Error) -> SilicateError { + SilicateError::IoError(err) + } +} + +impl From for SilicateError { + fn from(err: hex::FromHexError) -> SilicateError { + SilicateError::HexError(err) + } +} + +impl From for SilicateError { + fn from(err: serde_json::Error) -> SilicateError { + SilicateError::SerdeJsonError(err) + } +} + +impl From for SilicateError { + fn from(err: argon2::password_hash::Error) -> SilicateError { + SilicateError::Argon2Error(err) + } +} + +impl From for SilicateError { + fn from(err: aes_gcm::Error) -> SilicateError { + SilicateError::AesGcmError(err) + } +} + +impl From for SilicateError { + fn from(err: std::string::FromUtf8Error) -> SilicateError { + SilicateError::Utf8Error(err) + } +} + +impl From for SilicateError { + fn from(err: aes_gcm::aes::cipher::InvalidLength) -> SilicateError { + SilicateError::AesInvalidKeyLengthError(err) + } +} + +impl From for SilicateError { + fn from(err: std::array::TryFromSliceError) -> SilicateError { + SilicateError::TryFromSliceError(err) + } +} + +impl From> for SilicateError { + fn from(err: Vec) -> SilicateError { + SilicateError::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Vec error: {:?}", err), + )) + } +} + +impl std::fmt::Display for SilicateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SilicateError::KeyringError(e) => write!(f, "Keyring error: {}", e), + SilicateError::IoError(e) => write!(f, "I/O error: {}", e), + SilicateError::HexError(e) => write!(f, "Hex decoding error: {}", e), + SilicateError::SerdeJsonError(e) => { + write!(f, "JSON serialization/deserialization error: {}", e) + } + SilicateError::Argon2Error(e) => write!(f, "Argon2 error: {}", e), + SilicateError::AesGcmError(e) => { + write!(f, "AES-GCM encryption/decryption error: {}", e) + } + SilicateError::Utf8Error(e) => write!(f, "UTF-8 conversion error: {}", e), + SilicateError::AesInvalidKeyLengthError(e) => { + write!(f, "AES invalid key length error: {}", e) + } + SilicateError::TryFromSliceError(e) => write!(f, "TryFromSlice error: {}", e), + SilicateError::Argon2PasswordHashError(e) => { + write!(f, "Argon2 password hash error: {}", e) + } + SilicateError::StdioError(e) => write!(f, "Stdio error: {}", e), + SilicateError::Plain(e) => { + let msg = e.to_string(); + write!(f, "Program Error: {msg}") + } + } + } +} diff --git a/crates/core/src/keyring.rs b/crates/core/src/keyring.rs new file mode 100644 index 0000000..59c3857 --- /dev/null +++ b/crates/core/src/keyring.rs @@ -0,0 +1,43 @@ +use crate::error::SilicateError; +use keyring::Entry; + +const SERVICE_NAME: &str = "silicate"; +const USERNAME: &str = "default"; + +/// This puts a randomly generated key into the system's keyring. +pub(crate) fn store_key_in_keyring(key: &[u8; 32]) -> Result<(), SilicateError> { + let entry = Entry::new(SERVICE_NAME, USERNAME)?; + entry.set_password(&hex::encode(key))?; + Ok(()) +} + +/// This retrieves the key from the system's keyring. +pub(crate) fn retrieve_key_from_keyring() -> Result<[u8; 32], SilicateError> { + let entry = Entry::new(SERVICE_NAME, USERNAME)?; + let key_hex = entry.get_password()?; + let key_bytes: [u8; 32] = hex::decode(key_hex)?.try_into()?; + Ok(key_bytes) +} + +/// This function checks if a keyring is available and can be accessed. +/// This will be for checking if the user has a secure key management solution in place. +pub(crate) fn is_keyring_available() -> bool { + let entry = Entry::new(SERVICE_NAME, USERNAME); + entry.is_ok() +} + +pub(crate) fn update_entry( + config_dir: &str, + website: &str, + tag: Option<&str>, + new_data: &str, +) -> Result<(), Box> { + let filename = if let Some(t) = tag { + format!("{}-{}.bin", website, t) + } else { + format!("{}.bin", website) + }; + let filepath = std::path::Path::new(config_dir).join(filename); + std::fs::write(filepath, new_data)?; + Ok(()) +} diff --git a/crates/core/src/keys.rs b/crates/core/src/keys.rs new file mode 100644 index 0000000..55616ca --- /dev/null +++ b/crates/core/src/keys.rs @@ -0,0 +1,79 @@ +use crate::error::SilicateError; +use aes_gcm::{Aes256Gcm, KeyInit, aead::OsRng}; +use argon2::password_hash::{PasswordHasher, rand_core::OsRng as ArOsRng}; +use argon2::{Argon2, password_hash::SaltString}; + +/// Generates a random 256-bit key for AES encryption. +pub(crate) fn generate_key() -> [u8; 32] { + let key = Aes256Gcm::generate_key(OsRng); + key.into() +} + +/// Generates a fallback key using a password-based key derivation. +/// This is used when the user doesn't have a secure key management solution in place. +/// Returns the derived key and the salt used for hashing. +pub(crate) fn generate_fallback_key(password: &str) -> Result<([u8; 32], [u8; 16]), SilicateError> { + let salt = SaltString::generate(&mut ArOsRng); + let argon2 = Argon2::default(); // 32-byte output by default + let hashed = argon2.hash_password(password.as_bytes(), &salt)?; + let key_bytes: [u8; 32] = hashed + .hash + .ok_or_else(|| { + SilicateError::Argon2PasswordHashError(argon2::password_hash::Error::Password) + })? + .as_bytes() + .try_into()?; + let mut salt_bytes = [0u8; 16]; + salt.decode_b64(&mut salt_bytes)?; + Ok((key_bytes, salt_bytes)) +} + +/// This function will take a salt and a password and derive the same key as the generate_fallback_key function. +/// This is used for retrieving the key when the user doesn't have a secure key management solution in place. +pub(crate) fn derive_key_from_password( + password: &str, + salt: &[u8; 16], +) -> Result<[u8; 32], SilicateError> { + let salt_string = SaltString::encode_b64(salt)?; + let argon2 = Argon2::default(); // 32-byte output by default + let hashed = argon2.hash_password(password.as_bytes(), &salt_string)?; + let key_bytes: [u8; 32] = hashed + .hash + .ok_or_else(|| { + SilicateError::Argon2PasswordHashError(argon2::password_hash::Error::Password) + })? + .as_bytes() + .try_into()?; + Ok(key_bytes) +} + +/// This function will export the key from the keyring to a file in the config directory. +/// This is for users who need to backup their key or export a key that was generated on a different machine. +pub(crate) fn export_key(file_path: &Option) -> Result<(), SilicateError> { + let key = crate::keyring::retrieve_key_from_keyring()?; + let path = file_path.as_ref().map_or_else( + || { + format!( + "./key-{}.bin", + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S") + ) + }, + |p| p.clone(), + ); + std::fs::write(&path, key).unwrap(); + println!("Key exported to {}", path); + Ok(()) +} + +/// This function imports the key from a file and stores it in the keyring. +/// This is for users who need to restore a key from a backup or import a key that was generated on a different machine. +pub(crate) fn import_key(file_path: &str) -> Result<(), SilicateError> { + let key_bytes = std::fs::read(file_path).unwrap(); + let key: [u8; 32] = key_bytes + .try_into() + .map_err(|_| "Invalid key file: expected 32 bytes") + .unwrap(); + crate::keyring::store_key_in_keyring(&key)?; + println!("Key imported and stored in keyring."); + Ok(()) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1006223..9e0fec1 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,416 +1,152 @@ -use aes_gcm::aead::rand_core::RngCore; -use aes_gcm::{ - Aes256Gcm, Nonce, - aead::{Aead, KeyInit, OsRng}, -}; -use argon2::{ - Argon2, - password_hash::{PasswordHasher, SaltString, rand_core::OsRng as ArOsRng}, -}; -use colored::*; -use keyring::Entry; -use std::fs; -use std::io::Write; -use std::process::{Command, Stdio}; - -const SERVICE_NAME: &str = "silicate"; -const USERNAME: &str = "default"; - -#[derive(Debug)] -pub enum SilicateError { - Plain(&'static str), - KeyringError(keyring::Error), - IoError(std::io::Error), - HexError(hex::FromHexError), - SerdeJsonError(serde_json::Error), - Argon2Error(argon2::password_hash::Error), - AesGcmError(aes_gcm::Error), - AesInvalidKeyLengthError(aes_gcm::aes::cipher::InvalidLength), - StdioError(std::io::Error), - Utf8Error(std::string::FromUtf8Error), - Argon2PasswordHashError(argon2::password_hash::Error), - TryFromSliceError(std::array::TryFromSliceError), -} +use crate::error::SilicateError; -impl From<&'static str> for SilicateError { - fn from(err: &'static str) -> SilicateError { - SilicateError::Plain(err) - } -} +pub mod crypto; +pub mod error; +pub mod keyring; +pub mod keys; +pub mod passwords; +pub mod stats; +pub mod tags; -impl From for SilicateError { - fn from(err: keyring::Error) -> SilicateError { - SilicateError::KeyringError(err) - } +pub struct Silicate { + config_dir: String, } -impl From for SilicateError { - fn from(err: std::io::Error) -> SilicateError { - SilicateError::IoError(err) +impl Silicate { + pub fn new(config_dir: String) -> Self { + Self { config_dir } } -} -impl From for SilicateError { - fn from(err: hex::FromHexError) -> SilicateError { - SilicateError::HexError(err) + pub fn config_dir(&self) -> &str { + &self.config_dir } -} -impl From for SilicateError { - fn from(err: serde_json::Error) -> SilicateError { - SilicateError::SerdeJsonError(err) + pub fn check_fzf_installed(&self) -> bool { + check_fzf_installed() } -} -impl From for SilicateError { - fn from(err: argon2::password_hash::Error) -> SilicateError { - SilicateError::Argon2Error(err) + pub fn encrypt_passwd( + &self, + key_bytes: &[u8; 32], + plaintext: String, + ) -> Result<(Vec, [u8; 12]), SilicateError> { + crypto::encrypt_passwd(key_bytes, plaintext) } -} -impl From for SilicateError { - fn from(err: aes_gcm::Error) -> SilicateError { - SilicateError::AesGcmError(err) + pub fn decrypt_passwd( + &self, + key_bytes: &[u8; 32], + ciphertext: Vec, + nonce_bytes: [u8; 12], + ) -> Result { + crypto::decrypt_passwd(key_bytes, ciphertext, nonce_bytes) } -} -impl From for SilicateError { - fn from(err: std::string::FromUtf8Error) -> SilicateError { - SilicateError::Utf8Error(err) + pub fn store_key_in_keyring(&self, key: &[u8; 32]) -> Result<(), SilicateError> { + keyring::store_key_in_keyring(key) } -} -impl From for SilicateError { - fn from(err: aes_gcm::aes::cipher::InvalidLength) -> SilicateError { - SilicateError::AesInvalidKeyLengthError(err) + pub fn retrieve_key_from_keyring(&self) -> Result<[u8; 32], SilicateError> { + keyring::retrieve_key_from_keyring() } -} -impl From for SilicateError { - fn from(err: std::array::TryFromSliceError) -> SilicateError { - SilicateError::TryFromSliceError(err) + pub fn is_keyring_available(&self) -> bool { + keyring::is_keyring_available() } -} -impl From> for SilicateError { - fn from(err: Vec) -> SilicateError { - SilicateError::IoError(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Vec error: {:?}", err), - )) + pub fn update_entry( + &self, + website: &str, + tag: Option<&str>, + new_data: &str, + ) -> Result<(), Box> { + keyring::update_entry(&self.config_dir, website, tag, new_data) } -} -impl std::fmt::Display for SilicateError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SilicateError::KeyringError(e) => write!(f, "Keyring error: {}", e), - SilicateError::IoError(e) => write!(f, "I/O error: {}", e), - SilicateError::HexError(e) => write!(f, "Hex decoding error: {}", e), - SilicateError::SerdeJsonError(e) => { - write!(f, "JSON serialization/deserialization error: {}", e) - } - SilicateError::Argon2Error(e) => write!(f, "Argon2 error: {}", e), - SilicateError::AesGcmError(e) => { - write!(f, "AES-GCM encryption/decryption error: {}", e) - } - SilicateError::Utf8Error(e) => write!(f, "UTF-8 conversion error: {}", e), - SilicateError::AesInvalidKeyLengthError(e) => { - write!(f, "AES invalid key length error: {}", e) - } - SilicateError::TryFromSliceError(e) => write!(f, "TryFromSlice error: {}", e), - SilicateError::Argon2PasswordHashError(e) => { - write!(f, "Argon2 password hash error: {}", e) - } - SilicateError::StdioError(e) => write!(f, "Stdio error: {}", e), - SilicateError::Plain(e) => { - let msg = e.to_string(); - write!(f, "Program Error: {msg}") - } - } + pub fn generate_key(&self) -> [u8; 32] { + keys::generate_key() } -} - -/// Encrypts the given plaintext using AES-256-GCM. Returns the ciphertext and the nonce used for encryption. -pub fn encrypt_passwd( - key_bytes: &[u8; 32], - plaintext: String, -) -> Result<(Vec, [u8; 12]), SilicateError> { - // Changed [u8; 12] to Vec - let cipher = Aes256Gcm::new_from_slice(key_bytes)?; - - let mut nonce_bytes = [0u8; 12]; - OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes())?; - Ok((ciphertext, nonce_bytes)) -} - -/// Decrypts the given ciphertext using AES-256-GCM. Requires the same key and nonce used for encryption. -pub fn decrypt_passwd( - key_bytes: &[u8; 32], - ciphertext: Vec, - nonce_bytes: [u8; 12], -) -> Result { - let cipher = Aes256Gcm::new_from_slice(key_bytes)?; - let nonce = Nonce::from_slice(&nonce_bytes); - - let plaintext_bytes = cipher.decrypt(nonce, ciphertext.as_ref())?; - let plaintext = String::from_utf8(plaintext_bytes)?; - Ok(plaintext) -} - -/// Generates a random 256-bit key for AES encryption. -pub fn generate_key() -> [u8; 32] { - let key = Aes256Gcm::generate_key(OsRng); - key.into() -} - -/// Generates a fallback key using a password-based key derivation. -/// This is used when the user doesn't have a secure key management solution in place. -/// Returns the derived key and the salt used for hashing. -pub fn generate_fallback_key(password: &str) -> Result<([u8; 32], [u8; 16]), SilicateError> { - let salt = SaltString::generate(&mut ArOsRng); - let argon2 = Argon2::default(); // 32-byte output by default - let hashed = argon2.hash_password(password.as_bytes(), &salt)?; - let key_bytes: [u8; 32] = hashed - .hash - .ok_or_else(|| { - SilicateError::Argon2PasswordHashError(argon2::password_hash::Error::Password) - })? - .as_bytes() - .try_into()?; - let mut salt_bytes = [0u8; 16]; - salt.decode_b64(&mut salt_bytes)?; - Ok((key_bytes, salt_bytes)) -} - -/// This function will take a salt and a password and derive the same key as the generate_fallback_key function. -/// This is used for retrieving the key when the user doesn't have a secure key management solution in place. -pub fn derive_key_from_password( - password: &str, - salt: &[u8; 16], -) -> Result<[u8; 32], SilicateError> { - let salt_string = SaltString::encode_b64(salt)?; - let argon2 = Argon2::default(); // 32-byte output by default - let hashed = argon2.hash_password(password.as_bytes(), &salt_string)?; - let key_bytes: [u8; 32] = hashed - .hash - .ok_or_else(|| { - SilicateError::Argon2PasswordHashError(argon2::password_hash::Error::Password) - })? - .as_bytes() - .try_into()?; - Ok(key_bytes) -} - -/// This puts a randomly generated key into the system's keyring. -pub fn store_key_in_keyring(key: &[u8; 32]) -> Result<(), SilicateError> { - let entry = Entry::new(SERVICE_NAME, USERNAME)?; - entry.set_password(&hex::encode(key))?; - Ok(()) -} - -/// This retrieves the key from the system's keyring. -pub fn retrieve_key_from_keyring() -> Result<[u8; 32], SilicateError> { - let entry = Entry::new(SERVICE_NAME, USERNAME)?; - let key_hex = entry.get_password()?; - let key_bytes: [u8; 32] = hex::decode(key_hex)?.try_into()?; - Ok(key_bytes) -} - -/// This function checks if a keyring is available and can be accessed. -/// This will be for checking if the user has a secure key management solution in place. -pub fn is_keyring_available() -> bool { - let entry = Entry::new(SERVICE_NAME, USERNAME); - entry.is_ok() -} - -/// This function lists all the password files in the config directory, excluding the salt file. -/// It returns a vector of website names (without the .bin extension). -pub fn list_passwords(config_dir: &str) -> Result, SilicateError> { - let mut websites = Vec::new(); - if let Ok(entries) = std::fs::read_dir(config_dir) { - for entry in entries.flatten() { - if let Some(filename) = entry.file_name().to_str() { - if filename.ends_with(".bin") && filename != "salt.bin" { - websites.push(filename.trim_end_matches(".bin").to_string()); - } - } - } + pub fn generate_fallback_key( + &self, + password: &str, + ) -> Result<([u8; 32], [u8; 16]), SilicateError> { + keys::generate_fallback_key(password) } - Ok(websites) -} - -/// This function checks if fzf is installed on the system by trying to find its path. -pub fn check_fzf_installed() -> bool { - which::which("fzf").is_ok() -} -/// This function takes the config directory and an optional tag, lists the passwords, filters them by tag if provided, -pub fn search_password( - config_dir: &str, - tag: &Option, -) -> Result, SilicateError> { - let websites = list_passwords(config_dir)?; - if websites.is_empty() { - println!("No passwords found in the config directory."); - return Ok(None); + pub fn derive_key_from_password( + &self, + password: &str, + salt: &[u8; 16], + ) -> Result<[u8; 32], SilicateError> { + keys::derive_key_from_password(password, salt) } - let websites = if let Some(t) = tag { - websites - .into_iter() - .filter(|w| { - if let Some((_, w_tag)) = w.split_once('-') { - w_tag == t - } else { - false - } - }) - .map(|w| { - if let Some((site, _)) = w.split_once('-') { - site.to_string() - } else { - w.clone() - } - }) - .collect::>() - } else { - websites - .into_iter() - .map(|w| { - if let Some((site, tag)) = w.split_once('-') { - format!("({}) {}", tag, site) - } else { - w.clone() - } - }) - .collect::>() - }; - - if websites.is_empty() { - println!("{}", "No passwords found for the specified tag.".red()); - return Ok(None); + pub fn export_key(&self, file_path: &Option) -> Result<(), SilicateError> { + keys::export_key(file_path) } - let fzf_input = websites.join("\n"); - - // 3. Spawn the fzf process - // We inherit stderr so fzf can draw its interactive UI on the terminal screen, - // while we pipe stdin (to send data) and stdout (to catch the choice). - let mut child = Command::new("fzf") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn()?; - - // 4. Write our database records to fzf's stdin asynchronously - if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(fzf_input.as_bytes())?; + pub fn import_key(&self, file_path: &str) -> Result<(), SilicateError> { + keys::import_key(file_path) } - // 5. Wait for the user to make a selection and exit - let output = child.wait_with_output()?; - - // 6. Handle the result based on the exit code - if output.status.success() { - let selection = String::from_utf8(output.stdout)?; - let trimmed_selection = selection.trim(); - - if trimmed_selection.is_empty() { - println!("{}", "No selection made.".red()); - Ok(None) - } else { - Ok(Some(trimmed_selection.to_string())) - } - } else { - // Exit code 130 typically means the user pressed Esc/Ctrl-C - println!("{}", "Selection canceled or fzf failed.".red()); - Ok(None) + pub fn list_passwords(&self) -> Result, SilicateError> { + passwords::list_passwords(&self.config_dir) } -} -/// This function generates a random password of the specified length. If use_symbols is true, it includes symbols in the password. -pub fn generate_password(length: usize, use_symbols: bool) -> String { - if length == 0 { - return String::new(); + pub fn search_password(&self, tag: &Option) -> Result, SilicateError> { + passwords::search_password(&self.config_dir, tag) } - let letters_and_digits = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let symbols = b"!@#$%^&*()_+-=[]{}|;:,.<>?"; - - if use_symbols { - let mut combined = Vec::with_capacity(letters_and_digits.len() + symbols.len()); - combined.extend_from_slice(letters_and_digits); - combined.extend_from_slice(symbols); - return sample_from_pool(&combined, length); + pub fn generate_password(&self, length: usize, use_symbols: bool) -> String { + passwords::generate_password(length, use_symbols) } - sample_from_pool(letters_and_digits, length) -} - -fn sample_from_pool(pool: &[u8], length: usize) -> String { - let mut rng = OsRng; - let mut result = String::with_capacity(length); - - let pool_len = pool.len() as u32; - // To prevent modulo bias, calculate the maximum allowable value - // that fits perfectly into multiples of our pool length. - let zone = u32::MAX - (u32::MAX % pool_len); + pub fn update_password( + &self, + key: &[u8; 32], + website: &str, + tag: Option<&str>, + new_plaintext: String, + ) -> Result<(), Box> { + passwords::update_password(&self.config_dir, key, website, tag, new_plaintext) + } - while result.len() < length { - // Use RngCore's next_u32 directly (always available on OsRng) - let random_val = rng.next_u32(); + pub fn list_tags(&self) -> Result, SilicateError> { + tags::list_tags(&self.config_dir) + } - // Rejection sampling: if it falls in the biased remainder zone, skip it - if random_val < zone { - let idx = (random_val % pool_len) as usize; - result.push(pool[idx] as char); - } + pub fn get_stats(&self) -> Result { + stats::get_stats(&self.config_dir) } - result -} + pub fn find_password_file( + &self, + target_website: &str, + ) -> Result, SilicateError> { + find_password_file(&self.config_dir, target_website) + } -pub fn update_password( - config_dir: &str, - key: &[u8; 32], - website: &str, - tag: Option<&str>, - new_plaintext: String, -) -> Result<(), Box> { - let (new_ciphertext, new_nonce) = - encrypt_passwd(key, new_plaintext).map_err(|e| format!("Encryption failed: {:?}", e))?; - let mut combined_data = Vec::new(); - combined_data.extend_from_slice(&new_nonce); - combined_data.extend_from_slice(&new_ciphertext); - update_entry(config_dir, website, tag, &hex::encode(combined_data))?; - Ok(()) + pub fn rename_password_file( + &self, + old_website: &str, + new_website: &str, + tag: &Option, + ) -> Result<(), SilicateError> { + rename_password_file(&self.config_dir, old_website, new_website, tag) + } } -fn update_entry( - config_dir: &str, - website: &str, - tag: Option<&str>, - new_data: &str, -) -> Result<(), Box> { - let filename = if let Some(t) = tag { - format!("{}-{}.bin", website, t) - } else { - format!("{}.bin", website) - }; - let filepath = std::path::Path::new(config_dir).join(filename); - std::fs::write(filepath, new_data)?; - Ok(()) +/// This function checks if fzf is installed on the system by trying to find its path. +fn check_fzf_installed() -> bool { + which::which("fzf").is_ok() } -pub fn find_password_file( +fn find_password_file( config_dir: &str, target_website: &str, ) -> Result, SilicateError> { - let passwords = list_passwords(config_dir)?; + let passwords = passwords::list_passwords(config_dir)?; Ok(passwords.into_iter().find(|filename| { // If it's an exact match (no tag) @@ -429,39 +165,8 @@ pub fn find_password_file( })) } -/// This function will export the key from the keyring to a file in the config directory. -/// This is for users who need to backup their key or export a key that was generated on a different machine. -pub fn export_key(file_path: &Option) -> Result<(), SilicateError> { - let key = retrieve_key_from_keyring()?; - let path = file_path.as_ref().map_or_else( - || { - format!( - "./key-{}.bin", - chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S") - ) - }, - |p| p.clone(), - ); - std::fs::write(&path, key).unwrap(); - println!("Key exported to {}", path); - Ok(()) -} - -/// This function imports the key from a file and stores it in the keyring. -/// This is for users who need to restore a key from a backup or import a key that was generated on a different machine. -pub fn import_key(file_path: &str) -> Result<(), SilicateError> { - let key_bytes = std::fs::read(file_path).unwrap(); - let key: [u8; 32] = key_bytes - .try_into() - .map_err(|_| "Invalid key file: expected 32 bytes") - .unwrap(); - store_key_in_keyring(&key)?; - println!("Key imported and stored in keyring."); - Ok(()) -} - /// This function will rename a password file in the config directory. -pub fn rename_password_file( +fn rename_password_file( config_dir: &str, old_website: &str, new_website: &str, @@ -499,73 +204,30 @@ pub fn rename_password_file( Ok(()) } -/// This function will get all unique tags from the password files in the config directory. -pub fn list_tags(config_dir: &str) -> Result, SilicateError> { - let mut tags = Vec::new(); - let passwords = list_passwords(config_dir)?; - for password in passwords { - if let Some((_, tag)) = password.split_once('-') { - if !tags.contains(&tag.to_string()) { - tags.push(tag.to_string()); - } - } - } - Ok(tags) -} - -pub struct Stats { - pub total_passwords: usize, - pub unique_tags: usize, - pub init_timestamp: chrono::DateTime, -} - -/// This function will get stats for the password manager, such as the total number of passwords and the number of unique tags. -pub fn get_stats(config_dir: &str) -> Result { - let passwords = list_passwords(config_dir)?; - let total_passwords = passwords.len(); - let unique_tags = list_tags(config_dir)?.len(); - let init_timestamp = match fs::read_to_string(config_dir.to_string() + "init_timestamp.txt") { - Ok(timestamp) => chrono::DateTime::parse_from_rfc3339(×tamp.trim()) - .map_err(|e| { - SilicateError::IoError(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Failed to parse init timestamp: {}", e), - )) - })? - .with_timezone(&chrono::Utc), - Err(_) => chrono::Utc::now(), - }; - Ok(Stats { - total_passwords, - unique_tags, - init_timestamp, - }) -} - #[cfg(test)] mod tests { use super::*; #[test] fn test_encrypt_decrypt() { - let key = generate_key(); + let key = keys::generate_key(); let plaintext = "This is a test password.".to_string(); - let (ciphertext, nonce) = encrypt_passwd(&key, plaintext.clone()).unwrap(); - let decrypted = decrypt_passwd(&key, ciphertext.to_vec(), nonce).unwrap(); + let (ciphertext, nonce) = crypto::encrypt_passwd(&key, plaintext.clone()).unwrap(); + let decrypted = crypto::decrypt_passwd(&key, ciphertext.to_vec(), nonce).unwrap(); assert_eq!(plaintext, decrypted); } #[test] fn test_fallback_key_derivation() { let password = "test_password"; - let (derived_key, salt) = generate_fallback_key(password).unwrap(); - let derived_key_again = derive_key_from_password(password, &salt).unwrap(); + let (derived_key, salt) = keys::generate_fallback_key(password).unwrap(); + let derived_key_again = keys::derive_key_from_password(password, &salt).unwrap(); assert_eq!(derived_key, derived_key_again); } #[test] fn test_password_generation() { - let password = generate_password(16, true); + let password = passwords::generate_password(16, true); assert_eq!(password.len(), 16); assert!( password @@ -576,7 +238,7 @@ mod tests { #[test] fn password_generation_no_symbols() { - let password = generate_password(16, false); + let password = passwords::generate_password(16, false); assert_eq!(password.len(), 16); assert!( !password diff --git a/crates/core/src/passwords.rs b/crates/core/src/passwords.rs new file mode 100644 index 0000000..bb90347 --- /dev/null +++ b/crates/core/src/passwords.rs @@ -0,0 +1,163 @@ +use crate::error::SilicateError; +use aes_gcm::aead::rand_core::{OsRng, RngCore}; +use colored::*; +use std::io::Write; +use std::process::{Command, Stdio}; + +/// This function lists all the password files in the config directory, excluding the salt file. +/// It returns a vector of website names (without the .bin extension). +pub(crate) fn list_passwords(config_dir: &str) -> Result, SilicateError> { + let mut websites = Vec::new(); + if let Ok(entries) = std::fs::read_dir(config_dir) { + for entry in entries.flatten() { + if let Some(filename) = entry.file_name().to_str() { + if filename.ends_with(".bin") && filename != "salt.bin" { + websites.push(filename.trim_end_matches(".bin").to_string()); + } + } + } + } + Ok(websites) +} + +/// This function takes the config directory and an optional tag, lists the passwords, filters them by tag if provided, +pub(crate) fn search_password( + config_dir: &str, + tag: &Option, +) -> Result, SilicateError> { + let websites = list_passwords(config_dir)?; + if websites.is_empty() { + println!("No passwords found in the config directory."); + return Ok(None); + } + + let websites = if let Some(t) = tag { + websites + .into_iter() + .filter(|w| { + if let Some((_, w_tag)) = w.split_once('-') { + w_tag == t + } else { + false + } + }) + .map(|w| { + if let Some((site, _)) = w.split_once('-') { + site.to_string() + } else { + w.clone() + } + }) + .collect::>() + } else { + websites + .into_iter() + .map(|w| { + if let Some((site, tag)) = w.split_once('-') { + format!("({}) {}", tag, site) + } else { + w.clone() + } + }) + .collect::>() + }; + + if websites.is_empty() { + println!("{}", "No passwords found for the specified tag.".red()); + return Ok(None); + } + + let fzf_input = websites.join("\n"); + + // 3. Spawn the fzf process + // We inherit stderr so fzf can draw its interactive UI on the terminal screen, + // while we pipe stdin (to send data) and stdout (to catch the choice). + let mut child = Command::new("fzf") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + + // 4. Write our database records to fzf's stdin asynchronously + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(fzf_input.as_bytes())?; + } + + // 5. Wait for the user to make a selection and exit + let output = child.wait_with_output()?; + + // 6. Handle the result based on the exit code + if output.status.success() { + let selection = String::from_utf8(output.stdout)?; + let trimmed_selection = selection.trim(); + + if trimmed_selection.is_empty() { + println!("{}", "No selection made.".red()); + Ok(None) + } else { + Ok(Some(trimmed_selection.to_string())) + } + } else { + // Exit code 130 typically means the user pressed Esc/Ctrl-C + println!("{}", "Selection canceled or fzf failed.".red()); + Ok(None) + } +} + +/// This function generates a random password of the specified length. If use_symbols is true, it includes symbols in the password. +pub(crate) fn generate_password(length: usize, use_symbols: bool) -> String { + if length == 0 { + return String::new(); + } + + let letters_and_digits = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let symbols = b"!@#$%^&*()_+-=[]{}|;:,.<>?"; + + if use_symbols { + let mut combined = Vec::with_capacity(letters_and_digits.len() + symbols.len()); + combined.extend_from_slice(letters_and_digits); + combined.extend_from_slice(symbols); + return sample_from_pool(&combined, length); + } + + sample_from_pool(letters_and_digits, length) +} + +fn sample_from_pool(pool: &[u8], length: usize) -> String { + let mut rng = OsRng; + let mut result = String::with_capacity(length); + + let pool_len = pool.len() as u32; + // To prevent modulo bias, calculate the maximum allowable value + // that fits perfectly into multiples of our pool length. + let zone = u32::MAX - (u32::MAX % pool_len); + + while result.len() < length { + // Use RngCore's next_u32 directly (always available on OsRng) + let random_val = rng.next_u32(); + + // Rejection sampling: if it falls in the biased remainder zone, skip it + if random_val < zone { + let idx = (random_val % pool_len) as usize; + result.push(pool[idx] as char); + } + } + + result +} + +pub(crate) fn update_password( + config_dir: &str, + key: &[u8; 32], + website: &str, + tag: Option<&str>, + new_plaintext: String, +) -> Result<(), Box> { + let (new_ciphertext, new_nonce) = crate::crypto::encrypt_passwd(key, new_plaintext) + .map_err(|e| format!("Encryption failed: {:?}", e))?; + let mut combined_data = Vec::new(); + combined_data.extend_from_slice(&new_nonce); + combined_data.extend_from_slice(&new_ciphertext); + crate::keyring::update_entry(config_dir, website, tag, &hex::encode(combined_data))?; + Ok(()) +} diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs new file mode 100644 index 0000000..6b7391b --- /dev/null +++ b/crates/core/src/stats.rs @@ -0,0 +1,31 @@ +use crate::error::SilicateError; +use std::fs; + +pub struct Stats { + pub total_passwords: usize, + pub unique_tags: usize, + pub init_timestamp: chrono::DateTime, +} + +/// This function will get stats for the password manager, such as the total number of passwords and the number of unique tags. +pub(crate) fn get_stats(config_dir: &str) -> Result { + let passwords = crate::passwords::list_passwords(config_dir)?; + let total_passwords = passwords.len(); + let unique_tags = crate::tags::list_tags(config_dir)?.len(); + let init_timestamp = match fs::read_to_string(config_dir.to_string() + "init_timestamp.txt") { + Ok(timestamp) => chrono::DateTime::parse_from_rfc3339(×tamp.trim()) + .map_err(|e| { + SilicateError::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to parse init timestamp: {}", e), + )) + })? + .with_timezone(&chrono::Utc), + Err(_) => chrono::Utc::now(), + }; + Ok(Stats { + total_passwords, + unique_tags, + init_timestamp, + }) +} diff --git a/crates/core/src/tags.rs b/crates/core/src/tags.rs new file mode 100644 index 0000000..e716315 --- /dev/null +++ b/crates/core/src/tags.rs @@ -0,0 +1,15 @@ +use crate::error::SilicateError; + +/// This function will get all unique tags from the password files in the config directory. +pub(crate) fn list_tags(config_dir: &str) -> Result, SilicateError> { + let mut tags = Vec::new(); + let passwords = crate::passwords::list_passwords(config_dir)?; + for password in passwords { + if let Some((_, tag)) = password.split_once('-') { + if !tags.contains(&tag.to_string()) { + tags.push(tag.to_string()); + } + } + } + Ok(tags) +}