diff --git a/Cargo.lock b/Cargo.lock index 9cacc82ba..a0ee721e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,14 +771,20 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" name = "bw" version = "0.0.2" dependencies = [ + "bat", "bitwarden", "bitwarden-cli", "bitwarden-crypto", + "chrono", "clap", "color-eyre", + "comfy-table", "env_logger", "inquire", "log", + "serde", + "serde_json", + "serde_yaml", "tempfile", "tokio", ] diff --git a/crates/bitwarden-core/src/auth/login/api_key.rs b/crates/bitwarden-core/src/auth/login/api_key.rs index cce246528..bc023ad61 100644 --- a/crates/bitwarden-core/src/auth/login/api_key.rs +++ b/crates/bitwarden-core/src/auth/login/api_key.rs @@ -13,6 +13,9 @@ use crate::{ require, Client, }; +#[cfg(feature = "state")] +use super::AuthSettings; + pub(crate) async fn login_api_key( client: &Client, input: &ApiKeyLoginRequest, @@ -45,16 +48,37 @@ pub(crate) async fn login_api_key( .set_login_method(LoginMethod::User(UserLoginMethod::ApiKey { client_id: input.client_id.to_owned(), client_secret: input.client_secret.to_owned(), - email, - kdf, + email: email.clone(), + kdf: kdf.clone(), })); let user_key: EncString = require!(r.key.as_deref()).parse()?; let private_key: EncString = require!(r.private_key.as_deref()).parse()?; - client - .internal - .initialize_user_crypto_master_key(master_key, user_key, private_key)?; + client.internal.initialize_user_crypto_master_key( + master_key, + user_key.clone(), + private_key.clone(), + )?; + + #[cfg(feature = "state")] + { + let setting = AuthSettings { + email: email.clone(), + token: r.access_token.clone(), + refresh_token: r.refresh_token.clone(), + kdf: kdf.clone(), + user_key: user_key.to_string(), + private_key: private_key.to_string(), + }; + + client + .platform() + .settings_repository + .set("auth", &serde_json::to_string(&setting)?) + .await + .unwrap(); + } } ApiKeyLoginResponse::process_response(response) diff --git a/crates/bitwarden-core/src/auth/login/mod.rs b/crates/bitwarden-core/src/auth/login/mod.rs index 0c90dc973..3652db9ed 100644 --- a/crates/bitwarden-core/src/auth/login/mod.rs +++ b/crates/bitwarden-core/src/auth/login/mod.rs @@ -92,3 +92,15 @@ pub(crate) fn parse_prelogin(response: PreloginResponseModel) -> Result { }, }) } + +#[cfg(feature = "state")] +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct AuthSettings { + pub email: String, + pub token: String, + pub refresh_token: Option, + pub kdf: Kdf, + + pub user_key: String, + pub private_key: String, +} diff --git a/crates/bitwarden-core/src/platform/client_platform.rs b/crates/bitwarden-core/src/platform/client_platform.rs index 1f117d5fe..99df0cdd9 100644 --- a/crates/bitwarden-core/src/platform/client_platform.rs +++ b/crates/bitwarden-core/src/platform/client_platform.rs @@ -5,8 +5,13 @@ use super::{ }; use crate::{error::Result, Client}; +#[cfg(feature = "state")] +use super::settings_repository::SettingsRepository; + pub struct ClientPlatform<'a> { pub(crate) client: &'a Client, + #[cfg(feature = "state")] + pub settings_repository: SettingsRepository, } impl<'a> ClientPlatform<'a> { @@ -28,6 +33,10 @@ impl<'a> ClientPlatform<'a> { impl<'a> Client { pub fn platform(&'a self) -> ClientPlatform<'a> { - ClientPlatform { client: self } + ClientPlatform { + client: self, + #[cfg(feature = "state")] + settings_repository: SettingsRepository::new(self.internal.db.clone()), + } } } diff --git a/crates/bitwarden-core/src/platform/mod.rs b/crates/bitwarden-core/src/platform/mod.rs index 031554be0..50ae497e8 100644 --- a/crates/bitwarden-core/src/platform/mod.rs +++ b/crates/bitwarden-core/src/platform/mod.rs @@ -2,6 +2,8 @@ pub mod client_platform; mod generate_fingerprint; mod get_user_api_key; mod secret_verification_request; +#[cfg(feature = "state")] +mod settings_repository; pub use generate_fingerprint::{FingerprintRequest, FingerprintResponse}; pub(crate) use get_user_api_key::get_user_api_key; diff --git a/crates/bitwarden-core/src/platform/settings_repository.rs b/crates/bitwarden-core/src/platform/settings_repository.rs new file mode 100644 index 000000000..f04e48a3a --- /dev/null +++ b/crates/bitwarden-core/src/platform/settings_repository.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +use bitwarden_db::{params, Database, DatabaseError, DatabaseTrait}; +use tokio::sync::Mutex; + +pub struct SettingsRepository { + db: Arc>, +} + +impl SettingsRepository { + pub fn new(db: Arc>) -> Self { + Self { db: db.clone() } + } + + pub async fn get(&self, key: &str) -> Result, DatabaseError> { + let guard = self.db.lock().await; + + let res = guard + .query_map( + "SELECT value FROM settings WHERE key = ?1", + [key], + |row| -> Result { row.get(0) }, + ) + .await? + .first() + .map(|x| x.to_owned()); + + Ok(res) + } + + pub async fn set(&self, key: &str, value: &str) -> Result<(), DatabaseError> { + let guard = self.db.lock().await; + + guard + .execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + ) + .await?; + + Ok(()) + } +} diff --git a/crates/bw/Cargo.toml b/crates/bw/Cargo.toml index 7361f15b6..800958789 100644 --- a/crates/bw/Cargo.toml +++ b/crates/bw/Cargo.toml @@ -14,15 +14,26 @@ repository.workspace = true license-file.workspace = true [dependencies] -bitwarden = { workspace = true, features = ["internal"] } +bat = { version = "0.24.0", features = [ + "regex-onig", +], default-features = false } +bitwarden = { workspace = true, features = ["internal", "state"] } bitwarden-cli = { workspace = true } bitwarden-crypto = { workspace = true } +chrono = { version = "0.4.38", features = [ + "clock", + "std", +], default-features = false } clap = { version = "4.5.4", features = ["derive", "env"] } +comfy-table = "7.1.1" color-eyre = "0.6.3" env_logger = "0.11.1" inquire = "0.7.0" log = "0.4.20" tokio = { version = "1.36.0", features = ["rt-multi-thread", "macros"] } +serde = "1.0.196" +serde_json = ">=1.0.96, <2" +serde_yaml = "0.9" [dev-dependencies] tempfile = "3.10.0" diff --git a/crates/bw/src/auth/login.rs b/crates/bw/src/auth/login.rs index 51fe64a39..abfa2bc94 100644 --- a/crates/bw/src/auth/login.rs +++ b/crates/bw/src/auth/login.rs @@ -111,6 +111,13 @@ pub(crate) async fn login_api_key( }) .await?; + let res = client + .vault() + .sync(&SyncRequest { + exclude_subdomains: Some(true), + }) + .await?; + debug!("{:?}", result); Ok(()) diff --git a/crates/bw/src/commands/mod.rs b/crates/bw/src/commands/mod.rs new file mode 100644 index 000000000..f0731793c --- /dev/null +++ b/crates/bw/src/commands/mod.rs @@ -0,0 +1 @@ +pub(crate) mod vault; diff --git a/crates/bw/src/commands/vault.rs b/crates/bw/src/commands/vault.rs new file mode 100644 index 000000000..758be994a --- /dev/null +++ b/crates/bw/src/commands/vault.rs @@ -0,0 +1,77 @@ +use bitwarden::{ + auth::login::AuthSettings, + mobile::crypto::{InitUserCryptoMethod, InitUserCryptoRequest}, + vault::{CipherListView, ClientVaultExt}, + Client, Error, +}; +use bitwarden_cli::Color; + +use clap::Subcommand; + +use crate::render::{serialize_response, Output, OutputSettings, TableSerialize}; + +#[derive(Subcommand, Clone)] +pub(crate) enum VaultCommands { + Get { id: String }, + List {}, + Create {}, +} + +pub(crate) async fn process_command( + command: VaultCommands, + client: Client, + password: Option, +) -> Result<(), Error> { + // TODO: This should be moved into the SDK + let setting = client + .platform() + .settings_repository + .get("auth") + .await + .unwrap() + .unwrap(); + let setting = serde_json::from_str::(&setting)?; + + client + .crypto() + .initialize_user_crypto(InitUserCryptoRequest { + kdf_params: setting.kdf, + email: setting.email, + private_key: setting.private_key, + method: InitUserCryptoMethod::Password { + password: password.unwrap(), + user_key: setting.user_key, + }, + }) + .await + .unwrap(); + + match command { + VaultCommands::Get { id } => todo!(), + VaultCommands::List {} => { + let ciphers = client.vault().cipher_repository.get_all().await.unwrap(); + + let dec = client.vault().ciphers().decrypt_list(ciphers)?; + + /*for cipher in dec { + println!("{}", cipher.name); + }*/ + + let output_settings = OutputSettings::new(Output::Table, Color::Auto); + serialize_response(dec, output_settings); + + Ok(()) + } + VaultCommands::Create {} => todo!(), + } +} + +impl TableSerialize<2> for CipherListView { + fn get_headers() -> [&'static str; 2] { + ["ID", "Name"] + } + + fn get_values(&self) -> Vec<[String; 2]> { + vec![[self.id.unwrap_or_default().to_string(), self.name.clone()]] + } +} diff --git a/crates/bw/src/main.rs b/crates/bw/src/main.rs index f9938c1a9..0bc5b1c84 100644 --- a/crates/bw/src/main.rs +++ b/crates/bw/src/main.rs @@ -10,8 +10,11 @@ use inquire::Password; use render::Output; mod auth; +mod commands; mod render; +use commands::vault::{self, VaultCommands}; + #[derive(Parser, Clone)] #[command(name = "Bitwarden CLI", version, about = "Bitwarden CLI", long_about = None)] struct Cli { @@ -44,9 +47,11 @@ enum Commands { }, #[command(long_about = "Manage vault items")] - Item { + Vault { #[command(subcommand)] - command: ItemCommands, + command: VaultCommands, + #[arg(long, global = true, help = "Master password")] + password: Option, }, #[command(long_about = "Pull the latest vault data from the server")] @@ -85,12 +90,6 @@ enum LoginCommands { }, } -#[derive(Subcommand, Clone)] -enum ItemCommands { - Get { id: String }, - Create {}, -} - #[derive(Subcommand, Clone)] enum GeneratorCommands { Password(PasswordGeneratorArgs), @@ -213,7 +212,9 @@ async fn process_commands() -> Result<()> { match command { Commands::Login(_) => unreachable!(), Commands::Register { .. } => unreachable!(), - Commands::Item { command: _ } => todo!(), + Commands::Vault { command, password } => { + vault::process_command(command, client, password).await? + } Commands::Sync {} => todo!(), Commands::Generate { command } => match command { GeneratorCommands::Password(args) => { diff --git a/crates/bw/src/render.rs b/crates/bw/src/render.rs index da8ed4997..433960380 100644 --- a/crates/bw/src/render.rs +++ b/crates/bw/src/render.rs @@ -1,4 +1,8 @@ +use bitwarden_cli::Color; +use chrono::{DateTime, Utc}; use clap::ValueEnum; +use comfy_table::Table; +use serde::Serialize; #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] #[allow(clippy::upper_case_acronyms)] @@ -9,3 +13,94 @@ pub(crate) enum Output { TSV, None, } + +const ASCII_HEADER_ONLY: &str = " -- "; + +pub(crate) struct OutputSettings { + pub(crate) output: Output, + pub(crate) color: Color, +} + +impl OutputSettings { + pub(crate) fn new(output: Output, color: Color) -> Self { + OutputSettings { output, color } + } +} + +pub(crate) fn serialize_response, const N: usize>( + data: T, + output_settings: OutputSettings, +) { + match output_settings.output { + Output::JSON => { + let mut text = + serde_json::to_string_pretty(&data).expect("Serialize should be infallible"); + // Yaml/table/tsv serializations add a newline at the end, so we do the same here for + // consistency + text.push('\n'); + pretty_print("json", &text, output_settings.color); + } + Output::YAML => { + let text = serde_yaml::to_string(&data).expect("Serialize should be infallible"); + pretty_print("yaml", &text, output_settings.color); + } + Output::Table => { + let mut table = Table::new(); + table + .load_preset(ASCII_HEADER_ONLY) + .set_header(T::get_headers()) + .add_rows(data.get_values()); + + println!("{table}"); + } + Output::TSV => { + println!("{}", T::get_headers().join("\t")); + + let rows: Vec = data + .get_values() + .into_iter() + .map(|row| row.join("\t")) + .collect(); + println!("{}", rows.join("\n")); + } + Output::None => {} + } +} + +fn pretty_print(language: &str, data: &str, color: Color) { + if color.is_enabled() { + bat::PrettyPrinter::new() + .input_from_bytes(data.as_bytes()) + .language(language) + .print() + .expect("Input is valid"); + } else { + print!("{}", data); + } +} + +// We're using const generics for the array lengths to make sure the header count and value count +// match +pub(crate) trait TableSerialize: Sized { + fn get_headers() -> [&'static str; N]; + fn get_values(&self) -> Vec<[String; N]>; +} + +// Generic impl for Vec so we can call `serialize_response` with both individual +// elements and lists of elements, like we do with the JSON and YAML cases +impl, const N: usize> TableSerialize for Vec { + fn get_headers() -> [&'static str; N] { + T::get_headers() + } + fn get_values(&self) -> Vec<[String; N]> { + let mut values = Vec::new(); + for t in self { + values.append(&mut t.get_values()); + } + values + } +} + +fn format_date(date: &DateTime) -> String { + date.format("%Y-%m-%d %H:%M:%S").to_string() +}