From f336cbc8e038d5efbcd39413c70a0e6ad1962df4 Mon Sep 17 00:00:00 2001 From: Maaz Khokhar Date: Fri, 2 Oct 2026 10:20:11 -0500 Subject: [PATCH 1/3] core crate is more distributed --- Cargo.lock | 33 +-- crates/cli/src/main.rs | 5 +- crates/cli/src/tui/mod.rs | 156 ++++++----- crates/core/src/crypto.rs | 35 +++ crates/core/src/error.rs | 114 ++++++++ crates/core/src/keyring.rs | 43 +++ crates/core/src/keys.rs | 79 ++++++ crates/core/src/lib.rs | 499 ++--------------------------------- crates/core/src/passwords.rs | 163 ++++++++++++ crates/core/src/stats.rs | 31 +++ crates/core/src/tags.rs | 15 ++ 11 files changed, 576 insertions(+), 597 deletions(-) create mode 100644 crates/core/src/crypto.rs create mode 100644 crates/core/src/error.rs create mode 100644 crates/core/src/keyring.rs create mode 100644 crates/core/src/keys.rs create mode 100644 crates/core/src/passwords.rs create mode 100644 crates/core/src/stats.rs create mode 100644 crates/core/src/tags.rs 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/main.rs b/crates/cli/src/main.rs index 66be319..394e471 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1129,9 +1129,8 @@ fn main() { } }, Command::List { tag } => { - let websites = - silicate_core::list_passwords(&config_dir()) - .expect("Failed to list passwords."); + let websites = silicate_core::list_passwords(&config_dir()) + .expect("Failed to list passwords."); if websites.is_empty() { println!("{}", "No passwords stored yet.".yellow()); } else { diff --git a/crates/cli/src/tui/mod.rs b/crates/cli/src/tui/mod.rs index 584e69d..38b1e7b 100644 --- a/crates/cli/src/tui/mod.rs +++ b/crates/cli/src/tui/mod.rs @@ -1,22 +1,13 @@ -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; @@ -71,7 +62,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 +81,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 +94,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 +112,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 +139,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 +150,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 +194,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 +223,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 +236,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,7 +262,9 @@ 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); @@ -276,7 +272,7 @@ impl App { let decrypted = silicate_core::decrypt_passwd( &self.key, cipher_bytes.to_vec(), - nonce_bytes.try_into()? + nonce_bytes.try_into()?, )?; Ok(decrypted) @@ -293,16 +289,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..bfcc7ed --- /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 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) +} 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..9ce9018 --- /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 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() +} + +pub 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..4fee7a0 --- /dev/null +++ b/crates/core/src/keys.rs @@ -0,0 +1,79 @@ +use crate::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 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 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 = 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 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..b54e532 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,416 +1,23 @@ -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}; +use error::SilicateError; -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), -} - -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}") - } - } - } -} - -/// 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()); - } - } - } - } - Ok(websites) -} +pub mod crypto; +pub mod error; +pub mod keyring; +pub mod keys; +pub mod passwords; +pub mod stats; +pub mod tags; /// 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); - } - - 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 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 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(()) -} - -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(()) -} - pub 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,37 +36,6 @@ 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( config_dir: &str, @@ -499,73 +75,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 +109,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..f674004 --- /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 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 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 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 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..173fc72 --- /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 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..3a10f3c --- /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 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) +} From d85a293f1e6a445b005ac9e7d883a5490a98631f Mon Sep 17 00:00:00 2001 From: Maaz Khokhar Date: Fri, 2 Oct 2026 10:29:50 -0500 Subject: [PATCH 2/3] made cli actually compile - will create an actual pub api --- crates/core/src/lib.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index b54e532..24ccae1 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,4 +1,13 @@ -use error::SilicateError; +pub use error::SilicateError; + +pub use crypto::{decrypt_passwd, encrypt_passwd}; +pub use keyring::{is_keyring_available, retrieve_key_from_keyring, store_key_in_keyring}; +pub use keys::{ + derive_key_from_password, export_key, generate_fallback_key, generate_key, import_key, +}; +pub use passwords::{generate_password, list_passwords, search_password}; +pub use stats::{Stats, get_stats}; +pub use tags::list_tags; pub mod crypto; pub mod error; From 6c7a900bd06b8d27ad92854183825becf381a426 Mon Sep 17 00:00:00 2001 From: Maaz Khokhar Date: Fri, 2 Oct 2026 10:42:50 -0500 Subject: [PATCH 3/3] struct pub api --- crates/cli/src/json/mod.rs | 7 +- crates/cli/src/main.rs | 133 ++++++++++++++++--------------- crates/cli/src/tui/mod.rs | 9 ++- crates/core/src/crypto.rs | 4 +- crates/core/src/keyring.rs | 8 +- crates/core/src/keys.rs | 12 +-- crates/core/src/lib.rs | 146 +++++++++++++++++++++++++++++++---- crates/core/src/passwords.rs | 8 +- crates/core/src/stats.rs | 2 +- crates/core/src/tags.rs | 2 +- 10 files changed, 231 insertions(+), 100 deletions(-) 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 394e471..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,7 +1133,8 @@ fn main() { } }, Command::List { tag } => { - let websites = silicate_core::list_passwords(&config_dir()) + let websites = silicate + .list_passwords() .expect("Failed to list passwords."); if websites.is_empty() { println!("{}", "No passwords stored yet.".yellow()); @@ -1173,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!( @@ -1213,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(); @@ -1222,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 38b1e7b..d3cabf0 100644 --- a/crates/cli/src/tui/mod.rs +++ b/crates/cli/src/tui/mod.rs @@ -10,7 +10,8 @@ use ratatui::{ 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 { @@ -32,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 { @@ -46,6 +48,7 @@ impl App { entries, state, key, + silicate: Silicate::new(config_dir), search_query: String::new(), is_searching: false, search_target: SearchTarget::Name, @@ -269,7 +272,7 @@ impl App { 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()?, diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index bfcc7ed..fd378e5 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -5,7 +5,7 @@ use aes_gcm::{ }; /// Encrypts the given plaintext using AES-256-GCM. Returns the ciphertext and the nonce used for encryption. -pub fn encrypt_passwd( +pub(crate) fn encrypt_passwd( key_bytes: &[u8; 32], plaintext: String, ) -> Result<(Vec, [u8; 12]), SilicateError> { @@ -21,7 +21,7 @@ pub fn encrypt_passwd( } /// Decrypts the given ciphertext using AES-256-GCM. Requires the same key and nonce used for encryption. -pub fn decrypt_passwd( +pub(crate) fn decrypt_passwd( key_bytes: &[u8; 32], ciphertext: Vec, nonce_bytes: [u8; 12], diff --git a/crates/core/src/keyring.rs b/crates/core/src/keyring.rs index 9ce9018..59c3857 100644 --- a/crates/core/src/keyring.rs +++ b/crates/core/src/keyring.rs @@ -5,14 +5,14 @@ const SERVICE_NAME: &str = "silicate"; const USERNAME: &str = "default"; /// This puts a randomly generated key into the system's keyring. -pub fn store_key_in_keyring(key: &[u8; 32]) -> Result<(), SilicateError> { +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 fn retrieve_key_from_keyring() -> Result<[u8; 32], SilicateError> { +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()?; @@ -21,12 +21,12 @@ pub fn retrieve_key_from_keyring() -> Result<[u8; 32], SilicateError> { /// 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 { +pub(crate) fn is_keyring_available() -> bool { let entry = Entry::new(SERVICE_NAME, USERNAME); entry.is_ok() } -pub fn update_entry( +pub(crate) fn update_entry( config_dir: &str, website: &str, tag: Option<&str>, diff --git a/crates/core/src/keys.rs b/crates/core/src/keys.rs index 4fee7a0..55616ca 100644 --- a/crates/core/src/keys.rs +++ b/crates/core/src/keys.rs @@ -1,10 +1,10 @@ -use crate::SilicateError; +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 fn generate_key() -> [u8; 32] { +pub(crate) fn generate_key() -> [u8; 32] { let key = Aes256Gcm::generate_key(OsRng); key.into() } @@ -12,7 +12,7 @@ pub fn generate_key() -> [u8; 32] { /// 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> { +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)?; @@ -30,7 +30,7 @@ pub fn generate_fallback_key(password: &str) -> Result<([u8; 32], [u8; 16]), Sil /// 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( +pub(crate) fn derive_key_from_password( password: &str, salt: &[u8; 16], ) -> Result<[u8; 32], SilicateError> { @@ -49,7 +49,7 @@ pub fn derive_key_from_password( /// 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> { +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( || { @@ -67,7 +67,7 @@ pub fn export_key(file_path: &Option) -> Result<(), SilicateError> { /// 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> { +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() diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 24ccae1..9e0fec1 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,13 +1,4 @@ -pub use error::SilicateError; - -pub use crypto::{decrypt_passwd, encrypt_passwd}; -pub use keyring::{is_keyring_available, retrieve_key_from_keyring, store_key_in_keyring}; -pub use keys::{ - derive_key_from_password, export_key, generate_fallback_key, generate_key, import_key, -}; -pub use passwords::{generate_password, list_passwords, search_password}; -pub use stats::{Stats, get_stats}; -pub use tags::list_tags; +use crate::error::SilicateError; pub mod crypto; pub mod error; @@ -17,12 +8,141 @@ pub mod passwords; pub mod stats; pub mod tags; +pub struct Silicate { + config_dir: String, +} + +impl Silicate { + pub fn new(config_dir: String) -> Self { + Self { config_dir } + } + + pub fn config_dir(&self) -> &str { + &self.config_dir + } + + pub fn check_fzf_installed(&self) -> bool { + check_fzf_installed() + } + + pub fn encrypt_passwd( + &self, + key_bytes: &[u8; 32], + plaintext: String, + ) -> Result<(Vec, [u8; 12]), SilicateError> { + crypto::encrypt_passwd(key_bytes, plaintext) + } + + pub fn decrypt_passwd( + &self, + key_bytes: &[u8; 32], + ciphertext: Vec, + nonce_bytes: [u8; 12], + ) -> Result { + crypto::decrypt_passwd(key_bytes, ciphertext, nonce_bytes) + } + + pub fn store_key_in_keyring(&self, key: &[u8; 32]) -> Result<(), SilicateError> { + keyring::store_key_in_keyring(key) + } + + pub fn retrieve_key_from_keyring(&self) -> Result<[u8; 32], SilicateError> { + keyring::retrieve_key_from_keyring() + } + + pub fn is_keyring_available(&self) -> bool { + keyring::is_keyring_available() + } + + 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) + } + + pub fn generate_key(&self) -> [u8; 32] { + keys::generate_key() + } + + pub fn generate_fallback_key( + &self, + password: &str, + ) -> Result<([u8; 32], [u8; 16]), SilicateError> { + keys::generate_fallback_key(password) + } + + pub fn derive_key_from_password( + &self, + password: &str, + salt: &[u8; 16], + ) -> Result<[u8; 32], SilicateError> { + keys::derive_key_from_password(password, salt) + } + + pub fn export_key(&self, file_path: &Option) -> Result<(), SilicateError> { + keys::export_key(file_path) + } + + pub fn import_key(&self, file_path: &str) -> Result<(), SilicateError> { + keys::import_key(file_path) + } + + pub fn list_passwords(&self) -> Result, SilicateError> { + passwords::list_passwords(&self.config_dir) + } + + pub fn search_password(&self, tag: &Option) -> Result, SilicateError> { + passwords::search_password(&self.config_dir, tag) + } + + pub fn generate_password(&self, length: usize, use_symbols: bool) -> String { + passwords::generate_password(length, use_symbols) + } + + 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) + } + + pub fn list_tags(&self) -> Result, SilicateError> { + tags::list_tags(&self.config_dir) + } + + pub fn get_stats(&self) -> Result { + stats::get_stats(&self.config_dir) + } + + pub fn find_password_file( + &self, + target_website: &str, + ) -> Result, SilicateError> { + find_password_file(&self.config_dir, target_website) + } + + 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) + } +} + /// This function checks if fzf is installed on the system by trying to find its path. -pub fn check_fzf_installed() -> bool { +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> { @@ -46,7 +166,7 @@ pub fn find_password_file( } /// 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, diff --git a/crates/core/src/passwords.rs b/crates/core/src/passwords.rs index f674004..bb90347 100644 --- a/crates/core/src/passwords.rs +++ b/crates/core/src/passwords.rs @@ -6,7 +6,7 @@ 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 fn list_passwords(config_dir: &str) -> Result, SilicateError> { +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() { @@ -21,7 +21,7 @@ pub fn list_passwords(config_dir: &str) -> Result, SilicateError> { } /// This function takes the config directory and an optional tag, lists the passwords, filters them by tag if provided, -pub fn search_password( +pub(crate) fn search_password( config_dir: &str, tag: &Option, ) -> Result, SilicateError> { @@ -105,7 +105,7 @@ pub fn search_password( } /// 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 { +pub(crate) fn generate_password(length: usize, use_symbols: bool) -> String { if length == 0 { return String::new(); } @@ -146,7 +146,7 @@ fn sample_from_pool(pool: &[u8], length: usize) -> String { result } -pub fn update_password( +pub(crate) fn update_password( config_dir: &str, key: &[u8; 32], website: &str, diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs index 173fc72..6b7391b 100644 --- a/crates/core/src/stats.rs +++ b/crates/core/src/stats.rs @@ -8,7 +8,7 @@ pub struct Stats { } /// 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 { +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(); diff --git a/crates/core/src/tags.rs b/crates/core/src/tags.rs index 3a10f3c..e716315 100644 --- a/crates/core/src/tags.rs +++ b/crates/core/src/tags.rs @@ -1,7 +1,7 @@ use crate::error::SilicateError; /// This function will get all unique tags from the password files in the config directory. -pub fn list_tags(config_dir: &str) -> Result, SilicateError> { +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 {