From bc9c3fa9c129e668a180e6c7999d7097e541d3ba Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Sun, 7 Sep 2025 10:48:26 +0200 Subject: [PATCH 1/2] Adding option to specify paths for input and output --- crates/cli/README.md | 41 ++++- crates/cli/src/logic/get_input.rs | 124 +++++++++++++++ crates/cli/src/logic/mod.rs | 2 + crates/cli/src/logic/run.rs | 149 +++++++++++++----- crates/cli/src/main.rs | 50 ++++-- crates/cli/src/models/error.rs | 11 ++ .../security_question_answer_and_salt.rs | 2 +- 7 files changed, 323 insertions(+), 56 deletions(-) create mode 100644 crates/cli/src/logic/get_input.rs diff --git a/crates/cli/README.md b/crates/cli/README.md index 03b7066..86b5b20 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -22,12 +22,14 @@ cargo install --git https://github.com/sajjon/svar ``` ## Usage + +### Seal (Encrypt) Simply run the `svar` command in your terminal after installation: ```sh,no_run -svar +svar seal ``` -If run for the first time this will prompt you to enter a secret to protect +This will prompt you to enter a secret to protect and then prompt you to answer a set of security questions. The secret will be encrypted using the answers to the security questions. @@ -37,15 +39,40 @@ as a JSON file in the local data directory, which on macOS is `$HOME/.local/share/svar/sealed_secret.json` and on Windows: `C:\Users\\AppData\Local\svar\sealed_secret.json`. -On subsequent runs the program will try to read the sealed secret from -the file and prompt you to answer the security questions again. If you -answer enough questions correctly, the secret will be decrypted and the -program will ask you if you want to print the secret in the terminal. +#### Custom paths +Alternatively you can specify a custom path to read the secret from +and a custom path to save the sealed secret to using the `-i` and +`-o` flags respectively: +```sh,no_run +svar seal -i /path/to/your/secret.txt -o /path/to/save/sealed_secret.json +``` + +When the `-i` flag is provided, the program will not prompt you to input +the secret, but will read it from the specified file instead. The sealed +secret will be written to the path specified by the `-o` flag. + +### Open (Decrypt) +You can open a sealed secret using the `open` command: +```sh,no_run +svar open +``` + +This will try to read the a sealed secret at the default path and prompt you +to answer the security questions again. If you answer enough questions +correctly, the secret will be decrypted and the program will ask you if you +want to print the secret in the terminal. If you which to change the secret, simply delete the sealed secret file and run the program again. It will then prompt you to enter a new secret and answer the security questions again. +#### Custom path +You can also specify a custom path to read the sealed secret from +using the `-i` flag: +```sh,no_run +svar open -i /path/to/sealed_secret.json +``` + > [!TIP] > When decrypting a sealed secret, try inputting an incorrect answer to any > of the questions and it will still decrypt the secret. You can also notice @@ -55,8 +82,6 @@ and answer the security questions again. Later we might make this example CLI application more advanced by allowing you to specify the number of questions and answers, and the minimum number of correct answers required to decrypt the secret. -We might also allow you to pass a path allowing you to have multiple -sealed secrets. # Etymology diff --git a/crates/cli/src/logic/get_input.rs b/crates/cli/src/logic/get_input.rs new file mode 100644 index 0000000..5737129 --- /dev/null +++ b/crates/cli/src/logic/get_input.rs @@ -0,0 +1,124 @@ +use crate::prelude::*; + +use clap::{Args, Parser, Subcommand}; + +const BINARY_NAME: &str = env!("CARGO_PKG_NAME"); + +#[derive(Debug, Parser)] +#[command(name = BINARY_NAME, about = "Protect a secret using security questions and answers.")] +#[command(version = env!("CARGO_PKG_VERSION"))] +pub struct CliArgs { + #[command(subcommand)] + pub command: CommandArgs, +} + +#[derive(Debug, Subcommand)] +pub enum CommandArgs { + Open(OpenArgs), + Seal(SealArgs), +} + +pub enum Command { + Open(OpenInput), + Seal(SealInput), +} + +#[derive(Debug, Args, PartialEq)] +#[command(name = "open", about = "Decrypts a sealed secret.")] +pub struct OpenArgs { + /// An optional override of where to read the sealed secret from. + /// If not provided, the default data local directory will be used. + #[arg( + long, + short = 'i', + help = "Path to the sealed secret file, if not provided the default data local directory will be used." + )] + sealed_path: Option, +} + +impl OpenArgs { + pub fn non_existent_path_to_sealed_secret(&self) -> Option { + let path = self.sealed_path.clone().unwrap_or( + default_path_for_sealed_secret_without_checking_existence() + .expect("Failed to get default data local directory"), + ); + if !path.exists() { + Some(path.clone()) + } else { + None + } + } + + pub fn to_input(self) -> Result { + if let Some(path) = self.sealed_path { + Ok(OpenInput { sealed_path: path }) + } else { + let dir = default_path_for_sealed_secret(false)?; + Ok(OpenInput { sealed_path: dir }) + } + } +} + +#[derive(Debug, PartialEq)] +pub struct OpenInput { + sealed_path: PathBuf, +} +impl OpenInput { + pub fn sealed_path(&self) -> &PathBuf { + &self.sealed_path + } +} + +#[derive(Debug, Args, PartialEq)] +#[command(name = "seal", about = "Encrypts a secret using security questions.")] +pub struct SealArgs { + /// An optional override of where to read the secret from, if not + /// provided the user will be prompted to enter a secret. + #[arg( + long, + short = 'i', + help = "Path to a file containing the secret to protect, if not provided the user will be prompted to enter a secret." + )] + secret_path: Option, + + /// An optional override of where to save the output sealed secret, if not + /// provided the default data local directory will be used. + #[arg( + long, + short = 'o', + help = "Path to the output sealed secret file, if not provided the default data local directory will be used." + )] + sealed_path: Option, +} + +impl SealArgs { + pub fn to_input(self) -> Result { + if let Some(path) = self.sealed_path { + Ok(SealInput { + sealed_path: path, + secret_path: self.secret_path, + }) + } else { + let sealed_path = default_path_for_sealed_secret(true)?; + Ok(SealInput { + sealed_path, + secret_path: self.secret_path, + }) + } + } +} + +#[derive(Debug, PartialEq)] +pub struct SealInput { + secret_path: Option, + sealed_path: PathBuf, +} +impl SealInput { + pub fn secret_path(&self) -> Option { + self.secret_path.clone() + } + + pub fn sealed_path(&self) -> &PathBuf { + &self.sealed_path + } +} diff --git a/crates/cli/src/logic/mod.rs b/crates/cli/src/logic/mod.rs index a010096..4cc7e1b 100644 --- a/crates/cli/src/logic/mod.rs +++ b/crates/cli/src/logic/mod.rs @@ -1,5 +1,7 @@ +mod get_input; mod init_logging; mod run; +pub use get_input::*; pub use init_logging::*; pub(crate) use run::*; diff --git a/crates/cli/src/logic/run.rs b/crates/cli/src/logic/run.rs index a0152ef..771f77c 100644 --- a/crates/cli/src/logic/run.rs +++ b/crates/cli/src/logic/run.rs @@ -29,13 +29,17 @@ fn prompt_answer( }) } +fn data_local_dir() -> Result { + dirs_next::data_local_dir() + .ok_or(Error::FailedToFindDataLocalDir) + .map(|dir| dir.join(env!("CARGO_PKG_NAME"))) +} + /// Returns the directory to use for storing the sealed secret, /// if `create_if_needed` is true, it will create the directory if it does not /// exist - if `false` is passed it will panic if the directory does not exist. fn dir_created_if_needed(create_if_needed: bool) -> Result { - let dir = dirs_next::data_local_dir() - .ok_or(Error::FailedToFindDataLocalDir)? - .join(env!("CARGO_PKG_NAME")); + let dir = data_local_dir()?; if !dir.exists() { if create_if_needed { fs::create_dir_all(&dir).map_err(|e| { @@ -45,13 +49,28 @@ fn dir_created_if_needed(create_if_needed: bool) -> Result { } })?; } else { - panic!("Data local directory does not exist: {}", dir.display()); + return Err(Error::DataLocalDirectoryDoesNotExist { + dir: dir.display().to_string(), + }); } } Ok(dir) } +pub fn default_path_for_sealed_secret_without_checking_existence() +-> Result { + let dir = data_local_dir()?; + Ok(dir.join(SECRET_FILE_NAME)) +} + +pub fn default_path_for_sealed_secret( + create_if_needed: bool, +) -> Result { + let dir = dir_created_if_needed(create_if_needed)?; + Ok(dir.join(SECRET_FILE_NAME)) +} + const SECRET_FILE_NAME: &str = "sealed_secret.json"; /// Prompt for answers to security questions and return them as a collection. @@ -78,18 +97,33 @@ fn get_answers_from_questions( /// Protects a new secret by prompting the user for a secret and security /// questions and answers. -fn protect_new_secret_at(file_path: impl AsRef) -> Result<()> { - let secret_to_protect = - inquire::Password::new("Enter the secret to protect:") - .with_display_toggle_enabled() - .with_display_mode(PasswordDisplayMode::Hidden) - .without_confirmation() - .with_formatter(&|_| String::from("Input received")) - .with_help_message("Press CTRL+R to toggle reveal/hide your input.") - .prompt() - .map_err(|e| Error::FailedToInputSecret { - underlying: e.to_string(), - })?; +fn protect_new_secret( + maybe_input_path_secret: Option, + output_path_sealed: impl AsRef, +) -> Result<()> { + let secret_to_protect = { + if let Some(path) = maybe_input_path_secret { + std::fs::read_to_string(path.clone()).map_err(|e| { + Error::FailedToReadSecretFromFile { + file_path: path.display().to_string(), + underlying: e.to_string(), + } + }) + } else { + inquire::Password::new("Enter the secret to protect:") + .with_display_toggle_enabled() + .with_display_mode(PasswordDisplayMode::Hidden) + .without_confirmation() + .with_formatter(&|_| String::from("Input received")) + .with_help_message( + "Press CTRL+R to toggle reveal/hide your input.", + ) + .prompt() + .map_err(|e| Error::FailedToInputSecret { + underlying: e.to_string(), + }) + } + }?; info!( "Secret to protect received: #{} chars", @@ -125,17 +159,24 @@ fn protect_new_secret_at(file_path: impl AsRef) -> Result<()> { underlying: e.to_string(), } })?; - let file_path = file_path.as_ref(); + + let output_path_sealed = output_path_sealed.as_ref(); debug!("Serialized sealed secret."); - debug!("Saving sealed secret to file: {}", file_path.display()); - fs::write(file_path, sealed_json).map_err(|e| { + debug!( + "Saving sealed secret to file: {}", + output_path_sealed.display() + ); + fs::write(output_path_sealed, sealed_json).map_err(|e| { Error::FailedToWriteSealedSecretToFile { - file_path: file_path.display().to_string(), + file_path: output_path_sealed.display().to_string(), underlying: e.to_string(), } })?; - info!("Saved sealed secret to file: {}", file_path.display()); + info!( + "Saved sealed secret to file: {}", + output_path_sealed.display() + ); Ok(()) } @@ -185,26 +226,62 @@ fn open_sealed_secret_at(file_path: impl AsRef) -> Result<()> { Ok(()) } -/// Seals or opens a sealed secret based on the existence of the sealed secret -/// file. -fn seal_or_open() -> Result<()> { - let dir = dir_created_if_needed(false)?; - let file = dir.join(SECRET_FILE_NAME); - if file.exists() { - info!("Sealed secret file exists, opening it..."); - open_sealed_secret_at(file) - } else { - info!("Sealed secret file does not exist, creating a new one..."); - protect_new_secret_at(file) +fn open(input: OpenInput) -> Result<()> { + open_sealed_secret_at(input.sealed_path()) +} + +fn ask_if_override_existing_sealed_secret(input: &SealInput) -> Result<()> { + let path = input.sealed_path(); + if path.exists() { + let override_existing = inquire::Confirm::new(&format!( + "A sealed secret already exists at '{}'. Do you want to override it?", + path.display() + )) + .with_default(false) + .prompt() + .unwrap_or_default(); + + if !override_existing { + info!("Aborting sealing new secret."); + std::process::exit(0); + } + } + Ok(()) +} + +fn seal(input: SealInput) -> Result<()> { + ask_if_override_existing_sealed_secret(&input)?; + protect_new_secret(input.secret_path(), input.sealed_path()) +} + +/// Seals or opens a sealed secret based on the command line arguments. +fn seal_or_open(args: CliArgs) -> Result<()> { + match args.command { + CommandArgs::Open(input) => { + if let Some(non_existing_custom_path) = + input.non_existent_path_to_sealed_secret() + { + warn!( + "No sealed secret found at: {}", + non_existing_custom_path.display() + ); + return Ok(()); + } + let input = input.to_input()?; + open(input) + } + CommandArgs::Seal(args) => { + let input = args.to_input()?; + seal(input) + } } } -/// Seals or opens a sealed secret based on the existence of the sealed secret -/// file, +/// Seals or opens a sealed secret based on the command line arguments. /// /// Logs any error that occurs during the process. -pub(crate) fn run() { - match seal_or_open() { +pub(crate) fn run(args: CliArgs) { + match seal_or_open(args) { Ok(_) => {} Err(e) => { error!("Error protecting secret: {}", e); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index aac6b63..ce9a2ad 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -18,12 +18,14 @@ //! ``` //! //! # Usage +//! +//! ## Seal (Encrypt) //! Simply run the `svar` command in your terminal after installation: //! ```sh,no_run -//! svar +//! svar seal //! ``` //! -//! If run for the first time this will prompt you to enter a secret to protect +//! This will prompt you to enter a secret to protect //! and then prompt you to answer a set of security questions. The secret will //! be encrypted using the answers to the security questions. //! @@ -33,15 +35,40 @@ //! `$HOME/.local/share/svar/sealed_secret.json` and on Windows: //! `C:\Users\\AppData\Local\svar\sealed_secret.json`. //! -//! On subsequent runs the program will try to read the sealed secret from -//! the file and prompt you to answer the security questions again. If you -//! answer enough questions correctly, the secret will be decrypted and the -//! program will ask you if you want to print the secret in the terminal. +//! ### Custom paths +//! Alternatively you can specify a custom path to read the secret from +//! and a custom path to save the sealed secret to using the `-i` and +//! `-o` flags respectively: +//! ```sh,no_run +//! svar seal -i /path/to/your/secret.txt -o /path/to/save/sealed_secret.json +//! ``` +//! +//! When the `-i` flag is provided, the program will not prompt you to input +//! the secret, but will read it from the specified file instead. The sealed +//! secret will be written to the path specified by the `-o` flag. +//! +//! ## Open (Decrypt) +//! You can open a sealed secret using the `open` command: +//! ```sh,no_run +//! svar open +//! ``` +//! +//! This will try to read the a sealed secret at the default path and prompt you +//! to answer the security questions again. If you answer enough questions +//! correctly, the secret will be decrypted and the program will ask you if you +//! want to print the secret in the terminal. //! //! If you which to change the secret, simply delete the sealed secret file //! and run the program again. It will then prompt you to enter a new secret //! and answer the security questions again. //! +//! ### Custom path +//! You can also specify a custom path to read the sealed secret from +//! using the `-i` flag: +//! ```sh,no_run +//! svar open -i /path/to/sealed_secret.json +//! ``` +//! //! > [!TIP] //! > When decrypting a sealed secret, try inputting an incorrect answer to any //! > of the questions and it will still decrypt the secret. You can also notice @@ -51,8 +78,6 @@ //! Later we might make this example CLI application more advanced by //! allowing you to specify the number of questions and answers, and the //! minimum number of correct answers required to decrypt the secret. -//! We might also allow you to pass a path allowing you to have multiple -//! sealed secrets. mod logic; mod models; @@ -76,10 +101,13 @@ pub mod prelude { }; pub use inquire::PasswordDisplayMode; - pub use log::{debug, error, info}; + pub use log::{debug, error, info, warn}; } fn main() { - crate::prelude::init_logging(); - crate::prelude::run() + use clap::Parser; + use prelude::*; + init_logging(); + let input = CliArgs::parse(); + run(input) } diff --git a/crates/cli/src/models/error.rs b/crates/cli/src/models/error.rs index 084a7e9..9be4737 100644 --- a/crates/cli/src/models/error.rs +++ b/crates/cli/src/models/error.rs @@ -2,9 +2,20 @@ pub type Result = std::result::Result; #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] pub enum Error { + #[error("Data local directory does not exist at: '{dir}'")] + DataLocalDirectoryDoesNotExist { dir: String }, + #[error("Failed to input new secret to protect, underlying: {underlying}")] FailedToInputSecret { underlying: String }, + #[error( + "Failed to read secret from file: '{file_path}', underlying: {underlying}" + )] + FailedToReadSecretFromFile { + file_path: String, + underlying: String, + }, + #[error( "Failed to create data local directory at '{dir}', underlying: {underlying}" )] diff --git a/crates/core/src/models/answer/security_question_answer_and_salt.rs b/crates/core/src/models/answer/security_question_answer_and_salt.rs index 739a0ce..6da99bc 100644 --- a/crates/core/src/models/answer/security_question_answer_and_salt.rs +++ b/crates/core/src/models/answer/security_question_answer_and_salt.rs @@ -34,7 +34,7 @@ use crate::prelude::*; /// assert_eq!(qa_salt.question, question); /// assert!(qa_salt.answer.starts_with("Answer to:")); /// assert_eq!(qa_salt.salt.0.len(), 32); // Salt is always 32 bytes -/// +/// /// # Ok::<(), svar_core::Error>(()) /// ``` /// From e07a6bfe3512e7e29575bad3dd82eecd4b6c26bd Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Sun, 7 Sep 2025 14:38:51 +0200 Subject: [PATCH 2/2] tarpaulin ignore get_input.rs --- .tarpaulin.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.tarpaulin.toml b/.tarpaulin.toml index 9c90c05..6c84670 100644 --- a/.tarpaulin.toml +++ b/.tarpaulin.toml @@ -2,6 +2,7 @@ exclude-files = [ "crates/cli/src/logic/init_logging.rs", "crates/cli/src/logic/run.rs", + "crates/cli/src/logic/get_input.rs", "crates/cli/src/main.rs", ] verbose = false