diff options
-rw-r--r-- | src/actions.rs | 9 | ||||
-rw-r--r-- | src/api.rs | 69 | ||||
-rw-r--r-- | src/bin/rbw-agent/actions.rs | 284 | ||||
-rw-r--r-- | src/bin/rbw-agent/agent.rs | 19 | ||||
-rw-r--r-- | src/bin/rbw/actions.rs | 8 | ||||
-rw-r--r-- | src/bin/rbw/commands.rs | 8 | ||||
-rw-r--r-- | src/bin/rbw/main.rs | 12 | ||||
-rw-r--r-- | src/error.rs | 3 | ||||
-rw-r--r-- | src/locked.rs | 79 | ||||
-rw-r--r-- | src/protocol.rs | 1 |
10 files changed, 384 insertions, 108 deletions
diff --git a/src/actions.rs b/src/actions.rs index 03cd160..dc4f9df 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -2,7 +2,7 @@ use crate::prelude::*; pub async fn login( email: &str, - password: &crate::locked::Password, + creds: crate::locked::LoginCredentials, two_factor_token: Option<&str>, two_factor_provider: Option<crate::api::TwoFactorProviderType>, ) -> Result<(String, String, u32, String)> { @@ -11,13 +11,10 @@ pub async fn login( crate::api::Client::new(&config.base_url(), &config.identity_url()); let iterations = client.prelogin(email).await?; - let identity = - crate::identity::Identity::new(email, password, iterations)?; - let (access_token, refresh_token, protected_key) = client .login( - &identity.email, - &identity.master_password_hash, + email, + &creds.to_hashed(email, iterations)?, two_factor_token, two_factor_provider, ) @@ -148,9 +148,10 @@ struct PreloginRes { struct ConnectPasswordReq { grant_type: String, username: String, - password: String, + password: Option<String>, scope: String, client_id: String, + client_secret: Option<String>, #[serde(rename = "deviceType")] device_type: u32, #[serde(rename = "deviceIdentifier")] @@ -178,7 +179,7 @@ struct ConnectPasswordRes { #[derive(serde::Deserialize, Debug)] struct ConnectErrorRes { error: String, - error_description: String, + error_description: Option<String>, #[serde(rename = "ErrorModel")] error_model: Option<ConnectErrorResErrorModel>, #[serde(rename = "TwoFactorProviders")] @@ -578,16 +579,38 @@ impl Client { pub async fn login( &self, email: &str, - master_password_hash: &crate::locked::PasswordHash, + creds: &crate::locked::HashedLoginCredentials, two_factor_token: Option<&str>, two_factor_provider: Option<TwoFactorProviderType>, ) -> Result<(String, String, String)> { + let (grant_type, scope, password, client_id, client_secret) = + match creds { + crate::locked::HashedLoginCredentials::Password { + password_hash, + } => ( + "password", + "api offline_access", + Some(base64::encode(password_hash.hash())), + &b"desktop"[..], + None, + ), + crate::locked::HashedLoginCredentials::ApiKey { apikey } => ( + "client_credentials", + "api", + None, + apikey.client_id(), + Some(apikey.client_secret()), + ), + }; let connect_req = ConnectPasswordReq { - grant_type: "password".to_string(), + grant_type: grant_type.to_string(), username: email.to_string(), - password: base64::encode(master_password_hash.hash()), - scope: "api offline_access".to_string(), - client_id: "desktop".to_string(), + password, + scope: scope.to_string(), + // XXX unwraps here are not necessarily safe + client_id: String::from_utf8(client_id.to_vec()).unwrap(), + client_secret: client_secret + .map(|secret| String::from_utf8(secret.to_vec()).unwrap()), device_type: 8, device_identifier: uuid::Uuid::new_v4() .to_hyphenated() @@ -599,13 +622,24 @@ impl Client { two_factor_provider: two_factor_provider.map(|ty| ty as u32), }; let client = reqwest::Client::new(); + let mut headers = reqwest::header::HeaderMap::new(); + if connect_req.client_secret.is_none() + && connect_req.password.is_some() + { + headers.insert( + "auth-email", + // unwrap is safe because url safe base64 strings are always + // valid header values + reqwest::header::HeaderValue::from_str( + &base64::encode_config(email, base64::URL_SAFE_NO_PAD), + ) + .unwrap(), + ); + } let res = client .post(&self.identity_url("/connect/token")) .form(&connect_req) - .header( - "auth-email", - base64::encode_config(email, base64::URL_SAFE_NO_PAD), - ) + .headers(headers) .send() .await .map_err(|source| Error::Reqwest { source })?; @@ -1050,15 +1084,17 @@ impl Client { } fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { + let error_desc = error_res.error_description.clone(); + let error_desc = error_desc.as_deref(); match error_res.error.as_str() { - "invalid_grant" => match error_res.error_description.as_str() { - "invalid_username_or_password" => { + "invalid_grant" => match error_desc { + Some("invalid_username_or_password") => { if let Some(error_model) = error_res.error_model.as_ref() { let message = error_model.message.as_str().to_string(); return Error::IncorrectPassword { message }; } } - "Two factor required." => { + Some("Two factor required.") => { if let Some(providers) = error_res.two_factor_providers.as_ref() { @@ -1069,10 +1105,13 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { } _ => {} }, + "invalid_client" => { + return Error::IncorrectApiKey; + } "" => { // bitwarden_rs returns an empty error and error_description for // this case, for some reason - if error_res.error_description.is_empty() { + if error_desc.is_none() || error_desc == Some("") { if let Some(error_model) = error_res.error_model.as_ref() { let message = error_model.message.as_str().to_string(); match message.as_str() { diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index cb30551..d29321c 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -4,6 +4,7 @@ pub async fn login( sock: &mut crate::sock::Sock, state: std::sync::Arc<tokio::sync::RwLock<crate::agent::State>>, tty: Option<&str>, + apikey: bool, ) -> anyhow::Result<()> { let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); @@ -23,49 +24,66 @@ pub async fn login( let email = config_email().await?; let mut err_msg = None; - for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - &format!("Log in to {}", host), - err.as_deref(), - tty, - ) - .await - .context("failed to read password from pinentry")?; - match rbw::actions::login(&email, &password, None, None).await { - Ok(( - access_token, - refresh_token, - iterations, - protected_key, - )) => { - login_success( - sock, - state, + if apikey { + for i in 1_u8..=3 { + let err = if i > 1 { + // this unwrap is safe because we only ever continue the loop + // if we have set err_msg + Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) + } else { + None + }; + let client_id = rbw::pinentry::getpin( + &config_pinentry().await?, + "API key client_id", + &format!("Log in to {}", host), + err.as_deref(), + tty, + ) + .await + .context("failed to read client_id from pinentry")?; + let client_secret = rbw::pinentry::getpin( + &config_pinentry().await?, + "API key client_secret", + &format!("Log in to {}", host), + err.as_deref(), + tty, + ) + .await + .context("failed to read client_secret from pinentry")?; + let creds = rbw::locked::LoginCredentials::from_apikey( + rbw::locked::ApiKey::new(client_id, client_secret), + ); + match rbw::actions::login(&email, creds.clone(), None, None) + .await + { + Ok(( access_token, refresh_token, iterations, protected_key, - password, - db, - email, - ) - .await?; - break; - } - Err(rbw::error::Error::TwoFactorRequired { providers }) => { - if providers.contains( - &rbw::api::TwoFactorProviderType::Authenticator, - ) { - let ( + )) => { + login_success( + sock, + state, + access_token, + refresh_token, + iterations, + protected_key, + None, + db, + email, + ) + .await?; + break; + } + Err(rbw::error::Error::TwoFactorRequired { + providers, + }) => { + if providers.contains( + &rbw::api::TwoFactorProviderType::Authenticator, + ) { + let ( access_token, refresh_token, iterations, @@ -73,10 +91,79 @@ pub async fn login( ) = two_factor( tty, &email, - &password, + creds, rbw::api::TwoFactorProviderType::Authenticator, ) .await?; + login_success( + sock, + state, + access_token, + refresh_token, + iterations, + protected_key, + None, + db, + email, + ) + .await?; + break; + } else { + return Err(anyhow::anyhow!("TODO")); + } + } + Err(rbw::error::Error::IncorrectPassword { message }) => { + if i == 3 { + return Err( + rbw::error::Error::IncorrectPassword { + message, + }, + ) + .context( + "failed to log in to bitwarden instance", + ); + } else { + err_msg = Some(message); + continue; + } + } + Err(e) => { + return Err(e).context( + "failed to log in to bitwarden instance", + ) + } + } + } + } else { + for i in 1_u8..=3 { + let err = if i > 1 { + // this unwrap is safe because we only ever continue the loop + // if we have set err_msg + Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) + } else { + None + }; + let password = rbw::pinentry::getpin( + &config_pinentry().await?, + "Master Password", + &format!("Log in to {}", host), + err.as_deref(), + tty, + ) + .await + .context("failed to read password from pinentry")?; + let creds = rbw::locked::LoginCredentials::from_password( + password.clone(), + ); + match rbw::actions::login(&email, creds.clone(), None, None) + .await + { + Ok(( + access_token, + refresh_token, + iterations, + protected_key, + )) => { login_success( sock, state, @@ -84,30 +171,68 @@ pub async fn login( refresh_token, iterations, protected_key, - password, + Some(password), db, email, ) .await?; break; - } else { - return Err(anyhow::anyhow!("TODO")); } - } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to log in to bitwarden instance"); - } else { - err_msg = Some(message); - continue; + Err(rbw::error::Error::TwoFactorRequired { + providers, + }) => { + if providers.contains( + &rbw::api::TwoFactorProviderType::Authenticator, + ) { + let ( + access_token, + refresh_token, + iterations, + protected_key, + ) = two_factor( + tty, + &email, + creds.clone(), + rbw::api::TwoFactorProviderType::Authenticator, + ) + .await?; + login_success( + sock, + state, + access_token, + refresh_token, + iterations, + protected_key, + Some(password), + db, + email, + ) + .await?; + break; + } else { + return Err(anyhow::anyhow!("TODO")); + } + } + Err(rbw::error::Error::IncorrectPassword { message }) => { + if i == 3 { + return Err( + rbw::error::Error::IncorrectPassword { + message, + }, + ) + .context( + "failed to log in to bitwarden instance", + ); + } else { + err_msg = Some(message); + continue; + } + } + Err(e) => { + return Err(e).context( + "failed to log in to bitwarden instance", + ) } - } - Err(e) => { - return Err(e) - .context("failed to log in to bitwarden instance") } } } @@ -121,7 +246,7 @@ pub async fn login( async fn two_factor( tty: Option<&str>, email: &str, - password: &rbw::locked::Password, + creds: rbw::locked::LoginCredentials, provider: rbw::api::TwoFactorProviderType, ) -> anyhow::Result<(String, String, u32, String)> { let mut err_msg = None; @@ -144,8 +269,13 @@ async fn two_factor( .context("failed to read code from pinentry")?; let code = std::str::from_utf8(code.password()) .context("code was not valid utf8")?; - match rbw::actions::login(email, password, Some(code), Some(provider)) - .await + match rbw::actions::login( + email, + creds.clone(), + Some(code), + Some(provider), + ) + .await { Ok((access_token, refresh_token, iterations, protected_key)) => { return Ok(( @@ -196,7 +326,7 @@ async fn login_success( refresh_token: String, iterations: u32, protected_key: String, - password: rbw::locked::Password, + password: Option<rbw::locked::Password>, mut db: rbw::db::Db, email: String, ) -> anyhow::Result<()> { @@ -218,23 +348,25 @@ async fn login_success( )); }; - let res = rbw::actions::unlock( - &email, - &password, - iterations, - &protected_key, - &protected_private_key, - &db.protected_org_keys, - ) - .await; + if let Some(password) = password { + let res = rbw::actions::unlock( + &email, + &password, + iterations, + &protected_key, + &protected_private_key, + &db.protected_org_keys, + ) + .await; - match res { - Ok((keys, org_keys)) => { - let mut state = state.write().await; - state.priv_key = Some(keys); - state.org_keys = Some(org_keys); + match res { + Ok((keys, org_keys)) => { + let mut state = state.write().await; + state.priv_key = Some(keys); + state.org_keys = Some(org_keys); + } + Err(e) => return Err(e).context("failed to unlock database"), } - Err(e) => return Err(e).context("failed to unlock database"), } Ok(()) diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 8db8a59..7ca519b 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -134,8 +134,23 @@ async fn handle_request( }; let set_timeout = match &req.action { rbw::protocol::Action::Login => { - crate::actions::login(sock, state.clone(), req.tty.as_deref()) - .await?; + crate::actions::login( + sock, + state.clone(), + req.tty.as_deref(), + false, + ) + .await?; + true + } + rbw::protocol::Action::LoginApiKey => { + crate::actions::login( + sock, + state.clone(), + req.tty.as_deref(), + true, + ) + .await?; true } rbw::protocol::Action::Unlock => { diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index 75703f9..e0e1e55 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -1,8 +1,12 @@ use anyhow::Context as _; use std::io::Read as _; -pub fn login() -> anyhow::Result<()> { - simple_action(rbw::protocol::Action::Login) +pub fn login(apikey: bool) -> anyhow::Result<()> { + if apikey { + simple_action(rbw::protocol::Action::LoginApiKey) + } else { + simple_action(rbw::protocol::Action::Login) + } } pub fn unlock() -> anyhow::Result<()> { diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index db385a8..dddd501 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -465,16 +465,16 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { Ok(()) } -pub fn login() -> anyhow::Result<()> { +pub fn login(apikey: bool) -> anyhow::Result<()> { ensure_agent()?; - crate::actions::login()?; + crate::actions::login(apikey)?; Ok(()) } pub fn unlock() -> anyhow::Result<()> { ensure_agent()?; - crate::actions::login()?; + crate::actions::login(false)?; crate::actions::unlock()?; Ok(()) @@ -489,7 +489,7 @@ pub fn unlocked() -> anyhow::Result<()> { pub fn sync() -> anyhow::Result<()> { ensure_agent()?; - crate::actions::login()?; + crate::actions::login(false)?; crate::actions::sync()?; Ok(()) diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index e6e1249..2612398 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -18,7 +18,13 @@ enum Opt { }, #[structopt(about = "Log in to the Bitwarden server")] - Login, + Login { + #[structopt( + long, + help = "Log in to the Bitwarden server using your user API key (see https://bitwarden.com/help/article/personal-api-key/)" + )] + apikey: bool, + }, #[structopt(about = "Unlock the local Bitwarden database")] Unlock, @@ -214,7 +220,7 @@ impl Opt { Self::Config { config } => { format!("config {}", config.subcommand_name()) } - Self::Login => "login".to_string(), + Self::Login { .. } => "login".to_string(), Self::Unlock => "unlock".to_string(), Self::Unlocked => "unlocked".to_string(), Self::Sync => "sync".to_string(), @@ -284,7 +290,7 @@ fn main(opt: Opt) { Config::Set { key, value } => commands::config_set(key, value), Config::Unset { key } => commands::config_unset(key), }, - Opt::Login => commands::login(), + Opt::Login { apikey } => commands::login(*apikey), Opt::Unlock => commands::unlock(), Opt::Unlocked => commands::unlocked(), Opt::Sync => commands::sync(), diff --git a/src/error.rs b/src/error.rs index eb9f5c1..5eb4b4b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -46,6 +46,9 @@ pub enum Error { #[error("failed to expand with hkdf")] HkdfExpand, + #[error("incorrect api key")] + IncorrectApiKey, + #[error("{message}")] IncorrectPassword { message: String }, diff --git a/src/locked.rs b/src/locked.rs index 96bb846..4008912 100644 --- a/src/locked.rs +++ b/src/locked.rs @@ -1,3 +1,5 @@ +use crate::prelude::*; + use zeroize::Zeroize; const LEN: usize = 4096; @@ -51,6 +53,15 @@ impl Drop for Vec { } } +impl Clone for Vec { + fn clone(&self) -> Self { + let mut new_vec = Self::new(); + new_vec.extend(self.data().iter().copied()); + new_vec + } +} + +#[derive(Clone)] pub struct Password { password: Vec, } @@ -65,6 +76,7 @@ impl Password { } } +#[derive(Clone)] pub struct Keys { keys: Vec, } @@ -83,6 +95,7 @@ impl Keys { } } +#[derive(Clone)] pub struct PasswordHash { hash: Vec, } @@ -97,6 +110,7 @@ impl PasswordHash { } } +#[derive(Clone)] pub struct PrivateKey { private_key: Vec, } @@ -110,3 +124,68 @@ impl PrivateKey { self.private_key.data() } } + +#[derive(Clone)] +pub struct ApiKey { + client_id: Password, + client_secret: Password, +} + +impl ApiKey { + pub fn new(client_id: Password, client_secret: Password) -> Self { + Self { + client_id, + client_secret, + } + } + + pub fn client_id(&self) -> &[u8] { + self.client_id.password() + } + + pub fn client_secret(&self) -> &[u8] { + self.client_secret.password() + } +} + +#[derive(Clone)] +pub enum LoginCredentials { + Password { password: Password }, + ApiKey { apikey: ApiKey }, +} + +impl LoginCredentials { + pub fn from_password(password: Password) -> Self { + Self::Password { password } + } + + pub fn from_apikey(apikey: ApiKey) -> Self { + Self::ApiKey { apikey } + } + + pub fn to_hashed( + self, + email: &str, + iterations: u32, + ) -> Result<HashedLoginCredentials> { + match self { + Self::Password { password } => { + let identity = crate::identity::Identity::new( + email, &password, iterations, + )?; + Ok(HashedLoginCredentials::Password { + password_hash: identity.master_password_hash, + }) + } + Self::ApiKey { apikey } => { + Ok(HashedLoginCredentials::ApiKey { apikey }) + } + } + } +} + +#[derive(Clone)] +pub enum HashedLoginCredentials { + Password { password_hash: PasswordHash }, + ApiKey { apikey: ApiKey }, +} diff --git a/src/protocol.rs b/src/protocol.rs index a4b9722..d36f909 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -23,6 +23,7 @@ pub struct Request { #[serde(tag = "type")] pub enum Action { Login, + LoginApiKey, Unlock, CheckLock, Lock, |