diff --git a/crates/bitwarden-cli/src/lib.rs b/crates/bitwarden-cli/src/lib.rs index 8658a254a..ec85f71c2 100644 --- a/crates/bitwarden-cli/src/lib.rs +++ b/crates/bitwarden-cli/src/lib.rs @@ -15,3 +15,29 @@ pub fn text_prompt_when_none(prompt: &str, val: Option) -> InquireResult Text::new(prompt).prompt()? }) } + +/// Try to get a value from CLI arg, then from environment variables, then prompt +/// +/// Checks multiple environment variable names in order (e.g., BW_CLIENTID, BW_CLIENT_ID) +pub fn resolve_user_input_value( + prompt: &str, + cli_val: Option, + env_var_names: &[&str], +) -> InquireResult { + // First check if provided via CLI + if let Some(val) = cli_val { + return Ok(val); + } + + // Then check environment variables + for env_var in env_var_names { + if let Ok(val) = std::env::var(env_var) { + if !val.is_empty() { + return Ok(val); + } + } + } + + // Finally, prompt the user + Text::new(prompt).prompt() +} diff --git a/crates/bitwarden-core/src/client/internal.rs b/crates/bitwarden-core/src/client/internal.rs index 591041177..a5163ee7e 100644 --- a/crates/bitwarden-core/src/client/internal.rs +++ b/crates/bitwarden-core/src/client/internal.rs @@ -285,6 +285,122 @@ impl InternalClient { self.user_id.get().copied() } + /// Export a full session containing all data needed to restore the client state + /// This includes the user key, tokens, and encrypted private/signing keys + #[cfg(feature = "internal")] + pub fn export_session(&self) -> Result { + use bitwarden_encoding::B64; + use serde::{Deserialize, Serialize}; + + use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; + + #[derive(Serialize, Deserialize)] + struct SessionData { + user_key: String, + private_key: Option, + access_token: Option, + refresh_token: Option, + expires_on: Option, + } + + // Get the user encryption key and private key + #[allow(deprecated)] + let (user_key, private_key) = { + let ctx = self.key_store.context(); + let user_key = ctx.dangerous_get_symmetric_key(SymmetricKeyId::User)?; + let private_key = if ctx.has_asymmetric_key(AsymmetricKeyId::UserPrivateKey) { + let key = ctx.dangerous_get_asymmetric_key(AsymmetricKeyId::UserPrivateKey)?; + Some(B64::from(key.to_der()?.as_ref()).to_string()) + } else { + None + }; + (user_key.to_base64().to_string(), private_key) + }; + + // Get the tokens + let tokens = self.tokens.read().expect("RwLock is not poisoned"); + let (access_token, refresh_token, expires_on) = match &*tokens { + Tokens::SdkManaged(sdk_tokens) => ( + sdk_tokens.access_token.clone(), + sdk_tokens.refresh_token.clone(), + sdk_tokens.expires_on, + ), + Tokens::ClientManaged(_) => (None, None, None), + }; + + let session_data = SessionData { + user_key, + private_key, + access_token, + refresh_token, + expires_on, + }; + + // Serialize to JSON and then base64 encode + let json = serde_json::to_string(&session_data).map_err(|_| CryptoError::InvalidKey)?; + let encoded = bitwarden_encoding::B64::from(json.as_bytes()); + + Ok(encoded.to_string()) + } + + /// Import a session and restore the client state + /// This includes restoring the user key, private key, and setting tokens + #[cfg(feature = "internal")] + pub fn import_session(&self, session: &str) -> Result<(), CryptoError> { + use bitwarden_crypto::{AsymmetricCryptoKey, Pkcs8PrivateKeyBytes}; + use bitwarden_encoding::B64; + use serde::{Deserialize, Serialize}; + + use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; + + #[derive(Serialize, Deserialize)] + struct SessionData { + user_key: String, + private_key: Option, + access_token: Option, + refresh_token: Option, + expires_on: Option, + } + + // Decode from base64 and parse JSON + let decoded = B64::try_from(session.to_string()).map_err(|_| CryptoError::InvalidKey)?; + let json_str = + String::from_utf8(decoded.as_bytes().to_vec()).map_err(|_| CryptoError::InvalidKey)?; + let session_data: SessionData = + serde_json::from_str(&json_str).map_err(|_| CryptoError::InvalidKey)?; + + // Restore the user key and private key + let user_key = SymmetricCryptoKey::try_from(session_data.user_key)?; + + #[allow(deprecated)] + { + let mut ctx = self.key_store.context_mut(); + ctx.set_symmetric_key(SymmetricKeyId::User, user_key)?; + + // Restore private key if present + if let Some(private_key_b64) = session_data.private_key { + let private_key_b64_parsed = + B64::try_from(private_key_b64).map_err(|_| CryptoError::InvalidKey)?; + let private_key_der = Pkcs8PrivateKeyBytes::from(private_key_b64_parsed.as_bytes()); + let private_key = AsymmetricCryptoKey::from_der(&private_key_der)?; + ctx.set_asymmetric_key(AsymmetricKeyId::UserPrivateKey, private_key)?; + } + } + + // Restore the tokens + if let Some(access_token) = session_data.access_token { + *self.tokens.write().expect("RwLock is not poisoned") = + Tokens::SdkManaged(SdkManagedTokens { + access_token: Some(access_token.clone()), + refresh_token: session_data.refresh_token, + expires_on: session_data.expires_on, + }); + self.set_api_tokens_internal(access_token); + } + + Ok(()) + } + #[cfg(feature = "internal")] pub(crate) fn initialize_user_crypto_master_key( &self, diff --git a/crates/bw/README.md b/crates/bw/README.md index e0c76f14c..475ec3977 100644 --- a/crates/bw/README.md +++ b/crates/bw/README.md @@ -1,3 +1,50 @@ # Bitwarden CLI (testing) A testing CLI for the Bitwarden Password Manager SDK. + +## Authentication + +### Login with API Key + +```bash +# With environment variables +export BW_CLIENTID="user.xxx" +export BW_CLIENTSECRET="xxx" +export BW_PASSWORD="xxx" +bw login api-key + +# Or with interactive prompts +bw login api-key +``` + +The login command returns a session key that can be used for subsequent commands. + +### Using Sessions + +```bash +# Save session to environment variable +export BW_SESSION="" + +# Or pass directly to commands +bw list items --session "" +``` + +## Commands + +### List Items + +```bash +# List all items +bw list items + +# Search items +bw list items --search "github" + +# Filter by folder, collection, or organization +bw list items --folderid "" +bw list items --collectionid "" +bw list items --organizationid "" + +# Show deleted items +bw list items --trash +``` diff --git a/crates/bw/src/auth/login.rs b/crates/bw/src/auth/login.rs index c5d50af38..0475a33d6 100644 --- a/crates/bw/src/auth/login.rs +++ b/crates/bw/src/auth/login.rs @@ -1,4 +1,4 @@ -use bitwarden_cli::text_prompt_when_none; +use bitwarden_cli::{resolve_user_input_value, text_prompt_when_none}; use bitwarden_core::{ Client, auth::login::{ @@ -93,11 +93,25 @@ pub(crate) async fn login_api_key( client: Client, client_id: Option, client_secret: Option, -) -> Result<()> { - let client_id = text_prompt_when_none("Client ID", client_id)?; - let client_secret = text_prompt_when_none("Client Secret", client_secret)?; - - let password = Password::new("Password").without_confirmation().prompt()?; +) -> Result { + let client_id = + resolve_user_input_value("Client ID", client_id, &["BW_CLIENTID", "BW_CLIENT_ID"])?; + let client_secret = resolve_user_input_value( + "Client Secret", + client_secret, + &["BW_CLIENTSECRET", "BW_CLIENT_SECRET"], + )?; + + // Check for password in environment variable first + let password = if let Ok(pwd) = std::env::var("BW_PASSWORD") { + if !pwd.is_empty() { + pwd + } else { + Password::new("Password").without_confirmation().prompt()? + } + } else { + Password::new("Password").without_confirmation().prompt()? + }; let result = client .auth() @@ -110,7 +124,19 @@ pub(crate) async fn login_api_key( debug!("{result:?}"); - Ok(()) + // Sync vault data after successful login + let sync_result = client + .vault() + .sync(&SyncRequest { + exclude_subdomains: Some(true), + }) + .await?; + info!("Synced {} ciphers", sync_result.ciphers.len()); + + // Export the full session (user key + tokens) + let session = client.internal.export_session()?; + + Ok(session) } pub(crate) async fn login_device( diff --git a/crates/bw/src/auth/mod.rs b/crates/bw/src/auth/mod.rs index d790698cf..d5963a76d 100644 --- a/crates/bw/src/auth/mod.rs +++ b/crates/bw/src/auth/mod.rs @@ -47,19 +47,23 @@ impl LoginArgs { // FIXME: Rust CLI will not support password login! LoginCommands::Password { email } => { login::login_password(client, email).await?; + Ok("Successfully logged in!".into()) } LoginCommands::ApiKey { client_id, client_secret, - } => login::login_api_key(client, client_id, client_secret).await?, + } => { + let session = login::login_api_key(client, client_id, client_secret).await?; + Ok(session.into()) + } LoginCommands::Device { email, device_identifier, } => { login::login_device(client, email, device_identifier).await?; + Ok("Successfully logged in!".into()) } } - Ok("Successfully logged in!".into()) } } diff --git a/crates/bw/src/command.rs b/crates/bw/src/command.rs index 2f829cb06..41fdb7cb6 100644 --- a/crates/bw/src/command.rs +++ b/crates/bw/src/command.rs @@ -145,7 +145,28 @@ Notes: // These are the old style action-name commands, to be replaced by name-action commands in the // future #[command(long_about = "List an array of objects from the vault.")] - List, + List { + /// Object type to list (items, folders, collections, etc.) + object: crate::vault::ObjectType, + + #[arg(long, help = "Perform a search on the listed objects")] + search: Option, + + #[arg(long, help = "Filter items by folder id")] + folderid: Option, + + #[arg(long, help = "Filter items by collection id")] + collectionid: Option, + + #[arg(long, help = "Filter items by organization id")] + organizationid: Option, + + #[arg(long, help = "Filter items that are deleted and in the trash")] + trash: bool, + + #[arg(long, help = "Filter items that are archived")] + archived: bool, + }, #[command(long_about = "Get an object from the vault.")] Get, #[command(long_about = "Create an object in the vault.")] @@ -156,6 +177,8 @@ Notes: Delete, #[command(long_about = "Restores an object from the trash.")] Restore, + #[command(long_about = "Archive an object from the vault.")] + Archive, #[command(long_about = "Move an item to an organization.")] Move, diff --git a/crates/bw/src/main.rs b/crates/bw/src/main.rs index bc5c201ca..af5f5301c 100644 --- a/crates/bw/src/main.rs +++ b/crates/bw/src/main.rs @@ -39,7 +39,7 @@ async fn main() -> Result<()> { render_config.render_result(result) } -async fn process_commands(command: Commands, _session: Option) -> CommandResult { +async fn process_commands(command: Commands, session: Option) -> CommandResult { // Try to initialize the client with the session if provided // Ideally we'd have separate clients and this would be an enum, something like: // enum CliClient { @@ -52,6 +52,15 @@ async fn process_commands(command: Commands, _session: Option) -> Comman // to do two matches over the whole command tree let client = bitwarden_pm::PasswordManagerClient::new(None); + // If a session was provided, import it to restore the client state + if let Some(ref session_str) = session { + client + .0 + .internal + .import_session(session_str) + .map_err(|e| color_eyre::eyre::eyre!("Failed to import session: {}", e))?; + } + match command { // Auth commands Commands::Login(args) => args.run().await, @@ -94,12 +103,35 @@ async fn process_commands(command: Commands, _session: Option) -> Comman Commands::Item { command: _ } => todo!(), Commands::Template { command } => command.run(), - Commands::List => todo!(), + Commands::List { + object, + search, + folderid, + collectionid, + organizationid, + trash, + archived, + } => { + vault::list( + &client.0, + vault::ListOptions { + object, + search, + folderid, + collectionid, + organizationid, + trash, + archived, + }, + ) + .await + } Commands::Get => todo!(), Commands::Create => todo!(), Commands::Edit => todo!(), Commands::Delete => todo!(), Commands::Restore => todo!(), + Commands::Archive => todo!(), Commands::Move => todo!(), // Admin console commands diff --git a/crates/bw/src/vault/list.rs b/crates/bw/src/vault/list.rs new file mode 100644 index 000000000..f3c73295c --- /dev/null +++ b/crates/bw/src/vault/list.rs @@ -0,0 +1,153 @@ +use bitwarden_core::Client; +use bitwarden_vault::{CipherListView, SyncRequest, VaultClientExt}; +use clap::ValueEnum; +use color_eyre::eyre::{Result, bail}; + +use crate::render::CommandOutput; + +#[derive(Debug, Clone, Copy, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub enum ObjectType { + Items, + Folders, + Collections, + Organizations, + OrgCollections, + OrgMembers, +} + +#[derive(Debug)] +pub struct ListOptions { + pub object: ObjectType, + pub search: Option, + pub folderid: Option, + pub collectionid: Option, + pub organizationid: Option, + pub trash: bool, + pub archived: bool, +} + +pub async fn list(client: &Client, options: ListOptions) -> Result { + match options.object { + ObjectType::Items => list_items(client, options).await, + ObjectType::Folders => { + bail!("Listing folders is not yet implemented") + } + ObjectType::Collections => { + bail!("Listing collections is not yet implemented") + } + ObjectType::Organizations => { + bail!("Listing organizations is not yet implemented") + } + ObjectType::OrgCollections => { + bail!("Listing org-collections is not yet implemented") + } + ObjectType::OrgMembers => { + bail!("Listing org-members is not yet implemented") + } + } +} + +async fn list_items(client: &Client, options: ListOptions) -> Result { + // Sync to get the latest vault data + let sync_response = client + .vault() + .sync(&SyncRequest { + exclude_subdomains: Some(true), + }) + .await?; + + // Decrypt the ciphers + let mut cipher_views: Vec = client + .vault() + .ciphers() + .decrypt_list(sync_response.ciphers)?; + + // Apply filters (retaining matching items) + cipher_views.retain(|item| { + // Filter by trash status + if options.trash { + if item.deleted_date.is_none() { + return false; + } + } else if item.deleted_date.is_some() { + return false; + } + + // Filter by folder + if let Some(ref folder_id) = options.folderid { + if item.folder_id.as_ref().map(|id| id.to_string()) != Some(folder_id.clone()) { + return false; + } + } + + // Filter by collection + if let Some(ref collection_id) = options.collectionid { + if !item + .collection_ids + .iter() + .any(|id| id.to_string() == *collection_id) + { + return false; + } + } + + // Filter by organization + if let Some(ref org_id) = options.organizationid { + if item.organization_id.as_ref().map(|id| id.to_string()) != Some(org_id.clone()) { + return false; + } + } + + // Search filter (case-insensitive search in id, name, subtitle, and uris) + if let Some(ref search_term) = options.search { + let search_lower = search_term.to_lowercase(); + + // Check if search term matches ID (first 8 characters when search term >= 8 chars) + let id_matches = if search_lower.len() >= 8 { + item.id + .as_ref() + .map(|id| id.to_string().to_lowercase().starts_with(&search_lower)) + .unwrap_or(false) + } else { + false + }; + + // Check if search term matches name + let name_matches = item.name.to_lowercase().contains(&search_lower); + + // Check if search term matches subtitle + let subtitle_matches = item.subtitle.to_lowercase().contains(&search_lower); + + // Check if search term matches any URIs (for login items) + let uri_matches = if let bitwarden_vault::CipherListViewType::Login(ref login) = item.r#type { + login + .uris + .as_ref() + .map(|uris| { + uris.iter().any(|uri| { + uri.uri + .as_ref() + .map(|u| u.to_lowercase().contains(&search_lower)) + .unwrap_or(false) + }) + }) + .unwrap_or(false) + } else { + false + }; + + // Item matches if any of the fields match + if !(id_matches || name_matches || subtitle_matches || uri_matches) { + return false; + } + } + + true + }); + + // Sort by name for consistent output + cipher_views.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(CommandOutput::Object(Box::new(cipher_views))) +} diff --git a/crates/bw/src/vault/mod.rs b/crates/bw/src/vault/mod.rs index 0a3106c90..fc0cfcb31 100644 --- a/crates/bw/src/vault/mod.rs +++ b/crates/bw/src/vault/mod.rs @@ -2,6 +2,9 @@ use clap::Subcommand; use crate::render::{CommandOutput, CommandResult}; +pub mod list; +pub use list::{ListOptions, ObjectType, list}; + #[derive(Subcommand, Clone)] pub enum ItemCommands { Get { id: String },