From bebbc93e736b9cc75ade477fac5c7fc147883130 Mon Sep 17 00:00:00 2001 From: holly sparkles Date: Tue, 1 Aug 2023 23:06:18 +0200 Subject: [PATCH] Merge branch 'feature/bw-integration' into develop --- Cargo.toml | 4 +++- src/bwutil.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- src/util.rs | 29 +++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 src/bwutil.rs create mode 100644 src/util.rs diff --git a/Cargo.toml b/Cargo.toml index e10e353..8e90ff6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,6 @@ edition = "2021" [dependencies] gumdrop = "0.8.1" -anyhow = "1.0.72" \ No newline at end of file +anyhow = "1.0.72" +serde = { version = "1.0.177", features = ["derive"] } +serde_json = "1.0.104" \ No newline at end of file diff --git a/src/bwutil.rs b/src/bwutil.rs new file mode 100644 index 0000000..0e007e5 --- /dev/null +++ b/src/bwutil.rs @@ -0,0 +1,117 @@ +use std::process::{Command, Output, Stdio}; +use serde::Deserialize; +use anyhow::{anyhow, Result, Context}; + +/// Represents a Bitwarden folder. +#[derive(Default, Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitwardenFolder { + pub id: String, + pub name: String +} + +/// Represents an item in Bitwarden containing an SSH key. +#[derive(Default, Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitwardenItem { + pub id: String, + pub name: String, + pub attachments: Option>, + pub fields: Option> +} + +/// Represents a custom field in Bitwarden. +#[derive(Default, Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitwardenFieldItem { + pub name: String, + pub value: String +} + +/// Represents a file attachment in Bitwarden. +#[derive(Default, Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitwardenAttachment { + pub id: String, + pub file_name: String +} + +/// Gets the user's logged-in status from Bitwarden. +pub fn is_logged_in() -> Result { + let logged_in = Command::new("bw") + .arg("login") + .arg("--check") + .output()?; + + Ok(logged_in.stdout.len() > 0) +} + +/// Gets a session token from Bitwarden. +pub fn get_session_token() -> Result { + let mut token = String::new(); + let mut operation = String::new(); + + // No session token found. check to see if Bitwarden needs a login or is locked + if is_logged_in()? { + println!("Vault is locked."); + operation.push_str("unlock") + + } + else { + println!("You are not logged in."); + operation.push_str("login") + } + + let success: Output = exec_piped_command("bw", + ["--raw", &operation].to_vec()); + if success.status.success() { + token.push_str(&String::from_utf8(success.stdout)?); + } + + Ok(token) +} + +/// Search Bitwarden for folders matching `folder_name` and return the results. +pub fn exec_folder_search(session: &str, folder_name: &str) -> Result> { + let folders: Output = exec_piped_command("bw", + ["list", "folders", "--search", &folder_name, "--session", &session].to_vec()); + let result: String = String::from_utf8_lossy(&folders.stdout).to_string(); + if result.is_empty() { + Err(anyhow!("Could not authenticate.")) + } + else { + Ok(serde_json::from_str(&result).with_context(|| "Could not deserialize folder search results.")?) + } +} + +// List items in a folder matching `folder_id` and return the results +pub fn exec_list_folder_items(session: &str, folder_id: &str) -> Result> { + let items = exec_piped_command("bw", + ["list", "items", "--folderid", &folder_id, "--session", &session].to_vec()); + let result = String::from_utf8_lossy(&items.stdout).to_string(); + if result.is_empty() { + Err(anyhow!("Could not authenticate.")) + } + else { + Ok(serde_json::from_str(&result).with_context(|| "Could not deserialize item search results.")?) + } +} + +/// Execute an interactive command. +/// +/// The resulting output will be returned as `Output`. This is a modified version of: +/// +/// https://users.rust-lang.org/t/command-if-a-child-process-is-asking-for-input-how-to-forward-the-question-to-the-user/37490/3 +fn exec_piped_command(cmd: &str, args: Vec<&str>) -> Output { + let cli_command = match Command::new(cmd) + .args(&args) + .stdout(Stdio::piped()) + .spawn() + { + Err(err) => panic!("Error spawning: {}", err), + Ok(process) => process, + }; + + let output = cli_command.wait_with_output().unwrap(); + return output; +} diff --git a/src/main.rs b/src/main.rs index 92be148..4fc1059 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,24 +1,100 @@ +use std::env; use gumdrop::Options; +use anyhow::Result; -#[derive(Debug, Options)] -struct Cli { - #[options(help = "print help message and exit")] - help: bool, - #[options(help = "show debug output")] - debug: bool, - #[options(help = "folder name to use to search for SSH keys", default = "ssh-agent")] - folder: String, - #[options(help = "custom field name where the private key is stored", meta = "PRIVATE_KEY", default = "private-key")] - key: String, - #[options(help = "custom field name where the key passphrase is stored", meta = "PASS", default = "passphrase")] - passphrase: String, - #[options(help = "session to use to log in to bitwarden-cli")] - session: Option, - #[options(help = "print version and exit")] - version: bool, +mod util; +mod bwutil; + +/// Environment variable housing an existing Bitwarden session. +const SESSION_ENV_KEY: &str = "BW_SESSION"; +/// Environment variable referencing the name of the field containing the SSH key filename. +const BW_FIELD_KEY_FILENAME: &str = "private"; +/// Environment variable referencing the name of the field containing the SSH key passphrase (if any). +const BW_FIELD_KEY_PASSPHRASE: &str = "passphrase"; + +fn main() -> Result<()> { + let args: util::Cli = util::Cli::parse_args_default_or_exit(); + if args.version { + println!("{}", &util::get_version_string()?); + return Ok(()); + } + + let session_token: String = check_session_token(&args)?; + if !session_token.is_empty() { + let folders = bwutil::exec_folder_search(&session_token, &args.folder)?; + // Retrieve items from each folder since there may be multiple folders with the same name. + for folder in folders { + let folder_items = bwutil::exec_list_folder_items(&session_token, &folder.id)?; + for item in folder_items { + // In order for this to be considered a valid SSH key item, this item needs to have the following fields: + // - private - (required) this is the filename of the attachment containing the SSH key. + // The field name is not case sensitive in order to be more user-friendly. + // - passphrase - (optional) this is the passphrase (if any) for the SSH key. + // The field name is not case sensitive in order to be more user-friendly. + // - attachment (required) an attachment with the name specified in `private`. + if let Some(fields) = &item.fields { + let mut key_filename = String::new(); + let mut key_passphrase = String::new(); + for field in fields { + if field.name.to_lowercase().eq(&String::from(BW_FIELD_KEY_FILENAME).to_lowercase()) { + key_filename.push_str(&field.value); + } + if field.name.to_lowercase().eq(&String::from(BW_FIELD_KEY_PASSPHRASE).to_lowercase()) { + key_passphrase.push_str(&field.value); + } + } + + if let Some(attachments) = &item.attachments { + for attachment in attachments { + if attachment.file_name.eq(&key_filename) { + println!("{:#?}", item); + } + } + } + } + } + } + } + + Ok(()) } -fn main() { - let args: Cli = Cli::parse_args_default_or_exit(); - println!("{:#?}", args); +/// Gets a session token from the active Bitwarden session. The checking process is as follows: +/// 1. Check for `--session` argument and use that. +/// 2. Check for `BW_SESSION` environment variable and use that. +/// 3. Check for login status from `bw` +/// 4. Login or unlock based on login status and use that. +/// An invalid `BW_SESSION` or `--session` will prompt a login. +fn check_session_token(args: &util::Cli) -> Result { + println!("Getting Bitwarden session..."); + let mut session_token: String = String::new(); + // Get session flag from the user + if args.session.trim().is_empty() { + // No session flag, check for an environment variable + let env_key = env::var(SESSION_ENV_KEY); + match env_key { + Ok(key) => { + println!("{} is set. Reusing existing session.", SESSION_ENV_KEY); + // We found it, set our session key + session_token.push_str(&key); + }, + Err(_) => { + // We don't have a token to reuse, so get it from Bitwarden + println!("{} is not set. Attempting to login.", SESSION_ENV_KEY); + let token: &String = &bwutil::get_session_token()?; + + if !token.is_empty() { + println!("Successfully unlocked. To re-use this session, run:"); + println!("export {}=\"{}\"", SESSION_ENV_KEY, token); + } + session_token.push_str(token) + } + } + } + else { + // Session flag is already defined, pass it on + session_token.push_str(&args.session); + } + + Ok(session_token) } diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..9421fed --- /dev/null +++ b/src/util.rs @@ -0,0 +1,29 @@ +use std::env; +use anyhow::Result; +use gumdrop::Options; + +/// Represents command-line parameters. +#[derive(Debug, Options)] +pub struct Cli { + #[options(help = "print help message and exit")] + pub help: bool, + #[options(help = "show debug output")] + pub debug: bool, + #[options(help = "folder name to use to search for SSH keys", default = "ssh-agent")] + pub folder: String, + #[options(help = "custom field name where the private key is stored", meta = "PRIVATE_KEY", default = "private-key")] + pub key: String, + #[options(help = "custom field name where the key passphrase is stored", meta = "PASS", default = "passphrase")] + pub passphrase: String, + #[options(help = "session to use to log in to bitwarden-cli")] + pub session: String, + #[options(help = "print version and exit")] + pub version: bool, +} + +/// Retrieves the version information in the form of `PKG_NAME` - `PKG_VERSION`. +pub fn get_version_string() -> Result { + let name = env!("CARGO_PKG_NAME"); + let version = env!("CARGO_PKG_VERSION"); + return Ok(format!("{} {}", name, version)) +} \ No newline at end of file -- libgit2 1.7.2