From b174e496a9c4052447abb3d3f2ddd6b62097d629 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Fri, 19 Jan 2024 10:36:17 +0100 Subject: [PATCH] [PM-5692] Extract generators to separate crate (#511) Continuation on #402. This PR extracts the generators to their own crate to establish clear boundaries between the code. There is still some logic in the client to expose generators that we may want to revisit. Also extracts explicit errors for the different generators for better error handling downstream when/if desired. --- .github/workflows/build-rust-crates.yml | 3 +- .github/workflows/publish-rust-crates.yml | 11 + .github/workflows/version-bump.yml | 7 + Cargo.lock | 18 ++ crates/bitwarden-crypto/Cargo.toml | 2 +- crates/bitwarden-crypto/README.md | 6 + crates/bitwarden-generators/Cargo.toml | 33 +++ crates/bitwarden-generators/README.md | 6 + crates/bitwarden-generators/src/lib.rs | 11 + .../src}/passphrase.rs | 29 ++- .../src}/password.rs | 26 ++- .../src}/username.rs | 34 ++- .../src/username_forwarders/addyio.rs | 158 +++++++++++++ .../src}/username_forwarders/duckduckgo.rs | 63 +++--- .../src}/username_forwarders/fastmail.rs | 94 ++++---- .../src}/username_forwarders/firefox.rs | 102 +++++---- .../src/username_forwarders/forwardemail.rs | 209 ++++++++++++++++++ .../src}/username_forwarders/mod.rs | 0 .../src/username_forwarders/simplelogin.rs | 125 +++++++++++ crates/bitwarden-generators/src/util.rs | 10 + crates/bitwarden-uniffi/src/docs.rs | 3 +- crates/bitwarden-uniffi/src/tool/mod.rs | 6 +- crates/bitwarden/Cargo.toml | 2 + crates/bitwarden/src/error.rs | 8 + crates/bitwarden/src/lib.rs | 6 + .../tool/{generators => }/client_generator.rs | 25 +-- crates/bitwarden/src/tool/generators/mod.rs | 10 - .../generators/username_forwarders/addyio.rs | 143 ------------ .../username_forwarders/forwardemail.rs | 193 ---------------- .../username_forwarders/simplelogin.rs | 114 ---------- crates/bitwarden/src/tool/mod.rs | 8 +- crates/bitwarden/src/util.rs | 12 - crates/bw/src/main.rs | 2 +- 33 files changed, 843 insertions(+), 636 deletions(-) create mode 100644 crates/bitwarden-crypto/README.md create mode 100644 crates/bitwarden-generators/Cargo.toml create mode 100644 crates/bitwarden-generators/README.md create mode 100644 crates/bitwarden-generators/src/lib.rs rename crates/{bitwarden/src/tool/generators => bitwarden-generators/src}/passphrase.rs (90%) rename crates/{bitwarden/src/tool/generators => bitwarden-generators/src}/password.rs (95%) rename crates/{bitwarden/src/tool/generators => bitwarden-generators/src}/username.rs (91%) create mode 100644 crates/bitwarden-generators/src/username_forwarders/addyio.rs rename crates/{bitwarden/src/tool/generators => bitwarden-generators/src}/username_forwarders/duckduckgo.rs (50%) rename crates/{bitwarden/src/tool/generators => bitwarden-generators/src}/username_forwarders/fastmail.rs (67%) rename crates/{bitwarden/src/tool/generators => bitwarden-generators/src}/username_forwarders/firefox.rs (50%) create mode 100644 crates/bitwarden-generators/src/username_forwarders/forwardemail.rs rename crates/{bitwarden/src/tool/generators => bitwarden-generators/src}/username_forwarders/mod.rs (100%) create mode 100644 crates/bitwarden-generators/src/username_forwarders/simplelogin.rs create mode 100644 crates/bitwarden-generators/src/util.rs rename crates/bitwarden/src/tool/{generators => }/client_generator.rs (81%) delete mode 100644 crates/bitwarden/src/tool/generators/mod.rs delete mode 100644 crates/bitwarden/src/tool/generators/username_forwarders/addyio.rs delete mode 100644 crates/bitwarden/src/tool/generators/username_forwarders/forwardemail.rs delete mode 100644 crates/bitwarden/src/tool/generators/username_forwarders/simplelogin.rs diff --git a/.github/workflows/build-rust-crates.yml b/.github/workflows/build-rust-crates.yml index 9d575d5d1..5d45b6f95 100644 --- a/.github/workflows/build-rust-crates.yml +++ b/.github/workflows/build-rust-crates.yml @@ -29,9 +29,10 @@ jobs: package: - bitwarden - - bitwarden-crypto - bitwarden-api-api - bitwarden-api-identity + - bitwarden-crypto + - bitwarden-generators steps: - name: Checkout diff --git a/.github/workflows/publish-rust-crates.yml b/.github/workflows/publish-rust-crates.yml index a264815ed..2f3ea6380 100644 --- a/.github/workflows/publish-rust-crates.yml +++ b/.github/workflows/publish-rust-crates.yml @@ -34,6 +34,11 @@ on: required: true default: true type: boolean + publish_bitwarden-generators: + description: "Publish bitwarden-generators crate" + required: true + default: true + type: boolean defaults: run: @@ -67,6 +72,7 @@ jobs: PUBLISH_BITWARDEN_API_API: ${{ github.event.inputs.publish_bitwarden-api-api }} PUBLISH_BITWARDEN_API_IDENTITY: ${{ github.event.inputs.publish_bitwarden-api-identity }} PUBLISH_BITWARDEN_CRYPTO: ${{ github.event.inputs.publish_bitwarden-crypto }} + PUBLISH_BITWARDEN_GENERATORS: ${{ github.event.inputs.publish_bitwarden-generators }} run: | if [[ "$PUBLISH_BITWARDEN" == "false" ]] && [[ "$PUBLISH_BITWARDEN_API_API" == "false" ]] && [[ "$PUBLISH_BITWARDEN_API_IDENTITY" == "false" ]]; then echo "===================================" @@ -98,6 +104,11 @@ jobs: PACKAGES_LIST="$PACKAGES_LIST bitwarden-crypto" fi + if [[ "$PUBLISH_BITWARDEN_GENERATORS" == "true" ]]; then + PACKAGES_COMMAND="$PACKAGES_COMMAND -p bitwarden-generators" + PACKAGES_LIST="$PACKAGES_LIST bitwarden-generators" + fi + echo "Packages command: " $PACKAGES_COMMAND echo "Packages list: " $PACKAGES_LIST diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index ade4134e1..c3055f097 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -14,6 +14,7 @@ on: - bitwarden-api-api - bitwarden-api-identity - bitwarden-crypto + - bitwarden-generators - bitwarden-json - cli - napi @@ -123,6 +124,12 @@ jobs: if: ${{ inputs.project == 'bitwarden-crypto' }} run: cargo-set-version set-version -p bitwarden-crypto ${{ inputs.version_number }} + ### bitwarden-generators + + - name: Bump bitwarden-generators crate Version + if: ${{ inputs.project == 'bitwarden-generators' }} + run: cargo-set-version set-version -p bitwarden-generators ${{ inputs.version_number }} + ### cli - name: Bump cli Version diff --git a/Cargo.lock b/Cargo.lock index 31a4b4d05..960a7707f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,6 +350,7 @@ dependencies = [ "bitwarden-api-api", "bitwarden-api-identity", "bitwarden-crypto", + "bitwarden-generators", "chrono", "data-encoding", "getrandom 0.2.11", @@ -446,6 +447,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "bitwarden-generators" +version = "0.1.0" +dependencies = [ + "bitwarden-crypto", + "rand 0.8.5", + "rand_chacha 0.3.1", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "uniffi", + "wiremock", +] + [[package]] name = "bitwarden-json" version = "0.3.0" diff --git a/crates/bitwarden-crypto/Cargo.toml b/crates/bitwarden-crypto/Cargo.toml index 4b7ac6cc4..ddcaceb06 100644 --- a/crates/bitwarden-crypto/Cargo.toml +++ b/crates/bitwarden-crypto/Cargo.toml @@ -6,7 +6,7 @@ license-file = "LICENSE" repository = "https://github.com/bitwarden/sdk" homepage = "https://bitwarden.com" description = """ -Bitwarden Cryptographic primitives +Internal crate for the bitwarden crate. Do not use. """ keywords = ["bitwarden"] edition = "2021" diff --git a/crates/bitwarden-crypto/README.md b/crates/bitwarden-crypto/README.md new file mode 100644 index 000000000..fd697aa3c --- /dev/null +++ b/crates/bitwarden-crypto/README.md @@ -0,0 +1,6 @@ +# Bitwarden Crypto + +This is an internal crate for the Bitwarden SDK do not depend on this directly and use the +[`bitwarden`](https://crates.io/crates/bitwarden) crate instead. + +This crate does not follow semantic versioning and the public interface may change at any time. diff --git a/crates/bitwarden-generators/Cargo.toml b/crates/bitwarden-generators/Cargo.toml new file mode 100644 index 000000000..d29c311aa --- /dev/null +++ b/crates/bitwarden-generators/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "bitwarden-generators" +version = "0.1.0" +authors = ["Bitwarden Inc"] +license-file = "LICENSE" +repository = "https://github.com/bitwarden/sdk" +homepage = "https://bitwarden.com" +description = """ +Internal crate for the bitwarden crate. Do not use. +""" +keywords = ["bitwarden"] +edition = "2021" +rust-version = "1.57" + +[features] +mobile = ["uniffi"] # Mobile-specific features + +[dependencies] +bitwarden-crypto = { path = "../bitwarden-crypto", version = "=0.1.0" } +rand = ">=0.8.5, <0.9" +reqwest = { version = ">=0.11, <0.12", features = [ + "json", +], default-features = false } +schemars = { version = ">=0.8.9, <0.9", features = ["uuid1", "chrono"] } +serde = { version = ">=1.0, <2.0", features = ["derive"] } +serde_json = ">=1.0.96, <2.0" +thiserror = ">=1.0.40, <2.0" +uniffi = { version = "=0.25.2", optional = true } + +[dev-dependencies] +rand_chacha = "0.3.1" +tokio = { version = "1.35.1", features = ["rt", "macros"] } +wiremock = "0.5.22" diff --git a/crates/bitwarden-generators/README.md b/crates/bitwarden-generators/README.md new file mode 100644 index 000000000..db70c11df --- /dev/null +++ b/crates/bitwarden-generators/README.md @@ -0,0 +1,6 @@ +# Bitwarden Generators + +This is an internal crate for the Bitwarden SDK do not depend on this directly and use the +[`bitwarden`](https://crates.io/crates/bitwarden) crate instead. + +This crate does not follow semantic versioning and the public interface may change at any time. diff --git a/crates/bitwarden-generators/src/lib.rs b/crates/bitwarden-generators/src/lib.rs new file mode 100644 index 000000000..335ec92b9 --- /dev/null +++ b/crates/bitwarden-generators/src/lib.rs @@ -0,0 +1,11 @@ +mod passphrase; +pub use passphrase::{passphrase, PassphraseError, PassphraseGeneratorRequest}; +mod password; +mod util; +pub use password::{password, PasswordError, PasswordGeneratorRequest}; +mod username; +pub use username::{username, ForwarderServiceType, UsernameError, UsernameGeneratorRequest}; +mod username_forwarders; + +#[cfg(feature = "mobile")] +uniffi::setup_scaffolding!(); diff --git a/crates/bitwarden/src/tool/generators/passphrase.rs b/crates/bitwarden-generators/src/passphrase.rs similarity index 90% rename from crates/bitwarden/src/tool/generators/passphrase.rs rename to crates/bitwarden-generators/src/passphrase.rs index f64b39dc2..4094e4b70 100644 --- a/crates/bitwarden/src/tool/generators/passphrase.rs +++ b/crates/bitwarden-generators/src/passphrase.rs @@ -2,8 +2,17 @@ use bitwarden_crypto::EFF_LONG_WORD_LIST; use rand::{seq::SliceRandom, Rng, RngCore}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use thiserror::Error; -use crate::{error::Result, util::capitalize_first_letter}; +use crate::util::capitalize_first_letter; + +#[derive(Debug, Error)] +pub enum PassphraseError { + #[error("'num_words' must be between {} and {}", minimum, maximum)] + InvalidNumWords { minimum: u8, maximum: u8 }, + #[error("'word_separator' cannot be empty")] + EmptyWordSeparator, +} /// Passphrase generator request options. #[derive(Serialize, Deserialize, Debug, JsonSchema)] @@ -46,16 +55,19 @@ struct ValidPassphraseGeneratorOptions { impl PassphraseGeneratorRequest { /// Validates the request and returns an immutable struct with valid options to use with the passphrase generator. - fn validate_options(self) -> Result { + fn validate_options(self) -> Result { // TODO: Add password generator policy checks if !(MINIMUM_PASSPHRASE_NUM_WORDS..=MAXIMUM_PASSPHRASE_NUM_WORDS).contains(&self.num_words) { - return Err(format!("'num_words' must be between {MINIMUM_PASSPHRASE_NUM_WORDS} and {MAXIMUM_PASSPHRASE_NUM_WORDS}").into()); + return Err(PassphraseError::InvalidNumWords { + minimum: MINIMUM_PASSPHRASE_NUM_WORDS, + maximum: MAXIMUM_PASSPHRASE_NUM_WORDS, + }); } if self.word_separator.chars().next().is_none() { - return Err("'word_separator' cannot be empty".into()); + return Err(PassphraseError::EmptyWordSeparator); }; Ok(ValidPassphraseGeneratorOptions { @@ -67,13 +79,8 @@ impl PassphraseGeneratorRequest { } } -/// Implementation of the random passphrase generator. This is not accessible to the public API. -/// See [`ClientGenerator::passphrase`](crate::ClientGenerator::passphrase) for the API function. -/// -/// # Arguments: -/// * `options`: Valid parameters used to generate the passphrase. To create it, use -/// [`PassphraseGeneratorRequest::validate_options`](PassphraseGeneratorRequest::validate_options). -pub(super) fn passphrase(request: PassphraseGeneratorRequest) -> Result { +/// Implementation of the random passphrase generator. +pub fn passphrase(request: PassphraseGeneratorRequest) -> Result { let options = request.validate_options()?; Ok(passphrase_with_rng(rand::thread_rng(), options)) } diff --git a/crates/bitwarden/src/tool/generators/password.rs b/crates/bitwarden-generators/src/password.rs similarity index 95% rename from crates/bitwarden/src/tool/generators/password.rs rename to crates/bitwarden-generators/src/password.rs index d091d1a45..c7a8fc252 100644 --- a/crates/bitwarden/src/tool/generators/password.rs +++ b/crates/bitwarden-generators/src/password.rs @@ -3,8 +3,15 @@ use std::collections::BTreeSet; use rand::{distributions::Distribution, seq::SliceRandom, RngCore}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; - -use crate::error::Result; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PasswordError { + #[error("No character set enabled")] + NoCharacterSetEnabled, + #[error("Invalid password length")] + InvalidLength, +} /// Password generator request options. #[derive(Serialize, Deserialize, Debug, JsonSchema)] @@ -32,7 +39,7 @@ pub struct PasswordGeneratorRequest { /// When set, the value must be between 1 and 9. This value is ignored is lowercase is false pub min_lowercase: Option, /// The minimum number of uppercase characters in the generated password. - /// When set, the value must be between 1 and 9. This value is ignored is uppercase is false + /// When set, the value must be between 1 and 9. This value is ignored is uppercase is false pub min_uppercase: Option, /// The minimum number of numbers in the generated password. /// When set, the value must be between 1 and 9. This value is ignored is numbers is false @@ -128,16 +135,16 @@ struct PasswordGeneratorOptions { impl PasswordGeneratorRequest { /// Validates the request and returns an immutable struct with valid options to use with the password generator. - fn validate_options(self) -> Result { + fn validate_options(self) -> Result { // TODO: Add password generator policy checks // We always have to have at least one character set enabled if !self.lowercase && !self.uppercase && !self.numbers && !self.special { - return Err("At least one character set must be enabled".into()); + return Err(PasswordError::NoCharacterSetEnabled); } if self.length < 4 { - return Err("A password must be at least 4 characters long".into()); + return Err(PasswordError::InvalidLength); } // Make sure the minimum values are zero when the character @@ -159,7 +166,7 @@ impl PasswordGeneratorRequest { // Check that the minimum lengths aren't larger than the password length let minimum_length = min_lowercase + min_uppercase + min_number + min_special; if minimum_length > length { - return Err("Password length can't be less than the sum of the minimums".into()); + return Err(PasswordError::InvalidLength); } let lower = ( @@ -208,9 +215,8 @@ impl PasswordGeneratorRequest { } } -/// Implementation of the random password generator. This is not accessible to the public API. -/// See [`ClientGenerator::password`](crate::ClientGenerator::password) for the API function. -pub(super) fn password(input: PasswordGeneratorRequest) -> Result { +/// Implementation of the random password generator. +pub fn password(input: PasswordGeneratorRequest) -> Result { let options = input.validate_options()?; Ok(password_with_rng(rand::thread_rng(), options)) } diff --git a/crates/bitwarden/src/tool/generators/username.rs b/crates/bitwarden-generators/src/username.rs similarity index 91% rename from crates/bitwarden/src/tool/generators/username.rs rename to crates/bitwarden-generators/src/username.rs index 20b101fa0..ccb46604b 100644 --- a/crates/bitwarden/src/tool/generators/username.rs +++ b/crates/bitwarden-generators/src/username.rs @@ -1,9 +1,25 @@ use bitwarden_crypto::EFF_LONG_WORD_LIST; use rand::{distributions::Distribution, seq::SliceRandom, Rng, RngCore}; +use reqwest::StatusCode; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use thiserror::Error; -use crate::{error::Result, util::capitalize_first_letter}; +use crate::util::capitalize_first_letter; + +#[derive(Debug, Error)] +pub enum UsernameError { + #[error("Invalid API Key")] + InvalidApiKey, + #[error("Unknown error")] + Unknown, + + #[error("Received error message from server: [{}] {}", .status, .message)] + ResponseContent { status: StatusCode, message: String }, + + #[error(transparent)] + Reqwest(#[from] reqwest::Error), +} #[derive(Serialize, Deserialize, Debug, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -85,10 +101,14 @@ pub enum UsernameGeneratorRequest { 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 { + pub async fn generate( + self, + http: &reqwest::Client, + website: Option, + ) -> Result { use ForwarderServiceType::*; - use crate::tool::generators::username_forwarders::*; + use crate::username_forwarders::*; match self { AddyIo { @@ -107,14 +127,14 @@ impl ForwarderServiceType { } } -/// Implementation of the username generator. This is not accessible to the public API. -/// See [`ClientGenerator::username`](crate::ClientGenerator::username) for the API function. +/// Implementation of the username generator. +/// /// 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( +pub async fn username( input: UsernameGeneratorRequest, http: &reqwest::Client, -) -> Result { +) -> Result { use rand::thread_rng; use UsernameGeneratorRequest::*; match input { diff --git a/crates/bitwarden-generators/src/username_forwarders/addyio.rs b/crates/bitwarden-generators/src/username_forwarders/addyio.rs new file mode 100644 index 000000000..4b75e1c84 --- /dev/null +++ b/crates/bitwarden-generators/src/username_forwarders/addyio.rs @@ -0,0 +1,158 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::username::UsernameError; + +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(UsernameError::InvalidApiKey); + } + + // 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; + + use crate::username::UsernameError; + #[tokio::test] + async fn test_mock_server() { + use wiremock::{matchers, Mock, ResponseTemplate}; + + let server = wiremock::MockServer::start().await; + + // Mock the request to the addy.io API, and verify that the correct request is made + server + .register( + 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), + ) + .await; + // Mock an invalid API token request + server + .register( + 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), + ) + .await; + // Mock an invalid domain + server + .register( + 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_eq!( + fake_token_error.to_string(), + UsernameError::InvalidApiKey.to_string() + ); + + 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-generators/src/username_forwarders/duckduckgo.rs similarity index 50% rename from crates/bitwarden/src/tool/generators/username_forwarders/duckduckgo.rs rename to crates/bitwarden-generators/src/username_forwarders/duckduckgo.rs index 512db7812..3f21fd3a5 100644 --- a/crates/bitwarden/src/tool/generators/username_forwarders/duckduckgo.rs +++ b/crates/bitwarden-generators/src/username_forwarders/duckduckgo.rs @@ -1,7 +1,8 @@ use reqwest::{header::CONTENT_TYPE, StatusCode}; -use crate::error::Result; -pub async fn generate(http: &reqwest::Client, token: String) -> Result { +use crate::username::UsernameError; + +pub async fn generate(http: &reqwest::Client, token: String) -> Result { generate_with_api_url(http, token, "https://quack.duckduckgo.com".into()).await } @@ -9,7 +10,7 @@ async fn generate_with_api_url( http: &reqwest::Client, token: String, api_url: String, -) -> Result { +) -> Result { let response = http .post(format!("{api_url}/api/email/addresses")) .header(CONTENT_TYPE, "application/json") @@ -18,7 +19,7 @@ async fn generate_with_api_url( .await?; if response.status() == StatusCode::UNAUTHORIZED { - return Err("Invalid DuckDuckGo API token".into()); + return Err(UsernameError::InvalidApiKey); } // Throw any other errors @@ -36,29 +37,38 @@ async fn generate_with_api_url( #[cfg(test)] mod tests { use serde_json::json; + + use crate::username::UsernameError; #[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 server = wiremock::MockServer::start().await; + + // Mock the request to the DDG API, and verify that the correct request is made + server + .register( + 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), + ) + .await; + // Mock an invalid token request + server + .register( + 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(), @@ -77,9 +87,10 @@ mod tests { .await .unwrap_err(); - assert!(fake_token_error - .to_string() - .contains("Invalid DuckDuckGo API token")); + assert_eq!( + fake_token_error.to_string(), + UsernameError::InvalidApiKey.to_string() + ); server.verify().await; } diff --git a/crates/bitwarden/src/tool/generators/username_forwarders/fastmail.rs b/crates/bitwarden-generators/src/username_forwarders/fastmail.rs similarity index 67% rename from crates/bitwarden/src/tool/generators/username_forwarders/fastmail.rs rename to crates/bitwarden-generators/src/username_forwarders/fastmail.rs index d2a7554e2..6cc63647a 100644 --- a/crates/bitwarden/src/tool/generators/username_forwarders/fastmail.rs +++ b/crates/bitwarden-generators/src/username_forwarders/fastmail.rs @@ -3,12 +3,13 @@ use std::collections::HashMap; use reqwest::{header::CONTENT_TYPE, StatusCode}; use serde_json::json; -use crate::error::Result; +use crate::username::UsernameError; + pub async fn generate( http: &reqwest::Client, api_token: String, website: Option, -) -> Result { +) -> Result { generate_with_api_url(http, api_token, website, "https://api.fastmail.com".into()).await } @@ -17,7 +18,7 @@ pub async fn generate_with_api_url( api_token: String, website: Option, api_url: String, -) -> Result { +) -> Result { let account_id = get_account_id(http, &api_token, &api_url).await?; let response = http @@ -44,13 +45,14 @@ pub async fn generate_with_api_url( .send() .await?; - if response.status() == StatusCode::UNAUTHORIZED { - return Err("Invalid Fastmail API token".into()); + let status_code = response.status(); + if status_code == StatusCode::UNAUTHORIZED { + return Err(UsernameError::InvalidApiKey); } - 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 response_json: serde_json::Value = response.json().await?; + let Some(r) = response_json.get("methodResponses").and_then(|r| r.get(0)) else { + return Err(UsernameError::Unknown); }; let method_response = r.get(0).and_then(|r| r.as_str()); let response_value = r.get(1); @@ -72,24 +74,30 @@ pub async fn generate_with_api_url( .and_then(|r| r.as_str()) .unwrap_or("Unknown error"); - return Err(format!("Fastmail error: {error_description}").into()); + return Err(UsernameError::ResponseContent { + status: status_code, + message: error_description.to_owned(), + }); } 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()); + return Err(UsernameError::ResponseContent { + status: status_code, + message: error_description.to_owned(), + }); } - Err("Unknown Fastmail error occurred.".into()) + Err(UsernameError::Unknown) } async fn get_account_id( client: &reqwest::Client, api_token: &str, api_url: &str, -) -> Result { +) -> Result { #[derive(serde::Deserialize)] struct Response { #[serde(rename = "primaryAccounts")] @@ -102,7 +110,7 @@ async fn get_account_id( .await?; if response.status() == StatusCode::UNAUTHORIZED { - return Err("Invalid Fastmail API token".into()); + return Err(UsernameError::InvalidApiKey); } response.error_for_status_ref()?; @@ -117,13 +125,16 @@ async fn get_account_id( #[cfg(test)] mod tests { use serde_json::json; + + use crate::username::UsernameError; #[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")) + let server = wiremock::MockServer::start().await; + + // Mock a valid request to FastMail API + server.register(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!({ @@ -131,9 +142,9 @@ mod tests { "https://www.fastmail.com/dev/maskedemail": "ca0a4e09-c266-4f6f-845c-958db5090f09" } }))) - .expect(1), + .expect(1)).await; - Mock::given(matchers::path("/jmap/api/")) + server.register(Mock::given(matchers::path("/jmap/api/")) .and(matchers::method("POST")) .and(matchers::header("Content-Type", "application/json")) .and(matchers::header("Authorization", "Bearer MY_TOKEN")) @@ -142,23 +153,29 @@ mod tests { ["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; + .expect(1)).await; + + // Mock an invalid token request + server + .register( + 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), + ) + .await; + + server + .register( + 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(), @@ -179,9 +196,10 @@ mod tests { .await .unwrap_err(); - assert!(fake_token_error - .to_string() - .contains("Invalid Fastmail API token")); + assert_eq!( + fake_token_error.to_string(), + UsernameError::InvalidApiKey.to_string() + ); server.verify().await; } diff --git a/crates/bitwarden/src/tool/generators/username_forwarders/firefox.rs b/crates/bitwarden-generators/src/username_forwarders/firefox.rs similarity index 50% rename from crates/bitwarden/src/tool/generators/username_forwarders/firefox.rs rename to crates/bitwarden-generators/src/username_forwarders/firefox.rs index e53931358..66c2a3a2c 100644 --- a/crates/bitwarden/src/tool/generators/username_forwarders/firefox.rs +++ b/crates/bitwarden-generators/src/username_forwarders/firefox.rs @@ -3,13 +3,13 @@ use reqwest::{ StatusCode, }; -use crate::error::Result; +use crate::username::UsernameError; pub async fn generate( http: &reqwest::Client, api_token: String, website: Option, -) -> Result { +) -> Result { generate_with_api_url(http, api_token, website, "https://relay.firefox.com".into()).await } @@ -18,7 +18,7 @@ async fn generate_with_api_url( api_token: String, website: Option, api_url: String, -) -> Result { +) -> Result { #[derive(serde::Serialize)] struct Request { enabled: bool, @@ -41,7 +41,7 @@ async fn generate_with_api_url( .await?; if response.status() == StatusCode::UNAUTHORIZED { - return Err("Invalid Firefox Relay API key".into()); + return Err(UsernameError::InvalidApiKey); } // Throw any other errors @@ -60,24 +60,30 @@ async fn generate_with_api_url( mod tests { use serde_json::json; + use crate::username::UsernameError; + #[tokio::test] async fn test_mock_success() { use wiremock::{matchers, Mock, ResponseTemplate}; - let (server, _client) = - crate::util::start_mock(vec![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)]) + let server = wiremock::MockServer::start().await; + + server + .register( + 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), + ) .await; let address = super::generate_with_api_url( @@ -97,19 +103,23 @@ mod tests { async fn test_mock_without_website() { use wiremock::{matchers, Mock, ResponseTemplate}; - let (server, _client) = - crate::util::start_mock(vec![Mock::given(matchers::path("/api/v1/relayaddresses/")) - .and(matchers::method("POST")) - .and(matchers::header("Content-Type", "application/json")) - .and(matchers::header("Authorization", "Token MY_OTHER_TOKEN")) - .and(matchers::body_json(json!({ - "enabled": true, - "description": "Generated by Bitwarden." - }))) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({ - "full_address": "856f7765@mozmail.com" - }))) - .expect(1)]) + let server = wiremock::MockServer::start().await; + + server + .register( + Mock::given(matchers::path("/api/v1/relayaddresses/")) + .and(matchers::method("POST")) + .and(matchers::header("Content-Type", "application/json")) + .and(matchers::header("Authorization", "Token MY_OTHER_TOKEN")) + .and(matchers::body_json(json!({ + "enabled": true, + "description": "Generated by Bitwarden." + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "full_address": "856f7765@mozmail.com" + }))) + .expect(1), + ) .await; let address = super::generate_with_api_url( @@ -129,18 +139,22 @@ mod tests { async fn test_mock_invalid_token() { use wiremock::{matchers, Mock, ResponseTemplate}; - let (server, _client) = - crate::util::start_mock(vec![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)]) + let server = wiremock::MockServer::start().await; + + server + .register( + 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 error = super::generate_with_api_url( @@ -152,7 +166,7 @@ mod tests { .await .unwrap_err(); - assert!(error.to_string().contains("Invalid Firefox Relay API key")); + assert_eq!(error.to_string(), UsernameError::InvalidApiKey.to_string()); server.verify().await; } diff --git a/crates/bitwarden-generators/src/username_forwarders/forwardemail.rs b/crates/bitwarden-generators/src/username_forwarders/forwardemail.rs new file mode 100644 index 000000000..1cec22882 --- /dev/null +++ b/crates/bitwarden-generators/src/username_forwarders/forwardemail.rs @@ -0,0 +1,209 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::username::UsernameError; + +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(UsernameError::InvalidApiKey); + } + + #[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(UsernameError::ResponseContent { status, message }); + } + if let Some(message) = response.error { + return Err(UsernameError::ResponseContent { status, message }); + } + + Err(UsernameError::Unknown) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::username::UsernameError; + + #[tokio::test] + async fn test_mock_server() { + use wiremock::{matchers, Mock, ResponseTemplate}; + + let server = wiremock::MockServer::start().await; + + // Mock the request to the ForwardEmail API, and verify that the correct request is made + server + .register( + 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), + ) + .await; + + // Mock an invalid API token request + server + .register( + 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), + ) + .await; + + // Mock a free API token request + server + .register( + 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_eq!( + invalid_token_error.to_string(), + UsernameError::InvalidApiKey.to_string() + ); + + 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-generators/src/username_forwarders/mod.rs similarity index 100% rename from crates/bitwarden/src/tool/generators/username_forwarders/mod.rs rename to crates/bitwarden-generators/src/username_forwarders/mod.rs diff --git a/crates/bitwarden-generators/src/username_forwarders/simplelogin.rs b/crates/bitwarden-generators/src/username_forwarders/simplelogin.rs new file mode 100644 index 000000000..fa9342267 --- /dev/null +++ b/crates/bitwarden-generators/src/username_forwarders/simplelogin.rs @@ -0,0 +1,125 @@ +use reqwest::{header::CONTENT_TYPE, StatusCode}; + +use crate::username::UsernameError; + +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") + .header("Authentication", api_key) + .json(&Request { note }) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err(UsernameError::InvalidApiKey); + } + + // 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; + + use crate::username::UsernameError; + #[tokio::test] + async fn test_mock_server() { + use wiremock::{matchers, Mock, ResponseTemplate}; + + let server = wiremock::MockServer::start().await; + + // Mock the request to the SimpleLogin API, and verify that the correct request is made + server + .register( + 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("Authentication", "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), + ) + .await; + // Mock an invalid token request + server + .register( + 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("Authentication", "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_eq!( + fake_token_error.to_string(), + UsernameError::InvalidApiKey.to_string() + ); + + server.verify().await; + } +} diff --git a/crates/bitwarden-generators/src/util.rs b/crates/bitwarden-generators/src/util.rs new file mode 100644 index 000000000..e434500ea --- /dev/null +++ b/crates/bitwarden-generators/src/util.rs @@ -0,0 +1,10 @@ +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(), + } +} diff --git a/crates/bitwarden-uniffi/src/docs.rs b/crates/bitwarden-uniffi/src/docs.rs index 3ba0e061b..55d81beec 100644 --- a/crates/bitwarden-uniffi/src/docs.rs +++ b/crates/bitwarden-uniffi/src/docs.rs @@ -1,8 +1,9 @@ use bitwarden::{ auth::password::MasterPasswordPolicyOptions, + generators::{PassphraseGeneratorRequest, PasswordGeneratorRequest}, mobile::crypto::{InitOrgCryptoRequest, InitUserCryptoRequest}, platform::FingerprintRequest, - tool::{ExportFormat, PassphraseGeneratorRequest, PasswordGeneratorRequest}, + tool::ExportFormat, vault::{ Cipher, CipherView, Collection, Folder, FolderView, Send, SendListView, SendView, TotpResponse, diff --git a/crates/bitwarden-uniffi/src/tool/mod.rs b/crates/bitwarden-uniffi/src/tool/mod.rs index 6b443618c..4a4ea2401 100644 --- a/crates/bitwarden-uniffi/src/tool/mod.rs +++ b/crates/bitwarden-uniffi/src/tool/mod.rs @@ -1,10 +1,8 @@ use std::sync::Arc; use bitwarden::{ - tool::{ - ExportFormat, PassphraseGeneratorRequest, PasswordGeneratorRequest, - UsernameGeneratorRequest, - }, + generators::{PassphraseGeneratorRequest, PasswordGeneratorRequest, UsernameGeneratorRequest}, + tool::ExportFormat, vault::{Cipher, Collection, Folder}, }; diff --git a/crates/bitwarden/Cargo.toml b/crates/bitwarden/Cargo.toml index d69279881..22ee6eaef 100644 --- a/crates/bitwarden/Cargo.toml +++ b/crates/bitwarden/Cargo.toml @@ -21,6 +21,7 @@ mobile = [ "uniffi", "internal", "bitwarden-crypto/mobile", + "bitwarden-generators/mobile", ] # Mobile-specific features wasm-bindgen = ["chrono/wasmbind"] @@ -30,6 +31,7 @@ base64 = ">=0.21.2, <0.22" bitwarden-api-api = { path = "../bitwarden-api-api", version = "=0.2.3" } bitwarden-api-identity = { path = "../bitwarden-api-identity", version = "=0.2.3" } bitwarden-crypto = { path = "../bitwarden-crypto", version = "=0.1.0" } +bitwarden-generators = { path = "../bitwarden-generators", version = "0.1.0" } chrono = { version = ">=0.4.26, <0.5", features = [ "clock", "serde", diff --git a/crates/bitwarden/src/error.rs b/crates/bitwarden/src/error.rs index 627e92e78..173557b04 100644 --- a/crates/bitwarden/src/error.rs +++ b/crates/bitwarden/src/error.rs @@ -4,6 +4,7 @@ use std::{borrow::Cow, fmt::Debug}; use bitwarden_api_api::apis::Error as ApiError; use bitwarden_api_identity::apis::Error as IdentityError; +use bitwarden_generators::{PassphraseError, PasswordError, UsernameError}; use reqwest::StatusCode; use thiserror::Error; @@ -49,6 +50,13 @@ pub enum Error { #[error("The state file could not be read")] InvalidStateFile, + #[error(transparent)] + UsernameError(#[from] UsernameError), + #[error(transparent)] + PassphraseError(#[from] PassphraseError), + #[error(transparent)] + PasswordError(#[from] PasswordError), + #[error("Internal error: {0}")] Internal(Cow<'static, str>), } diff --git a/crates/bitwarden/src/lib.rs b/crates/bitwarden/src/lib.rs index a0a1c1b95..5468be855 100644 --- a/crates/bitwarden/src/lib.rs +++ b/crates/bitwarden/src/lib.rs @@ -75,3 +75,9 @@ pub use client::Client; // Ensure the readme docs compile #[doc = include_str!("../README.md")] mod readme {} + +pub mod generators { + pub use bitwarden_generators::{ + PassphraseGeneratorRequest, PasswordGeneratorRequest, UsernameGeneratorRequest, + }; +} diff --git a/crates/bitwarden/src/tool/generators/client_generator.rs b/crates/bitwarden/src/tool/client_generator.rs similarity index 81% rename from crates/bitwarden/src/tool/generators/client_generator.rs rename to crates/bitwarden/src/tool/client_generator.rs index 301383635..bf7c66ef3 100644 --- a/crates/bitwarden/src/tool/generators/client_generator.rs +++ b/crates/bitwarden/src/tool/client_generator.rs @@ -1,13 +1,10 @@ -use crate::{ - error::Result, - tool::generators::{ - passphrase::{passphrase, PassphraseGeneratorRequest}, - password::{password, PasswordGeneratorRequest}, - username::{username, UsernameGeneratorRequest}, - }, - Client, +use bitwarden_generators::{ + passphrase, password, username, PassphraseGeneratorRequest, PasswordGeneratorRequest, + UsernameGeneratorRequest, }; +use crate::{error::Result, Client}; + pub struct ClientGenerator<'a> { pub(crate) client: &'a crate::Client, } @@ -20,7 +17,7 @@ impl<'a> ClientGenerator<'a> { /// # Examples /// /// ``` - /// use bitwarden::{Client, tool::PasswordGeneratorRequest, error::Result}; + /// use bitwarden::{Client, generators::PasswordGeneratorRequest, error::Result}; /// async fn test() -> Result<()> { /// let input = PasswordGeneratorRequest { /// lowercase: true, @@ -35,7 +32,7 @@ impl<'a> ClientGenerator<'a> { /// } /// ``` pub async fn password(&self, input: PasswordGeneratorRequest) -> Result { - password(input) + Ok(password(input)?) } /// Generates a random passphrase. @@ -48,7 +45,7 @@ impl<'a> ClientGenerator<'a> { /// # Examples /// /// ``` - /// use bitwarden::{Client, tool::PassphraseGeneratorRequest, error::Result}; + /// use bitwarden::{Client, generators::PassphraseGeneratorRequest, error::Result}; /// async fn test() -> Result<()> { /// let input = PassphraseGeneratorRequest { /// num_words: 4, @@ -60,7 +57,7 @@ impl<'a> ClientGenerator<'a> { /// } /// ``` pub async fn passphrase(&self, input: PassphraseGeneratorRequest) -> Result { - passphrase(input) + Ok(passphrase(input)?) } /// Generates a random username. @@ -70,7 +67,7 @@ impl<'a> ClientGenerator<'a> { /// services, which may require a specific setup or API key. /// /// ``` - /// use bitwarden::{Client, tool::{UsernameGeneratorRequest}, error::Result}; + /// use bitwarden::{Client, generators::{UsernameGeneratorRequest}, error::Result}; /// async fn test() -> Result<()> { /// let input = UsernameGeneratorRequest::Word { /// capitalize: true, @@ -82,7 +79,7 @@ impl<'a> ClientGenerator<'a> { /// } /// ``` pub async fn username(&self, input: UsernameGeneratorRequest) -> Result { - username(input, self.client.get_http_client()).await + Ok(username(input, self.client.get_http_client()).await?) } } diff --git a/crates/bitwarden/src/tool/generators/mod.rs b/crates/bitwarden/src/tool/generators/mod.rs deleted file mode 100644 index 7966c58a9..000000000 --- a/crates/bitwarden/src/tool/generators/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -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/username_forwarders/addyio.rs b/crates/bitwarden/src/tool/generators/username_forwarders/addyio.rs deleted file mode 100644 index 0fc5937f6..000000000 --- a/crates/bitwarden/src/tool/generators/username_forwarders/addyio.rs +++ /dev/null @@ -1,143 +0,0 @@ -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/forwardemail.rs b/crates/bitwarden/src/tool/generators/username_forwarders/forwardemail.rs deleted file mode 100644 index f4ba6ced6..000000000 --- a/crates/bitwarden/src/tool/generators/username_forwarders/forwardemail.rs +++ /dev/null @@ -1,193 +0,0 @@ -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/simplelogin.rs b/crates/bitwarden/src/tool/generators/username_forwarders/simplelogin.rs deleted file mode 100644 index 6c4f9dab4..000000000 --- a/crates/bitwarden/src/tool/generators/username_forwarders/simplelogin.rs +++ /dev/null @@ -1,114 +0,0 @@ -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") - .header("Authentication", 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("Authentication", "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("Authentication", "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 fe41b68db..a94528163 100644 --- a/crates/bitwarden/src/tool/mod.rs +++ b/crates/bitwarden/src/tool/mod.rs @@ -1,8 +1,4 @@ mod exporters; -mod generators; - pub use exporters::{ClientExporters, ExportFormat}; -pub use generators::{ - AppendType, ClientGenerator, ForwarderServiceType, PassphraseGeneratorRequest, - PasswordGeneratorRequest, UsernameGeneratorRequest, -}; +mod client_generator; +pub use client_generator::ClientGenerator; diff --git a/crates/bitwarden/src/util.rs b/crates/bitwarden/src/util.rs index 038c3d7ee..f6568986d 100644 --- a/crates/bitwarden/src/util.rs +++ b/crates/bitwarden/src/util.rs @@ -27,18 +27,6 @@ const INDIFFERENT: GeneralPurposeConfig = pub const STANDARD_INDIFFERENT: GeneralPurpose = GeneralPurpose::new(&alphabet::STANDARD, INDIFFERENT); -#[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; diff --git a/crates/bw/src/main.rs b/crates/bw/src/main.rs index 236aef22d..0e7cd975e 100644 --- a/crates/bw/src/main.rs +++ b/crates/bw/src/main.rs @@ -1,7 +1,7 @@ use bitwarden::{ auth::RegisterRequest, client::client_settings::ClientSettings, - tool::{PassphraseGeneratorRequest, PasswordGeneratorRequest}, + generators::{PassphraseGeneratorRequest, PasswordGeneratorRequest}, }; use bitwarden_cli::{install_color_eyre, text_prompt_when_none, Color}; use clap::{command, Args, CommandFactory, Parser, Subcommand};