From a77fce943cdaf9784276035eb28c3fe67d69eed3 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 22 May 2023 15:33:01 -0500 Subject: initial commit --- src/main.rs | 364 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 src/main.rs (limited to 'src/main.rs') diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f771a14 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,364 @@ +use clap::{Arg, App}; +use cli_table::{print_stdout, Cell, Style, Table}; +use rand::{thread_rng, Rng}; +use rusqlite::{Connection, Result}; +use std::{fs, str}; +use uuid::Uuid; + +pub const SQLITE_DB: &str = "vault.sqlite"; +pub const KEY_FILE: &str = "vault.key"; +pub const UPPERCASE: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +pub const LOWERCASE: &str = "abcdefghijklmnopqrstuvwxyz"; +pub const NUMBERS: &str = "0123456789"; +pub const SYMBOLS: &str = ")(*&^%$#@!~"; +static DEFAULT_WORDLIST: &'static str = include_str!("wordlist.txt"); + +#[derive(Debug)] +struct Account { + uuid: String, + title: String, + username: String, + password: String, + url: String, +} + +// Read user input as a string +fn read_string() -> String { + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .expect("can not read user input"); + let cleaned_input = input.trim().to_string(); + cleaned_input +} + +// Read user input as a 32-bit unsigned integer +fn read_integer() -> u32 { + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .expect("can not read user input"); + let cleaned_input: u32 = input.trim().parse().expect("Error"); + cleaned_input +} + +// Generate a random password string +fn generate_password(n: u32) -> String { + // Get a random list of characters + let mut charset = String::from(UPPERCASE); + charset.push_str(LOWERCASE); + charset.push_str(SYMBOLS); + charset.push_str(NUMBERS); + let char_vec: Vec = charset.chars().collect(); + + // Map random characters to a password + let mut rng = rand::thread_rng(); + let password: String = (0..n) + .map(|_| { + let idx = rng.gen_range(0..char_vec.len()); + char_vec[idx] as char + }) + .collect(); + password +} + +// Generate a random passphrase string +fn generate_passphrase(n: u32, passphrase_symbol: String) -> String { + // Load the words from file + let words: Vec<&str> = DEFAULT_WORDLIST.lines() + .collect(); + + // Get random words + let len = words.len(); + let mut rng = thread_rng(); + let password_words: Vec<&str> = (0..n) + .map(|_| words[(rng.gen::() % len) - 1]) + .collect(); + + // Join passphrase together with a symbol + let passphrase = password_words.join(&*passphrase_symbol); + passphrase +} + +// Create the database table, if it doesn't exist +fn create_db() -> Result<()> { + let conn = Connection::open(SQLITE_DB)?; + conn.execute( + "create table if not exists accounts ( + uuid text, + application text, + username text, + password text, + url text + )", + [], + )?; + Ok(()) +} + +// Insert data into the database +fn insert_account(uuid: String, application: String, username: String, password: String, url: String) -> Result<()> { + let conn = Connection::open(SQLITE_DB)?; + conn.execute( + "INSERT INTO accounts (uuid, application, username, password, url) values (?1, ?2, ?3, ?4, ?5)", + [uuid, application, username, password, url], + )?; + Ok(()) +} + +// Delete data from the database +fn update_account(uuid: String, field_string: String, new_value: String) -> Result<()> { + let mut field: usize = usize::MAX; + if (field_string == "title") | (field_string == "Title") { + field = 0; + } else if (field_string == "username") | (field_string == "Username") { + field = 1; + } else if (field_string == "password") | (field_string == "Password") { + field = 2; + } else if (field_string == "url") | (field_string == "URL") { + field = 3; + } else { + eprintln!("Error: Provided field to edit does not match a field in the database."); + } + println!("Field: {}: {}", field_string, field); + println!("New Value/UUID: {}: {}", new_value, uuid); + let queries = vec![ + "UPDATE accounts SET application = ?1 WHERE uuid = ?2", + "UPDATE accounts SET username = ?1 WHERE uuid = ?2", + "UPDATE accounts SET password = ?1 WHERE uuid = ?2", + "UPDATE accounts SET url = ?1 WHERE uuid = ?2", + ]; + println!("Query: {}", queries[field]); + let conn = Connection::open(SQLITE_DB)?; + conn.execute( + queries[field], + [new_value, uuid], + )?; + Ok(()) +} + +// Delete data from the database +fn delete_account(uuid: String) -> Result<()> { + let conn = Connection::open(SQLITE_DB)?; + conn.execute( + "DELETE FROM accounts WHERE uuid = ?1", + [uuid], + )?; + Ok(()) +} + +// Read all records from the database and print +fn read_db() -> Result<()> { + // Connect to the database and select all accounts + let conn = Connection::open(SQLITE_DB)?; + let mut stmt = conn.prepare( + "SELECT * from accounts", + )?; + + // Map each account returned from SQLite to an Account struct + let accounts = stmt.query_map([], |row| { + Ok(Account { + uuid: row.get(0)?, + title: row.get(1)?, + username: row.get(2)?, + password: row.get(3)?, + url: row.get(4)?, + }) + })?; + + // Loop through saved accounts and collect them in a vec + let mut tmp_table = vec![]; + for account in accounts { + let tmp_account = account.unwrap(); + tmp_table.push( + vec![ + decrypt(tmp_account.uuid).cell(), + decrypt(tmp_account.title).cell(), + decrypt(tmp_account.username).cell(), + decrypt(tmp_account.password).cell(), + decrypt(tmp_account.url).cell(), + ] + ); + } + + // Create a new, non-mutable vec to display + let table = tmp_table + .table() + .title(vec![ + "UUID".cell().bold(true), + "Title".cell().bold(true), + "Username".cell().bold(true), + "Password".cell().bold(true), + "URL".cell().bold(true), + ]) + .bold(true); + + assert!(print_stdout(table).is_ok()); + Ok(()) +} + +// Generate a new account +fn new() { + // Generate UUID + let uuid = Uuid::new_v4(); + println!("UUID: {}", uuid); + + // Gather input + println!("Enter a title for this account:"); + let title = read_string(); + + println!("Enter your username:"); + let username = read_string(); + + println!("(Optional) Enter a URL for this account:"); + let url = read_string(); + + let password: String = loop { + println!("Do you want an XKCD-style passphrase [1] or a random password [2]? (1/2)"); + let password_choice = read_integer(); + if password_choice == 1 { + let passphrase_words = loop { + println!("Please enter number of words to include (min. 4):"); + let passphrase_words = read_integer(); + if passphrase_words >= 3 { + break passphrase_words; + } + println!("Invalid length. Please enter a number >= 3."); + }; + println!("Please enter your desired separator symbol (_, -, ~, etc.:"); + let passphrase_symbol = read_string(); + let password = generate_passphrase(passphrase_words, passphrase_symbol); + break password; + } else if password_choice == 2 { + let password_length = loop { + println!("Please enter desired password length (min. 8):"); + let password_length = read_integer(); + if password_length >= 8 { + break password_length; + } + println!("Invalid length. Please enter a number >= 8."); + }; + let password = generate_password(password_length); + break password; + } + println!("Invalid response. Please respond with 1 or 2."); + }; + + // Generate an Account struct + let account = Account { + uuid: encrypt(uuid.to_string()), + title: encrypt(title), + username: encrypt(username), + password: encrypt(password), + url: encrypt(url), + }; + + // Create the database, if necessary, and insert data + create_db(); + insert_account(account.uuid, account.title, account.username, account.password, account.url); + println!("Account saved to the vault. Use `rpass --list` to see all saved accounts."); +} + +// List all saved accounts +fn list() -> Result<()> { + read_db(); + Ok(()) +} + +// TODO: Edit a saved account +// WARNING: This process does not currently work as expected; / +// I think the encrypted UUID differs from the encrypted UUID in the database +fn edit(uuid: String, field_name: String, new_value: String) { + update_account(encrypt(uuid), field_name, encrypt(new_value)); +} + +// TODO: Delete a saved account +// WARNING: This process does not currently work as expected; / +// I think the encrypted UUID differs from the encrypted UUID in the database +fn delete(uuid: String) { + delete_account(uuid); +} + +// TODO: Delete all saved accounts and delete the vault file +fn purge() { + println!(); +} + +// Encrypt plaintext using a generated key file +fn encrypt(plaintext: String) -> String { + let key_exists: bool = std::path::Path::new(KEY_FILE).exists(); + let mut key = String::from(""); + if key_exists { + key = fs::read_to_string(KEY_FILE).expect("Unable to read saved key file."); + } else { + key = fernet::Fernet::generate_key(); + fs::write(KEY_FILE, &key).expect("Unable to save key to file."); + println!("Key file has been written to: {}. DO NOT DELETE OR MODIFY THIS FILE.", KEY_FILE); + } + let fernet = fernet::Fernet::new(&key).unwrap(); + let ciphertext = fernet.encrypt(plaintext.as_ref()); + ciphertext +} + +// Decrypt ciphertext using a saved key file +fn decrypt(ciphertext: String) -> String { + let key = fs::read_to_string(KEY_FILE).expect("Unable to read saved key file."); + let fernet = fernet::Fernet::new(&key).unwrap(); + let decrypted_plaintext = fernet.decrypt(&ciphertext).expect("Error decrypting data - the key file may have been modified or deleted."); + let plaintext = String::from_utf8(decrypted_plaintext).unwrap(); + plaintext +} + +// Interpret user commands +fn main() { + let matches = App::new("rpass") + .version("1.0") + .author("cmc ") + .about("A safe and convenient command-line password vault.") + .arg(Arg::with_name("new") + .short("n") + .long("new") + .help("Create a new account") + .takes_value(false)) + .arg(Arg::with_name("list") + .short("l") + .long("list") + .help("List all saved accounts") + .takes_value(false)) + .arg(Arg::with_name("edit") + .short("e") + .long("edit") + .help("Edit a saved account") + .value_names(&["uuid", "field_name", "new_value"]) + .takes_value(true)) + .arg(Arg::with_name("delete") + .short("d") + .long("delete") + .help("Delete a saved account") + .value_name("uuid") + .takes_value(true)) + .arg(Arg::with_name("purge") + .short("p") + .long("purge") + .help("Purge all saved accounts") + .takes_value(false)) + .get_matches(); + + if matches.is_present("new") { + new(); + } else if matches.is_present("list") { + list(); + } else if matches.is_present("edit") { + let values: Vec<_> = matches.values_of("edit").unwrap().collect(); + edit( + String::from(values[0]), + String::from(values[1]), + String::from(values[2]), + ); + } else if matches.is_present("delete") { + let values: Vec<_> = matches.values_of("delete").unwrap().collect(); + delete(String::from(values[0])); + } else if matches.is_present("purge") { + purge(); + } +} -- cgit v1.2.3-70-g09d2