Skip to content
Open
26 changes: 26 additions & 0 deletions crates/bitwarden-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,29 @@ pub fn text_prompt_when_none(prompt: &str, val: Option<String>) -> 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<String>,
env_var_names: &[&str],
) -> InquireResult<String> {
// 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()
}
116 changes: 116 additions & 0 deletions crates/bitwarden-core/src/client/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, CryptoError> {
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<String>,
access_token: Option<String>,
refresh_token: Option<String>,
expires_on: Option<i64>,
}

// 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<String>,
access_token: Option<String>,
refresh_token: Option<String>,
expires_on: Option<i64>,
}

// 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,
Expand Down
47 changes: 47 additions & 0 deletions crates/bw/README.md
Original file line number Diff line number Diff line change
@@ -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="<session-key-from-login>"

# Or pass directly to commands
bw list items --session "<session-key>"
```

## 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 "<folder-id>"
bw list items --collectionid "<collection-id>"
bw list items --organizationid "<org-id>"

# Show deleted items
bw list items --trash
```
40 changes: 33 additions & 7 deletions crates/bw/src/auth/login.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -93,11 +93,25 @@ pub(crate) async fn login_api_key(
client: Client,
client_id: Option<String>,
client_secret: Option<String>,
) -> 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<String> {
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()
Expand All @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions crates/bw/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}

Expand Down
25 changes: 24 additions & 1 deletion crates/bw/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

#[arg(long, help = "Filter items by folder id")]
folderid: Option<String>,

#[arg(long, help = "Filter items by collection id")]
collectionid: Option<String>,

#[arg(long, help = "Filter items by organization id")]
organizationid: Option<String>,

#[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.")]
Expand All @@ -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,

Expand Down
36 changes: 34 additions & 2 deletions crates/bw/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async fn main() -> Result<()> {
render_config.render_result(result)
}

async fn process_commands(command: Commands, _session: Option<String>) -> CommandResult {
async fn process_commands(command: Commands, session: Option<String>) -> 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 {
Expand All @@ -52,6 +52,15 @@ async fn process_commands(command: Commands, _session: Option<String>) -> 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,
Expand Down Expand Up @@ -94,12 +103,35 @@ async fn process_commands(command: Commands, _session: Option<String>) -> 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
Expand Down
Loading
Loading