diff --git a/Cargo.lock b/Cargo.lock index 1a0e6d906..c01f6f59c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,13 +902,21 @@ dependencies = [ "bitwarden-core", "bitwarden-crypto", "bitwarden-encoding", + "bitwarden-error", + "bitwarden-state", + "bitwarden-test", + "bitwarden-uuid", "chrono", "serde", "serde_repr", "sha2 0.10.9", "thiserror 2.0.18", + "tokio", + "tsify", "uniffi", "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", "zeroize", ] diff --git a/crates/bitwarden-pm/Cargo.toml b/crates/bitwarden-pm/Cargo.toml index ee3586299..aa752dca1 100644 --- a/crates/bitwarden-pm/Cargo.toml +++ b/crates/bitwarden-pm/Cargo.toml @@ -34,6 +34,7 @@ wasm = [ "bitwarden-core/wasm", "bitwarden-exporters/wasm", "bitwarden-generators/wasm", + "bitwarden-send/wasm", "bitwarden-state/wasm", "bitwarden-vault/wasm", "dep:wasm-bindgen", diff --git a/crates/bitwarden-send/Cargo.toml b/crates/bitwarden-send/Cargo.toml index 95ee017ec..49e28cb88 100644 --- a/crates/bitwarden-send/Cargo.toml +++ b/crates/bitwarden-send/Cargo.toml @@ -15,25 +15,40 @@ license-file.workspace = true keywords.workspace = true [features] -uniffi = [ - "bitwarden-core/uniffi", - "bitwarden-crypto/uniffi", - "dep:uniffi", -] # Uniffi bindings +uniffi = ["bitwarden-core/uniffi", "bitwarden-crypto/uniffi", "dep:uniffi"] +wasm = [ + "bitwarden-core/wasm", + "bitwarden-encoding/wasm", + "bitwarden-state/wasm", + "dep:tsify", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", +] [dependencies] bitwarden-api-api = { workspace = true } -bitwarden-core = { workspace = true } +bitwarden-core = { workspace = true, features = ["internal"] } bitwarden-crypto = { workspace = true } bitwarden-encoding = { workspace = true } +bitwarden-error = { workspace = true } +bitwarden-state = { workspace = true } +bitwarden-uuid = { workspace = true } chrono = { workspace = true } serde = { workspace = true } serde_repr = { workspace = true } sha2 = { version = ">=0.10.6, <0.11" } thiserror = { workspace = true } +tsify = { workspace = true, optional = true } uniffi = { workspace = true, optional = true } uuid = { workspace = true } +wasm-bindgen = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } zeroize = { version = ">=1.7.0, <2.0" } +[dev-dependencies] +bitwarden-api-api = { workspace = true, features = ["mockall"] } +bitwarden-test = { workspace = true } +tokio = { workspace = true, features = ["rt"] } + [lints] workspace = true diff --git a/crates/bitwarden-send/src/create.rs b/crates/bitwarden-send/src/create.rs new file mode 100644 index 000000000..a93c630cb --- /dev/null +++ b/crates/bitwarden-send/src/create.rs @@ -0,0 +1,301 @@ +use bitwarden_core::{ + ApiError, MissingFieldError, + key_management::{KeyIds, SymmetricKeyId}, + require, +}; +use bitwarden_crypto::{ + CompositeEncryptable, CryptoError, IdentifyKey, KeyStore, KeyStoreContext, OctetStreamBytes, + PrimitiveEncryptable, generate_random_bytes, +}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::{Repository, RepositoryError}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +#[cfg(feature = "wasm")] +use tsify::Tsify; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::{Send, SendAuthType, SendParseError, SendView, SendViewType}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum CreateSendError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + SendParse(#[from] SendParseError), +} + +/// Request model for creating a new Send. +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct SendAddRequest { + /// The name of the Send. + pub name: String, + /// Optional notes visible to the sender. + pub notes: Option, + + /// The type and content of the Send. + pub view_type: SendViewType, + + /// Maximum number of times the Send can be accessed. + pub max_access_count: Option, + /// Whether the Send is disabled and cannot be accessed. + pub disabled: bool, + /// Whether to hide the sender's email from recipients. + pub hide_email: bool, + + /// Date and time when the Send will be permanently deleted. + pub deletion_date: DateTime, + /// Optional date and time when the Send expires and can no longer be accessed. + pub expiration_date: Option>, + + /// Authentication method for accessing this Send. + pub auth: SendAuthType, +} + +impl CompositeEncryptable + for SendAddRequest +{ + fn encrypt_composite( + &self, + ctx: &mut KeyStoreContext, + key: SymmetricKeyId, + ) -> Result { + // Generate the send key + let k = generate_random_bytes::<[u8; 16]>().to_vec(); + + // Derive the shareable send key for encrypting content + let send_key = Send::derive_shareable_key(ctx, &k)?; + + let (send_type, file, text) = self.view_type.clone().encrypt_composite(ctx, send_key)?; + + let (password, emails) = self.auth.auth_data(&k); + + Ok(bitwarden_api_api::models::SendRequestModel { + r#type: Some(send_type), + auth_type: Some(self.auth.auth_type().into()), + file_length: None, + name: Some(self.name.encrypt(ctx, send_key)?.to_string()), + notes: self + .notes + .as_ref() + .map(|n| n.encrypt(ctx, send_key)) + .transpose()? + .map(|e| e.to_string()), + // Encrypt the send key itself with the user key + key: OctetStreamBytes::from(k).encrypt(ctx, key)?.to_string(), + max_access_count: self.max_access_count.map(|c| c as i32), + expiration_date: self.expiration_date.map(|d| d.to_rfc3339()), + deletion_date: self.deletion_date.to_rfc3339(), + file, + text, + password, + emails, + disabled: self.disabled, + hide_email: Some(self.hide_email), + }) + } +} + +impl IdentifyKey for SendAddRequest { + fn key_identifier(&self) -> SymmetricKeyId { + SymmetricKeyId::User + } +} + +pub(super) async fn create_send + ?Sized>( + key_store: &KeyStore, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &R, + request: SendAddRequest, +) -> Result { + let send_request = key_store.encrypt(request)?; + + let resp = api_client + .sends_api() + .post(Some(send_request)) + .await + .map_err(ApiError::from)?; + + let send: Send = resp.try_into()?; + + repository.set(require!(send.id), send.clone()).await?; + + Ok(key_store.decrypt(&send)?) +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{apis::ApiClient, models::SendResponseModel}; + use bitwarden_crypto::SymmetricKeyAlgorithm; + use bitwarden_test::MemoryRepository; + use uuid::uuid; + + use super::*; + use crate::{AuthType, SendId, SendTextView, SendType, SendView}; + + #[tokio::test] + async fn test_create_send() { + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User) + .unwrap(); + } + + let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1"); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.sends_api + .expect_post() + .returning(move |model| { + let model = model.unwrap(); + Ok(SendResponseModel { + id: Some(send_id), + name: model.name, + revision_date: Some("2025-01-01T00:00:00Z".to_string()), + object: Some("send".to_string()), + access_id: None, + r#type: model.r#type, + auth_type: model.auth_type, + notes: model.notes, + file: model.file, + text: model.text, + key: Some(model.key), + max_access_count: model.max_access_count, + access_count: Some(0), + password: model.password, + emails: model.emails, + disabled: Some(model.disabled), + expiration_date: model.expiration_date, + deletion_date: Some(model.deletion_date), + hide_email: model.hide_email, + }) + }) + .once(); + }); + + let repository = MemoryRepository::::default(); + + let result = create_send( + &store, + &api_client, + &repository, + SendAddRequest { + name: "test".to_string(), + notes: Some("notes".to_string()), + view_type: SendViewType::Text(SendTextView { + text: Some("test".to_string()), + hidden: false, + }), + max_access_count: None, + disabled: false, + hide_email: false, + deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(), + expiration_date: None, + auth: SendAuthType::None, + }, + ) + .await + .unwrap(); + + // Verify the result (excluding the generated key which is random) + assert_eq!(result.id, Some(crate::send::SendId::new(send_id))); + assert_eq!(result.name, "test"); + assert_eq!(result.notes, Some("notes".to_string())); + assert!(result.key.is_some(), "Expected a generated key"); + assert_eq!(result.new_password, None); + assert!(!result.has_password); + assert_eq!(result.r#type, SendType::Text); + assert_eq!(result.file, None); + assert_eq!( + result.text, + Some(SendTextView { + text: Some("test".to_string()), + hidden: false, + }) + ); + assert_eq!(result.max_access_count, None); + assert_eq!(result.access_count, 0); + assert!(!result.disabled); + assert!(!result.hide_email); + assert_eq!( + result.deletion_date, + "2025-01-10T00:00:00Z".parse::>().unwrap() + ); + assert_eq!(result.expiration_date, None); + assert_eq!(result.emails, Vec::::new()); + assert_eq!(result.auth_type, AuthType::None); + assert_eq!( + result.revision_date, + "2025-01-01T00:00:00Z".parse::>().unwrap() + ); + + // Confirm the send was stored in the repository + assert_eq!( + store + .decrypt::( + &repository.get(SendId::new(send_id)).await.unwrap().unwrap() + ) + .unwrap(), + result + ); + } + + #[tokio::test] + async fn test_create_send_http_error() { + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User) + .unwrap(); + } + + let api_client = ApiClient::new_mocked(move |mock| { + mock.sends_api.expect_post().returning(move |_model| { + Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other( + "Simulated error", + ))) + }); + }); + + let repository = MemoryRepository::::default(); + + let result = create_send( + &store, + &api_client, + &repository, + SendAddRequest { + name: "test".to_string(), + notes: Some("notes".to_string()), + view_type: SendViewType::Text(SendTextView { + text: Some("test".to_string()), + hidden: false, + }), + max_access_count: None, + disabled: false, + hide_email: false, + deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(), + expiration_date: None, + auth: SendAuthType::None, + }, + ) + .await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CreateSendError::Api(_))); + } +} diff --git a/crates/bitwarden-send/src/edit.rs b/crates/bitwarden-send/src/edit.rs new file mode 100644 index 000000000..18d96b178 --- /dev/null +++ b/crates/bitwarden-send/src/edit.rs @@ -0,0 +1,440 @@ +use bitwarden_core::{ + ApiError, MissingFieldError, + key_management::{KeyIds, SymmetricKeyId}, +}; +use bitwarden_crypto::{ + CompositeEncryptable, CryptoError, IdentifyKey, KeyStore, KeyStoreContext, OctetStreamBytes, + PrimitiveEncryptable, +}; +use bitwarden_encoding::B64Url; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::{Repository, RepositoryError}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +#[cfg(feature = "wasm")] +use tsify::Tsify; +use uuid::Uuid; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::{ + Send, SendAuthType, SendId, SendView, SendViewType, + error::{ItemNotFoundError, SendParseError}, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum EditSendError { + #[error(transparent)] + ItemNotFound(#[from] ItemNotFoundError), + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + Uuid(#[from] uuid::Error), + #[error(transparent)] + SendParse(#[from] SendParseError), + #[error("Server returned Send with ID {returned:?} but expected {expected}")] + IdMismatch { + expected: Uuid, + returned: Option, + }, +} + +/// Request model for editing an existing Send. +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct SendEditRequest { + /// The name of the Send. + pub name: String, + /// Optional notes visible to the sender. + pub notes: Option, + + /// The type and content of the Send. + pub view_type: SendViewType, + + /// Maximum number of times the Send can be accessed. + pub max_access_count: Option, + /// Whether the Send is disabled and cannot be accessed. + pub disabled: bool, + /// Whether to hide the sender's email from recipients. + pub hide_email: bool, + + /// Date and time when the Send will be permanently deleted. + pub deletion_date: DateTime, + /// Optional date and time when the Send expires and can no longer be accessed. + pub expiration_date: Option>, + + /// Authentication method for accessing this Send. + pub auth: SendAuthType, +} + +/// Internal helper struct that includes the send key for encryption. +/// The key is retrieved from state during edit operations. +#[derive(Debug)] +struct SendEditRequestWithKey { + request: SendEditRequest, + send_key: String, +} + +impl CompositeEncryptable + for SendEditRequestWithKey +{ + fn encrypt_composite( + &self, + ctx: &mut KeyStoreContext, + key: SymmetricKeyId, + ) -> Result { + // Decode the send key from the existing send + let k = B64Url::try_from(self.send_key.as_str()) + .map_err(|_| CryptoError::InvalidKey)? + .as_bytes() + .to_vec(); + + // Derive the shareable send key for encrypting content + let send_key = Send::derive_shareable_key(ctx, &k)?; + + let (send_type, file, text) = self + .request + .view_type + .clone() + .encrypt_composite(ctx, send_key)?; + + let (password, emails) = self.request.auth.auth_data(&k); + + Ok(bitwarden_api_api::models::SendRequestModel { + r#type: Some(send_type), + auth_type: Some(self.request.auth.auth_type().into()), + file_length: None, + name: Some(self.request.name.encrypt(ctx, send_key)?.to_string()), + notes: self + .request + .notes + .as_ref() + .map(|n| n.encrypt(ctx, send_key)) + .transpose()? + .map(|e| e.to_string()), + // Encrypt the send key itself with the user key + key: OctetStreamBytes::from(k).encrypt(ctx, key)?.to_string(), + max_access_count: self.request.max_access_count.map(|c| c as i32), + expiration_date: self.request.expiration_date.map(|d| d.to_rfc3339()), + deletion_date: self.request.deletion_date.to_rfc3339(), + file, + text, + password, + emails, + disabled: self.request.disabled, + hide_email: Some(self.request.hide_email), + }) + } +} + +impl IdentifyKey for SendEditRequestWithKey { + fn key_identifier(&self) -> SymmetricKeyId { + SymmetricKeyId::User + } +} + +pub(super) async fn edit_send + ?Sized>( + key_store: &KeyStore, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &R, + send_id: SendId, + request: SendEditRequest, +) -> Result { + let id = send_id.to_string(); + + // Retrieve the existing send to get its key (keys cannot be modified during edit) + let existing_send = repository.get(send_id).await?.ok_or(ItemNotFoundError)?; + + // Decrypt to get the key - we only need the key field + let existing_send_view: SendView = key_store.decrypt(&existing_send)?; + let send_key = existing_send_view.key.ok_or(MissingFieldError("key"))?; + + // Create the wrapper with the key from the existing send + let request_with_key = SendEditRequestWithKey { request, send_key }; + + let send_request = key_store.encrypt(request_with_key)?; + + let resp = api_client + .sends_api() + .put(&id, Some(send_request)) + .await + .map_err(ApiError::from)?; + + let send: Send = resp.try_into()?; + + // Verify the server returned the correct send ID + if send.id != Some(send_id) { + return Err(EditSendError::IdMismatch { + expected: send_id.into(), + returned: send.id.map(Into::into), + }); + } + + repository.set(send_id, send.clone()).await?; + + Ok(key_store.decrypt(&send)?) +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{apis::ApiClient, models::SendResponseModel}; + use bitwarden_core::key_management::SymmetricKeyId; + use bitwarden_crypto::SymmetricKeyAlgorithm; + use bitwarden_test::MemoryRepository; + use chrono::{DateTime, Utc}; + use uuid::uuid; + + use super::*; + use crate::{AuthType, SendTextView, SendType, SendViewType}; + + #[tokio::test] + async fn test_edit_send() { + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User) + .unwrap(); + } + + let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1"); + + // Pre-populate the repository with an existing send by encrypting a SendView + let repository = MemoryRepository::::default(); + let existing_send_view = SendView { + id: None, // No ID initially to allow key generation + access_id: None, + name: "original".to_string(), + notes: Some("original notes".to_string()), + key: None, // Generates a new key when first encrypted + new_password: None, + has_password: false, + r#type: SendType::Text, + file: None, + text: Some(SendTextView { + text: Some("original text".to_string()), + hidden: false, + }), + max_access_count: None, + access_count: 0, + disabled: false, + hide_email: false, + revision_date: "2025-01-01T00:00:00Z".parse().unwrap(), + deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(), + expiration_date: None, + emails: Vec::new(), + auth_type: AuthType::None, + }; + let mut existing_send = store.encrypt(existing_send_view).unwrap(); + existing_send.id = Some(crate::send::SendId::new(send_id)); // Set the ID after encryption + repository + .set(SendId::new(send_id), existing_send) + .await + .unwrap(); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.sends_api + .expect_put() + .returning(move |_id, model| { + let model = model.unwrap(); + Ok(SendResponseModel { + id: Some(send_id), + name: model.name, + revision_date: Some("2025-01-02T00:00:00Z".to_string()), + object: Some("send".to_string()), + access_id: None, + r#type: model.r#type, + auth_type: model.auth_type, + notes: model.notes, + file: model.file, + text: model.text, + key: Some(model.key), + max_access_count: model.max_access_count, + access_count: Some(0), + password: model.password, + emails: model.emails, + disabled: Some(model.disabled), + expiration_date: model.expiration_date, + deletion_date: Some(model.deletion_date), + hide_email: model.hide_email, + }) + }) + .once(); + }); + + let result = edit_send( + &store, + &api_client, + &repository, + SendId::new(send_id), + SendEditRequest { + name: "updated".to_string(), + notes: Some("updated notes".to_string()), + view_type: SendViewType::Text(SendTextView { + text: Some("updated text".to_string()), + hidden: false, + }), + max_access_count: None, + disabled: false, + hide_email: false, + deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(), + expiration_date: None, + auth: SendAuthType::None, + }, + ) + .await + .unwrap(); + + // Verify the result + assert_eq!(result.id, Some(crate::send::SendId::new(send_id))); + assert_eq!(result.name, "updated"); + assert_eq!(result.notes, Some("updated notes".to_string())); + assert!(result.key.is_some(), "Expected a key"); + assert_eq!( + result.revision_date, + "2025-01-02T00:00:00Z".parse::>().unwrap() + ); + + // Confirm the send was updated in the repository + let stored = repository.get(SendId::new(send_id)).await.unwrap().unwrap(); + assert_eq!( + store + .decrypt::(&stored) + .unwrap() + .name, + "updated" + ); + } + + #[tokio::test] + async fn test_edit_send_not_found() { + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User) + .unwrap(); + } + + let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1"); + let repository = MemoryRepository::::default(); + let api_client = ApiClient::new_mocked(move |_mock| {}); + + let result = edit_send( + &store, + &api_client, + &repository, + SendId::new(send_id), + SendEditRequest { + name: "test".to_string(), + notes: None, + view_type: SendViewType::Text(SendTextView { + text: Some("test".to_string()), + hidden: false, + }), + max_access_count: None, + disabled: false, + hide_email: false, + deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(), + expiration_date: None, + auth: SendAuthType::None, + }, + ) + .await; + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + EditSendError::ItemNotFound(_) + )); + } + + #[tokio::test] + async fn test_edit_send_http_error() { + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User) + .unwrap(); + } + + let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1"); + + // Pre-populate the repository with an existing send by encrypting a SendView + let repository = MemoryRepository::::default(); + let existing_send_view = SendView { + id: None, // No ID initially to allow key generation + access_id: None, + name: "original".to_string(), + notes: Some("original notes".to_string()), + key: None, // Generates a new key when first encrypted + new_password: None, + has_password: false, + r#type: SendType::Text, + file: None, + text: Some(SendTextView { + text: Some("original text".to_string()), + hidden: false, + }), + max_access_count: None, + access_count: 0, + disabled: false, + hide_email: false, + revision_date: "2025-01-01T00:00:00Z".parse().unwrap(), + deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(), + expiration_date: None, + emails: Vec::new(), + auth_type: AuthType::None, + }; + let mut existing_send = store.encrypt(existing_send_view).unwrap(); + existing_send.id = Some(crate::send::SendId::new(send_id)); // Set the ID after encryption + repository + .set(SendId::new(send_id), existing_send) + .await + .unwrap(); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.sends_api.expect_put().returning(move |_id, _model| { + Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other( + "Simulated error", + ))) + }); + }); + + let result = edit_send( + &store, + &api_client, + &repository, + SendId::new(send_id), + SendEditRequest { + name: "test".to_string(), + notes: None, + view_type: SendViewType::Text(SendTextView { + text: Some("test".to_string()), + hidden: false, + }), + max_access_count: None, + disabled: false, + hide_email: false, + deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(), + expiration_date: None, + auth: SendAuthType::None, + }, + ) + .await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), EditSendError::Api(_))); + } +} diff --git a/crates/bitwarden-send/src/error.rs b/crates/bitwarden-send/src/error.rs index d224a82ad..6d69c1372 100644 --- a/crates/bitwarden-send/src/error.rs +++ b/crates/bitwarden-send/src/error.rs @@ -10,3 +10,8 @@ pub enum SendParseError { #[error(transparent)] MissingField(#[from] bitwarden_core::MissingFieldError), } + +/// Item does not exist error. +#[derive(Debug, thiserror::Error)] +#[error("Item does not exist")] +pub struct ItemNotFoundError; diff --git a/crates/bitwarden-send/src/get_list.rs b/crates/bitwarden-send/src/get_list.rs new file mode 100644 index 000000000..cd2a0f864 --- /dev/null +++ b/crates/bitwarden-send/src/get_list.rs @@ -0,0 +1,238 @@ +use bitwarden_core::{MissingFieldError, key_management::KeyIds}; +use bitwarden_crypto::{CryptoError, KeyStore}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::{Repository, RepositoryError}; +use thiserror::Error; + +use crate::{Send, SendId, SendView, error::ItemNotFoundError}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum GetSendError { + #[error(transparent)] + ItemNotFound(#[from] ItemNotFoundError), + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), + #[error(transparent)] + Repository(#[from] RepositoryError), +} + +pub(super) async fn get_send( + store: &KeyStore, + repository: &dyn Repository, + id: SendId, +) -> Result { + let send = repository.get(id).await?.ok_or(ItemNotFoundError)?; + + Ok(store.decrypt(&send)?) +} + +pub(super) async fn list_sends( + store: &KeyStore, + repository: &dyn Repository, +) -> Result, GetSendError> { + let sends = repository.list().await?; + let views = store.decrypt_list(&sends)?; + Ok(views) +} + +#[cfg(test)] +mod tests { + use bitwarden_core::key_management::SymmetricKeyId; + use bitwarden_crypto::SymmetricKeyAlgorithm; + use bitwarden_test::MemoryRepository; + use uuid::uuid; + + use super::*; + use crate::{AuthType, SendTextView, SendType, SendView}; + + #[tokio::test] + async fn test_get_send() { + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User) + .unwrap(); + } + + let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1"); + + // Create and store a send + let repository = MemoryRepository::::default(); + let send_view = SendView { + id: None, + access_id: None, + name: "Test Send".to_string(), + notes: Some("Test notes".to_string()), + key: None, + new_password: None, + has_password: false, + r#type: SendType::Text, + file: None, + text: Some(SendTextView { + text: Some("Secret text".to_string()), + hidden: false, + }), + max_access_count: None, + access_count: 0, + disabled: false, + hide_email: false, + revision_date: "2025-01-01T00:00:00Z".parse().unwrap(), + deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(), + expiration_date: None, + emails: Vec::new(), + auth_type: AuthType::None, + }; + let mut send = store.encrypt(send_view).unwrap(); + send.id = Some(crate::send::SendId::new(send_id)); + repository.set(SendId::new(send_id), send).await.unwrap(); + + // Test getting the send + let result = get_send(&store, &repository, SendId::new(send_id)) + .await + .unwrap(); + + assert_eq!(result.id, Some(crate::send::SendId::new(send_id))); + assert_eq!(result.name, "Test Send"); + assert_eq!(result.notes, Some("Test notes".to_string())); + assert_eq!( + result.text, + Some(SendTextView { + text: Some("Secret text".to_string()), + hidden: false, + }) + ); + } + + #[tokio::test] + async fn test_get_send_not_found() { + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User) + .unwrap(); + } + + let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1"); + let repository = MemoryRepository::::default(); + + // Try to get a send that doesn't exist + let result = get_send(&store, &repository, SendId::new(send_id)).await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), GetSendError::ItemNotFound(_))); + } + + #[tokio::test] + async fn test_list_sends() { + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User) + .unwrap(); + } + + let repository = MemoryRepository::::default(); + + // Create and store multiple sends + let send_id_1 = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1"); + let send_view_1 = SendView { + id: None, + access_id: None, + name: "Send 1".to_string(), + notes: None, + key: None, + new_password: None, + has_password: false, + r#type: SendType::Text, + file: None, + text: Some(SendTextView { + text: Some("Text 1".to_string()), + hidden: false, + }), + max_access_count: None, + access_count: 0, + disabled: false, + hide_email: false, + revision_date: "2025-01-01T00:00:00Z".parse().unwrap(), + deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(), + expiration_date: None, + emails: Vec::new(), + auth_type: AuthType::None, + }; + let mut send_1 = store.encrypt(send_view_1).unwrap(); + send_1.id = Some(crate::send::SendId::new(send_id_1)); + repository + .set(SendId::new(send_id_1), send_1) + .await + .unwrap(); + + let send_id_2 = uuid!("36afb22c-9c95-4db5-8bac-c21cb204a3f2"); + let send_view_2 = SendView { + id: None, + access_id: None, + name: "Send 2".to_string(), + notes: None, + key: None, + new_password: None, + has_password: false, + r#type: SendType::Text, + file: None, + text: Some(SendTextView { + text: Some("Text 2".to_string()), + hidden: false, + }), + max_access_count: None, + access_count: 0, + disabled: false, + hide_email: false, + revision_date: "2025-01-02T00:00:00Z".parse().unwrap(), + deletion_date: "2025-01-11T00:00:00Z".parse().unwrap(), + expiration_date: None, + emails: Vec::new(), + auth_type: AuthType::None, + }; + let mut send_2 = store.encrypt(send_view_2).unwrap(); + send_2.id = Some(crate::send::SendId::new(send_id_2)); + repository + .set(SendId::new(send_id_2), send_2) + .await + .unwrap(); + + // Test listing all sends + let result = list_sends(&store, &repository).await.unwrap(); + + assert_eq!(result.len(), 2); + + // Find sends by name (order may vary) + let send1 = result.iter().find(|s| s.name == "Send 1").unwrap(); + let send2 = result.iter().find(|s| s.name == "Send 2").unwrap(); + + assert_eq!(send1.id, Some(crate::send::SendId::new(send_id_1))); + assert_eq!(send2.id, Some(crate::send::SendId::new(send_id_2))); + } + + #[tokio::test] + async fn test_list_sends_empty() { + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User) + .unwrap(); + } + + let repository = MemoryRepository::::default(); + + // Test listing when repository is empty + let result = list_sends(&store, &repository).await.unwrap(); + + assert_eq!(result.len(), 0); + } +} diff --git a/crates/bitwarden-send/src/lib.rs b/crates/bitwarden-send/src/lib.rs index 924df122e..34a537315 100644 --- a/crates/bitwarden-send/src/lib.rs +++ b/crates/bitwarden-send/src/lib.rs @@ -5,12 +5,21 @@ uniffi::setup_scaffolding!(); #[cfg(feature = "uniffi")] mod uniffi_support; +mod create; +pub use create::{CreateSendError, SendAddRequest}; +mod edit; +pub use edit::{EditSendError, SendEditRequest}; mod error; pub use error::SendParseError; +mod get_list; +pub use get_list::GetSendError; mod send_client; pub use send_client::{ SendClient, SendClientExt, SendDecryptError, SendDecryptFileError, SendEncryptError, SendEncryptFileError, }; mod send; -pub use send::{AuthType, Send, SendListView, SendTextView, SendType, SendView}; +pub use send::{ + AuthType, Send, SendAuthType, SendFileView, SendId, SendListView, SendTextView, SendType, + SendView, SendViewType, +}; diff --git a/crates/bitwarden-send/src/send.rs b/crates/bitwarden-send/src/send.rs index d07f0a49b..d5cb28c42 100644 --- a/crates/bitwarden-send/src/send.rs +++ b/crates/bitwarden-send/src/send.rs @@ -10,41 +10,56 @@ use bitwarden_crypto::{ OctetStreamBytes, PrimitiveEncryptable, generate_random_bytes, }; use bitwarden_encoding::{B64, B64Url}; +use bitwarden_uuid::uuid_newtype; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; -use uuid::Uuid; use zeroize::Zeroizing; +#[cfg(feature = "wasm")] +use {tsify::Tsify, wasm_bindgen::prelude::*}; use crate::SendParseError; +pub const SEND_ITERATIONS: u32 = 100_000; -const SEND_ITERATIONS: u32 = 100_000; +uuid_newtype!(pub SendId); -#[derive(Serialize, Deserialize, Debug)] +/// File-based send content +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SendFile { + /// The file's ID pub id: Option, + /// The encrypted file name pub file_name: EncString, + /// The file size in bytes as a string pub size: Option, /// Readable size, ex: "4.2 KB" or "1.43 GB" pub size_name: Option, } +/// View model for decrypted SendFile #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SendFileView { + /// The file's ID pub id: Option, + /// The file name pub file_name: String, + /// The file size in bytes as a string pub size: Option, /// Readable size, ex: "4.2 KB" or "1.43 GB" pub size_name: Option, } -#[derive(Serialize, Deserialize, Debug)] +/// Text-based send content +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SendText { pub text: Option, pub hidden: bool, @@ -54,6 +69,7 @@ pub struct SendText { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SendTextView { /// The text content of the send pub text: Option, @@ -65,6 +81,7 @@ pub struct SendTextView { #[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)] #[repr(u8)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub enum SendType { /// Text-based send Text = 0, @@ -76,6 +93,7 @@ pub enum SendType { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)] #[repr(u8)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub enum AuthType { /// Email-based OTP authentication Email = 0, @@ -87,12 +105,116 @@ pub enum AuthType { None = 2, } +/// Type-safe authentication method for a Send, including the authentication data. +/// This ensures that password and email authentication are mutually exclusive. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub enum SendAuthType { + /// No authentication required + None, + /// Password-based authentication + Password { + /// The password required to access the Send + password: String, + }, + /// Email-based OTP authentication + Emails { + /// List of email addresses that will receive OTP codes + emails: Vec, + }, +} + +impl SendAuthType { + /// Returns the AuthType discriminant for this authentication method + pub fn auth_type(&self) -> AuthType { + match self { + SendAuthType::None => AuthType::None, + SendAuthType::Password { .. } => AuthType::Password, + SendAuthType::Emails { .. } => AuthType::Email, + } + } + + /// Returns the password if this is a Password variant, emails if this is an Emails variant, or + /// None otherwise + pub(crate) fn auth_data(&self, k: &[u8]) -> (Option, Option) { + match self { + SendAuthType::Password { password } => { + let hashed = bitwarden_crypto::pbkdf2(password.as_bytes(), k, SEND_ITERATIONS); + (Some(B64::from(hashed.as_slice()).to_string()), None) + } + SendAuthType::Emails { emails } => { + let emails_str = if emails.is_empty() { + None + } else { + Some(emails.join(",")) + }; + (None, emails_str) + } + SendAuthType::None => (None, None), + } + } +} + +/// View model for decrypted Send type +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub enum SendViewType { + /// File-based send + File(SendFileView), + /// Text-based send + Text(SendTextView), +} + +/// Type alias for the tuple returned by SendViewType::into_api_models +type SendApiModels = ( + bitwarden_api_api::models::SendType, + Option>, + Option>, +); + +impl CompositeEncryptable for SendViewType { + fn encrypt_composite( + &self, + ctx: &mut KeyStoreContext, + key: SymmetricKeyId, + ) -> Result { + match self { + SendViewType::File(f) => Ok(( + bitwarden_api_api::models::SendType::File, + Some(Box::new(bitwarden_api_api::models::SendFileModel { + id: f.id.clone(), + file_name: Some(f.file_name.encrypt(ctx, key)?.to_string()), + size: f.size.as_ref().and_then(|s| s.parse::().ok()), + size_name: f.size_name.clone(), + })), + None, + )), + SendViewType::Text(t) => Ok(( + bitwarden_api_api::models::SendType::Text, + None, + Some(Box::new(bitwarden_api_api::models::SendTextModel { + text: t + .text + .as_ref() + .map(|txt| txt.encrypt(ctx, key)) + .transpose()? + .map(|e| e.to_string()), + hidden: Some(t.hidden), + })), + )), + } + } +} + #[allow(missing_docs)] -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct Send { - pub id: Option, + pub id: Option, pub access_id: Option, pub name: EncString, @@ -113,13 +235,16 @@ pub struct Send { pub deletion_date: DateTime, pub expiration_date: Option>, - /// Email addresses for OTP authentication. - /// **Note**: Mutually exclusive with `new_password`. If both are set, - /// only password authentication will be used. + /// Email addresses for OTP authentication (comma-separated). + /// + /// **Note**: Mutually exclusive with `password`. If both `password` and `emails` are + /// set, password authentication takes precedence and email OTP is ignored. pub emails: Option, pub auth_type: AuthType, } +bitwarden_state::register_repository_item!(SendId => Send, "Send"); + impl From for SendWithIdRequestModel { fn from(send: Send) -> Self { let file_length = send.file.as_ref().and_then(|file| { @@ -146,7 +271,8 @@ impl From for SendWithIdRequestModel { hide_email: Some(send.hide_email), id: send .id - .expect("SendWithIdRequestModel conversion requires send id"), + .expect("SendWithIdRequestModel conversion requires send id") + .into(), } } } @@ -155,8 +281,9 @@ impl From for SendWithIdRequestModel { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SendView { - pub id: Option, + pub id: Option, pub access_id: Option, pub name: String, @@ -195,8 +322,9 @@ pub struct SendView { #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SendListView { - pub id: Option, + pub id: Option, pub access_id: Option, pub name: String, @@ -222,7 +350,7 @@ impl Send { Self::derive_shareable_key(ctx, &key) } - fn derive_shareable_key( + pub(crate) fn derive_shareable_key( ctx: &mut KeyStoreContext, key: &[u8], ) -> Result { @@ -450,7 +578,7 @@ impl TryFrom for Send { } }; Ok(Send { - id: send.id, + id: send.id.map(SendId::new), access_id: send.access_id, name: require!(send.name).parse()?, notes: EncString::try_from_optional(send.notes)?, @@ -486,6 +614,15 @@ impl TryFrom for SendType { } } +impl From for bitwarden_api_api::models::SendType { + fn from(t: SendType) -> Self { + match t { + SendType::Text => bitwarden_api_api::models::SendType::Text, + SendType::File => bitwarden_api_api::models::SendType::File, + } + } +} + impl TryFrom for AuthType { type Error = bitwarden_core::MissingFieldError; @@ -501,15 +638,6 @@ impl TryFrom for AuthType { } } -impl From for bitwarden_api_api::models::SendType { - fn from(t: SendType) -> Self { - match t { - SendType::Text => bitwarden_api_api::models::SendType::Text, - SendType::File => bitwarden_api_api::models::SendType::File, - } - } -} - impl From for bitwarden_api_api::models::AuthType { fn from(value: AuthType) -> Self { match value { @@ -817,7 +945,7 @@ mod tests { #[test] fn test_send_into_send_with_id_request_model() { - let send_id = Uuid::parse_str("3d80dd72-2d14-4f26-812c-b0f0018aa144").unwrap(); + let send_id = "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().unwrap(); let revision_date = DateTime::parse_from_rfc3339("2024-01-07T23:56:48Z") .unwrap() .with_timezone(&Utc); @@ -835,7 +963,7 @@ mod tests { let text_value = "2.2VPyLzk1tMLug0X3x7RkaQ==|mrMt9vbZsCJhJIj4eebKyg==|aZ7JeyndytEMR1+uEBupEvaZuUE69D/ejhfdJL8oKq0="; let send = Send { - id: Some(send_id), + id: Some(SendId::new(send_id)), access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_string()), name: name.parse().unwrap(), notes: Some(notes.parse().unwrap()), diff --git a/crates/bitwarden-send/src/send_client.rs b/crates/bitwarden-send/src/send_client.rs index 546d422eb..cd2c2cb60 100644 --- a/crates/bitwarden-send/src/send_client.rs +++ b/crates/bitwarden-send/src/send_client.rs @@ -1,12 +1,20 @@ -use std::path::Path; +use std::{path::Path, sync::Arc}; use bitwarden_core::Client; use bitwarden_crypto::{ Decryptable, EncString, IdentifyKey, OctetStreamBytes, PrimitiveEncryptable, }; +use bitwarden_state::repository::{Repository, RepositoryError}; use thiserror::Error; - -use crate::{Send, SendListView, SendView}; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::{ + Send, SendId, SendListView, SendView, + create::{CreateSendError, SendAddRequest, create_send}, + edit::{EditSendError, SendEditRequest, edit_send}, + get_list::{GetSendError, get_send, list_sends}, +}; /// Generic error type for send encryption errors. #[allow(missing_docs)] @@ -49,6 +57,7 @@ pub enum SendDecryptFileError { } #[allow(missing_docs)] +#[cfg_attr(feature = "wasm", wasm_bindgen)] pub struct SendClient { client: Client, } @@ -134,6 +143,61 @@ impl SendClient { } } +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl SendClient { + /// Create a new [Send] and save it to the server. + pub async fn create(&self, request: SendAddRequest) -> Result { + let key_store = self.client.internal.get_key_store(); + let config = self.client.internal.get_api_configurations(); + let repository = self.get_repository()?; + + create_send(key_store, &config.api_client, repository.as_ref(), request).await + } + + /// Edit the [Send] and save it to the server. + pub async fn edit( + &self, + send_id: SendId, + request: SendEditRequest, + ) -> Result { + let key_store = self.client.internal.get_key_store(); + let config = self.client.internal.get_api_configurations(); + let repository = self.get_repository()?; + + edit_send( + key_store, + &config.api_client, + repository.as_ref(), + send_id, + request, + ) + .await + } + + /// Get all sends from state and decrypt them to a list of [SendView]. + pub async fn list(&self) -> Result, GetSendError> { + let key_store = self.client.internal.get_key_store(); + let repository = self.get_repository()?; + + list_sends(key_store, repository.as_ref()).await + } + + /// Get a specific [Send] by its ID from state and decrypt it to a [SendView]. + pub async fn get(&self, send_id: SendId) -> Result { + let key_store = self.client.internal.get_key_store(); + let repository = self.get_repository()?; + + get_send(key_store, repository.as_ref(), send_id).await + } +} + +impl SendClient { + /// Helper for getting the repository for sends. + fn get_repository(&self) -> Result>, RepositoryError> { + Ok(self.client.platform().state().get::()?) + } +} + #[allow(missing_docs)] pub trait SendClientExt { fn sends(&self) -> SendClient; diff --git a/crates/bitwarden-user-crypto-management/src/key_rotation/sync.rs b/crates/bitwarden-user-crypto-management/src/key_rotation/sync.rs index 7d96ba582..98a53ec36 100644 --- a/crates/bitwarden-user-crypto-management/src/key_rotation/sync.rs +++ b/crates/bitwarden-user-crypto-management/src/key_rotation/sync.rs @@ -383,6 +383,7 @@ mod tests { }, }; use bitwarden_encoding::B64; + use bitwarden_send::SendId; use bitwarden_vault::{CipherId, FolderId}; use super::*; @@ -670,7 +671,7 @@ mod tests { // Verify sends assert_eq!(data.sends.len(), 1); - assert_eq!(data.sends[0].id, Some(send_id)); + assert_eq!(data.sends[0].id, Some(SendId::new(send_id))); assert_eq!(data.sends[0].name, TEST_ENC_STRING.parse().unwrap()); assert_eq!(data.sends[0].key, KEY_ENC_STRING.parse().unwrap()); diff --git a/crates/bitwarden-wasm-internal/src/client.rs b/crates/bitwarden-wasm-internal/src/client.rs index 0edffa9be..a9d7b04cc 100644 --- a/crates/bitwarden-wasm-internal/src/client.rs +++ b/crates/bitwarden-wasm-internal/src/client.rs @@ -101,6 +101,11 @@ impl PasswordManagerClient { pub fn exporters(&self) -> ExporterClient { self.0.exporters() } + + /// Send related operations. + pub fn sends(&self) -> SendClient { + self.0.sends() + } } #[bitwarden_error(basic)]