diff --git a/Cargo.lock b/Cargo.lock index c1d317d4e..845b7c46f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,6 +339,7 @@ dependencies = [ "num-traits", "pbkdf2", "rand 0.8.5", + "rand_chacha 0.3.1", "reqwest", "rsa", "schemars", diff --git a/crates/bitwarden/Cargo.toml b/crates/bitwarden/Cargo.toml index 0090ebb0e..bef8ef12e 100644 --- a/crates/bitwarden/Cargo.toml +++ b/crates/bitwarden/Cargo.toml @@ -57,6 +57,7 @@ getrandom = { version = ">=0.2.9", features = ["js"] } bitwarden-api-identity = { path = "../bitwarden-api-identity", version = "=0.2.2" } bitwarden-api-api = { path = "../bitwarden-api-api", version = "=0.2.2" } +rand_chacha = "0.3.1" [dev-dependencies] tokio = { version = "1.28.2", features = ["rt", "macros"] } diff --git a/crates/bitwarden/src/tool/generators/client_generator.rs b/crates/bitwarden/src/tool/generators/client_generator.rs index 0384eec6a..147f6c045 100644 --- a/crates/bitwarden/src/tool/generators/client_generator.rs +++ b/crates/bitwarden/src/tool/generators/client_generator.rs @@ -1,7 +1,8 @@ use crate::{ error::Result, - tool::generators::password::{ - passphrase, password, PassphraseGeneratorRequest, PasswordGeneratorRequest, + tool::generators::{ + password::{passphrase, password, PassphraseGeneratorRequest, PasswordGeneratorRequest}, + username::{username, UsernameGeneratorRequest}, }, Client, }; @@ -18,6 +19,10 @@ impl<'a> ClientGenerator<'a> { pub async fn passphrase(&self, input: PassphraseGeneratorRequest) -> Result { passphrase(input) } + + pub async fn username(&self, input: UsernameGeneratorRequest) -> Result { + username(input).await + } } impl<'a> Client { diff --git a/crates/bitwarden/src/tool/generators/mod.rs b/crates/bitwarden/src/tool/generators/mod.rs index bdc0fb260..5e60cf1f0 100644 --- a/crates/bitwarden/src/tool/generators/mod.rs +++ b/crates/bitwarden/src/tool/generators/mod.rs @@ -1,4 +1,9 @@ mod client_generator; mod password; +mod username; +mod username_forwarders; pub use password::{PassphraseGeneratorRequest, PasswordGeneratorRequest}; +pub use username::{ + AddressType, ForwarderServiceType, UsernameGeneratorRequest, UsernameGeneratorType, +}; diff --git a/crates/bitwarden/src/tool/generators/username.rs b/crates/bitwarden/src/tool/generators/username.rs new file mode 100644 index 000000000..38cdec1bb --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username.rs @@ -0,0 +1,245 @@ +use crate::{error::Result, wordlist::EFF_LONG_WORD_LIST}; +use rand::{seq::SliceRandom, Rng, RngCore}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "mobile", derive(uniffi::Enum))] +pub enum AddressType { + /// Generates a random string of 8 lowercase characters as part of your username + Random, + /// Uses the websitename as part of your username + WebsiteName { website: String }, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "mobile", derive(uniffi::Enum))] +/// Configures the email forwarding service to use. +/// For instructions on how to configure each service, see the documentation: +/// https://bitwarden.com/help/generator/#username-types +pub enum ForwarderServiceType { + /// Previously known as "AnonAddy" + AddyIo { + api_token: String, + domain: String, + base_url: String, + }, + DuckDuckGo { + token: String, + }, + Firefox { + api_token: String, + }, + Fastmail { + api_token: String, + }, + ForwardEmail { + api_token: String, + domain: String, + }, + SimpleLogin { + api_key: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "mobile", derive(uniffi::Enum))] +pub enum UsernameGeneratorType { + /// Generates a single word username + Word { + /// When set to true, capitalizes the first letter of the word. Defaults to false + capitalize: Option, + /// When set to true, includes a 4 digit number at the end of the word. Defaults to false + include_number: Option, + }, + /// Generates an email using your provider's subaddressing capabilities. + /// Note that not all providers support this functionality. + /// This will generate an address of the format `youremail+generated@domain.tld` + Subaddress { + /// The type of subaddress to add to the base email + r#type: AddressType, + /// The full email address to use as the base for the subaddress + email: String, + }, + Catchall { + /// The type of username to use with the catchall email domain + r#type: AddressType, + /// The domain to use for the catchall email address + domain: String, + }, + Forwarded { + /// The email forwarding service to use, see [ForwarderServiceType] + /// for instructions on how to configure each + service: ForwarderServiceType, + /// The website for which the email address is being generated + /// This is not used in all services, and is only used for display purposes + website: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "mobile", derive(uniffi::Record))] +pub struct UsernameGeneratorRequest { + pub r#type: UsernameGeneratorType, +} + +pub(super) async fn username(input: UsernameGeneratorRequest) -> Result { + use UsernameGeneratorType::*; + let mut rng = rand::thread_rng(); + + match input.r#type { + Word { + capitalize, + include_number, + } => { + let capitalize = capitalize.unwrap_or(true); + let include_number = include_number.unwrap_or(true); + Ok(username_word(&mut rng, capitalize, include_number)) + } + Subaddress { r#type, email } => Ok(username_subaddress(&mut rng, r#type, email)), + Catchall { r#type, domain } => Ok(username_catchall(&mut rng, r#type, domain)), + Forwarded { service, website } => { + use crate::tool::generators::username_forwarders::*; + use ForwarderServiceType::*; + match service { + AddyIo { + api_token, + domain, + base_url, + } => addyio::generate(api_token, domain, base_url, website).await, + DuckDuckGo { token } => duckduckgo::generate(token).await, + Firefox { api_token } => firefox::generate(api_token, website).await, + Fastmail { api_token } => fastmail::generate(api_token, website).await, + ForwardEmail { api_token, domain } => { + forwardemail::generate(api_token, domain, website).await + } + SimpleLogin { api_key } => simplelogin::generate(api_key, website).await, + } + } + } +} + +fn username_word(mut rng: impl RngCore, capitalize: bool, include_number: bool) -> String { + let word = EFF_LONG_WORD_LIST + .choose(&mut rng) + .expect("slice is not empty"); + + let mut word = if capitalize { + capitalize_first_letter(word) + } else { + word.to_string() + }; + + if include_number { + word.push_str(&random_number(&mut rng)); + } + + word +} + +fn username_subaddress(mut rng: impl RngCore, r#type: AddressType, email: String) -> String { + if email.len() < 3 { + return email; + } + + let (email_begin, email_end) = match email.find('@') { + Some(pos) if pos > 0 && pos < email.len() - 1 => { + email.split_once('@').expect("The email contains @") + } + _ => return email, + }; + + let email_middle = match r#type { + AddressType::Random => random_lowercase_string(&mut rng, 8), + AddressType::WebsiteName { website } => website, + }; + + format!("{}+{}@{}", email_begin, email_middle, email_end) +} + +fn username_catchall(mut rng: impl RngCore, r#type: AddressType, domain: String) -> String { + if domain.is_empty() { + return domain; + } + + let email_start = match r#type { + AddressType::Random => random_lowercase_string(&mut rng, 8), + AddressType::WebsiteName { website } => website, + }; + + format!("{}@{}", email_start, domain) +} + +fn random_number(mut rng: impl RngCore) -> String { + let num = rng.gen_range(0..=9999); + format!("{num:0>4}") +} + +fn random_lowercase_string(mut rng: impl RngCore, length: usize) -> String { + const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz1234567890"; + (0..length) + .map(|_| (*CHARSET.choose(&mut rng).expect("slice is not empty")) as char) + .collect() +} + +fn capitalize_first_letter(s: &str) -> String { + // Unicode case conversion can change the length of the string, so we can't capitalize in place. + // Instead we extract the first character and convert it to uppercase. This returns + // an iterator which we collect into a string, and then append the rest of the input. + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + + pub use super::*; + + #[test] + fn test_username_word() { + let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]); + assert_eq!(username_word(&mut rng, true, true), "Subsystem6314"); + assert_eq!(username_word(&mut rng, true, false), "Silenced"); + assert_eq!(username_word(&mut rng, false, true), "dinginess4487"); + } + + #[test] + fn test_username_subaddress() { + let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]); + let user = username_subaddress(&mut rng, AddressType::Random, "demo@test.com".into()); + assert_eq!(user, "demo+52iteqjo@test.com"); + + let user = username_subaddress( + &mut rng, + AddressType::WebsiteName { + website: "bitwarden.com".into(), + }, + "demo@test.com".into(), + ); + assert_eq!(user, "demo+bitwarden.com@test.com"); + } + + #[test] + fn test_username_catchall() { + let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]); + let user = username_catchall(&mut rng, AddressType::Random, "test.com".into()); + assert_eq!(user, "52iteqjo@test.com"); + + let user = username_catchall( + &mut rng, + AddressType::WebsiteName { + website: "bitwarden.com".into(), + }, + "test.com".into(), + ); + assert_eq!(user, "bitwarden.com@test.com"); + } +} diff --git a/crates/bitwarden/src/tool/generators/username_forwarders/addyio.rs b/crates/bitwarden/src/tool/generators/username_forwarders/addyio.rs new file mode 100644 index 000000000..2d55c673c --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/addyio.rs @@ -0,0 +1,62 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::error::{Error, Result}; +pub async fn generate( + api_token: String, + domain: String, + base_url: String, + website: Option, +) -> Result { + if api_token.is_empty() { + return Err(Error::Internal("Invalid addy.io API token.")); + } + if domain.is_empty() { + return Err(Error::Internal("Invalid addy.io domain.")); + } + if base_url.is_empty() { + return Err(Error::Internal("Invalid addy.io url.")); + } + + let description = website + .as_ref() + .map(|w| format!("Website: {w}. ")) + .unwrap_or_default(); + let description = format!("{description}Generated by Bitwarden."); + + #[derive(serde::Serialize)] + struct Request { + domain: String, + description: String, + } + + let response = reqwest::Client::new() + .post(format!("{base_url}/api/v1/aliases")) + .header(CONTENT_TYPE, "application/json") + .bearer_auth(api_token) + .header("X-Requested-With", "XMLHttpRequest") + .json(&Request { + domain, + description, + }) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err(Error::Internal("Invalid addy.io API token.")); + } + + // Throw any other errors + response.error_for_status_ref()?; + + #[derive(serde::Deserialize)] + struct ResponseData { + email: String, + } + #[derive(serde::Deserialize)] + struct Response { + data: ResponseData, + } + let response: Response = response.json().await?; + + Ok(response.data.email) +} diff --git a/crates/bitwarden/src/tool/generators/username_forwarders/duckduckgo.rs b/crates/bitwarden/src/tool/generators/username_forwarders/duckduckgo.rs new file mode 100644 index 000000000..329aa0bf6 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/duckduckgo.rs @@ -0,0 +1,30 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::error::{Error, Result}; +pub async fn generate(token: String) -> Result { + if token.is_empty() { + return Err(Error::Internal("Invalid DuckDuckGo API token")); + } + + let response = reqwest::Client::new() + .post("https://quack.duckduckgo.com/api/email/addresses") + .header(CONTENT_TYPE, "application/json") + .bearer_auth(token) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err(Error::Internal("Invalid DuckDuckGo API token")); + } + + // Throw any other errors + response.error_for_status_ref()?; + + #[derive(serde::Deserialize)] + struct Response { + address: String, + } + let response: Response = response.json().await?; + + Ok(format!("{}@duck.com", response.address)) +} diff --git a/crates/bitwarden/src/tool/generators/username_forwarders/fastmail.rs b/crates/bitwarden/src/tool/generators/username_forwarders/fastmail.rs new file mode 100644 index 000000000..69cfc95a5 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/fastmail.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; + +use reqwest::{header::CONTENT_TYPE, StatusCode}; +use serde_json::json; + +use crate::error::{Error, Result}; +pub async fn generate(api_token: String, website: Option) -> Result { + if api_token.is_empty() { + return Err(Error::Internal("Invalid Fastmail API token")); + } + + let mut client = reqwest::Client::new(); + + let account_id = get_account_id(&mut client, &api_token).await?; + + let response = reqwest::Client::new() + .post("https://api.fastmail.com/jmap/api/") + .header(CONTENT_TYPE, "application/json") + .bearer_auth(api_token) + .json(&json!({ + "using": ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"], + "methodCalls": [[ + "MaskedEmail/set", { + "accountId": account_id, + "create": { + "new-masked-email": { + "state": "enabled", + "description": "", + "url": website, + "emailPrefix": null, + }, + }, + }, + "0", + ]], + })) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err(Error::Internal("Invalid DuckDuckGo API token")); + } + + // Throw any other errors + response.error_for_status_ref()?; + + let response: serde_json::Value = response.json().await?; + let Some(r) = response.get("methodResponses").and_then(|r| r.get(0)) else { + return Err(Error::Internal("Unknown Fastmail error occurred.")); + }; + let method_response = r.get(0).and_then(|r| r.as_str()); + let response_value = r.get(1); + + if method_response == Some("MaskedEmail/set") { + if let Some(email) = response_value + .and_then(|r| r.get("created")) + .and_then(|r| r.get("new-masked-email")) + .and_then(|r| r.get("email")) + .and_then(|r| r.as_str()) + { + return Ok(email.to_owned()); + }; + + if let Some(_error_description) = response_value + .and_then(|r| r.get("notCreated")) + .and_then(|r| r.get("new-masked-email")) + .and_then(|r| r.get("description")) + .and_then(|r| r.as_str()) + { + // TODO: Once we have a more flexible type of error, we can return this error_description + return Err(Error::Internal("Unknown Fastmail error occurred.")); + }; + } else if method_response == Some("error") { + let _description = response_value + .and_then(|r| r.get("description")) + .and_then(|r| r.as_str()); + + // TODO: Once we have a more flexible type of error, we can return this error_description + return Err(Error::Internal("Unknown Fastmail error occurred.")); + } + + Err(Error::Internal("Unknown Fastmail error occurred.")) +} + +async fn get_account_id(client: &mut reqwest::Client, api_token: &str) -> Result { + #[derive(serde::Deserialize)] + struct Response { + #[serde(rename = "primaryAccounts")] + primary_accounts: HashMap, + } + let mut response: Response = client + .get("https://api.fastmail.com/.well-known/jmap") + .bearer_auth(api_token) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(response + .primary_accounts + .remove("https://www.fastmail.com/dev/maskedemail") + .unwrap_or_default()) +} diff --git a/crates/bitwarden/src/tool/generators/username_forwarders/firefox.rs b/crates/bitwarden/src/tool/generators/username_forwarders/firefox.rs new file mode 100644 index 000000000..d37450ae4 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/firefox.rs @@ -0,0 +1,50 @@ +use reqwest::{ + header::{self}, + StatusCode, +}; + +use crate::error::{Error, Result}; +pub async fn generate(api_token: String, website: Option) -> Result { + if api_token.is_empty() { + return Err(Error::Internal("Invalid Firefox Relay API API key")); + } + + #[derive(serde::Serialize)] + struct Request { + enabled: bool, + generated_for: Option, + description: String, + } + + let description = website + .as_ref() + .map(|w| format!("{w} - ")) + .unwrap_or_default(); + let description = format!("{description}Generated by Bitwarden."); + + let response = reqwest::Client::new() + .post("https://relay.firefox.com/api/v1/relayaddresses/") + .header(header::AUTHORIZATION, format!("Token {api_token}")) + .json(&Request { + enabled: true, + generated_for: website, + description, + }) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err(Error::Internal("Invalid Firefox Relay API API key")); + } + + // Throw any other errors + response.error_for_status_ref()?; + + #[derive(serde::Deserialize)] + struct Response { + full_address: String, + } + let response: Response = response.json().await?; + + Ok(response.full_address) +} diff --git a/crates/bitwarden/src/tool/generators/username_forwarders/forwardemail.rs b/crates/bitwarden/src/tool/generators/username_forwarders/forwardemail.rs new file mode 100644 index 000000000..9bc968866 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/forwardemail.rs @@ -0,0 +1,81 @@ +use base64::Engine; +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::{ + error::{Error, Result}, + util::BASE64_ENGINE, +}; +pub async fn generate( + api_token: String, + domain: String, + website: Option, +) -> Result { + if api_token.is_empty() { + return Err(Error::Internal("Invalid Forward Email API key.")); + } + if domain.is_empty() { + return Err(Error::Internal("Invalid Forward Email domain.")); + } + + let api_token_b64 = BASE64_ENGINE.encode(format!("{api_token}:").as_bytes()); + + let description = website + .as_ref() + .map(|w| format!("Website: {w}. ")) + .unwrap_or_default(); + let description = format!("{description}Generated by Bitwarden."); + + #[derive(serde::Serialize)] + struct Request { + labels: Option, + description: String, + } + + let response = reqwest::Client::new() + .post(format!( + "https://api.forwardemail.net/v1/domains/${domain}/aliases" + )) + .header(CONTENT_TYPE, "application/json") + .bearer_auth(api_token_b64) + .json(&Request { + description, + labels: website, + }) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err(Error::Internal("Invalid Forward Email API key.")); + } + + // Throw any other errors + response.error_for_status_ref()?; + + #[derive(serde::Deserialize)] + struct ResponseDomain { + name: Option, + } + #[derive(serde::Deserialize)] + struct Response { + name: String, + domain: ResponseDomain, + + message: Option, + error: Option, + } + let status = response.status(); + let response: Response = response.json().await?; + + if let Some(message) = response.message { + return Err(Error::ResponseContent { status, message }); + } + if let Some(message) = response.error { + return Err(Error::ResponseContent { status, message }); + } + + Ok(format!( + "{}@{}", + response.name, + response.domain.name.unwrap_or(domain) + )) +} diff --git a/crates/bitwarden/src/tool/generators/username_forwarders/mod.rs b/crates/bitwarden/src/tool/generators/username_forwarders/mod.rs new file mode 100644 index 000000000..50fa41175 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/mod.rs @@ -0,0 +1,6 @@ +pub(super) mod addyio; +pub(super) mod duckduckgo; +pub(super) mod fastmail; +pub(super) mod firefox; +pub(super) mod forwardemail; +pub(super) mod simplelogin; diff --git a/crates/bitwarden/src/tool/generators/username_forwarders/simplelogin.rs b/crates/bitwarden/src/tool/generators/username_forwarders/simplelogin.rs new file mode 100644 index 000000000..c431eb593 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/simplelogin.rs @@ -0,0 +1,49 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::error::{Error, Result}; +pub async fn generate(api_key: String, website: Option) -> Result { + if api_key.is_empty() { + return Err(Error::Internal("Invalid SimpleLogin API key.")); + } + + let query = website + .as_ref() + .map(|w| format!("?hostname={}", w)) + .unwrap_or_default(); + + let note = website + .as_ref() + .map(|w| format!("Website: {w}. ")) + .unwrap_or_default(); + let note = format!("{note}Generated by Bitwarden."); + + #[derive(serde::Serialize)] + struct Request { + note: String, + } + + let response = reqwest::Client::new() + .post(format!( + "https://app.simplelogin.io/api/alias/random/new{query}" + )) + .header(CONTENT_TYPE, "application/json") + .bearer_auth(api_key) + .json(&Request { note }) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err(Error::Internal("Invalid SimpleLogin API key.")); + } + + // Throw any other errors + response.error_for_status_ref()?; + + #[derive(serde::Deserialize)] + struct Response { + alias: String, + } + let response: Response = response.json().await?; + + Ok(response.alias) +} diff --git a/crates/bitwarden/src/tool/mod.rs b/crates/bitwarden/src/tool/mod.rs index 2130a6b0c..481be3144 100644 --- a/crates/bitwarden/src/tool/mod.rs +++ b/crates/bitwarden/src/tool/mod.rs @@ -2,4 +2,7 @@ mod exporters; mod generators; pub use exporters::ExportFormat; -pub use generators::{PassphraseGeneratorRequest, PasswordGeneratorRequest}; +pub use generators::{ + AddressType, ForwarderServiceType, PassphraseGeneratorRequest, PasswordGeneratorRequest, + UsernameGeneratorRequest, UsernameGeneratorType, +};