use rand::distr::SampleString as _;
use sha2::Digest as _;
use tokio::io::AsyncReadExt as _;
use super::sso::{
classify_login_error, find_free_port, start_sso_callback_server,
};
use super::types::{KdfType, TwoFactorProviderType};
use super::wire::{
CipherCard, CipherField, CipherIdentity, CipherLogin, CipherLoginUri,
CipherSecureNote, CiphersPostReq, CiphersPutReq, CiphersPutReqHistory,
ConnectErrorRes, ConnectRefreshTokenReq, ConnectRefreshTokenRes,
ConnectTokenAuth, ConnectTokenAuthCode, ConnectTokenClientCredentials,
ConnectTokenPassword, ConnectTokenReq, ConnectTokenRes, FoldersPostReq,
FoldersRes, FoldersResData, PreloginReq, PreloginRes, SendEmailLoginReq,
SyncRes,
};
use crate::json::{
DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _,
};
use crate::prelude::*;
const BITWARDEN_CLIENT: &str = "cli";
const DEVICE_TYPE: u8 = 8;
#[derive(Debug)]
pub struct Client {
base_url: String,
identity_url: String,
ui_url: String,
client_cert_path: Option<std::path::PathBuf>,
}
impl Client {
pub fn new(
base_url: &str,
identity_url: &str,
ui_url: &str,
client_cert_path: Option<&std::path::Path>,
) -> Self {
Self {
base_url: base_url.to_string(),
identity_url: identity_url.to_string(),
ui_url: ui_url.to_string(),
client_cert_path: client_cert_path
.map(std::path::Path::to_path_buf),
}
}
async fn reqwest_client(&self) -> Result<reqwest::Client> {
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
"Bitwarden-Client-Name",
reqwest::header::HeaderValue::from_static(BITWARDEN_CLIENT),
);
default_headers.insert(
"Bitwarden-Client-Version",
reqwest::header::HeaderValue::from_static(env!(
"CARGO_PKG_VERSION"
)),
);
default_headers.append(
"Device-Type",
reqwest::header::HeaderValue::from_str(&DEVICE_TYPE.to_string())
.unwrap(),
);
let user_agent = format!(
"{}/{}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
);
if let Some(client_cert_path) = self.client_cert_path.as_ref() {
let mut buf = Vec::new();
let mut f = tokio::fs::File::open(client_cert_path)
.await
.map_err(|e| Error::LoadClientCert {
source: e,
file: client_cert_path.clone(),
})?;
f.read_to_end(&mut buf).await.map_err(|e| {
Error::LoadClientCert {
source: e,
file: client_cert_path.clone(),
}
})?;
let pem = reqwest::Identity::from_pem(&buf)
.map_err(|e| Error::CreateReqwestClient { source: e })?;
Ok(reqwest::Client::builder()
.user_agent(user_agent)
.identity(pem)
.default_headers(default_headers)
.build()
.map_err(|e| Error::CreateReqwestClient { source: e })?)
} else {
Ok(reqwest::Client::builder()
.user_agent(user_agent)
.default_headers(default_headers)
.build()
.map_err(|e| Error::CreateReqwestClient { source: e })?)
}
}
pub async fn prelogin(
&self,
email: &str,
) -> Result<(KdfType, u32, Option<u32>, Option<u32>)> {
let prelogin = PreloginReq {
email: email.to_string(),
};
let client = self.reqwest_client().await?;
let res = client
.post(self.identity_url("/accounts/prelogin"))
.json(&prelogin)
.send()
.await
.map_err(|source| Error::Reqwest { source })?;
let prelogin_res: PreloginRes = res.json_with_path().await?;
Ok((
prelogin_res.kdf,
prelogin_res.kdf_iterations,
prelogin_res.kdf_memory,
prelogin_res.kdf_parallelism,
))
}
pub async fn register(
&self,
email: &str,
device_id: &str,
apikey: &crate::locked::ApiKey,
) -> Result<()> {
let connect_req = ConnectTokenReq {
auth: ConnectTokenAuth::ClientCredentials(
ConnectTokenClientCredentials {
username: email.to_string(),
client_secret: String::from_utf8(
apikey.client_secret().to_vec(),
)
.unwrap(),
},
),
grant_type: "client_credentials".to_string(),
scope: "api".to_string(),
client_id: String::from_utf8(apikey.client_id().to_vec())
.unwrap(),
device_type: u32::from(DEVICE_TYPE),
device_identifier: device_id.to_string(),
device_name: "bwx".to_string(),
device_push_token: String::new(),
two_factor_token: None,
two_factor_provider: None,
};
let client = self.reqwest_client().await?;
let res = client
.post(self.identity_url("/connect/token"))
.form(&connect_req)
.send()
.await
.map_err(|source| Error::Reqwest { source })?;
if res.status() == reqwest::StatusCode::OK {
Ok(())
} else {
let code = res.status().as_u16();
match res.text().await {
Ok(body) => match body.clone().json_with_path() {
Ok(json) => Err(classify_login_error(&json, code)),
Err(e) => {
log::warn!("{e}: {body}");
Err(Error::RequestFailed { status: code })
}
},
Err(e) => {
log::warn!("failed to read response body: {e}");
Err(Error::RequestFailed { status: code })
}
}
}
}
pub async fn login(
&self,
email: &str,
sso_id: Option<&str>,
device_id: &str,
password_hash: &crate::locked::PasswordHash,
two_factor_token: Option<&str>,
two_factor_provider: Option<TwoFactorProviderType>,
) -> Result<(String, String, String)> {
let connect_req = match sso_id {
Some(sso_id) => {
let (sso_code, sso_code_verifier, callback_url) =
self.obtain_sso_code(sso_id).await?;
ConnectTokenReq {
auth: ConnectTokenAuth::AuthCode(ConnectTokenAuthCode {
code: sso_code,
code_verifier: sso_code_verifier,
redirect_uri: callback_url,
}),
grant_type: "authorization_code".to_string(),
scope: "api offline_access".to_string(),
client_id: "cli".to_string(),
device_type: u32::from(DEVICE_TYPE),
device_identifier: device_id.to_string(),
device_name: "bwx".to_string(),
device_push_token: String::new(),
two_factor_token: two_factor_token
.map(std::string::ToString::to_string),
two_factor_provider: two_factor_provider
.map(|ty| ty as u32),
}
}
None => ConnectTokenReq {
auth: ConnectTokenAuth::Password(ConnectTokenPassword {
username: email.to_string(),
password: crate::base64::encode(password_hash.hash()),
}),
grant_type: "password".to_string(),
scope: "api offline_access".to_string(),
client_id: "cli".to_string(),
device_type: 8,
device_identifier: device_id.to_string(),
device_name: "bwx".to_string(),
device_push_token: String::new(),
two_factor_token: two_factor_token
.map(std::string::ToString::to_string),
two_factor_provider: two_factor_provider.map(|ty| ty as u32),
},
};
let client = self.reqwest_client().await?;
let res = client
.post(self.identity_url("/connect/token"))
.form(&connect_req)
.header(
"auth-email",
crate::base64::encode_url_safe_no_pad(email),
)
.send()
.await
.map_err(|source| Error::Reqwest { source })?;
if res.status() == reqwest::StatusCode::OK {
let connect_res: ConnectTokenRes = res.json_with_path().await?;
Ok((
connect_res.access_token,
connect_res.refresh_token,
connect_res.key,
))
} else {
let code = res.status().as_u16();
match res.text().await {
Ok(body) => match body.clone().json_with_path() {
Ok(json) => {
let json: ConnectErrorRes = json;
Err(classify_login_error(&json, code))
}
Err(e) => {
log::warn!("{e}: {body}");
Err(Error::RequestFailed { status: code })
}
},
Err(e) => {
log::warn!("failed to read response body: {e}");
Err(Error::RequestFailed { status: code })
}
}
}
}
pub async fn send_email_login(
&self,
email: &str,
device_id: &str,
sso_email_2fa_session_token: &str,
) -> Result<()> {
let send_email_login_req = SendEmailLoginReq {
email: email.to_string(),
device_identifier: device_id.to_string(),
sso_email_2fa_session_token: sso_email_2fa_session_token
.to_string(),
};
let client = self.reqwest_client().await?;
let res = client
.post(self.api_url("/two-factor/send-email-login"))
.json(&send_email_login_req)
.header(
"auth-email",
crate::base64::encode_url_safe_no_pad(email),
)
.send()
.await
.map_err(|source| Error::Reqwest { source })?;
if res.status() == reqwest::StatusCode::OK {
Ok(())
} else {
let code = res.status().as_u16();
log::warn!("{code}: {:?}", res.text().await);
Err(Error::RequestFailed { status: code })
}
}
async fn obtain_sso_code(
&self,
sso_id: &str,
) -> Result<(String, String, String)> {
let state =
rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64);
let sso_code_verifier =
rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64);
let mut hasher = sha2::Sha256::new();
hasher.update(sso_code_verifier.clone());
let code_challenge =
crate::base64::encode_url_safe_no_pad(hasher.finalize());
let port = find_free_port(8065, 8070).await?;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
.await
.map_err(|e| Error::CreateSSOCallbackServer { err: e })?;
let callback_server =
start_sso_callback_server(listener, state.as_str());
let callback_url =
"http://localhost:".to_string() + port.to_string().as_str();
let url = self.ui_url.clone()
+ "/#/sso?clientId="
+ "cli"
+ "&redirectUri="
+ urlencoding::encode(callback_url.as_str())
.into_owned()
.as_str()
+ "&state="
+ state.as_str()
+ "&codeChallenge="
+ code_challenge.as_str()
+ "&identifier="
+ sso_id;
#[cfg(feature = "sso-browser")]
open::that(&url)
.map_err(|e| Error::FailedToOpenWebBrowser { err: e })?;
#[cfg(not(feature = "sso-browser"))]
eprintln!("Open this URL to continue: {url}");
let sso_code = callback_server.await?;
Ok((sso_code, sso_code_verifier, callback_url))
}
pub async fn sync(
&self,
access_token: &str,
) -> Result<(
String,
String,
std::collections::HashMap<String, String>,
Vec<crate::db::Entry>,
)> {
let client = self.reqwest_client().await?;
let res = client
.get(self.api_url("/sync"))
.header("Authorization", format!("Bearer {access_token}"))
.header("Bitwarden-Client-Version", "2024.12.0")
.send()
.await
.map_err(|source| Error::Reqwest { source })?;
match res.status() {
reqwest::StatusCode::OK => {
let sync_res: SyncRes = res.json_with_path().await?;
let folders = sync_res.folders.clone();
let ciphers = sync_res
.ciphers
.iter()
.filter_map(|cipher| cipher.to_entry(&folders))
.collect();
let org_keys = sync_res
.profile
.organizations
.iter()
.map(|org| (org.id.clone(), org.key.clone()))
.collect();
Ok((
sync_res.profile.key,
sync_res.profile.private_key,
org_keys,
ciphers,
))
}
reqwest::StatusCode::UNAUTHORIZED => {
Err(Error::RequestUnauthorized)
}
_ => Err(Error::RequestFailed {
status: res.status().as_u16(),
}),
}
}
pub fn add(
&self,
access_token: &str,
name: &str,
data: &crate::db::EntryData,
notes: Option<&str>,
folder_id: Option<&str>,
) -> Result<()> {
let mut req = CiphersPostReq {
ty: 1,
folder_id: folder_id.map(std::string::ToString::to_string),
name: name.to_string(),
notes: notes.map(std::string::ToString::to_string),
login: None,
card: None,
identity: None,
secure_note: None,
};
match data {
crate::db::EntryData::Login {
username,
password,
totp,
uris,
} => {
let uris = if uris.is_empty() {
None
} else {
Some(
uris.iter()
.map(|s| CipherLoginUri {
uri: Some(s.uri.clone()),
match_type: s.match_type,
})
.collect(),
)
};
req.login = Some(CipherLogin {
username: username.clone(),
password: password.clone(),
totp: totp.clone(),
uris,
});
}
crate::db::EntryData::Card {
cardholder_name,
number,
brand,
exp_month,
exp_year,
code,
} => {
req.card = Some(CipherCard {
cardholder_name: cardholder_name.clone(),
number: number.clone(),
brand: brand.clone(),
exp_month: exp_month.clone(),
exp_year: exp_year.clone(),
code: code.clone(),
});
}
crate::db::EntryData::Identity {
title,
first_name,
middle_name,
last_name,
address1,
address2,
address3,
city,
state,
postal_code,
country,
phone,
email,
ssn,
license_number,
passport_number,
username,
} => {
req.identity = Some(CipherIdentity {
title: title.clone(),
first_name: first_name.clone(),
middle_name: middle_name.clone(),
last_name: last_name.clone(),
address1: address1.clone(),
address2: address2.clone(),
address3: address3.clone(),
city: city.clone(),
state: state.clone(),
postal_code: postal_code.clone(),
country: country.clone(),
phone: phone.clone(),
email: email.clone(),
ssn: ssn.clone(),
license_number: license_number.clone(),
passport_number: passport_number.clone(),
username: username.clone(),
});
}
crate::db::EntryData::SecureNote => {
req.secure_note = Some(CipherSecureNote {});
}
crate::db::EntryData::SshKey { .. } => unreachable!(),
}
let client = reqwest::blocking::Client::new();
let res = client
.post(self.api_url("/ciphers"))
.header("Authorization", format!("Bearer {access_token}"))
.json(&req)
.send()
.map_err(|source| Error::Reqwest { source })?;
match res.status() {
reqwest::StatusCode::OK => Ok(()),
reqwest::StatusCode::UNAUTHORIZED => {
Err(Error::RequestUnauthorized)
}
_ => Err(Error::RequestFailed {
status: res.status().as_u16(),
}),
}
}
pub fn edit(
&self,
access_token: &str,
id: &str,
org_id: Option<&str>,
name: &str,
data: &crate::db::EntryData,
fields: &[crate::db::Field],
notes: Option<&str>,
folder_uuid: Option<&str>,
history: &[crate::db::HistoryEntry],
) -> Result<()> {
let mut req = CiphersPutReq {
ty: match data {
crate::db::EntryData::Login { .. } => 1,
crate::db::EntryData::SecureNote => 2,
crate::db::EntryData::Card { .. } => 3,
crate::db::EntryData::Identity { .. } => 4,
crate::db::EntryData::SshKey { .. } => unreachable!(),
},
folder_id: folder_uuid.map(std::string::ToString::to_string),
organization_id: org_id.map(std::string::ToString::to_string),
name: name.to_string(),
notes: notes.map(std::string::ToString::to_string),
login: None,
card: None,
identity: None,
secure_note: None,
fields: fields
.iter()
.map(|field| CipherField {
ty: field.ty,
name: field.name.clone(),
value: field.value.clone(),
linked_id: field.linked_id,
})
.collect(),
password_history: history
.iter()
.map(|entry| CiphersPutReqHistory {
last_used_date: entry.last_used_date.clone(),
password: entry.password.clone(),
})
.collect(),
};
match data {
crate::db::EntryData::Login {
username,
password,
totp,
uris,
} => {
let uris = if uris.is_empty() {
None
} else {
Some(
uris.iter()
.map(|s| CipherLoginUri {
uri: Some(s.uri.clone()),
match_type: s.match_type,
})
.collect(),
)
};
req.login = Some(CipherLogin {
username: username.clone(),
password: password.clone(),
totp: totp.clone(),
uris,
});
}
crate::db::EntryData::Card {
cardholder_name,
number,
brand,
exp_month,
exp_year,
code,
} => {
req.card = Some(CipherCard {
cardholder_name: cardholder_name.clone(),
number: number.clone(),
brand: brand.clone(),
exp_month: exp_month.clone(),
exp_year: exp_year.clone(),
code: code.clone(),
});
}
crate::db::EntryData::Identity {
title,
first_name,
middle_name,
last_name,
address1,
address2,
address3,
city,
state,
postal_code,
country,
phone,
email,
ssn,
license_number,
passport_number,
username,
} => {
req.identity = Some(CipherIdentity {
title: title.clone(),
first_name: first_name.clone(),
middle_name: middle_name.clone(),
last_name: last_name.clone(),
address1: address1.clone(),
address2: address2.clone(),
address3: address3.clone(),
city: city.clone(),
state: state.clone(),
postal_code: postal_code.clone(),
country: country.clone(),
phone: phone.clone(),
email: email.clone(),
ssn: ssn.clone(),
license_number: license_number.clone(),
passport_number: passport_number.clone(),
username: username.clone(),
});
}
crate::db::EntryData::SecureNote => {
req.secure_note = Some(CipherSecureNote {});
}
crate::db::EntryData::SshKey { .. } => unreachable!(),
}
let client = reqwest::blocking::Client::new();
let res = client
.put(self.api_url(&format!("/ciphers/{id}")))
.header("Authorization", format!("Bearer {access_token}"))
.json(&req)
.send()
.map_err(|source| Error::Reqwest { source })?;
match res.status() {
reqwest::StatusCode::OK => Ok(()),
reqwest::StatusCode::UNAUTHORIZED => {
Err(Error::RequestUnauthorized)
}
_ => Err(Error::RequestFailed {
status: res.status().as_u16(),
}),
}
}
pub fn remove(&self, access_token: &str, id: &str) -> Result<()> {
let client = reqwest::blocking::Client::new();
let res = client
.delete(self.api_url(&format!("/ciphers/{id}")))
.header("Authorization", format!("Bearer {access_token}"))
.send()
.map_err(|source| Error::Reqwest { source })?;
match res.status() {
reqwest::StatusCode::OK => Ok(()),
reqwest::StatusCode::UNAUTHORIZED => {
Err(Error::RequestUnauthorized)
}
_ => Err(Error::RequestFailed {
status: res.status().as_u16(),
}),
}
}
pub fn folders(
&self,
access_token: &str,
) -> Result<Vec<(String, String)>> {
let client = reqwest::blocking::Client::new();
let res = client
.get(self.api_url("/folders"))
.header("Authorization", format!("Bearer {access_token}"))
.send()
.map_err(|source| Error::Reqwest { source })?;
match res.status() {
reqwest::StatusCode::OK => {
let folders_res: FoldersRes = res.json_with_path()?;
Ok(folders_res
.data
.iter()
.map(|folder| (folder.id.clone(), folder.name.clone()))
.collect())
}
reqwest::StatusCode::UNAUTHORIZED => {
Err(Error::RequestUnauthorized)
}
_ => Err(Error::RequestFailed {
status: res.status().as_u16(),
}),
}
}
pub fn create_folder(
&self,
access_token: &str,
name: &str,
) -> Result<String> {
let req = FoldersPostReq {
name: name.to_string(),
};
let client = reqwest::blocking::Client::new();
let res = client
.post(self.api_url("/folders"))
.header("Authorization", format!("Bearer {access_token}"))
.json(&req)
.send()
.map_err(|source| Error::Reqwest { source })?;
match res.status() {
reqwest::StatusCode::OK => {
let folders_res: FoldersResData = res.json_with_path()?;
Ok(folders_res.id)
}
reqwest::StatusCode::UNAUTHORIZED => {
Err(Error::RequestUnauthorized)
}
_ => Err(Error::RequestFailed {
status: res.status().as_u16(),
}),
}
}
pub fn exchange_refresh_token(
&self,
refresh_token: &str,
) -> Result<String> {
let connect_req = ConnectRefreshTokenReq {
grant_type: "refresh_token".to_string(),
client_id: "cli".to_string(),
refresh_token: refresh_token.to_string(),
};
let client = reqwest::blocking::Client::new();
let res = client
.post(self.identity_url("/connect/token"))
.form(&connect_req)
.send()
.map_err(|source| Error::Reqwest { source })?;
let connect_res: ConnectRefreshTokenRes = res.json_with_path()?;
Ok(connect_res.access_token)
}
pub async fn exchange_refresh_token_async(
&self,
refresh_token: &str,
) -> Result<String> {
let connect_req = ConnectRefreshTokenReq {
grant_type: "refresh_token".to_string(),
client_id: "cli".to_string(),
refresh_token: refresh_token.to_string(),
};
let client = self.reqwest_client().await?;
let res = client
.post(self.identity_url("/connect/token"))
.form(&connect_req)
.send()
.await
.map_err(|source| Error::Reqwest { source })?;
let connect_res: ConnectRefreshTokenRes =
res.json_with_path().await?;
Ok(connect_res.access_token)
}
fn api_url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn identity_url(&self, path: &str) -> String {
format!("{}{}", self.identity_url, path)
}
}