Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 text_or_env_prompt(
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
```
39 changes: 32 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::{text_or_env_prompt, text_prompt_when_none};
use bitwarden_core::{
Client,
auth::login::{
Expand Down Expand Up @@ -93,11 +93,24 @@ 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 = text_or_env_prompt("Client ID", client_id, &["BW_CLIENTID", "BW_CLIENT_ID"])?;
let client_secret = text_or_env_prompt(
"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 +123,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
20 changes: 19 additions & 1 deletion crates/bw/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,25 @@ 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: String,

#[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,
},
#[command(long_about = "Get an object from the vault.")]
Get,
#[command(long_about = "Create an object in the vault.")]
Expand Down
33 changes: 31 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,7 +103,27 @@ 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,
} => {
vault::list(
&client.0,
vault::ListOptions {
object,
search,
folderid,
collectionid,
organizationid,
trash,
},
)
.await
}
Commands::Get => todo!(),
Commands::Create => todo!(),
Commands::Edit => todo!(),
Expand Down
Loading
Loading