diff --git a/crates/bitwarden-uniffi/src/tool/mod.rs b/crates/bitwarden-uniffi/src/tool/mod.rs index 6622a1b2d..6b443618c 100644 --- a/crates/bitwarden-uniffi/src/tool/mod.rs +++ b/crates/bitwarden-uniffi/src/tool/mod.rs @@ -1,7 +1,10 @@ use std::sync::Arc; use bitwarden::{ - tool::{ExportFormat, PassphraseGeneratorRequest, PasswordGeneratorRequest}, + tool::{ + ExportFormat, PassphraseGeneratorRequest, PasswordGeneratorRequest, + UsernameGeneratorRequest, + }, vault::{Cipher, Collection, Folder}, }; @@ -35,6 +38,18 @@ impl ClientGenerators { .passphrase(settings) .await?) } + + /// **API Draft:** Generate Username + pub async fn username(&self, settings: UsernameGeneratorRequest) -> Result { + Ok(self + .0 + .0 + .read() + .await + .generator() + .username(settings) + .await?) + } } #[derive(uniffi::Object)] diff --git a/crates/bitwarden/src/client/client.rs b/crates/bitwarden/src/client/client.rs index 0c69ba917..bb0f65c09 100644 --- a/crates/bitwarden/src/client/client.rs +++ b/crates/bitwarden/src/client/client.rs @@ -132,6 +132,11 @@ impl Client { &self.__api_configurations } + #[cfg(feature = "mobile")] + pub(crate) fn get_http_client(&self) -> &reqwest::Client { + &self.__api_configurations.api.client + } + #[cfg(feature = "secrets")] #[deprecated(note = "Use auth().login_access_token() instead")] pub async fn access_token_login( diff --git a/crates/bitwarden/src/tool/generators/client_generator.rs b/crates/bitwarden/src/tool/generators/client_generator.rs index 4675ad99d..301383635 100644 --- a/crates/bitwarden/src/tool/generators/client_generator.rs +++ b/crates/bitwarden/src/tool/generators/client_generator.rs @@ -1,12 +1,15 @@ use crate::{ error::Result, - tool::generators::passphrase::{passphrase, PassphraseGeneratorRequest}, - tool::generators::password::{password, PasswordGeneratorRequest}, + tool::generators::{ + passphrase::{passphrase, PassphraseGeneratorRequest}, + password::{password, PasswordGeneratorRequest}, + username::{username, UsernameGeneratorRequest}, + }, Client, }; pub struct ClientGenerator<'a> { - pub(crate) _client: &'a crate::Client, + pub(crate) client: &'a crate::Client, } impl<'a> ClientGenerator<'a> { @@ -59,10 +62,32 @@ impl<'a> ClientGenerator<'a> { pub async fn passphrase(&self, input: PassphraseGeneratorRequest) -> Result { passphrase(input) } + + /// Generates a random username. + /// There are different username generation strategies, which can be customized using the `input` parameter. + /// + /// Note that most generation strategies will be executed on the client side, but `Forwarded` will use third-party + /// services, which may require a specific setup or API key. + /// + /// ``` + /// use bitwarden::{Client, tool::{UsernameGeneratorRequest}, error::Result}; + /// async fn test() -> Result<()> { + /// let input = UsernameGeneratorRequest::Word { + /// capitalize: true, + /// include_number: true, + /// }; + /// let username = Client::new(None).generator().username(input).await.unwrap(); + /// println!("{}", username); + /// Ok(()) + /// } + /// ``` + pub async fn username(&self, input: UsernameGeneratorRequest) -> Result { + username(input, self.client.get_http_client()).await + } } impl<'a> Client { pub fn generator(&'a self) -> ClientGenerator<'a> { - ClientGenerator { _client: self } + ClientGenerator { client: self } } } diff --git a/crates/bitwarden/src/tool/generators/mod.rs b/crates/bitwarden/src/tool/generators/mod.rs index 12c526930..7966c58a9 100644 --- a/crates/bitwarden/src/tool/generators/mod.rs +++ b/crates/bitwarden/src/tool/generators/mod.rs @@ -1,7 +1,10 @@ mod client_generator; mod passphrase; mod password; +mod username; +mod username_forwarders; pub use client_generator::ClientGenerator; pub use passphrase::PassphraseGeneratorRequest; pub use password::PasswordGeneratorRequest; +pub use username::{AppendType, ForwarderServiceType, UsernameGeneratorRequest}; diff --git a/crates/bitwarden/src/tool/generators/passphrase.rs b/crates/bitwarden/src/tool/generators/passphrase.rs index 5abad368b..7c5c8e434 100644 --- a/crates/bitwarden/src/tool/generators/passphrase.rs +++ b/crates/bitwarden/src/tool/generators/passphrase.rs @@ -1,4 +1,4 @@ -use crate::{error::Result, wordlist::EFF_LONG_WORD_LIST}; +use crate::{error::Result, util::capitalize_first_letter, wordlist::EFF_LONG_WORD_LIST}; use rand::{seq::SliceRandom, Rng, RngCore}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -109,17 +109,6 @@ fn capitalize_words(words: &mut [String]) { .for_each(|w| *w = capitalize_first_letter(w)); } -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; diff --git a/crates/bitwarden/src/tool/generators/username.rs b/crates/bitwarden/src/tool/generators/username.rs new file mode 100644 index 000000000..666772714 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username.rs @@ -0,0 +1,244 @@ +use crate::{error::Result, util::capitalize_first_letter, wordlist::EFF_LONG_WORD_LIST}; +use rand::{distributions::Distribution, 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 AppendType { + /// 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: +/// +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 UsernameGeneratorRequest { + /// Generates a single word username + Word { + /// Capitalize the first letter of the word + capitalize: bool, + /// Include a 4 digit number at the end of the word + include_number: bool, + }, + /// 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: AppendType, + /// 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: AppendType, + /// 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, + }, +} + +impl ForwarderServiceType { + // Generate a username using the specified email forwarding service + // This requires an HTTP client to be passed in, as the service will need to make API calls + pub async fn generate(self, http: &reqwest::Client, website: Option) -> Result { + use crate::tool::generators::username_forwarders::*; + use ForwarderServiceType::*; + + match self { + AddyIo { + api_token, + domain, + base_url, + } => addyio::generate(http, api_token, domain, base_url, website).await, + DuckDuckGo { token } => duckduckgo::generate(http, token).await, + Firefox { api_token } => firefox::generate(http, api_token, website).await, + Fastmail { api_token } => fastmail::generate(http, api_token, website).await, + ForwardEmail { api_token, domain } => { + forwardemail::generate(http, api_token, domain, website).await + } + SimpleLogin { api_key } => simplelogin::generate(http, api_key, website).await, + } + } +} + +/// Implementation of the username generator. This is not accessible to the public API. +/// See [`ClientGenerator::username`](crate::ClientGenerator::username) for the API function. +/// Note: The HTTP client is passed in as a required parameter for convenience, +/// as some username generators require making API calls. +pub(super) async fn username( + input: UsernameGeneratorRequest, + http: &reqwest::Client, +) -> Result { + use rand::thread_rng; + use UsernameGeneratorRequest::*; + match input { + Word { + capitalize, + include_number, + } => Ok(username_word(&mut thread_rng(), capitalize, include_number)), + Subaddress { r#type, email } => Ok(username_subaddress(&mut thread_rng(), r#type, email)), + Catchall { r#type, domain } => Ok(username_catchall(&mut thread_rng(), r#type, domain)), + Forwarded { service, website } => service.generate(http, 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 +} + +/// Generate a random 4 digit number, including leading zeros +fn random_number(mut rng: impl RngCore) -> String { + let num = rng.gen_range(0..=9999); + format!("{num:0>4}") +} + +/// Generate a username using a plus addressed email address +/// The format is +@ +fn username_subaddress(mut rng: impl RngCore, r#type: AppendType, 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 { + AppendType::Random => random_lowercase_string(&mut rng, 8), + AppendType::WebsiteName { website } => website, + }; + + format!("{}+{}@{}", email_begin, email_middle, email_end) +} + +/// Generate a username using a catchall email address +/// The format is @ +fn username_catchall(mut rng: impl RngCore, r#type: AppendType, domain: String) -> String { + if domain.is_empty() { + return domain; + } + + let email_start = match r#type { + AppendType::Random => random_lowercase_string(&mut rng, 8), + AppendType::WebsiteName { website } => website, + }; + + format!("{}@{}", email_start, domain) +} + +fn random_lowercase_string(mut rng: impl RngCore, length: usize) -> String { + const LOWERCASE_ALPHANUMERICAL: &[u8] = b"abcdefghijklmnopqrstuvwxyz1234567890"; + let dist = rand::distributions::Slice::new(LOWERCASE_ALPHANUMERICAL).expect("Non-empty slice"); + + dist.sample_iter(&mut rng) + .take(length) + .map(|&b| b as char) + .collect() +} + +#[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, AppendType::Random, "demo@test.com".into()); + assert_eq!(user, "demo+5wiejdaj@test.com"); + + let user = username_subaddress( + &mut rng, + AppendType::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([1u8; 32]); + let user = username_catchall(&mut rng, AppendType::Random, "test.com".into()); + assert_eq!(user, "k9y6yw7j@test.com"); + + let user = username_catchall( + &mut rng, + AppendType::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..0fc5937f6 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/addyio.rs @@ -0,0 +1,143 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::error::Result; +pub async fn generate( + http: &reqwest::Client, + api_token: String, + domain: String, + base_url: String, + website: Option, +) -> Result { + let description = super::format_description(&website); + + #[derive(serde::Serialize)] + struct Request { + domain: String, + description: String, + } + + let response = http + .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("Invalid addy.io API token.".into()); + } + + // 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) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + #[tokio::test] + async fn test_mock_server() { + use wiremock::{matchers, Mock, ResponseTemplate}; + + let (server, _client) = crate::util::start_mock(vec![ + // Mock the request to the addy.io API, and verify that the correct request is made + Mock::given(matchers::path("/api/v1/aliases")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Bearer MY_TOKEN")) + .and(matchers::body_json(json!({ + "domain": "myemail.com", + "description": "Website: example.com. Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "data": { + "id": "50c9e585-e7f5-41c4-9016-9014c15454bc", + "user_id": "ca0a4e09-c266-4f6f-845c-958db5090f09", + "local_part": "50c9e585-e7f5-41c4-9016-9014c15454bc", + "domain": "myemail.com", + "email": "50c9e585-e7f5-41c4-9016-9014c15454bc@myemail.com", + "active": true + } + }))) + .expect(1), + // Mock an invalid API token request + Mock::given(matchers::path("/api/v1/aliases")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Bearer MY_FAKE_TOKEN")) + .and(matchers::body_json(json!({ + "domain": "myemail.com", + "description": "Website: example.com. Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(401)) + .expect(1), + // Mock an invalid domain + Mock::given(matchers::path("/api/v1/aliases")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Bearer MY_TOKEN")) + .and(matchers::body_json(json!({ + "domain": "gmail.com", + "description": "Website: example.com. Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(403)) + .expect(1), + ]) + .await; + + let address = super::generate( + &reqwest::Client::new(), + "MY_TOKEN".into(), + "myemail.com".into(), + format!("http://{}", server.address()), + Some("example.com".into()), + ) + .await + .unwrap(); + + let fake_token_error = super::generate( + &reqwest::Client::new(), + "MY_FAKE_TOKEN".into(), + "myemail.com".into(), + format!("http://{}", server.address()), + Some("example.com".into()), + ) + .await + .unwrap_err(); + + assert!(fake_token_error + .to_string() + .contains("Invalid addy.io API token.")); + + let fake_domain_error = super::generate( + &reqwest::Client::new(), + "MY_TOKEN".into(), + "gmail.com".into(), + format!("http://{}", server.address()), + Some("example.com".into()), + ) + .await + .unwrap_err(); + + assert!(fake_domain_error.to_string().contains("403 Forbidden")); + + server.verify().await; + assert_eq!(address, "50c9e585-e7f5-41c4-9016-9014c15454bc@myemail.com"); + } +} 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..512db7812 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/duckduckgo.rs @@ -0,0 +1,86 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::error::Result; +pub async fn generate(http: &reqwest::Client, token: String) -> Result { + generate_with_api_url(http, token, "https://quack.duckduckgo.com".into()).await +} + +async fn generate_with_api_url( + http: &reqwest::Client, + token: String, + api_url: String, +) -> Result { + let response = http + .post(format!("{api_url}/api/email/addresses")) + .header(CONTENT_TYPE, "application/json") + .bearer_auth(token) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err("Invalid DuckDuckGo API token".into()); + } + + // 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)) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + #[tokio::test] + async fn test_mock_server() { + use wiremock::{matchers, Mock, ResponseTemplate}; + + let (server, _client) = crate::util::start_mock(vec![ + // Mock the request to the DDG API, and verify that the correct request is made + Mock::given(matchers::path("/api/email/addresses")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Bearer MY_TOKEN")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "address": "bw7prt" + }))) + .expect(1), + // Mock an invalid token request + Mock::given(matchers::path("/api/email/addresses")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Bearer MY_FAKE_TOKEN")) + .respond_with(ResponseTemplate::new(401)) + .expect(1), + ]) + .await; + + let address = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_TOKEN".into(), + format!("http://{}", server.address()), + ) + .await + .unwrap(); + assert_eq!(address, "bw7prt@duck.com"); + + let fake_token_error = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_FAKE_TOKEN".into(), + format!("http://{}", server.address()), + ) + .await + .unwrap_err(); + + assert!(fake_token_error + .to_string() + .contains("Invalid DuckDuckGo API token")); + + server.verify().await; + } +} 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..d2a7554e2 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/fastmail.rs @@ -0,0 +1,188 @@ +use std::collections::HashMap; + +use reqwest::{header::CONTENT_TYPE, StatusCode}; +use serde_json::json; + +use crate::error::Result; +pub async fn generate( + http: &reqwest::Client, + api_token: String, + website: Option, +) -> Result { + generate_with_api_url(http, api_token, website, "https://api.fastmail.com".into()).await +} + +pub async fn generate_with_api_url( + http: &reqwest::Client, + api_token: String, + website: Option, + api_url: String, +) -> Result { + let account_id = get_account_id(http, &api_token, &api_url).await?; + + let response = http + .post(format!("{api_url}/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("Invalid Fastmail API token".into()); + } + + let response: serde_json::Value = response.json().await?; + let Some(r) = response.get("methodResponses").and_then(|r| r.get(0)) else { + return Err("Unknown Fastmail error occurred.".into()); + }; + 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()); + }; + + let 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()) + .unwrap_or("Unknown error"); + + return Err(format!("Fastmail error: {error_description}").into()); + } else if method_response == Some("error") { + let error_description = response_value + .and_then(|r| r.get("description")) + .and_then(|r| r.as_str()) + .unwrap_or("Unknown error"); + + return Err(format!("Fastmail error: {error_description}").into()); + } + + Err("Unknown Fastmail error occurred.".into()) +} + +async fn get_account_id( + client: &reqwest::Client, + api_token: &str, + api_url: &str, +) -> Result { + #[derive(serde::Deserialize)] + struct Response { + #[serde(rename = "primaryAccounts")] + primary_accounts: HashMap, + } + let response = client + .get(format!("{api_url}/.well-known/jmap")) + .bearer_auth(api_token) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err("Invalid Fastmail API token".into()); + } + + response.error_for_status_ref()?; + let mut response: Response = response.json().await?; + + Ok(response + .primary_accounts + .remove("https://www.fastmail.com/dev/maskedemail") + .unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + #[tokio::test] + async fn test_mock_server() { + use wiremock::{matchers, Mock, ResponseTemplate}; + + let (server, _client) = crate::util::start_mock(vec![ + // Mock a valid request to FastMail API + Mock::given(matchers::path("/.well-known/jmap")) + .and(matchers::method("GET")) + .and(matchers::header("Authorization", "Bearer MY_TOKEN")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "primaryAccounts": { + "https://www.fastmail.com/dev/maskedemail": "ca0a4e09-c266-4f6f-845c-958db5090f09" + } + }))) + .expect(1), + + Mock::given(matchers::path("/jmap/api/")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Bearer MY_TOKEN")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "methodResponses": [ + ["MaskedEmail/set", {"created": {"new-masked-email": {"email": "9f823dq23d123ds@mydomain.com"}}}] + ] + }))) + .expect(1), + + // Mock an invalid token request + Mock::given(matchers::path("/.well-known/jmap")) + .and(matchers::method("GET")) + .and(matchers::header("Authorization", "Bearer MY_FAKE_TOKEN")) + .respond_with(ResponseTemplate::new(401)) + .expect(1), + + Mock::given(matchers::path("/jmap/api/")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Bearer MY_FAKE_TOKEN")) + .respond_with(ResponseTemplate::new(201)) + .expect(0), + ]) + .await; + + let address = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_TOKEN".into(), + Some("example.com".into()), + format!("http://{}", server.address()), + ) + .await + .unwrap(); + assert_eq!(address, "9f823dq23d123ds@mydomain.com"); + + let fake_token_error = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_FAKE_TOKEN".into(), + Some("example.com".into()), + format!("http://{}", server.address()), + ) + .await + .unwrap_err(); + + assert!(fake_token_error + .to_string() + .contains("Invalid Fastmail API token")); + + server.verify().await; + } +} 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..2c45803a5 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/firefox.rs @@ -0,0 +1,120 @@ +use reqwest::{ + header::{self}, + StatusCode, +}; + +use crate::error::Result; + +pub async fn generate( + http: &reqwest::Client, + api_token: String, + website: Option, +) -> Result { + generate_with_api_url(http, api_token, website, "https://relay.firefox.com".into()).await +} + +async fn generate_with_api_url( + http: &reqwest::Client, + api_token: String, + website: Option, + api_url: String, +) -> Result { + #[derive(serde::Serialize)] + struct Request { + enabled: bool, + generated_for: Option, + description: String, + } + + let description = super::format_description_ff(&website); + + let response = http + .post(format!("{api_url}/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("Invalid Firefox Relay API key".into()); + } + + // 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) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + #[tokio::test] + async fn test_mock_server() { + use wiremock::{matchers, Mock, ResponseTemplate}; + + let (server, _client) = crate::util::start_mock(vec![ + // Mock the request to the Firefox API, and verify that the correct request is made + Mock::given(matchers::path("/api/v1/relayaddresses/")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Token MY_TOKEN")) + .and(matchers::body_json(json!({ + "enabled": true, + "generated_for": "example.com", + "description": "example.com - Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "full_address": "ofuj4d4qw@mozmail.com" + }))) + .expect(1), + // Mock an invalid API key + Mock::given(matchers::path("/api/v1/relayaddresses/")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Token MY_FAKE_TOKEN")) + .and(matchers::body_json(json!({ + "enabled": true, + "generated_for": "example.com", + "description": "example.com - Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(401)) + .expect(1), + ]) + .await; + + let address = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_TOKEN".into(), + Some("example.com".into()), + format!("http://{}", server.address()), + ) + .await + .unwrap(); + assert_eq!(address, "ofuj4d4qw@mozmail.com"); + + let fake_token_error = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_FAKE_TOKEN".into(), + Some("example.com".into()), + format!("http://{}", server.address()), + ) + .await + .unwrap_err(); + + assert!(fake_token_error + .to_string() + .contains("Invalid Firefox Relay API key")); + + server.verify().await; + } +} 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..f4ba6ced6 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/forwardemail.rs @@ -0,0 +1,193 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::error::{Error, Result}; + +pub async fn generate( + http: &reqwest::Client, + api_token: String, + domain: String, + website: Option, +) -> Result { + generate_with_api_url( + http, + api_token, + domain, + website, + "https://api.forwardemail.net".into(), + ) + .await +} + +async fn generate_with_api_url( + http: &reqwest::Client, + api_token: String, + domain: String, + website: Option, + api_url: String, +) -> Result { + let description = super::format_description(&website); + + #[derive(serde::Serialize)] + struct Request { + labels: Option, + description: String, + } + + let response = http + .post(format!("{api_url}/v1/domains/{domain}/aliases")) + .header(CONTENT_TYPE, "application/json") + .basic_auth(api_token, None::) + .json(&Request { + description, + labels: website, + }) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err("Invalid Forward Email API key.".into()); + } + + #[derive(serde::Deserialize)] + struct ResponseDomain { + name: Option, + } + #[derive(serde::Deserialize)] + struct Response { + name: Option, + domain: Option, + + message: Option, + error: Option, + } + let status = response.status(); + let response: Response = response.json().await?; + + if status.is_success() { + if let Some(name) = response.name { + if let Some(response_domain) = response.domain { + return Ok(format!( + "{}@{}", + name, + response_domain.name.unwrap_or(domain) + )); + } + } + } + + if let Some(message) = response.message { + return Err(Error::ResponseContent { status, message }); + } + if let Some(message) = response.error { + return Err(Error::ResponseContent { status, message }); + } + + Err("Unknown ForwardEmail error.".into()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + #[tokio::test] + async fn test_mock_server() { + use wiremock::{matchers, Mock, ResponseTemplate}; + + let (server, _client) = crate::util::start_mock(vec![ + // Mock the request to the ForwardEmail API, and verify that the correct request is made + Mock::given(matchers::path("/v1/domains/mydomain.com/aliases")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Basic TVlfVE9LRU46")) + .and(matchers::body_json(json!({ + "labels": "example.com", + "description": "Website: example.com. Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "name": "wertg8ad", + "domain": { + "name": "mydomain.com" + } + }))) + .expect(1), + // Mock an invalid API token request + Mock::given(matchers::path("/v1/domains/mydomain.com/aliases")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header( + "Authorization", + "Basic TVlfRkFLRV9UT0tFTjo=", + )) + .and(matchers::body_json(json!({ + "labels": "example.com", + "description": "Website: example.com. Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "statusCode": 401, + "error": "Unauthorized", + "message": "Invalid API token." + }))) + .expect(1), + // Mock a free API token request + Mock::given(matchers::path("/v1/domains/mydomain.com/aliases")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header( + "Authorization", + "Basic TVlfRlJFRV9UT0tFTjo=", + )) + .and(matchers::body_json(json!({ + "labels": "example.com", + "description": "Website: example.com. Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "statusCode": 402, + "error": "Payment required", + "message": "Please upgrade to a paid plan to unlock this feature." + }))) + .expect(1), + ]) + .await; + + let address = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_TOKEN".into(), + "mydomain.com".into(), + Some("example.com".into()), + format!("http://{}", server.address()), + ) + .await + .unwrap(); + assert_eq!(address, "wertg8ad@mydomain.com"); + + let invalid_token_error = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_FAKE_TOKEN".into(), + "mydomain.com".into(), + Some("example.com".into()), + format!("http://{}", server.address()), + ) + .await + .unwrap_err(); + + assert!(invalid_token_error + .to_string() + .contains("Invalid Forward Email API key.")); + + let free_token_error = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_FREE_TOKEN".into(), + "mydomain.com".into(), + Some("example.com".into()), + format!("http://{}", server.address()), + ) + .await + .unwrap_err(); + + assert!(free_token_error + .to_string() + .contains("Please upgrade to a paid plan")); + + server.verify().await; + } +} 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..8d445bc04 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/mod.rs @@ -0,0 +1,22 @@ +pub(super) mod addyio; +pub(super) mod duckduckgo; +pub(super) mod fastmail; +pub(super) mod firefox; +pub(super) mod forwardemail; +pub(super) mod simplelogin; + +fn format_description(website: &Option) -> String { + let description = website + .as_ref() + .map(|w| format!("Website: {w}. ")) + .unwrap_or_default(); + format!("{description}Generated by Bitwarden.") +} + +fn format_description_ff(website: &Option) -> String { + let description = website + .as_ref() + .map(|w| format!("{w} - ")) + .unwrap_or_default(); + format!("{description}Generated by Bitwarden.") +} 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..3c09fc691 --- /dev/null +++ b/crates/bitwarden/src/tool/generators/username_forwarders/simplelogin.rs @@ -0,0 +1,114 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::error::Result; + +pub async fn generate( + http: &reqwest::Client, + api_key: String, + website: Option, +) -> Result { + generate_with_api_url(http, api_key, website, "https://app.simplelogin.io".into()).await +} + +async fn generate_with_api_url( + http: &reqwest::Client, + api_key: String, + website: Option, + api_url: String, +) -> Result { + let query = website + .as_ref() + .map(|w| format!("?hostname={}", w)) + .unwrap_or_default(); + + let note = super::format_description(&website); + + #[derive(serde::Serialize)] + struct Request { + note: String, + } + + let response = http + .post(format!("{api_url}/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("Invalid SimpleLogin API key.".into()); + } + + // 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) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + #[tokio::test] + async fn test_mock_server() { + use wiremock::{matchers, Mock, ResponseTemplate}; + + let (server, _client) = crate::util::start_mock(vec![ + // Mock the request to the SimpleLogin API, and verify that the correct request is made + Mock::given(matchers::path("/api/alias/random/new")) + .and(matchers::method("POST")) + .and(matchers::query_param("hostname", "example.com")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Bearer MY_TOKEN")) + .and(matchers::body_json(json!({ + "note": "Website: example.com. Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "alias": "simplelogin.yut3g8@aleeas.com", + }))) + .expect(1), + // Mock an invalid token request + Mock::given(matchers::path("/api/alias/random/new")) + .and(matchers::method("POST")) + .and(matchers::query_param("hostname", "example.com")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Bearer MY_FAKE_TOKEN")) + .and(matchers::body_json(json!({ + "note": "Website: example.com. Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(401)) + .expect(1), + ]) + .await; + + let address = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_TOKEN".into(), + Some("example.com".into()), + format!("http://{}", server.address()), + ) + .await + .unwrap(); + assert_eq!(address, "simplelogin.yut3g8@aleeas.com"); + + let fake_token_error = super::generate_with_api_url( + &reqwest::Client::new(), + "MY_FAKE_TOKEN".into(), + Some("example.com".into()), + format!("http://{}", server.address()), + ) + .await + .unwrap_err(); + assert!(fake_token_error + .to_string() + .contains("Invalid SimpleLogin API key.")); + + server.verify().await; + } +} diff --git a/crates/bitwarden/src/tool/mod.rs b/crates/bitwarden/src/tool/mod.rs index 212ce4bf7..fe41b68db 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::{ClientExporters, ExportFormat}; -pub use generators::{ClientGenerator, PassphraseGeneratorRequest, PasswordGeneratorRequest}; +pub use generators::{ + AppendType, ClientGenerator, ForwarderServiceType, PassphraseGeneratorRequest, + PasswordGeneratorRequest, UsernameGeneratorRequest, +}; diff --git a/crates/bitwarden/src/util.rs b/crates/bitwarden/src/util.rs index b6c8465ec..4dbe21ca6 100644 --- a/crates/bitwarden/src/util.rs +++ b/crates/bitwarden/src/util.rs @@ -28,6 +28,18 @@ const BASE64_ENGINE_CONFIG: GeneralPurposeConfig = GeneralPurposeConfig::new() pub const BASE64_ENGINE: GeneralPurpose = GeneralPurpose::new(&alphabet::STANDARD, BASE64_ENGINE_CONFIG); +#[cfg(feature = "mobile")] +pub(crate) 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)] pub async fn start_mock(mocks: Vec) -> (wiremock::MockServer, crate::Client) { let server = wiremock::MockServer::start().await;