From 6f6966d5b25b2b5047081304f7597fe80ec95387 Mon Sep 17 00:00:00 2001 From: Awiteb Date: Thu, 2 May 2024 21:34:08 +0300 Subject: [PATCH] feat: Encrypt the hole vault file BREAKING-CHANGE: The previous format is not supported after this commit, so you must export your vaults in bit-warden format (before this commit) and then re-invoke them (after this commit) --- src/cli/clean_command.rs | 2 +- src/cli/export_command.rs | 9 +-- src/cli/import_command.rs | 14 +++-- src/cli/mod.rs | 17 ++++-- src/errors.rs | 12 ++-- src/main.rs | 13 ++--- src/utils.rs | 22 +++----- src/vault/cipher.rs | 73 +++++++++++------------- src/vault/mod.rs | 115 +++++++++++++++++++++++--------------- src/vault/validator.rs | 35 ------------ 10 files changed, 147 insertions(+), 165 deletions(-) delete mode 100644 src/vault/validator.rs diff --git a/src/cli/clean_command.rs b/src/cli/clean_command.rs index 88001f9..2e8cd7b 100644 --- a/src/cli/clean_command.rs +++ b/src/cli/clean_command.rs @@ -30,6 +30,6 @@ impl LprsCommand for Clean { "Cleaning the vaults file: {:?}", vault_manager.vaults_file.display() ); - fs::write(vault_manager.vaults_file, "[]").map_err(LprsError::Io) + fs::write(vault_manager.vaults_file, []).map_err(LprsError::Io) } } diff --git a/src/cli/export_command.rs b/src/cli/export_command.rs index 3d576a6..3e2023a 100644 --- a/src/cli/export_command.rs +++ b/src/cli/export_command.rs @@ -19,7 +19,7 @@ use std::{fs, io::Error as IoError, io::ErrorKind as IoErrorKind, path::PathBuf} use clap::Args; use crate::{ - vault::{BitWardenPasswords, Format, Vault, Vaults}, + vault::{BitWardenPasswords, Format, Vaults}, LprsCommand, LprsError, LprsResult, }; @@ -31,6 +31,7 @@ pub struct Export { /// Format to export vaults in #[arg(short, long, value_name = "FORMAT", default_value_t= Format::Lprs)] format: Format, + // TODO: `force` flag to write on existing file } impl LprsCommand for Export { @@ -42,9 +43,9 @@ impl LprsCommand for Export { self.format ); let exported_data = match self.format { - Format::Lprs => serde_json::to_string::>(&vault_manager.encrypt_vaults()?), - Format::BitWarden => serde_json::to_string(&BitWardenPasswords::from(vault_manager)), - }?; + Format::Lprs => vault_manager.json_export()?, + Format::BitWarden => serde_json::to_string(&BitWardenPasswords::from(vault_manager))?, + }; fs::write(&self.path, exported_data).map_err(LprsError::from) } diff --git a/src/cli/import_command.rs b/src/cli/import_command.rs index 31ca224..ff70302 100644 --- a/src/cli/import_command.rs +++ b/src/cli/import_command.rs @@ -14,7 +14,12 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::{fs::File, io::Error as IoError, io::ErrorKind as IoErrorKind, path::PathBuf}; +use std::{ + fs::{self, File}, + io::Error as IoError, + io::ErrorKind as IoErrorKind, + path::PathBuf, +}; use clap::Args; @@ -45,10 +50,11 @@ impl LprsCommand for Import { let imported_passwords_len = match self.format { Format::Lprs => { - let vaults = Vaults::try_reload(self.path, vault_manager.master_password.to_vec())?; - let vaults_len = vaults.vaults.len(); + let vaults = + Vaults::json_reload(&vault_manager.master_password, &fs::read(self.path)?)?; + let vaults_len = vaults.len(); - vault_manager.vaults.extend(vaults.vaults); + vault_manager.vaults.extend(vaults); vault_manager.try_export()?; vaults_len } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4a0a1dd..5f63b44 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::path::PathBuf; +use std::{fs, path::PathBuf}; use clap::Parser; @@ -73,6 +73,13 @@ impl Cli { pub fn run(self) -> LprsResult<()> { let vaults_file = if let Some(path) = self.vaults_file { log::info!("Using the given vaults file"); + if let Some(parent) = path.parent() { + if parent.to_str() != Some("") && !parent.exists() { + log::info!("Creating the parent vaults file directory"); + fs::create_dir_all(parent)?; + } + } + fs::File::create(&path)?; path } else { log::info!("Using the default vaults file"); @@ -91,11 +98,9 @@ impl Cli { } } else { log::info!("Reloading the vaults file"); - let master_password = utils::master_password_prompt(&vaults_file)?; - Vaults::try_reload( - vaults_file, - master_password.into_bytes().into_iter().take(32).collect(), - )? + let master_password = + utils::master_password_prompt(fs::read(&vaults_file)?.is_empty())?; + Vaults::try_reload(vaults_file, master_password)? }; self.command.run(vault_manager) diff --git a/src/errors.rs b/src/errors.rs index c6a2985..e7bde78 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -20,15 +20,11 @@ pub type Result = std::result::Result; #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("Invalid Json Path Error: {0}")] - InvalidJsonPath(String), #[error("Encryption Error: {0}")] Encryption(String), - #[error("Decryption Error: {0}")] - Decryption(String), - #[error( - "Wrong Master Password Error: Wrong password or you may have played with the password file" - )] + #[error("Decryption Error: The given key cannot decrypt the given data. Either the data has been tampered with or the key is incorrect.")] + Decryption, + #[error("Wrong Master Password Error: Wrong decryption password")] WrongMasterPassword, #[error("Weak Password Error: {0}")] WeakPassword(String), @@ -45,6 +41,8 @@ pub enum Error { InvalidRegex(#[from] regex::Error), #[error("UTF8 Error: {0}")] Utf8(#[from] FromUtf8Error), + #[error("Bincode Error: {0}")] + Bincode(#[from] bincode::Error), #[error("Base64 Decode Error: {0}")] BaseDecodeError(#[from] base64::DecodeError), #[error("Json Error: {0}")] diff --git a/src/main.rs b/src/main.rs index 5107b9b..1ab2191 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,13 +14,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::process::ExitCode; - -use base64::{ - alphabet, - engine::{general_purpose::PAD, GeneralPurpose}, -}; use clap::Parser; +use inquire::InquireError; +use std::process::ExitCode; pub mod cli; pub mod errors; @@ -30,12 +26,11 @@ pub mod vault; mod macros; mod traits; +pub use base64::engine::general_purpose::STANDARD as BASE64; pub use errors::{Error as LprsError, Result as LprsResult}; -use inquire::InquireError; pub use traits::*; -pub const STANDARDBASE: GeneralPurpose = GeneralPurpose::new(&alphabet::STANDARD, PAD); -pub const DEFAULT_VAULTS_FILE: &str = "vaults.json"; +pub const DEFAULT_VAULTS_FILE: &str = "vaults.lprs"; #[cfg(feature = "update-notify")] pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/utils.rs b/src/utils.rs index f88065d..98e38d7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -14,14 +14,12 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::{ - fs, - path::{Path, PathBuf}, -}; +use std::{fs, path::PathBuf}; use inquire::validator::Validation; +use sha2::Digest; -use crate::{vault, LprsError, LprsResult}; +use crate::{LprsError, LprsResult}; /// Returns the local project dir joined with the given file name pub fn local_project_file(filename: &str) -> LprsResult { @@ -42,11 +40,7 @@ pub fn local_project_file(filename: &str) -> LprsResult { pub fn vaults_file() -> LprsResult { let vaults_file = local_project_file(crate::DEFAULT_VAULTS_FILE)?; if !vaults_file.exists() { - log::info!( - "Vaults file not found, creating a new one: {:?}", - vaults_file.display() - ); - fs::write(&vaults_file, "[]")?; + fs::File::create(&vaults_file)?; } Ok(vaults_file) } @@ -71,9 +65,9 @@ pub fn password_validator(password: &str) -> Result LprsResult { - let is_new_vaults_file = vault::is_new_vaults_file(vaults_file)?; - +/// +/// Return's the password as 32 bytes after hash it (256 bit) +pub fn master_password_prompt(is_new_vaults_file: bool) -> LprsResult<[u8; 32]> { inquire::Password { message: "Master Password:", enable_confirmation: is_new_vaults_file, @@ -87,7 +81,7 @@ pub fn master_password_prompt(vaults_file: &Path) -> LprsResult { .with_formatter(&|p| "*".repeat(p.chars().count())) .with_display_mode(inquire::PasswordDisplayMode::Masked) .prompt() - .map(sha256::digest) + .map(|p| sha2::Sha256::digest(p).into()) .map_err(Into::into) } diff --git a/src/vault/cipher.rs b/src/vault/cipher.rs index 39a5c21..e71ea75 100644 --- a/src/vault/cipher.rs +++ b/src/vault/cipher.rs @@ -14,53 +14,44 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use base64::Engine; -use soft_aes::aes::{aes_dec_ecb, aes_enc_ecb}; +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use rand::{rngs::StdRng, Rng, SeedableRng}; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::{LprsError, LprsResult}; -/// Encrypt the string with AEC ECB -pub fn encrypt(master_password: &[u8], data: &str) -> LprsResult { - let padding = Some("PKCS7"); +type Aes256CbcEnc = cbc::Encryptor; +type Aes256CbcDec = cbc::Decryptor; - aes_enc_ecb(data.as_bytes(), master_password, padding) - .map(|d| crate::STANDARDBASE.encode(d)) - .map_err(|err| LprsError::Encryption(err.to_string())) -} - -/// Decrypt the string with AEC ECB -pub fn decrypt(master_password: &[u8], data: &str) -> LprsResult { - let padding = Some("PKCS7"); - - aes_dec_ecb( - crate::STANDARDBASE.decode(data)?.as_slice(), - master_password, - padding, +/// 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) +pub(crate) fn encrypt(master_password: &[u8; 32], data: &[u8]) -> Vec { + let iv: [u8; 16] = StdRng::seed_from_u64( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("SystemTime before UNIX EPOCH!") + .as_secs(), ) - .map_err(|err| { - if err.to_string().contains("Invalid padding") { - LprsError::WrongMasterPassword - } else { - LprsError::Decryption(err.to_string()) - } - }) - .map(|d| String::from_utf8(d).map_err(LprsError::Utf8))? + .gen(); + + let mut ciphertext = + Aes256CbcEnc::new(master_password.into(), &iv.into()).encrypt_padded_vec_mut::(data); + ciphertext.extend(&iv); + ciphertext } -/// Encrypt if the `Option` are `Some` -pub fn encrypt_some( - master_password: &[u8], - data: Option>, -) -> LprsResult> { - data.map(|d| encrypt(master_password, d.as_ref())) - .transpose() -} +/// Decrypt the given data by the given key, the data should +/// be encrypted by AES-256 CBC. The IV will be extraxted +/// from the last 16 bytes. +pub(crate) fn decrypt(master_password: &[u8; 32], data: &[u8]) -> LprsResult> { + let (ciphertext, iv) = data.split_at( + data.len() + .checked_sub(16) + .ok_or_else(|| LprsError::Decryption)?, + ); -/// Decrypt if the `Option` are `Some` -pub fn decrypt_some( - master_password: &[u8], - data: Option>, -) -> LprsResult> { - data.map(|d| decrypt(master_password, d.as_ref())) - .transpose() + Aes256CbcDec::new(master_password.into(), iv.into()) + .decrypt_padded_vec_mut::(ciphertext) + .map_err(|_| LprsError::Decryption) } diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 1c3ea8e..11d2595 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -16,6 +16,7 @@ use std::{fs, path::PathBuf}; +use base64::Engine; use clap::{Parser, ValueEnum}; use serde::{Deserialize, Serialize}; @@ -24,10 +25,8 @@ use crate::{LprsError, LprsResult}; pub mod cipher; mod bitwarden; -mod validator; pub use bitwarden::*; -pub use validator::*; #[derive(Clone, Debug, ValueEnum)] pub enum Format { @@ -36,7 +35,6 @@ pub enum Format { } /// The vault struct -#[serde_with_macros::skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize, Parser)] pub struct Vault { /// The name of the vault @@ -60,7 +58,7 @@ pub struct Vault { #[derive(Default)] pub struct Vaults { /// Hash of the master password - pub master_password: Vec, + pub master_password: [u8; 32], /// The json vaults file pub vaults_file: PathBuf, /// The vaults @@ -85,28 +83,6 @@ impl Vault { } } - /// Decrypt the vault - pub fn decrypt(&self, master_password: &[u8]) -> LprsResult { - Ok(Vault::new( - cipher::decrypt(master_password, &self.name)?, - cipher::decrypt_some(master_password, self.username.as_ref())?, - cipher::decrypt_some(master_password, self.password.as_ref())?, - cipher::decrypt_some(master_password, self.service.as_ref())?, - cipher::decrypt_some(master_password, self.note.as_ref())?, - )) - } - - /// Encrypt the vault - pub fn encrypt(&self, master_password: &[u8]) -> LprsResult { - Ok(Vault::new( - cipher::encrypt(master_password, &self.name)?, - cipher::encrypt_some(master_password, self.username.as_ref())?, - cipher::encrypt_some(master_password, self.password.as_ref())?, - cipher::encrypt_some(master_password, self.service.as_ref())?, - cipher::encrypt_some(master_password, self.note.as_ref())?, - )) - } - /// Return the name of the vault with the service if there pub fn list_name(&self) -> String { use std::fmt::Write; @@ -126,7 +102,7 @@ impl Vault { impl Vaults { /// Create new [`Vaults`] instnce - pub fn new(master_password: Vec, vaults_file: PathBuf, vaults: Vec) -> Self { + pub fn new(master_password: [u8; 32], vaults_file: PathBuf, vaults: Vec) -> Self { Self { master_password, vaults_file, @@ -134,22 +110,65 @@ impl Vaults { } } - /// Encrypt the vaults - pub fn encrypt_vaults(&self) -> LprsResult> { - self.vaults - .iter() - .map(|p| p.encrypt(&self.master_password)) - .collect() + /// Add new vault + pub fn add_vault(&mut self, vault: Vault) { + self.vaults.push(vault) } - /// Reload the vaults from the file then decrypt it - pub fn try_reload(vaults_file: PathBuf, master_password: Vec) -> LprsResult { - let vaults = serde_json::from_str::>(&fs::read_to_string(&vaults_file)?)? - .into_iter() - .map(|p| p.decrypt(master_password.as_slice())) - .collect::>>()?; + /// Encrypt the vaults then returns it as json. + /// + /// This function used to backup the vaults. + /// + /// Note: The returned string is `Vec` + pub fn json_export(&self) -> LprsResult { + let encrypt = |val: &str| { + LprsResult::Ok( + crate::BASE64.encode(cipher::encrypt(&self.master_password, val.as_ref())), + ) + }; - Ok(Self::new(master_password, vaults_file, vaults)) + serde_json::to_string( + &self + .vaults + .iter() + .map(|v| { + LprsResult::Ok(Vault::new( + encrypt(&v.name)?, + v.username.as_ref().and_then(|u| encrypt(u).ok()), + v.password.as_ref().and_then(|p| encrypt(p).ok()), + v.service.as_ref().and_then(|s| encrypt(s).ok()), + v.note.as_ref().and_then(|n| encrypt(n).ok()), + )) + }) + .collect::>>()?, + ) + .map_err(Into::into) + } + + /// Reload the vaults from json data. + /// + /// This function used to import backup vaults. + pub fn json_reload(master_password: &[u8; 32], json_data: &[u8]) -> LprsResult> { + let decrypt = |val: &str| { + String::from_utf8(cipher::decrypt( + master_password, + &crate::BASE64.decode(val)?, + )?) + .map_err(|err| LprsError::Other(err.to_string())) + }; + + serde_json::from_slice::>(json_data)? + .into_iter() + .map(|v| { + LprsResult::Ok(Vault::new( + decrypt(&v.name)?, + v.username.as_ref().and_then(|u| decrypt(u).ok()), + v.password.as_ref().and_then(|p| decrypt(p).ok()), + v.service.as_ref().and_then(|s| decrypt(s).ok()), + v.note.as_ref().and_then(|n| decrypt(n).ok()), + )) + }) + .collect() } /// Encrypt the vaults then export it to the file @@ -160,14 +179,22 @@ impl Vaults { ); fs::write( &self.vaults_file, - serde_json::to_string(&self.encrypt_vaults()?)?, + cipher::encrypt(&self.master_password, &bincode::serialize(&self.vaults)?), ) .map_err(LprsError::Io) } - /// Add new vault - pub fn add_vault(&mut self, vault: Vault) { - self.vaults.push(vault) + /// Reload the vaults from the file then decrypt it + pub fn try_reload(vaults_file: PathBuf, master_password: [u8; 32]) -> LprsResult { + let vaults_data = fs::read(&vaults_file)?; + + let vaults: Vec = if vaults_data.is_empty() { + vec![] + } else { + bincode::deserialize(&cipher::decrypt(&master_password, &vaults_data)?)? + }; + + Ok(Self::new(master_password, vaults_file, vaults)) } } diff --git a/src/vault/validator.rs b/src/vault/validator.rs deleted file mode 100644 index c29d69f..0000000 --- a/src/vault/validator.rs +++ /dev/null @@ -1,35 +0,0 @@ -// Lprs - A local CLI vault manager -// Copyright (C) 2024 Awiteb -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -use std::{fs, path::Path}; - -use crate::LprsResult; - -use super::Vault; - -/// Return if the vaults file new file or not -pub fn is_new_vaults_file(path: &Path) -> LprsResult { - if path.exists() { - let file_content = fs::read_to_string(path)?; - if !file_content.is_empty() - && file_content.trim() != "[]" - && serde_json::from_str::>(&file_content).is_ok() - { - return Ok(false); - } - } - Ok(true) -}