index : bitwarden-ssh-agent.git

ascending towards madness

author holly sparkles <sparkles@holly.sh> 2023-08-01 21:06:18.0 +00:00:00
committer holly sparkles <sparkles@holly.sh> 2023-08-01 21:06:18.0 +00:00:00
commit
bebbc93e736b9cc75ade477fac5c7fc147883130 [patch]
tree
dfcf96bd7e9dd28e6122497c39a29f24dad529bf
parent
05137fc1406b58bf3af06c8708f7048a276835e1
parent
83f70ae1ec1048f145a831343adb2a10c0d58959
download
bebbc93e736b9cc75ade477fac5c7fc147883130.tar.gz

Merge branch 'feature/bw-integration' into develop



Diff

 Cargo.toml    |   4 +-
 src/bwutil.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/main.rs   | 114 +++++++++++++++++++++++++++++++++++++++++++++++----------
 src/util.rs   |  29 +++++++++++++++-
 4 files changed, 244 insertions(+), 20 deletions(-)

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<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: 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<bool> {
	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<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")

	}
	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<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."))
	}
	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."))
	}
	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<String>,
	#[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<String> {
	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<String> {
	let name = env!("CARGO_PKG_NAME");
	let version = env!("CARGO_PKG_VERSION");
	return Ok(format!("{} {}", name, version))
}
\ No newline at end of file