From 8715683b4e26961fa26f1a618a70a64332d384b0 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Sun, 18 Feb 2024 20:44:31 +0100 Subject: [PATCH] initial commit --- Cargo.toml | 15 ++ src/bitwarden_api/mod.rs | 87 +++++++ src/bitwarden_api/model/mod.rs | 2 + src/bitwarden_api/model/response/mod.rs | 125 ++++++++++ src/main.rs | 290 ++++++++++++++++++++++++ 5 files changed, 519 insertions(+) create mode 100644 Cargo.toml create mode 100644 src/bitwarden_api/mod.rs create mode 100644 src/bitwarden_api/model/mod.rs create mode 100644 src/bitwarden_api/model/response/mod.rs create mode 100644 src/main.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7083cd9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[package] +name = "bw-menu" +version = "0.1.0" +authors = ["Navid Sassan "] +edition = "2021" + +[dependencies] +clap = { version = "4.4", features = ["derive"] } +env_logger = "0.10" +itertools = "0.11" +log = "0.4" +reqwest = { version = "0.11", features = ["blocking", "json"] } +serde = { version = "1.0", features = ["derive"] } diff --git a/src/bitwarden_api/mod.rs b/src/bitwarden_api/mod.rs new file mode 100644 index 0000000..0fbef6a --- /dev/null +++ b/src/bitwarden_api/mod.rs @@ -0,0 +1,87 @@ +use reqwest; + +pub mod model; + +// https://bitwarden.com/help/vault-management-api/ + +pub fn sync() -> Result> { + let client = reqwest::blocking::Client::new(); + let response = client.post("http://localhost:8087/sync") + .send()? + .json::()?; + + if response.success { + Ok(response) + } else { + Err("success != true in JSON response".into()) + } +} + +pub fn status() -> Result> { + let client = reqwest::blocking::Client::new(); + let response = client.get("http://localhost:8087/status") + .send()? + .json::()?; + + if response.success { + Ok(response) + } else { + Err("success != true in JSON response".into()) + } +} + +pub fn list_object_items() -> Result> { + let client = reqwest::blocking::Client::new(); + let response = client.get("http://localhost:8087/list/object/items") + // let response = client.get("http://localhost:8087/list/object/items?folderId=50d2f507-8f0a-416c-80a4-afb100a77700") + .send()?; + + // dbg!(response.text()); + // panic!(); + + let json = response.json::()?; + if json.success { + Ok(json) + } else { + Err("success != true in JSON response".into()) + } +} + +pub fn list_object_collections() -> Result> { + let client = reqwest::blocking::Client::new(); + let response = client.get("http://localhost:8087/list/object/collections") + .send()? + .json::()?; + + if response.success { + Ok(response) + } else { + Err("success != true in JSON response".into()) + } +} + +pub fn list_object_folders() -> Result> { + let client = reqwest::blocking::Client::new(); + let response = client.get("http://localhost:8087/list/object/folders") + .send()? + .json::()?; + + if response.success { + Ok(response) + } else { + Err("success != true in JSON response".into()) + } +} + +pub fn object_item(id: &String) -> Result> { + let client = reqwest::blocking::Client::new(); + let response = client.get(format!("http://localhost:8087/object/item/{}", id)) + .send()? + .json::()?; + + if response.success { + Ok(response) + } else { + Err("success != true in JSON response".into()) + } +} diff --git a/src/bitwarden_api/model/mod.rs b/src/bitwarden_api/model/mod.rs new file mode 100644 index 0000000..28432f5 --- /dev/null +++ b/src/bitwarden_api/model/mod.rs @@ -0,0 +1,2 @@ +#[allow(non_snake_case)] +pub mod response; diff --git a/src/bitwarden_api/model/response/mod.rs b/src/bitwarden_api/model/response/mod.rs new file mode 100644 index 0000000..e73628c --- /dev/null +++ b/src/bitwarden_api/model/response/mod.rs @@ -0,0 +1,125 @@ +use serde::Deserialize; + +// TODO: either use rbw-agent as the backend, or adjust structs to rbw db.rs & api.rs + +#[derive(Debug, Deserialize)] +pub struct Sync { + pub success: bool, +} + + +#[derive(Debug, Deserialize)] +pub struct Status { + pub success: bool, + pub data: StatusData, +} + +#[derive(Debug, Deserialize)] +pub struct StatusData { + pub object: String, + pub template: StatusDataTemplate, +} + +#[derive(Debug, Deserialize)] +pub struct StatusDataTemplate { + pub serverUrl: String, + pub lastSync: String, + pub userEmail: String, + pub userId: String, + pub status: String, +} + + +#[derive(Debug, Deserialize)] +pub struct ListObjectItems { + pub success: bool, + pub data: ListObjectItemsData, +} + +#[derive(Debug, Deserialize)] +pub struct ListObjectItemsData { + pub object: String, + pub data: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ListObjectItemsDataData { + pub object: String, + pub id: String, + pub organizationId: Option, + pub folderId: Option, + #[serde(rename="type")] + pub _type: i32, + pub reprompt: i32, + pub name: String, + pub notes: Option, + pub favorite: bool, + pub login: Option, + pub collectionIds: Vec, + pub revisionDate: String, +} + +#[derive(Debug, Deserialize)] +pub struct ListObjectItemsDataLogin { + pub uris: Option>, + pub username: Option, + pub password: Option, + pub totp: Option, + pub passwordRevisionDate: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ListObjectItemsDataLoginURIs { + #[serde(rename="match")] + pub _match: Option, + pub uri: Option, +} + + +#[derive(Debug, Deserialize)] +pub struct ListObjectCollections { + pub success: bool, + pub data: ListObjectCollectionsData, +} + +#[derive(Debug, Deserialize)] +pub struct ListObjectCollectionsData { + pub object: String, + pub data: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ListObjectCollectionsDataData { + pub object: String, + pub id: String, + pub organizationId: String, + pub name: String, + pub externalId: Option, +} + + +#[derive(Debug, Deserialize)] +pub struct ListObjectFolders { + pub success: bool, + pub data: ListObjectFoldersData, +} + +#[derive(Debug, Deserialize)] +pub struct ListObjectFoldersData { + pub object: String, + pub data: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ListObjectFoldersDataData { + pub object: String, + pub id: Option, + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub struct ObjectItem { + pub success: bool, + pub data: ListObjectItemsDataData, +} + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0610cf6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,290 @@ +use clap::{Parser, Subcommand}; +use itertools::Itertools; +use log::{debug, error, info}; +use std::env; +use std::io::Write; +use std::process::Stdio; +use std::{process, collections::HashMap}; + +use crate::bitwarden_api::model; + +mod bitwarden_api; + +// GOALS: +// * `search` item via dmenu / rofi / cli +// * `search --mark` parameter that remembers the marked entry +// * `mark get {field}` returns the field of the marked element (id, title, folder, username, password, etc) +// * "scroll" through marked history, mark as active again +// * automatically execute `export BW_SESSION="$(bw unlock --raw)" && bw serve --hostname 127.0.0.1&` if required +// * unlock with system keyring if possible +// * autotype? + + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] // Read from `Cargo.toml` +struct Cli { + #[command(subcommand)] + command: CliCommands, +} + +#[derive(Subcommand)] +enum CliCommands { + Select { + // Show the details of the item + title: Option, + }, +} + + +fn main() { + env_logger::init(); + + let cli = Cli::parse(); + match &cli.command { + CliCommands::Select{title: _} => { + // the title is always ignored, we want the id which we get via the environment variables + debug!("Running in select mode"); + rofi(); + } + } +} + + +// the flow is: +// 1. bw-menu starts rofi in the script mode +// 2. rofi calls bw-menu calls bw-menu to get a list of items to display +// 3. the user interacts with the list +// 4. rofi sets environment variables, and calls bw-menu with the title of the selected item +// 5. bw-menu acts based on the environment variables +fn rofi() { + let rofi_retv = env::var("ROFI_RETV"); + let rofi_info = env::var("ROFI_INFO"); + dbg!(&rofi_retv); + dbg!(&rofi_info); + + match rofi_retv { + Err(_) => { + // this means rofi is not running yet, so let's start it + debug!("starting initial rofi process"); + let _ = process::Command::new("rofi") + .arg("-show") + .arg("bw") + .arg("-modes") + .arg("bw:./target/debug/bw-menu select") // todo: set the path according to argv[0] + // we need to remap some default so that we can use the keys for custom binds + .arg("-kb-accept-alt") + .arg("F12") + .arg("-kb-accept-custom") + .arg("F11") + // set our custom keybinds + .arg("-kb-custom-1") + .arg("Shift+Return") + .arg("-kb-custom-2") + .arg("Control+Return") + .spawn() + .expect("Failed to spawn child process"); // TODO: better error handling + } + Ok(_) => { + // this means we are being called by rofi as a custom script + // either generate the list, or act on input + let rofi_retv = rofi_retv.unwrap_or_default().to_string().parse::().unwrap_or(0); + let rofi_info = rofi_info.unwrap_or_default(); + + if rofi_retv == 0 { + // 0: Initial call of script + generate_rofi_list(get_bitwarden_logins()); + } else { + let item = match crate::bitwarden_api::object_item(&rofi_info) { + Ok(response) => response, + Err(msg) => { + error!("Failed to get Bitwarden item with id {}:\n{}", rofi_info, msg); + process::exit(1); + } + }; + // 1: Selected an entry + if rofi_retv == 1 { + info!("copy password"); + if let Some(login) = item.data.login { + if let Some(password) = login.password { + save_to_clipboard(password); + } + } + // 10-28: Custom keybinding 1-19 + } else if rofi_retv == 10 { + info!("copy username"); + if let Some(login) = item.data.login { + if let Some(username) = login.username { + save_to_clipboard(username); + } + } + } else if rofi_retv == 11 { + info!("show item"); + println!("\0prompt\x1fSelect field"); + println!("\0message\x1fReturn | Copy field"); + println!("{}\0icon\x1f{}\x1finfo\x1f{}", "TODO", "folder", "single-item:todo"); + // TODO: not sure how to proceed here. currently pressing return leads to the "copy password" action + // one option is to pass the action in the info and parsing that + + // if let Some(login) = item.data.login { + // if let Some(username) = login.username { + // println!("Username: {}", username); + // } + // } + } + // we are done here + process::exit(0); + } + } + } +} + +fn get_bitwarden_logins() -> HashMap { + match crate::bitwarden_api::status() { + Ok(response) => { + if response.data.template.status != "unlocked" { + error!("Vault is {}, should be unlocked!", response.data.template.status); + process::exit(1); + } + } + Err(msg) => { + error!("Failed to get Bitwarden status:\n{msg}"); + process::exit(1); + } + } + + match crate::bitwarden_api::sync() { + Ok(_) => info!("Sync'd Bitwarden Vault."), + Err(msg) => { + error!("Failed to sync Bitwarden Vault:\n{msg}"); + process::exit(1); + } + } + + let items = match crate::bitwarden_api::list_object_items() { + Ok(response) => { + info!("Got list of Bitwarden items."); + response.data.data + } + Err(msg) => { + error!("Failed to get list of Bitwarden items:\n{msg}"); + process::exit(1); + } + }; + + let collections: HashMap = match crate::bitwarden_api::list_object_collections() { + Ok(response) => { + info!("Got list of Bitwarden collections."); + response.data.data.iter() + .map(|item| (item.id.clone(), item.name.clone())) + .collect() + } + Err(msg) => { + error!("Failed to get list of Bitwarden collections:\n{msg}"); + process::exit(1); + } + }; + + let folders: HashMap = match crate::bitwarden_api::list_object_folders() { + Ok(response) => { + info!("Got list of Bitwarden folders."); + response.data.data.iter() + .filter(|item| item.id.is_some()) + .map(|item| (item.id.as_ref().unwrap().clone(), item.name.clone())) + .collect() + } + Err(msg) => { + error!("Failed to get list of Bitwarden folders:\n{msg}"); + process::exit(1); + } + }; + + let mut logins: HashMap = HashMap::new(); + for item in items { + if item.login.is_some() { + let title = get_title(&item, &folders, &collections); + debug!("Login: {}", title); + logins.insert(title, item); + } + // break; + } + // dbg!(&logins); + logins +} + + +fn generate_rofi_list(logins: HashMap) { + println!("\0prompt\x1fSelect item"); + println!("\0message\x1fReturn | Copy password\rShift+Return | Copy username or selected field\rControl+Return | Show item"); + println!("\0use-hot-keys\x1ftrue"); + println!("\0no-custom\x1ftrue"); + println!("\0markup-rows\x1ftrue"); + + for title in logins.keys().sorted() { + print!("{title}"); + let icon = "folder"; // TODO: what icons are available? ~/.icons seems to work + println!("\0icon\x1f{}\x1finfo\x1f{}", icon, logins[title].id); + } +} + + +fn get_title(item: &model::response::ListObjectItemsDataData, folders: &HashMap, collections: &HashMap) -> String { + let mut title = String::new(); + let login = item.login.as_ref().unwrap(); + + if item.folderId.is_some() { + title.push_str(&format!("{}/", folders[item.folderId.as_ref().unwrap()])); + } + + // TODO: how do we handle multiple collections nicely? i dont like the pipe for searching. my + // fav solution right now is to display the entry multiple times, eg "000/000-p-xca01", "001/000-p-xca01" + for collection_id in &item.collectionIds { + title.push_str(&collections[collection_id]); + } + if !item.collectionIds.is_empty() { + title.push_str("/"); + } + + if title.len() == 0 { + title.push_str("ungrouped/"); + } + + title.push_str(&item.name); + + if login.username.is_some() { + title.push_str(&format!(" ({})", login.username.as_ref().unwrap())); + } + + title +} + + +fn save_to_clipboard(input: String) { + // using xclip blocks the app. maybe piping the output to /dev/null would fix this + // let mut cmd = process::Command::new("xclip") + // .arg("-in") + // .arg("-selection") + // .arg("clipboard") + // .stdin(Stdio::piped()) + // .spawn() + // .expect("Failed to spawn child process"); + + let mut cmd = process::Command::new("xsel") + .arg("--input") + .arg("--clipboard") + .stdin(Stdio::piped()) + .spawn() + .expect("Failed to spawn child process"); // TODO: better error handling + + let mut stdin = cmd.stdin.take().expect("Failed to open stdin."); + + let child_thread = std::thread::spawn(move || { + stdin.write_all(input.as_bytes()).expect("Failed to write to stdin"); + }); + + let result = child_thread.join(); + + match result { + Ok(_) => debug!("Child thread completed successfully"), + Err(e) => error!("Child thread encountered an error: {:?}", e), + } +}