aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJesse Luehrs <doy@tozt.net>2021-10-27 05:38:03 -0400
committerJesse Luehrs <doy@tozt.net>2021-10-27 05:44:08 -0400
commit9acabc3a7497e3eb3536992212d34f77a649ae16 (patch)
treee1433fe36c6768704ea63443910731c4ad7fbcc1
parenteb5e4ea7bcc3008a8d06da6884ee9713f8df0034 (diff)
downloadrbw-9acabc3a7497e3eb3536992212d34f77a649ae16.tar.gz
rbw-9acabc3a7497e3eb3536992212d34f77a649ae16.zip
start working on apikey authentication
not entirely working yet, because api keys aren't allowed to use the offline_access scope which provides access to the oauth refresh token. we will probably need to move to using stable device identifiers (should be able to generate one when the config is first created) and move this logic to a separate "rbw register" command which just does an apikey login but doesn't try to store any data, and then use the normal login flow after that
-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,