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};