From d70deac437333522725d0dd269e79e7edab7d55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Thu, 18 Jan 2024 13:10:30 +0100 Subject: [PATCH 1/3] [PM-5348] Encrypt/decrypt attachment files (#490) ## Type of change ``` - [ ] Bug fix - [x] New feature development - [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc) - [ ] Build/deploy pipeline (DevOps) - [ ] Other ``` ## Objective Add encryption/decryption support for attachment files --- .../bitwarden-uniffi/src/vault/attachments.rs | 94 +++++++++++ crates/bitwarden-uniffi/src/vault/mod.rs | 6 + .../src/mobile/vault/client_attachments.rs | 89 +++++++++++ crates/bitwarden/src/mobile/vault/mod.rs | 2 + .../bitwarden/src/vault/cipher/attachment.rs | 127 +++++++++++++++ crates/bitwarden/src/vault/cipher/cipher.rs | 2 +- crates/bitwarden/src/vault/mod.rs | 7 +- languages/kotlin/doc.md | 146 ++++++++++++++++++ support/docs/docs.ts | 1 + 9 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 crates/bitwarden-uniffi/src/vault/attachments.rs create mode 100644 crates/bitwarden/src/mobile/vault/client_attachments.rs diff --git a/crates/bitwarden-uniffi/src/vault/attachments.rs b/crates/bitwarden-uniffi/src/vault/attachments.rs new file mode 100644 index 000000000..0c099b779 --- /dev/null +++ b/crates/bitwarden-uniffi/src/vault/attachments.rs @@ -0,0 +1,94 @@ +use std::{path::Path, sync::Arc}; + +use bitwarden::vault::{Attachment, AttachmentEncryptResult, AttachmentView, Cipher}; + +use crate::{Client, Result}; + +#[derive(uniffi::Object)] +pub struct ClientAttachments(pub Arc); + +#[uniffi::export(async_runtime = "tokio")] +impl ClientAttachments { + /// Encrypt an attachment file in memory + pub async fn encrypt_buffer( + &self, + cipher: Cipher, + attachment: AttachmentView, + buffer: Vec, + ) -> Result { + Ok(self + .0 + .0 + .read() + .await + .vault() + .attachments() + .encrypt_buffer(cipher, attachment, &buffer) + .await?) + } + + /// Encrypt an attachment file located in the file system + pub async fn encrypt_file( + &self, + cipher: Cipher, + attachment: AttachmentView, + decrypted_file_path: String, + encrypted_file_path: String, + ) -> Result { + Ok(self + .0 + .0 + .read() + .await + .vault() + .attachments() + .encrypt_file( + cipher, + attachment, + Path::new(&decrypted_file_path), + Path::new(&encrypted_file_path), + ) + .await?) + } + /// Decrypt an attachment file in memory + pub async fn decrypt_buffer( + &self, + cipher: Cipher, + attachment: Attachment, + buffer: Vec, + ) -> Result> { + Ok(self + .0 + .0 + .read() + .await + .vault() + .attachments() + .decrypt_buffer(cipher, attachment, &buffer) + .await?) + } + + /// Decrypt an attachment file located in the file system + pub async fn decrypt_file( + &self, + cipher: Cipher, + attachment: Attachment, + encrypted_file_path: String, + decrypted_file_path: String, + ) -> Result<()> { + Ok(self + .0 + .0 + .read() + .await + .vault() + .attachments() + .decrypt_file( + cipher, + attachment, + Path::new(&encrypted_file_path), + Path::new(&decrypted_file_path), + ) + .await?) + } +} diff --git a/crates/bitwarden-uniffi/src/vault/mod.rs b/crates/bitwarden-uniffi/src/vault/mod.rs index 435e7f355..2205e0673 100644 --- a/crates/bitwarden-uniffi/src/vault/mod.rs +++ b/crates/bitwarden-uniffi/src/vault/mod.rs @@ -5,6 +5,7 @@ use chrono::{DateTime, Utc}; use crate::{error::Result, Client}; +pub mod attachments; pub mod ciphers; pub mod collections; pub mod folders; @@ -41,6 +42,11 @@ impl ClientVault { Arc::new(sends::ClientSends(self.0.clone())) } + /// Attachment file operations + pub fn attachments(self: Arc) -> Arc { + Arc::new(attachments::ClientAttachments(self.0.clone())) + } + /// Generate a TOTP code from a provided key. /// /// The key can be either: diff --git a/crates/bitwarden/src/mobile/vault/client_attachments.rs b/crates/bitwarden/src/mobile/vault/client_attachments.rs new file mode 100644 index 000000000..c436f10fd --- /dev/null +++ b/crates/bitwarden/src/mobile/vault/client_attachments.rs @@ -0,0 +1,89 @@ +use std::path::Path; + +use bitwarden_crypto::{EncString, KeyDecryptable, KeyEncryptable, LocateKey}; + +use super::client_vault::ClientVault; +use crate::{ + error::{Error, Result}, + vault::{ + Attachment, AttachmentEncryptResult, AttachmentFile, AttachmentFileView, AttachmentView, + Cipher, + }, + Client, +}; + +pub struct ClientAttachments<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientAttachments<'a> { + pub async fn encrypt_buffer( + &self, + cipher: Cipher, + attachment: AttachmentView, + buffer: &[u8], + ) -> Result { + let enc = self.client.get_encryption_settings()?; + let key = cipher.locate_key(enc, &None).ok_or(Error::VaultLocked)?; + + Ok(AttachmentFileView { + cipher, + attachment, + contents: buffer, + } + .encrypt_with_key(key)?) + } + pub async fn encrypt_file( + &self, + cipher: Cipher, + attachment: AttachmentView, + decrypted_file_path: &Path, + encrypted_file_path: &Path, + ) -> Result { + let data = std::fs::read(decrypted_file_path).unwrap(); + let AttachmentEncryptResult { + attachment, + contents, + } = self.encrypt_buffer(cipher, attachment, &data).await?; + std::fs::write(encrypted_file_path, contents)?; + Ok(attachment) + } + + pub async fn decrypt_buffer( + &self, + cipher: Cipher, + attachment: Attachment, + encrypted_buffer: &[u8], + ) -> Result> { + let enc = self.client.get_encryption_settings()?; + let key = cipher.locate_key(enc, &None).ok_or(Error::VaultLocked)?; + + AttachmentFile { + cipher, + attachment, + contents: EncString::from_buffer(encrypted_buffer)?, + } + .decrypt_with_key(key) + .map_err(Error::Crypto) + } + pub async fn decrypt_file( + &self, + cipher: Cipher, + attachment: Attachment, + encrypted_file_path: &Path, + decrypted_file_path: &Path, + ) -> Result<()> { + let data = std::fs::read(encrypted_file_path).unwrap(); + let decrypted = self.decrypt_buffer(cipher, attachment, &data).await?; + std::fs::write(decrypted_file_path, decrypted)?; + Ok(()) + } +} + +impl<'a> ClientVault<'a> { + pub fn attachments(&'a self) -> ClientAttachments<'a> { + ClientAttachments { + client: self.client, + } + } +} diff --git a/crates/bitwarden/src/mobile/vault/mod.rs b/crates/bitwarden/src/mobile/vault/mod.rs index 97f9556af..a2d4d91b3 100644 --- a/crates/bitwarden/src/mobile/vault/mod.rs +++ b/crates/bitwarden/src/mobile/vault/mod.rs @@ -1,3 +1,4 @@ +mod client_attachments; mod client_ciphers; mod client_collection; mod client_folders; @@ -6,6 +7,7 @@ mod client_sends; mod client_totp; mod client_vault; +pub use client_attachments::ClientAttachments; pub use client_ciphers::ClientCiphers; pub use client_collection::ClientCollections; pub use client_folders::ClientFolders; diff --git a/crates/bitwarden/src/vault/cipher/attachment.rs b/crates/bitwarden/src/vault/cipher/attachment.rs index fa4a35fc7..3e1e47025 100644 --- a/crates/bitwarden/src/vault/cipher/attachment.rs +++ b/crates/bitwarden/src/vault/cipher/attachment.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use crate::error::{Error, Result}; +use super::Cipher; + #[derive(Serialize, Deserialize, Debug, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "mobile", derive(uniffi::Record))] @@ -31,6 +33,65 @@ pub struct AttachmentView { pub key: Option, } +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "mobile", derive(uniffi::Record))] +pub struct AttachmentEncryptResult { + pub attachment: Attachment, + pub contents: Vec, +} + +pub struct AttachmentFile { + pub cipher: Cipher, + pub attachment: Attachment, + pub contents: EncString, +} + +pub struct AttachmentFileView<'a> { + pub cipher: Cipher, + pub attachment: AttachmentView, + pub contents: &'a [u8], +} + +impl<'a> KeyEncryptable for AttachmentFileView<'a> { + fn encrypt_with_key( + self, + key: &SymmetricCryptoKey, + ) -> Result { + let ciphers_key = Cipher::get_cipher_key(key, &self.cipher.key)?; + let ciphers_key = ciphers_key.as_ref().unwrap_or(key); + + let mut attachment = self.attachment; + + // Because this is a new attachment, we have to generate a key for it, encrypt the contents with it, and then encrypt the key with the cipher key + let attachment_key = SymmetricCryptoKey::generate(rand::thread_rng()); + let encrypted_contents = self.contents.encrypt_with_key(&attachment_key)?; + attachment.key = Some(attachment_key.to_vec().encrypt_with_key(ciphers_key)?); + + Ok(AttachmentEncryptResult { + attachment: attachment.encrypt_with_key(ciphers_key)?, + contents: encrypted_contents.to_buffer()?, + }) + } +} + +impl KeyDecryptable> for AttachmentFile { + fn decrypt_with_key(&self, key: &SymmetricCryptoKey) -> Result, CryptoError> { + let ciphers_key = Cipher::get_cipher_key(key, &self.cipher.key)?; + let ciphers_key = ciphers_key.as_ref().unwrap_or(key); + + let attachment_key: Vec = self + .attachment + .key + .as_ref() + .ok_or(CryptoError::MissingKey)? + .decrypt_with_key(ciphers_key)?; + let attachment_key = SymmetricCryptoKey::try_from(attachment_key.as_slice())?; + + self.contents.decrypt_with_key(&attachment_key) + } +} + impl KeyEncryptable for AttachmentView { fn encrypt_with_key(self, key: &SymmetricCryptoKey) -> Result { Ok(Attachment { @@ -71,3 +132,69 @@ impl TryFrom for Attachment }) } } + +#[cfg(test)] +mod tests { + use base64::{engine::general_purpose::STANDARD, Engine}; + + use bitwarden_crypto::{EncString, KeyDecryptable, SymmetricCryptoKey}; + + use crate::vault::{ + cipher::cipher::{CipherRepromptType, CipherType}, + Attachment, AttachmentFile, Cipher, + }; + + #[test] + fn test_attachment_key() { + let user_key : SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".parse().unwrap(); + + let attachment = Attachment { + id: None, + url: None, + size: Some("161".into()), + size_name: Some("161 Bytes".into()), + file_name: Some("2.M3z1MOO9eBG9BWRTEUbPog==|jPw0By1AakHDfoaY8UOwOQ==|eP9/J1583OJpHsSM4ZnXZzdBHfqVTXnOXGlkkmAKSfA=".parse().unwrap()), + key: Some("2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap()) + }; + + let cipher = Cipher { + id: None, + organization_id: None, + folder_id: None, + collection_ids: Vec::new(), + key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()), + name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(), + notes: None, + r#type: CipherType::Login, + login: None, + identity: None, + card: None, + secure_note: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(), + deleted_date: None, + revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(), + }; + + let enc_file = STANDARD.decode(b"Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap(); + let original = STANDARD.decode(b"rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap(); + + let dec = AttachmentFile { + cipher, + attachment, + contents: EncString::from_buffer(&enc_file).unwrap(), + } + .decrypt_with_key(&user_key) + .unwrap(); + + assert_eq!(dec, original); + } +} diff --git a/crates/bitwarden/src/vault/cipher/cipher.rs b/crates/bitwarden/src/vault/cipher/cipher.rs index 07d8da226..010f3cd8a 100644 --- a/crates/bitwarden/src/vault/cipher/cipher.rs +++ b/crates/bitwarden/src/vault/cipher/cipher.rs @@ -210,7 +210,7 @@ impl Cipher { /// Note that some ciphers do not have individual encryption keys, /// in which case this will return Ok(None) and the key associated /// with this cipher's user or organization must be used instead - fn get_cipher_key( + pub(super) fn get_cipher_key( key: &SymmetricCryptoKey, ciphers_key: &Option, ) -> Result, CryptoError> { diff --git a/crates/bitwarden/src/vault/mod.rs b/crates/bitwarden/src/vault/mod.rs index 6ac43a4d0..dce7b0e04 100644 --- a/crates/bitwarden/src/vault/mod.rs +++ b/crates/bitwarden/src/vault/mod.rs @@ -6,7 +6,12 @@ mod send; #[cfg(feature = "mobile")] mod totp; -pub use cipher::{Cipher, CipherListView, CipherView}; +pub use cipher::{ + attachment::{ + Attachment, AttachmentEncryptResult, AttachmentFile, AttachmentFileView, AttachmentView, + }, + Cipher, CipherListView, CipherView, +}; pub use collection::{Collection, CollectionView}; pub use folder::{Folder, FolderView}; pub use password_history::{PasswordHistory, PasswordHistoryView}; diff --git a/languages/kotlin/doc.md b/languages/kotlin/doc.md index 896cf7a76..ccf193301 100644 --- a/languages/kotlin/doc.md +++ b/languages/kotlin/doc.md @@ -138,6 +138,62 @@ password, use the email OTP. **Output**: std::result::Result<,BitwardenError> +## ClientAttachments + +### `encrypt_buffer` + +Encrypt an attachment file in memory + +**Arguments**: + +- self: +- cipher: [Cipher](#cipher) +- attachment: [AttachmentView](#attachmentview) +- buffer: Vec<> + +**Output**: std::result::Result + +### `encrypt_file` + +Encrypt an attachment file located in the file system + +**Arguments**: + +- self: +- cipher: [Cipher](#cipher) +- attachment: [AttachmentView](#attachmentview) +- decrypted_file_path: String +- encrypted_file_path: String + +**Output**: std::result::Result + +### `decrypt_buffer` + +Decrypt an attachment file in memory + +**Arguments**: + +- self: +- cipher: [Cipher](#cipher) +- attachment: [Attachment](#attachment) +- buffer: Vec<> + +**Output**: std::result::Result + +### `decrypt_file` + +Decrypt an attachment file located in the file system + +**Arguments**: + +- self: +- cipher: [Cipher](#cipher) +- attachment: [Attachment](#attachment) +- encrypted_file_path: String +- decrypted_file_path: String + +**Output**: std::result::Result<,BitwardenError> + ## ClientCiphers ### `encrypt` @@ -541,6 +597,16 @@ Sends operations **Output**: Arc +### `attachments` + +Attachment file operations + +**Arguments**: + +- self: Arc + +**Output**: Arc + ### `generate_totp` Generate a TOTP code from a provided key. @@ -564,6 +630,86 @@ The key can be either: References are generated from the JSON schemas and should mostly match the kotlin and swift implementations. +## `Attachment` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyTypeDescription
idstring,null
urlstring,null
sizestring,null
sizeNamestring,nullReadable size, ex: "4.2 KB" or "1.43 GB"
fileName
key
+ +## `AttachmentView` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyTypeDescription
idstring,null
urlstring,null
sizestring,null
sizeNamestring,null
fileNamestring,null
key
+ ## `Cipher` diff --git a/support/docs/docs.ts b/support/docs/docs.ts index b547b4502..067ff0827 100644 --- a/support/docs/docs.ts +++ b/support/docs/docs.ts @@ -21,6 +21,7 @@ const template = Handlebars.compile( const rootElements = [ "Client", "ClientAuth", + "ClientAttachments", "ClientCiphers", "ClientCollections", "ClientCrypto", From 9cad470324eafac94918e776fe86a0dea6182be9 Mon Sep 17 00:00:00 2001 From: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com> Date: Thu, 18 Jan 2024 07:09:14 -0800 Subject: [PATCH 2/3] Sm 989 create build pipeline for the python language wrapper (#369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Type of change - [ ] Bug fix - [ ] New feature development - [x] Tech debt (refactoring, code cleanup, dependency upgrades, etc) - [x] Build/deploy pipeline (DevOps) - [ ] Other ## Objective Package the Python SDK so that it can be published to [PyPI.org](pypi.org). ## Code changes - **`.github/workflows/build-python-wheels.yml`:** create wheels for Linux, macOS, and Windows (AMD64-only). A sdist is also created for other platforms. - **`languages/python/BitwardenClient/__init__.py`:** export functions and classes for autocompletion in IDEs and the interactive Python interpreter - **`languages/python/pyproject.toml`**: replaces `setup.py`, which is no longer officially supported. This uses Maturin to build the Rust shared-object library that will be bundled with the SDK. - **`languages/python/setup.py`**: replaced by `pyproject.toml` ## Before you submit - Please add **unit tests** where it makes sense to do so (encouraged but not required) --------- Co-authored-by: Daniel García --- .github/workflows/build-python-wheels.yml | 122 +++++++++++++++++++++ languages/python/README.md | 45 ++++---- languages/python/bitwarden_sdk/__init__.py | 13 +++ languages/python/example.py | 57 +++++++--- languages/python/pyproject.toml | 29 +++++ languages/python/setup.py | 12 -- 6 files changed, 228 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/build-python-wheels.yml mode change 100644 => 100755 languages/python/example.py create mode 100644 languages/python/pyproject.toml delete mode 100644 languages/python/setup.py diff --git a/.github/workflows/build-python-wheels.yml b/.github/workflows/build-python-wheels.yml new file mode 100644 index 000000000..e69f3eec1 --- /dev/null +++ b/.github/workflows/build-python-wheels.yml @@ -0,0 +1,122 @@ +--- +name: Build Python Wheels + +on: + pull_request: + push: + branches: + - "main" + - "rc" + - "hotfix-rc" + workflow_dispatch: + +defaults: + run: + shell: bash + working-directory: languages/python + +jobs: + generate_schemas: + uses: ./.github/workflows/generate_schemas.yml + + setup: + name: Setup + runs-on: ubuntu-22.04 + outputs: + package_version: ${{ steps.retrieve-version.outputs.package_version }} + steps: + - name: Checkout repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Get Package Version + id: retrieve-version + run: | + VERSION="$(grep -o '^version = ".*"' ../../crates/bitwarden-py/Cargo.toml | grep -Eo "[0-9]+\.[0-9]+\.[0-9]+")" + echo "package_version=$VERSION" >> $GITHUB_OUTPUT + + build: + name: Building Python wheel for - ${{ matrix.settings.os }} - ${{ matrix.settings.target }} + runs-on: ${{ matrix.settings.os || 'ubuntu-latest' }} + needs: + - generate_schemas + - setup + env: + _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + strategy: + fail-fast: false + matrix: + settings: + - os: macos-12 + target: x86_64-apple-darwin + + - os: macos-12 + target: aarch64-apple-darwin + + - os: windows-2022 + target: x86_64-pc-windows-msvc + + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + + - os: ubuntu-22.04 + target: aarch64-unknown-linux-gnu + + steps: + - name: Checkout repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Setup Node + uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 + with: + node-version: 18 + + - name: Install rust + uses: dtolnay/rust-toolchain@439cf607258077187679211f12aa6f19af4a0af7 # stable + with: + toolchain: stable + targets: ${{ matrix.settings.target }} + + - name: Cache cargo registry + uses: Swatinem/rust-cache@3cf7f8cc28d1b4e7d01e3783be10a97d55d483c8 # v2.7.1 + with: + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.os }} + + - name: Retrieve schemas + uses: actions/download-artifact@f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3c67110 # v4.1.0 + with: + name: schemas.py + path: ${{ github.workspace }}/languages/python/bitwarden_sdk + + - name: Build wheels + if: ${{ matrix.settings.target != 'x86_64-unknown-linux-gnu' }} + uses: PyO3/maturin-action@b9e8f88fd4448fdecf5095864cdc7e39a544aa9f # v1.40.7 + with: + target: ${{ matrix.settings.target }} + args: --release --find-interpreter --sdist + sccache: "true" + manylinux: "2_28" # https://github.com/pola-rs/polars/pull/12211 + working-directory: ${{ github.workspace }}/languages/python + + - name: Build wheels (Linux - x86_64) + if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} + uses: PyO3/maturin-action@b9e8f88fd4448fdecf5095864cdc7e39a544aa9f # v1.40.7 + with: + target: ${{ matrix.settings.target }} + args: --release --find-interpreter --sdist + container: quay.io/pypa/manylinux_2_28_x86_64:2023-11-20-745eb52 + sccache: "true" + manylinux: "2_28" # https://github.com/pola-rs/polars/pull/12211 + working-directory: ${{ github.workspace }}/languages/python + + - name: Upload wheels + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + with: + name: bitwarden_sdk-${{ env._PACKAGE_VERSION }}-${{ matrix.settings.target }} + path: ${{ github.workspace }}/target/wheels/bitwarden_sdk*.whl + + - name: Upload sdists + if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} # we only need one sdist + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + with: + name: bitwarden_sdk-${{ env._PACKAGE_VERSION }}-sdist + path: ${{ github.workspace }}/target/wheels/bitwarden_sdk-*.tar.gz diff --git a/languages/python/README.md b/languages/python/README.md index 871be274f..e77e7a8eb 100644 --- a/languages/python/README.md +++ b/languages/python/README.md @@ -1,31 +1,32 @@ -# Requirements - -- Python3 -- setuptools - ```bash - pip install setuptools - ``` -- setuptools_rust - ```bash - pip install setuptools_rust - ``` -- dateutil - ```bash - pip install python-dateutil - ``` - -# Installation - -From the `languages/python/` directory, +# Build locally +## Requirements +- Python 3 +- `maturin` (install with `pip install maturin[patchelf]`) +- `npm` + +## Build + +From the root of the repository: ```bash -python3 ./setup.py develop +npm run schemas # generate schemas.py + +cd languages/python/ +maturin develop ``` -Rename the the resulting `.so` file to `bitwarden_py.so`, if it isn't already there. +You can now import `BitwardenClient` in your Python code. + +# Use without building locally + +```bash +pip install BitwardenClient +``` # Run +Set the `ORGANIZATION_ID` and `ACCESS_TOKEN` environment variables to your organization ID and access token, respectively. + ```bash -python3 ./login.py +python3 ./example.py ``` diff --git a/languages/python/bitwarden_sdk/__init__.py b/languages/python/bitwarden_sdk/__init__.py index e69de29bb..b2aeffea1 100644 --- a/languages/python/bitwarden_sdk/__init__.py +++ b/languages/python/bitwarden_sdk/__init__.py @@ -0,0 +1,13 @@ +"""The official Bitwarden client library for Python.""" + +__version__ = "0.1.0" + +from .bitwarden_client import * +from .schemas import * + +__doc__ = bitwarden_client.__doc__ +if hasattr(bitwarden_client, "__all__"): + __all__ = bitwarden_client.__all__ + +if hasattr(schemas, "__all__"): + __all__ += schemas.__all__ diff --git a/languages/python/example.py b/languages/python/example.py old mode 100644 new mode 100755 index b3f2ab006..16367a0c5 --- a/languages/python/example.py +++ b/languages/python/example.py @@ -1,29 +1,35 @@ -import json +#!/usr/bin/env python3 import logging -import sys -from bitwarden_sdk.bitwarden_client import BitwardenClient -from bitwarden_sdk.schemas import client_settings_from_dict, DeviceType +import os + +from bitwarden_sdk import BitwardenClient, DeviceType, client_settings_from_dict # Create the BitwardenClient, which is used to interact with the SDK -client = BitwardenClient(client_settings_from_dict({ - "apiUrl": "http://localhost:4000", - "deviceType": DeviceType.SDK, - "identityUrl": "http://localhost:33656", - "userAgent": "Python", -})) +client = BitwardenClient( + client_settings_from_dict( + { + "apiUrl": os.getenv("API_URL", "http://localhost:4000"), + "deviceType": DeviceType.SDK, + "identityUrl": os.getenv("IDENTITY_URL", "http://localhost:33656"), + "userAgent": "Python", + } + ) +) # Add some logging & set the org id logging.basicConfig(level=logging.DEBUG) -organization_id = "org_id_here" +organization_id = os.getenv("ORGANIZATION_ID") # Attempt to authenticate with the Secrets Manager Access Token -client.access_token_login("access_token_here") +client.access_token_login(os.getenv("ACCESS_TOKEN")) # -- Example Project Commands -- project = client.projects().create("ProjectName", organization_id) project2 = client.projects().create("Project - Don't Delete Me!", organization_id) -updated_project = client.projects().update(project.data.id, "Cool New Project Name", organization_id) +updated_project = client.projects().update( + project.data.id, "Cool New Project Name", organization_id +) get_that_project = client.projects().get(project.data.id) input("Press Enter to delete the project...") @@ -33,9 +39,28 @@ # -- Example Secret Commands -- -secret = client.secrets().create("TEST_SECRET", "This is a test secret", organization_id, "Secret1234!", [project2.data.id]) -secret2 = client.secrets().create("Secret - Don't Delete Me!", "This is a test secret that will stay", organization_id, "Secret1234!", [project2.data.id]) -secret_updated = client.secrets().update(secret.data.id, "TEST_SECRET_UPDATED", "This as an updated test secret", organization_id, "Secret1234!_updated", [project2.data.id]) +secret = client.secrets().create( + "TEST_SECRET", + "This is a test secret", + organization_id, + "Secret1234!", + [project2.data.id], +) +secret2 = client.secrets().create( + "Secret - Don't Delete Me!", + "This is a test secret that will stay", + organization_id, + "Secret1234!", + [project2.data.id], +) +secret_updated = client.secrets().update( + secret.data.id, + "TEST_SECRET_UPDATED", + "This as an updated test secret", + organization_id, + "Secret1234!_updated", + [project2.data.id], +) secret_retrieved = client.secrets().get(secret.data.id) input("Press Enter to delete the secret...") diff --git a/languages/python/pyproject.toml b/languages/python/pyproject.toml new file mode 100644 index 000000000..28bb22507 --- /dev/null +++ b/languages/python/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +build-backend = "maturin" +requires = ["maturin>=1.0,<2.0", "setuptools_rust>=1.8.1"] + +[project] +authors = [{ name = "Bitwarden", email = "support@bitwarden.com" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: Other/Proprietary License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Rust", + "Topic :: Security", +] +dependencies = ["dateutils >= 0.6.6"] +description = "A Bitwarden Client for python" +name = "bitwarden_sdk" +readme = "README.md" +requires-python = ">=3.0" +version = "0.1.0" + +[tool.maturin] +bindings = "pyo3" +compatibility = "2_28" +include = [ + { path = "bitwarden_sdk/*.py", format = ["sdist", "wheel"] } +] +manifest-path = "../../crates/bitwarden-py/Cargo.toml" +python-packages = ["bitwarden_sdk"] diff --git a/languages/python/setup.py b/languages/python/setup.py deleted file mode 100644 index b243a4fe8..000000000 --- a/languages/python/setup.py +++ /dev/null @@ -1,12 +0,0 @@ -from setuptools import setup -from setuptools_rust import Binding, RustExtension - -setup( - name="bitwarden_sdk", - description="A Bitwarden Client for python", - version="0.1", - rust_extensions=[RustExtension( - "bitwarden_py", path="../../crates/bitwarden-py/Cargo.toml", binding=Binding.PyO3)], - packages=['bitwarden_sdk'], - zip_safe=False, -) From b174e496a9c4052447abb3d3f2ddd6b62097d629 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Fri, 19 Jan 2024 10:36:17 +0100 Subject: [PATCH 3/3] [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};