diff --git a/Cargo.lock b/Cargo.lock index 775a1c3..ac5de88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.21.7" @@ -329,6 +335,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -531,6 +538,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.12" @@ -725,6 +741,7 @@ name = "lprs" version = "1.2.1" dependencies = [ "aes", + "base32", "base64 0.22.1", "bincode", "cbc", @@ -742,6 +759,7 @@ dependencies = [ "serde_json", "sha2", "thiserror", + "totp-lite", ] [[package]] @@ -1165,6 +1183,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -1243,6 +1272,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "2.0.60" @@ -1374,6 +1409,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "totp-lite" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8e43134db17199f7f721803383ac5854edd0d3d523cc34dba321d6acfbe76c3" +dependencies = [ + "digest", + "hmac", + "sha1", + "sha2", +] + [[package]] name = "tower-service" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 5e0c137..dd39c15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ sha2 = "0.10.8" serde_json = "1.0.116" base64 = "0.22.1" clap_complete = "4.5.2" +totp-lite = "2.0.1" +base32 = "0.4.0" [features] default = ["update-notify"] diff --git a/src/cli/add_command.rs b/src/cli/add_command.rs index 709cd1a..8c3fa8d 100644 --- a/src/cli/add_command.rs +++ b/src/cli/add_command.rs @@ -31,10 +31,14 @@ use crate::{ pub struct Add { #[command(flatten)] vault_info: Vault, - /// The password, if there is no value for it you will prompt it + /// The password, if there is no value you will prompt it #[arg(short, long)] #[allow(clippy::option_option)] password: Option>, + /// The TOTP secret, if there is no value you will prompt it + #[arg(short, long)] + #[allow(clippy::option_option)] + totp_secret: Option>, /// Add a custom field to the vault #[arg(name = "KEY=VALUE", short = 'c', long = "custom")] #[arg(value_parser = clap_parsers::kv_parser)] @@ -51,7 +55,8 @@ impl LprsCommand for Add { fn run(mut self, mut vault_manager: Vaults) -> LprsResult<()> { if !self.vault_info.is_empty() { self.vault_info.name = self.vault_info.name.trim().to_string(); - self.vault_info.password = utils::user_password(self.password, "Vault password:")?; + self.vault_info.password = utils::user_secret(self.password, "Vault password:")?; + self.vault_info.totp_secret = utils::user_secret(self.totp_secret, "TOTP Secret:")?; self.vault_info.custom_fields = self.custom_fields.into_iter().collect(); vault_manager.add_vault(self.vault_info); vault_manager.try_export()?; @@ -71,6 +76,13 @@ impl LprsCommand for Add { ))); } } + if self + .custom_fields + .iter() + .any(|(k, _)| k.starts_with(crate::RESERVED_FIELD_PREFIX)) + { + return Err(LprsError::ReservedPrefix(crate::RESERVED_FIELD_PREFIX)); + } Ok(()) } diff --git a/src/cli/edit_command.rs b/src/cli/edit_command.rs index 6586e60..81e3c88 100644 --- a/src/cli/edit_command.rs +++ b/src/cli/edit_command.rs @@ -28,31 +28,35 @@ pub struct Edit { #[arg(short, long)] /// The new vault name - name: Option, + name: Option, #[arg(short, long)] /// The new vault username - username: Option, + username: Option, #[arg(short, long)] /// The new password, if there is no value for it you will prompt it #[allow(clippy::option_option)] - password: Option>, + password: Option>, #[arg(short, long)] /// The new vault service - service: Option, + service: Option, #[arg(short = 'o', long)] /// The new vault note - note: Option, + note: Option, + /// The TOTP secret, if there is no value you will prompt it + #[arg(short, long)] + #[allow(clippy::option_option)] + totp_secret: Option>, /// The custom field, make its value empty to delete it /// /// If the custom field not exist will created it, if it's will update it #[arg(name = "KEY=VALUE", short = 'c', long = "custom")] #[arg(value_parser = clap_parsers::kv_parser)] - pub custom_fields: Vec<(String, String)>, + custom_fields: Vec<(String, String)>, /// Force edit, will not return error if there is a problem with the args. /// /// For example, duplication in the custom fields and try to editing nothing #[arg(short, long)] - force: bool, + force: bool, } impl LprsCommand for Edit { @@ -73,7 +77,10 @@ impl LprsCommand for Edit { vault.name = new_name; } if self.password.is_some() { - vault.password = utils::user_password(self.password, "New vault password:")?; + vault.password = utils::user_secret(self.password, "New vault password:")?; + } + if self.totp_secret.is_some() { + vault.totp_secret = utils::user_secret(self.totp_secret, "TOTP Secret:")?; } if let Some(new_username) = self.username { vault.username = Some(new_username); @@ -109,6 +116,13 @@ impl LprsCommand for Edit { ))); } } + if self + .custom_fields + .iter() + .any(|(k, _)| k.starts_with(crate::RESERVED_FIELD_PREFIX)) + { + return Err(LprsError::ReservedPrefix(crate::RESERVED_FIELD_PREFIX)); + } Ok(()) } diff --git a/src/cli/export_command.rs b/src/cli/export_command.rs index d609854..dcebf62 100644 --- a/src/cli/export_command.rs +++ b/src/cli/export_command.rs @@ -55,7 +55,7 @@ impl LprsCommand for Export { ); let encryption_key: Option<[u8; 32]> = - utils::user_password(self.encryption_password, "Encryption Password:")? + utils::user_secret(self.encryption_password, "Encryption Password:")? .map(|p| sha2::Sha256::digest(p).into()); let exported_data = match self.format { diff --git a/src/cli/get_command.rs b/src/cli/get_command.rs index 73f72eb..6c6a39e 100644 --- a/src/cli/get_command.rs +++ b/src/cli/get_command.rs @@ -20,7 +20,7 @@ use clap::Args; use crate::{ utils, - vault::{Vault, Vaults}, + vault::{cipher, Vault, Vaults}, LprsCommand, LprsError, LprsResult, @@ -34,6 +34,8 @@ enum VaultGetField { Password, Service, Note, + TotpSecret, + TotpCode, Custom(String), } @@ -48,6 +50,8 @@ impl FromStr for VaultGetField { "password" => Self::Password, "service" => Self::Service, "note" => Self::Note, + "totp_secret" => Self::TotpSecret, + "totp_code" => Self::TotpCode, _ => Self::Custom(input.to_owned()), }) } @@ -63,6 +67,8 @@ impl VaultGetField { Self::Password => vault.password.as_deref(), Self::Service => vault.service.as_deref(), Self::Note => vault.note.as_deref(), + Self::TotpSecret => vault.totp_secret.as_deref(), + Self::TotpCode => None, Self::Custom(custom_field) => vault.custom_fields.get(custom_field).map(|x| x.as_str()), } } @@ -76,6 +82,8 @@ impl VaultGetField { Self::Password => "password", Self::Service => "service", Self::Note => "note", + Self::TotpSecret => "totp_secret", + Self::TotpCode => "totp_code", Self::Custom(field) => field.as_str(), } } @@ -90,8 +98,10 @@ pub struct Get { location: String, /// A Specific field to get. /// - /// Can be [name,username,password,service,note,"string"] where the string - /// means a custom field + /// Can be [name, username, password, service, note, totp_secret, totp_code, + /// "string"] + /// + /// where the string means a custom field #[arg(value_parser = VaultGetField::from_str)] field: Option, } @@ -106,13 +116,25 @@ impl LprsCommand for Get { print!("{index}"); return Ok(()); } + if field == VaultGetField::TotpCode { + if let Some(ref totp_secret) = vault.totp_secret { + let totp_code = cipher::totp_now(totp_secret, &vault.totp_hash)?.0; + print!("{totp_code}"); + return Ok(()); + } else { + return Err(LprsError::Other( + "There is no TOTP secret to get TOTP code".to_owned(), + )); + } + } if let Some(value) = field.get_from_vault(vault) { print!("{value}") } else { return Err(LprsError::Other(format!( - "There is no value for `{}`", - field.as_str() + "There is no value for `{}` at \"{}\" vault", + field.as_str(), + vault.name ))); } } else { diff --git a/src/cli/import_command.rs b/src/cli/import_command.rs index a641a78..4c11e8f 100644 --- a/src/cli/import_command.rs +++ b/src/cli/import_command.rs @@ -60,7 +60,7 @@ impl LprsCommand for Import { ); let decryption_key: Option<[u8; 32]> = - utils::user_password(self.decryption_password, "Decryption password:")? + utils::user_secret(self.decryption_password, "Decryption password:")? .map(|p| sha2::Sha256::digest(p).into()); let imported_passwords_len = match self.format { @@ -71,6 +71,14 @@ impl LprsCommand for Import { .unwrap_or(&vault_manager.master_password), &fs::read(self.path)?, )?; + + if vaults.iter().any(|v| { + v.custom_fields + .iter() + .any(|(k, _)| k.starts_with(crate::RESERVED_FIELD_PREFIX)) + }) { + return Err(LprsError::ReservedPrefix(crate::RESERVED_FIELD_PREFIX)); + } let vaults_len = vaults.len(); vault_manager.vaults.extend(vaults); diff --git a/src/cli/list_command.rs b/src/cli/list_command.rs index 2ae356b..a9db358 100644 --- a/src/cli/list_command.rs +++ b/src/cli/list_command.rs @@ -17,7 +17,13 @@ use clap::Args; use inquire::{InquireError, Select}; -use crate::{vault::Vaults, LprsCommand, LprsError, LprsResult}; +use crate::{ + vault::{cipher, Vaults}, + LprsCommand, + LprsError, + LprsResult, + RESERVED_FIELD_PREFIX, +}; #[derive(Debug, Args)] #[command(author, version, about, long_about = None)] @@ -35,7 +41,7 @@ pub struct List { } impl LprsCommand for List { - fn run(self, vault_manager: Vaults) -> LprsResult<()> { + fn run(self, mut vault_manager: Vaults) -> LprsResult<()> { if vault_manager.vaults.is_empty() { return Err(LprsError::Other( "Looks like there is no vaults to list".to_owned(), @@ -95,13 +101,20 @@ impl LprsCommand for List { log::debug!("The user selected the vault at index: {vault_idx}"); - println!( - "{}", - vault_manager - .vaults - .get(vault_idx - 1) - .expect("The index is correct") - ); + let vault = vault_manager + .vaults + .get_mut(vault_idx - 1) + .expect("The index is correct"); + + if let Some(ref totp_secret) = vault.totp_secret { + let (code, remaining) = cipher::totp_now(totp_secret, &vault.totp_hash)?; + vault.custom_fields.insert( + format!("{RESERVED_FIELD_PREFIX}TOTP Code"), + format!("{code} ({remaining}s remaining)"), + ); + } + + println!("{vault}",); } Ok(()) diff --git a/src/errors.rs b/src/errors.rs index f02bde3..ebcd46e 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -39,6 +39,13 @@ pub enum Error { InvalidVaultIndex(String), #[error("{0}")] ArgParse(String), + #[error( + "Reserved Prefix Error: Sorry, but the following prefix is reserved and cannot be used in \ + custom fields {0}" + )] + ReservedPrefix(&'static str), + #[error("Base32 Error: {0}")] + Base32(String), #[error("{0}")] Other(String), diff --git a/src/main.rs b/src/main.rs index 9e15e53..782fceb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,8 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); #[cfg(feature = "update-notify")] /// The last version check file. Used to store the last version check time. pub const LAST_VERSION_CHECK_FILE: &str = ".last_version_check"; +/// The prefix of the reserved custom fields +const RESERVED_FIELD_PREFIX: &str = ".lprsfield."; fn main() -> ExitCode { let lprs_cli = cli::Cli::parse(); diff --git a/src/utils.rs b/src/utils.rs index 71b999d..5577a04 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -45,24 +45,24 @@ pub fn local_project_file(filename: &str) -> LprsResult { Ok(local_dir.join(filename)) } -/// Returns the user password if any +/// Returns the user secret if any /// -/// - If the `password` is `None` will return `None` -/// - If the `password` is `Some(None)` will ask the user for a password in the +/// - If the `secret` is `None` will return `None` +/// - If the `secret` is `Some(None)` will ask the user for a secret in the /// stdin and return it -/// - If the `password` is `Some(Some(password))` will return `Some(password)` +/// - If the `secret` is `Some(Some(secret))` will return `Some(secret)` /// /// ## Errors -/// - When failed to get the password from stdin -pub fn user_password( - password: Option>, +/// - When failed to get the secret from stdin +pub fn user_secret( + secret: Option>, prompt_message: &str, ) -> LprsResult> { - Ok(match password { + Ok(match secret { None => None, Some(Some(p)) => Some(p), Some(None) => { - log::debug!("User didn't provide a password, prompting it"); + log::debug!("User didn't provide a secret, prompting it"); Some( Password::new(prompt_message) .without_confirmation() diff --git a/src/vault/bitwarden.rs b/src/vault/bitwarden.rs index 8e66576..ed22bdf 100644 --- a/src/vault/bitwarden.rs +++ b/src/vault/bitwarden.rs @@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize}; -use super::{Vault, Vaults}; +use super::{cipher::TotpHash, Vault, Vaults}; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct BitWardenLoginData { @@ -84,6 +84,8 @@ impl From for Vault { .into_iter() .map(|nv| (nv.name, nv.value)) .collect(), + None::, + TotpHash::default(), ) } } diff --git a/src/vault/cipher.rs b/src/vault/cipher.rs index a627a21..7422015 100644 --- a/src/vault/cipher.rs +++ b/src/vault/cipher.rs @@ -17,13 +17,63 @@ use std::time::{SystemTime, UNIX_EPOCH}; use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use base32::Alphabet as Base32Alphabet; +use clap::ValueEnum; use rand::{rngs::StdRng, Rng, SeedableRng}; +use serde::{Deserialize, Serialize}; use crate::{LprsError, LprsResult}; type Aes256CbcEnc = cbc::Encryptor; type Aes256CbcDec = cbc::Decryptor; +#[derive(Default, Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)] +/// The TOTP hash functions +pub enum TotpHash { + /// Sha1 hash function + #[default] + Sha1, + /// Sha256 hash function + Sha256, + /// Sha512 hash function + Sha512, +} + + +/// Create the TOTP code of the current time +/// +/// ## Errors +/// - If the given `secret_base32` are vaild base32 +pub fn totp_now(secret_base32: &str, hash_function: &TotpHash) -> LprsResult<(String, u8)> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("SystemTime before UNIX EPOCH!") + .as_secs(); + let remaining = 30 - (now % 30) as u8; + let secret = base32::decode(Base32Alphabet::RFC4648 { padding: true }, secret_base32) + .ok_or_else(|| LprsError::Base32("Can't decode the TOTP secret".to_owned()))?; + Ok(match hash_function { + TotpHash::Sha1 => { + ( + totp_lite::totp_custom::(30, 6, &secret, now), + remaining, + ) + } + TotpHash::Sha256 => { + ( + totp_lite::totp_custom::(30, 6, &secret, now), + remaining, + ) + } + TotpHash::Sha512 => { + ( + totp_lite::totp_custom::(30, 6, &secret, now), + remaining, + ) + } + }) +} + /// Encrypt the given data by the given key using AES-256 CBC /// /// Note: The IV will be add it to the end of the ciphertext (Last 16 bytes) diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 7c0c022..be76a67 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -29,6 +29,8 @@ mod bitwarden; pub use bitwarden::*; +use self::cipher::TotpHash; + #[derive(Clone, Debug, ValueEnum, Eq, PartialEq)] /// The vaults format pub enum Format { @@ -61,6 +63,12 @@ pub struct Vault { /// The vault custom fields #[arg(skip)] pub custom_fields: BTreeMap, + /// The TOTP secret + #[arg(skip)] + pub totp_secret: Option, + /// The TOTP hash function + #[arg(long, value_name = "HASH_FUNCTION", value_enum, default_value_t)] + pub totp_hash: TotpHash, } /// The vaults manager @@ -76,6 +84,7 @@ pub struct Vaults { impl Vault { /// Create new [`Vault`] instance + #[allow(clippy::too_many_arguments)] pub fn new( name: impl Into, username: Option>, @@ -83,6 +92,8 @@ impl Vault { service: Option>, note: Option>, custom_fields: BTreeMap, + totp_secret: Option>, + totp_hash: TotpHash, ) -> Self { Self { name: name.into(), @@ -91,6 +102,8 @@ impl Vault { service: service.map(Into::into), note: note.map(Into::into), custom_fields, + totp_secret: totp_secret.map(Into::into), + totp_hash, } } @@ -163,6 +176,8 @@ impl Vaults { .iter() .map(|(key, value)| (encrypt(key), encrypt(value))) .collect(), + v.totp_secret.as_ref().map(|t| encrypt(t)), + v.totp_hash.clone(), )) }) .collect::>>()?, @@ -199,6 +214,8 @@ impl Vaults { .into_iter() .map(|(key, value)| LprsResult::Ok((decrypt(&key)?, decrypt(&value)?))) .collect::>()?, + v.totp_secret.as_ref().and_then(|t| decrypt(t).ok()), + v.totp_hash, )) }) .collect() @@ -266,8 +283,16 @@ impl fmt::Display for Vault { if let Some(ref note) = self.note { write!(f, "\nNote:\n{note}")?; } + if let Some(ref totp_secret) = self.totp_secret { + write!(f, "\nTOTP Secret: {totp_secret}")?; + } for (key, value) in &self.custom_fields { - write!(f, "\n{key}: {value}")?; + write!( + f, + "\n{}: {value}", + key.strip_prefix(crate::RESERVED_FIELD_PREFIX) + .unwrap_or(key) + )?; } Ok(())