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/auth_repository.rs b/crates/bitwarden-core/src/auth/auth_repository.rs new file mode 100644 index 000000000..4c7444812 --- /dev/null +++ b/crates/bitwarden-core/src/auth/auth_repository.rs @@ -0,0 +1,40 @@ +use bitwarden_crypto::Kdf; + +use crate::platform::{SettingsRepository, SettingsRepositoryError}; + +const SETTINGS_KEY: &str = "auth"; + +#[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, +} + +pub struct AuthRepository { + settings_repository: SettingsRepository, +} + +impl AuthRepository { + pub fn new(settings_repository: SettingsRepository) -> Self { + Self { + settings_repository, + } + } + + pub(crate) async fn save(&self, setting: AuthSettings) -> Result<(), SettingsRepositoryError> { + self.settings_repository.set(SETTINGS_KEY, &setting).await?; + + Ok(()) + } + + pub(crate) async fn get(&self) -> Result, SettingsRepositoryError> { + let settings = self.settings_repository.get(SETTINGS_KEY).await?; + + Ok(settings) + } +} diff --git a/crates/bitwarden-core/src/auth/client_auth.rs b/crates/bitwarden-core/src/auth/client_auth.rs index 6f1afb135..9a7c7b029 100644 --- a/crates/bitwarden-core/src/auth/client_auth.rs +++ b/crates/bitwarden-core/src/auth/client_auth.rs @@ -1,6 +1,8 @@ #[cfg(feature = "internal")] use bitwarden_crypto::{AsymmetricEncString, DeviceKey, EncString, Kdf, TrustDeviceResponse}; +#[cfg(feature = "state")] +use super::{unlock, AuthRepository, UnlockError}; #[cfg(feature = "internal")] use crate::auth::login::NewAuthRequestResponse; #[cfg(feature = "secrets")] @@ -25,6 +27,8 @@ use crate::{auth::renew::renew_token, error::Result, Client}; pub struct ClientAuth<'a> { pub(crate) client: &'a crate::Client, + #[cfg(feature = "state")] + pub(super) repository: AuthRepository, } impl<'a> ClientAuth<'a> { @@ -132,10 +136,7 @@ impl<'a> ClientAuth<'a> { pub fn trust_device(&self) -> Result { trust_device(self.client) } -} -#[cfg(feature = "internal")] -impl<'a> ClientAuth<'a> { pub async fn login_device( &self, email: String, @@ -153,6 +154,13 @@ impl<'a> ClientAuth<'a> { } } +#[cfg(feature = "state")] +impl<'a> ClientAuth<'a> { + pub async fn unlock(&self, password: String) -> Result<(), UnlockError> { + unlock(self.client, password).await + } +} + #[cfg(feature = "internal")] fn trust_device(client: &Client) -> Result { let enc = client.internal.get_encryption_settings()?; @@ -164,7 +172,11 @@ fn trust_device(client: &Client) -> Result { impl<'a> Client { pub fn auth(&'a self) -> ClientAuth<'a> { - ClientAuth { client: self } + ClientAuth { + client: self, + #[cfg(feature = "state")] + repository: AuthRepository::new(self.platform().settings_repository), + } } } diff --git a/crates/bitwarden-core/src/auth/login/api_key.rs b/crates/bitwarden-core/src/auth/login/api_key.rs index cce246528..5cd00ef44 100644 --- a/crates/bitwarden-core/src/auth/login/api_key.rs +++ b/crates/bitwarden-core/src/auth/login/api_key.rs @@ -2,6 +2,8 @@ use bitwarden_crypto::{EncString, MasterKey}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +#[cfg(feature = "state")] +use crate::auth::AuthSettings; use crate::{ auth::{ api::{request::ApiTokenRequest, response::IdentityTokenResponse}, @@ -45,16 +47,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 + .auth() + .repository + .save(setting) + .await + .expect("Save settings"); + } } ApiKeyLoginResponse::process_response(response) diff --git a/crates/bitwarden-core/src/auth/mod.rs b/crates/bitwarden-core/src/auth/mod.rs index e52870570..558c748ce 100644 --- a/crates/bitwarden-core/src/auth/mod.rs +++ b/crates/bitwarden-core/src/auth/mod.rs @@ -34,6 +34,20 @@ pub use tde::RegisterTdeKeyResponse; #[cfg(feature = "internal")] use crate::error::Result; +#[cfg(feature = "state")] +#[path = ""] +mod state { + mod auth_repository; + pub use auth_repository::{AuthRepository, AuthSettings}; + + mod unlock; + pub(super) use unlock::unlock; + pub use unlock::UnlockError; +} + +#[cfg(feature = "state")] +pub use state::*; + #[cfg(feature = "internal")] fn determine_password_hash( email: &str, diff --git a/crates/bitwarden-core/src/auth/unlock.rs b/crates/bitwarden-core/src/auth/unlock.rs new file mode 100644 index 000000000..3e45e6e03 --- /dev/null +++ b/crates/bitwarden-core/src/auth/unlock.rs @@ -0,0 +1,42 @@ +use bitwarden_crypto::{CryptoError, MasterKey}; +use thiserror::Error; + +use crate::{platform::SettingsRepositoryError, Client}; + +#[derive(Debug, Error)] +pub enum UnlockError { + #[error(transparent)] + SettingRepository(#[from] SettingsRepositoryError), + + #[error(transparent)] + Crypto(#[from] CryptoError), + + #[error(transparent)] + Error(#[from] crate::Error), + + #[error("The client is not authenticated or the session has expired")] + NotAuthenticated, +} + +pub(crate) async fn unlock( + client: &Client, + password: String, +) -> std::result::Result<(), UnlockError> { + let settings = client + .auth() + .repository + .get() + .await? + .ok_or(UnlockError::NotAuthenticated)?; + + // client.internal.set_login_method(UserLoginMethod::ApiKey { client_id: (), client_secret: (), + // email: (), kdf: () }) + let master_key = MasterKey::derive(&password, &settings.email, &settings.kdf)?; + client.internal.initialize_user_crypto_master_key( + master_key, + settings.user_key.parse()?, + settings.private_key.parse()?, + )?; + + Ok(()) +} diff --git a/crates/bitwarden-core/src/platform/client_platform.rs b/crates/bitwarden-core/src/platform/client_platform.rs index 1f117d5fe..b3b4b1387 100644 --- a/crates/bitwarden-core/src/platform/client_platform.rs +++ b/crates/bitwarden-core/src/platform/client_platform.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "state")] +use super::settings_repository::SettingsRepository; use super::{ generate_fingerprint::{generate_fingerprint, generate_user_fingerprint}, get_user_api_key, FingerprintRequest, FingerprintResponse, SecretVerificationRequest, @@ -7,6 +9,8 @@ use crate::{error::Result, Client}; pub struct ClientPlatform<'a> { pub(crate) client: &'a Client, + #[cfg(feature = "state")] + pub settings_repository: SettingsRepository, } impl<'a> ClientPlatform<'a> { @@ -28,6 +32,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..b8c01b9c0 100644 --- a/crates/bitwarden-core/src/platform/mod.rs +++ b/crates/bitwarden-core/src/platform/mod.rs @@ -2,8 +2,11 @@ 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; pub use get_user_api_key::UserApiKeyResponse; pub use secret_verification_request::SecretVerificationRequest; +#[cfg(feature = "state")] +pub use settings_repository::{SettingsRepository, SettingsRepositoryError}; 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..c445b23bb --- /dev/null +++ b/crates/bitwarden-core/src/platform/settings_repository.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use bitwarden_db::{params, Database, DatabaseError, DatabaseTrait}; +use serde::{de::DeserializeOwned, Serialize}; +use thiserror::Error; +use tokio::sync::Mutex; + +#[derive(Debug, Error)] +pub enum SettingsRepositoryError { + #[error(transparent)] + Database(#[from] DatabaseError), + #[error(transparent)] + Serde(#[from] serde_json::Error), +} + +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, SettingsRepositoryError> { + 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| serde_json::from_str::(x)) + .transpose()?; + + Ok(res) + } + + pub async fn set( + &self, + key: &str, + value: &T, + ) -> Result<(), SettingsRepositoryError> { + let value = serde_json::to_string(value)?; + 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..ef730fd32 --- /dev/null +++ b/crates/bw/src/commands/vault.rs @@ -0,0 +1,51 @@ +use bitwarden::{ + 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> { + client.auth().unlock(password.unwrap()).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() +}