index : bitwarden-ssh-agent.git

ascending towards madness

author holly sparkles <sparkles@holly.sh> 2023-12-18 20:46:02.0 +00:00:00
committer holly sparkles <sparkles@holly.sh> 2023-12-18 20:46:50.0 +00:00:00
commit
286310eb05f3279f88b6b4764f7faaa331e03314 [patch]
tree
344903304b0e292b326001c5f6273f0fa086777c
parent
ce3b075b9b195ee5ee5a1ec8c9784cf076592e84
download
286310eb05f3279f88b6b4764f7faaa331e03314.tar.gz

chore(formatting): rustfmt



Diff

 src/bwutil.rs | 136 +++++++++++++++++---------------
 src/main.rs   | 251 +++++++++++++++++++++++++++++------------------------------
 src/util.rs   |  45 ++++++-----
 3 files changed, 228 insertions(+), 204 deletions(-)

diff --git a/src/bwutil.rs b/src/bwutil.rs
index 468cddd..e2295d2 100644
--- a/src/bwutil.rs
+++ b/src/bwutil.rs
@@ -1,118 +1,132 @@
use std::process::{Command, Output, Stdio};
use anyhow::{anyhow, Context, Result};
use colored::*;
use serde::Deserialize;
use anyhow::{anyhow, Result, Context};
use std::process::{Command, Output, Stdio};

/// Represents a Bitwarden folder.
#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BitwardenFolder {
	pub id: String,
	pub name: String
    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<Vec<BitwardenAttachment>>,
	pub fields: Option<Vec<BitwardenFieldItem>>
    pub id: String,
    pub name: String,
    pub attachments: Option<Vec<BitwardenAttachment>>,
    pub fields: Option<Vec<BitwardenFieldItem>>,
}

/// Represents a custom field in Bitwarden.
#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BitwardenFieldItem {
	pub name: String,
	pub value: Option<String>
    pub name: String,
    pub value: Option<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
    pub id: String,
    pub file_name: String,
}

/// Gets the user's logged-in status from Bitwarden.
pub fn is_logged_in() -> Result<bool> {
	let logged_in = Command::new("bw")
	.arg("login")
	.arg("--check")
	.output()?;
    let logged_in = Command::new("bw").arg("login").arg("--check").output()?;

	Ok(logged_in.stdout.len() > 0)
    Ok(logged_in.stdout.len() > 0)
}

/// Gets a session token from Bitwarden.
pub fn get_session_token() -> Result<String> {
	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")
    let mut token = String::new();
    let mut operation = String::new();

	}
	else {
		println!("You are not logged in.");
		operation.push_str("login")
	}
    // 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)?);
	}
    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)
    Ok(token)
}

/// Search Bitwarden for folders matching `folder_name` and return the results.
pub fn exec_folder_search(session: &str, folder_name: &str) -> Result<Vec<BitwardenFolder>> {
	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. This could mean your session has expired.".red()))
	}
	else {
		Ok(serde_json::from_str(&result).with_context(|| "Could not deserialize folder search results.")?)
	}
    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. This could mean your session has expired.".red()
        ))
    } 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<Vec<BitwardenItem>> {
	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. This could mean your session has expired.".red()))
	}
	else {
		Ok(serde_json::from_str(&result).with_context(|| "Could not deserialize item search results.")?)
	}
    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. This could mean your session has expired.".red()
        ))
    } 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()
	{
    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();
    let output = cli_command.wait_with_output().unwrap();
    return output;
}
diff --git a/src/main.rs b/src/main.rs
index 90e2946..41fa601 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17,95 +17,95 @@ const BW_FIELD_KEY_PASSPHRASE: &str = "BW_KEY_PASSPHRASE";
/// A macro to print when the debug CLI arg is enabled.
macro_rules! debug_println {
    ($($arg:tt)*) => {
		if let Ok(enabled) = env::var(BW_SSH_DEBUG_ENV_KEY) {
			if bool::from_str(&enabled).unwrap_or(false) {
				println!("{}",format!($($arg)*).yellow());
			}
		}
        if let Ok(enabled) = env::var(BW_SSH_DEBUG_ENV_KEY) {
            if bool::from_str(&enabled).unwrap_or(false) {
                println!("{}",format!($($arg)*).yellow());
            }
        }
    }
}

macro_rules! warn_println {
    ($($arg:tt)*) => {
		println!("{}",format!($($arg)*).yellow());
        println!("{}",format!($($arg)*).yellow());
    }
}

macro_rules! info_println {
    ($($arg:tt)*) => {
		println!("{}",format!($($arg)*).green());
        println!("{}",format!($($arg)*).green());
    }
}

fn main() -> Result<()> {
	// This environment variable only set when calling ssh-agent, so return the passphrase to authenticate and then quit.
	let unlock_passphrase = env::var(BW_FIELD_KEY_PASSPHRASE);
	if unlock_passphrase.is_ok() {
		println!("{}", unlock_passphrase.unwrap());
		return Ok(())
	}

	// Parse args and set the debug environment variable
    // This environment variable only set when calling ssh-agent, so return the passphrase to authenticate and then quit.
    let unlock_passphrase = env::var(BW_FIELD_KEY_PASSPHRASE);
    if unlock_passphrase.is_ok() {
        println!("{}", unlock_passphrase.unwrap());
        return Ok(())
    }

    // Parse args and set the debug environment variable
    let args: util::Cli = util::Cli::parse_args_default_or_exit();

	env::set_var(BW_SSH_DEBUG_ENV_KEY, OsString::from(args.debug.to_string()));

	// Print the version information and exit if the --version flag was passed.
	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)?;
		debug_println!("Found {} folder(s) named `{}`", folders.len().to_string().cyan(), args.folder.cyan());
		
		// Retrieve items from each folder since there may be multiple folders with the same name.
		// Pre-filter the items by checking for the presence of custom fields AND at least one attachment.
		for folder in folders {
			let folder_items: Vec<BitwardenItem> = bwutil::exec_list_folder_items(&session_token, &folder.id)?;
			let folder_items: Vec<&BitwardenItem> = folder_items
				.iter()
				.filter(|&item| item.fields.is_some() && item.attachments.is_some())
				.collect();
			
			debug_println!("Found {} item(s) in folder `{}` id({}) with at least one custom field and attachment", folder_items.len().to_string().cyan(), folder.name.cyan(), folder.id.cyan());
			
			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 key_filename: String = fields
						.iter()
						.find(|field| field.name.to_lowercase().eq(&args.key.to_lowercase()) && field.value.is_some())
						.map(|field| field.value.as_ref().unwrap().clone())
						.unwrap_or_default();

					if !&key_filename.is_empty() {
						let key_passphrase: String = fields
							.iter()
							.find(|field| field.name.to_lowercase().eq(&args.passphrase.to_lowercase()) && field.value.is_some())
							.map(|field| field.value.as_ref().unwrap().clone())
							.unwrap_or_default();

						if let Some(attachments) = &item.attachments {
							attachments
							.iter()
							.filter(|attachment| attachment.file_name.eq(&key_filename))
							.for_each(|attachment| register_key(&item, &attachment.id, &key_passphrase, &session_token).unwrap());
						}
					}
				}
			}
		}
	}

	Ok(())
    env::set_var(BW_SSH_DEBUG_ENV_KEY, OsString::from(args.debug.to_string()));

    // Print the version information and exit if the --version flag was passed.
    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)?;
        debug_println!("Found {} folder(s) named `{}`", folders.len().to_string().cyan(), args.folder.cyan());
        
        // Retrieve items from each folder since there may be multiple folders with the same name.
        // Pre-filter the items by checking for the presence of custom fields AND at least one attachment.
        for folder in folders {
            let folder_items: Vec<BitwardenItem> = bwutil::exec_list_folder_items(&session_token, &folder.id)?;
            let folder_items: Vec<&BitwardenItem> = folder_items
                .iter()
                .filter(|&item| item.fields.is_some() && item.attachments.is_some())
                .collect();
            
            debug_println!("Found {} item(s) in folder `{}` id({}) with at least one custom field and attachment", folder_items.len().to_string().cyan(), folder.name.cyan(), folder.id.cyan());
            
            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 key_filename: String = fields
                        .iter()
                        .find(|field| field.name.to_lowercase().eq(&args.key.to_lowercase()) && field.value.is_some())
                        .map(|field| field.value.as_ref().unwrap().clone())
                        .unwrap_or_default();

                    if !&key_filename.is_empty() {
                        let key_passphrase: String = fields
                            .iter()
                            .find(|field| field.name.to_lowercase().eq(&args.passphrase.to_lowercase()) && field.value.is_some())
                            .map(|field| field.value.as_ref().unwrap().clone())
                            .unwrap_or_default();

                        if let Some(attachments) = &item.attachments {
                            attachments
                            .iter()
                            .filter(|attachment| attachment.file_name.eq(&key_filename))
                            .for_each(|attachment| register_key(&item, &attachment.id, &key_passphrase, &session_token).unwrap());
                        }
                    }
                }
            }
        }
    }

    Ok(())
}

/// Gets a session token from the active Bitwarden session. The checking process is as follows:
@@ -115,75 +115,74 @@ fn main() -> Result<()> {
/// 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<String> {
	info_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) => {
				info_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
				warn_println!("{} is not set. Attempting to login.", SESSION_ENV_KEY);
				let token: &String = &bwutil::get_session_token()?;

				if !token.is_empty() {
					info_println!("Successfully unlocked. To re-use this session, run:");
					info_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)
    info_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) => {
                info_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
                warn_println!("{} is not set. Attempting to login.", SESSION_ENV_KEY);
                let token: &String = &bwutil::get_session_token()?;

                if !token.is_empty() {
                    info_println!("Successfully unlocked. To re-use this session, run:");
                    info_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)
}

/// Registers a key with `ssh-agent`.
/// First retrieves the attachment from `bitwarden-cli` and then passes it to `ssh-agent`.
fn register_key(item: &BitwardenItem, attachment_id: &str, passphrase: &str, session_token: &str) -> Result<()> {
	debug_println!("Item `{}` id({}) meets all requirements. Adding to `{}`", item.name.cyan(), item.id.to_string().cyan(), "ssh-agent".cyan());
    debug_println!("Item `{}` id({}) meets all requirements. Adding to `{}`", item.name.cyan(), item.id.to_string().cyan(), "ssh-agent".cyan());
    let bw_command = Command::new("bw")
		.arg("get")
		.arg("attachment")
		.arg(attachment_id)
		.arg("--itemid")
		.arg(&item.id)
		.arg("--raw")
		.arg("--session")
		.arg(session_token)
        .arg("get")
        .arg("attachment")
        .arg(attachment_id)
        .arg("--itemid")
        .arg(&item.id)
        .arg("--raw")
        .arg("--session")
        .arg(session_token)
        .stdout(Stdio::piped())
        .spawn()
		.with_context(|| 
			format!("Error running command:\n  `bw get attachment {} --itemid {} --raw --session **REDACTED**`", attachment_id, item.id)
		)?;
        .with_context(|| 
            format!("Error running command:\n  `bw get attachment {} --itemid {} --raw --session **REDACTED**`", attachment_id, item.id)
        )?;

	let app_path = env::current_exe()?;
	let app_path = app_path.to_string_lossy().to_string();
    let app_path = env::current_exe()?.to_string_lossy().to_string();

    let ssh_command = Command::new("ssh-add")
		.env("SSH_ASKPASS", &app_path)
		.env(BW_FIELD_KEY_PASSPHRASE, passphrase)
        .env("SSH_ASKPASS", &app_path)
        .env(BW_FIELD_KEY_PASSPHRASE, passphrase)
        .arg("-")
        .stdin(Stdio::from(bw_command.stdout.unwrap())) // Pipe through.
        .spawn()
		.with_context(|| 
			format!("Error running command:\n  `SSH_ASKPASS=\"{}\" {}=\"{}\"ssh-add -`", app_path, BW_FIELD_KEY_PASSPHRASE, passphrase)
		)?;
        .with_context(|| 
            format!("Error running command:\n  `SSH_ASKPASS=\"{}\" {}=\"{}\"ssh-add -`", app_path, BW_FIELD_KEY_PASSPHRASE, passphrase)
        )?;
    let output = ssh_command.wait_with_output().unwrap();
    let result = String::from_utf8(output.stdout).unwrap();
	if !result.is_empty() {
		println!("{:?}", result);
	}
    if !result.is_empty() {
        println!("{:?}", result);
    }
    
    Ok(())
}
\ No newline at end of file
diff --git a/src/util.rs b/src/util.rs
index 857abf5..05df314 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -1,29 +1,40 @@
use std::env;
use anyhow::Result;
use gumdrop::Options;
use std::env;

/// Represents command-line parameters.
#[derive(Debug, Options)]
pub struct Cli {
	#[options(help = "print help message and exit")]
    #[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 filename is stored", meta = "NAME", default = "private")]
	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 information and exit")]
    #[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 filename is stored",
        meta = "NAME",
        default = "private"
    )]
    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 information and exit")]
    pub version: bool,
}

/// Retrieves the version information in the form of `PKG_NAME` - `PKG_VERSION`.
pub fn get_version_string() -> Result<String> {
	let name = env!("CARGO_PKG_NAME");
	let version = env!("CARGO_PKG_VERSION");
	return Ok(format!("{} {}", name, version))
}
\ No newline at end of file
    let name = env!("CARGO_PKG_NAME");
    let version = env!("CARGO_PKG_VERSION");
    return Ok(format!("{} {}", name, version));
}