aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/actions.rs9
-rw-r--r--src/api.rs69
-rw-r--r--src/bin/rbw-agent/actions.rs284
-rw-r--r--src/bin/rbw-agent/agent.rs19
-rw-r--r--src/bin/rbw/actions.rs8
-rw-r--r--src/bin/rbw/commands.rs8
-rw-r--r--src/bin/rbw/main.rs12
-rw-r--r--src/error.rs3
-rw-r--r--src/locked.rs79
-rw-r--r--src/protocol.rs1
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,
)
diff --git a/src/api.rs b/src/api.rs
index 1a15b22..62a4a55 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -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,