Skip to main content

bitwarden_send/
create.rs

1use bitwarden_core::{
2    ApiError, MissingFieldError,
3    key_management::{KeySlotIds, SymmetricKeySlotId},
4    require,
5};
6use bitwarden_crypto::{
7    CompositeEncryptable, CryptoError, IdentifyKey, KeyStore, KeyStoreContext, OctetStreamBytes,
8    PrimitiveEncryptable, generate_random_bytes,
9};
10use bitwarden_error::bitwarden_error;
11use bitwarden_state::repository::{Repository, RepositoryError};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15#[cfg(feature = "wasm")]
16use tsify::Tsify;
17#[cfg(feature = "wasm")]
18use wasm_bindgen::prelude::*;
19
20use crate::{
21    EmptyEmailListError, Send, SendAuthType, SendParseError, SendView, SendViewType,
22    send_client::SendClient,
23};
24
25#[allow(missing_docs)]
26#[bitwarden_error(flat)]
27#[derive(Debug, Error)]
28pub enum CreateSendError {
29    #[error(transparent)]
30    Api(#[from] ApiError),
31    #[error(transparent)]
32    Crypto(#[from] CryptoError),
33    #[error(transparent)]
34    EmptyEmailList(#[from] EmptyEmailListError),
35    #[error(transparent)]
36    MissingField(#[from] MissingFieldError),
37    #[error(transparent)]
38    Repository(#[from] RepositoryError),
39    #[error(transparent)]
40    SendParse(#[from] SendParseError),
41}
42
43/// Request model for creating a new Send.
44#[derive(Serialize, Deserialize, Debug)]
45#[serde(rename_all = "camelCase")]
46#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
47pub struct SendAddRequest {
48    /// The name of the Send.
49    pub name: String,
50    /// Optional notes visible to the sender.
51    pub notes: Option<String>,
52
53    /// The type and content of the Send.
54    pub view_type: SendViewType,
55
56    /// Maximum number of times the Send can be accessed.
57    pub max_access_count: Option<u32>,
58    /// Whether the Send is disabled and cannot be accessed.
59    pub disabled: bool,
60    /// Whether to hide the sender's email from recipients.
61    pub hide_email: bool,
62
63    /// Date and time when the Send will be permanently deleted.
64    pub deletion_date: DateTime<Utc>,
65    /// Optional date and time when the Send expires and can no longer be accessed.
66    pub expiration_date: Option<DateTime<Utc>>,
67
68    /// Authentication method for accessing this Send.
69    pub auth: SendAuthType,
70}
71
72impl
73    CompositeEncryptable<
74        KeySlotIds,
75        SymmetricKeySlotId,
76        bitwarden_api_api::models::SendRequestModel,
77    > for SendAddRequest
78{
79    fn encrypt_composite(
80        &self,
81        ctx: &mut KeyStoreContext<KeySlotIds>,
82        key: SymmetricKeySlotId,
83    ) -> Result<bitwarden_api_api::models::SendRequestModel, CryptoError> {
84        // Generate the send key
85        let k = generate_random_bytes::<[u8; 16]>().to_vec();
86
87        // Derive the shareable send key for encrypting content
88        let send_key = Send::derive_shareable_key(ctx, &k)?;
89
90        let (send_type, file, text) = self.view_type.clone().encrypt_composite(ctx, send_key)?;
91
92        let (password, emails) = self.auth.auth_data(&k);
93
94        Ok(bitwarden_api_api::models::SendRequestModel {
95            r#type: Some(send_type),
96            auth_type: Some(self.auth.auth_type().into()),
97            file_length: None,
98            name: Some(self.name.encrypt(ctx, send_key)?.to_string()),
99            notes: self
100                .notes
101                .as_ref()
102                .map(|n| n.encrypt(ctx, send_key))
103                .transpose()?
104                .map(|e| e.to_string()),
105            // Encrypt the send key itself with the user key
106            key: OctetStreamBytes::from(k).encrypt(ctx, key)?.to_string(),
107            max_access_count: self.max_access_count.map(|c| c as i32),
108            expiration_date: self.expiration_date.map(|d| d.to_rfc3339()),
109            deletion_date: self.deletion_date.to_rfc3339(),
110            file,
111            text,
112            // TODO: Implement logic for item-based Sends
113            data: None,
114            password,
115            emails,
116            disabled: self.disabled,
117            hide_email: Some(self.hide_email),
118        })
119    }
120}
121
122impl IdentifyKey<SymmetricKeySlotId> for SendAddRequest {
123    fn key_identifier(&self) -> SymmetricKeySlotId {
124        SymmetricKeySlotId::User
125    }
126}
127
128async fn create_send<R: Repository<Send> + ?Sized>(
129    key_store: &KeyStore<KeySlotIds>,
130    api_client: &bitwarden_api_api::apis::ApiClient,
131    repository: &R,
132    request: SendAddRequest,
133) -> Result<SendView, CreateSendError> {
134    request.auth.validate()?;
135
136    let send_request = key_store.encrypt(request)?;
137
138    let resp = api_client.sends_api().post(Some(send_request)).await?;
139
140    let send: Send = resp.try_into()?;
141
142    repository.set(require!(send.id), send.clone()).await?;
143
144    Ok(key_store.decrypt(&send)?)
145}
146
147#[cfg_attr(feature = "wasm", wasm_bindgen)]
148impl SendClient {
149    /// Create a new [Send] and save it to the server.
150    pub async fn create(&self, request: SendAddRequest) -> Result<SendView, CreateSendError> {
151        let key_store = self.client.internal.get_key_store();
152        let config = self.client.internal.get_api_configurations();
153        let repository = self.get_repository()?;
154
155        create_send(key_store, &config.api_client, repository.as_ref(), request).await
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use bitwarden_api_api::{apis::ApiClient, models::SendResponseModel};
162    use bitwarden_crypto::SymmetricKeyAlgorithm;
163    use bitwarden_test::MemoryRepository;
164    use uuid::uuid;
165
166    use super::*;
167    use crate::{AuthType, SendId, SendTextView, SendType, SendView};
168
169    #[tokio::test]
170    async fn test_create_send() {
171        let store: KeyStore<KeySlotIds> = KeyStore::default();
172        {
173            let mut ctx = store.context_mut();
174            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
175            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
176                .unwrap();
177        }
178
179        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
180
181        let api_client = ApiClient::new_mocked(move |mock| {
182            mock.sends_api
183                .expect_post()
184                .returning(move |model| {
185                    let model = model.unwrap();
186                    Ok(SendResponseModel {
187                        id: Some(send_id),
188                        name: model.name,
189                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
190                        object: Some("send".to_string()),
191                        access_id: None,
192                        r#type: model.r#type,
193                        auth_type: model.auth_type,
194                        notes: model.notes,
195                        file: model.file,
196                        text: model.text,
197                        data: model.data,
198                        key: Some(model.key),
199                        max_access_count: model.max_access_count,
200                        access_count: Some(0),
201                        password: model.password,
202                        emails: model.emails,
203                        disabled: Some(model.disabled),
204                        expiration_date: model.expiration_date,
205                        deletion_date: Some(model.deletion_date),
206                        hide_email: model.hide_email,
207                    })
208                })
209                .once();
210        });
211
212        let repository = MemoryRepository::<Send>::default();
213
214        let result = create_send(
215            &store,
216            &api_client,
217            &repository,
218            SendAddRequest {
219                name: "test".to_string(),
220                notes: Some("notes".to_string()),
221                view_type: SendViewType::Text(SendTextView {
222                    text: Some("test".to_string()),
223                    hidden: false,
224                }),
225                max_access_count: None,
226                disabled: false,
227                hide_email: false,
228                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
229                expiration_date: None,
230                auth: SendAuthType::None,
231            },
232        )
233        .await
234        .unwrap();
235
236        // Verify the result (excluding the generated key which is random)
237        assert_eq!(result.id, Some(crate::send::SendId::new(send_id)));
238        assert_eq!(result.name, "test");
239        assert_eq!(result.notes, Some("notes".to_string()));
240        assert!(result.key.is_some(), "Expected a generated key");
241        assert_eq!(result.new_password, None);
242        assert!(!result.has_password);
243        assert_eq!(result.r#type, SendType::Text);
244        assert_eq!(result.file, None);
245        assert_eq!(
246            result.text,
247            Some(SendTextView {
248                text: Some("test".to_string()),
249                hidden: false,
250            })
251        );
252        assert_eq!(result.max_access_count, None);
253        assert_eq!(result.access_count, 0);
254        assert!(!result.disabled);
255        assert!(!result.hide_email);
256        assert_eq!(
257            result.deletion_date,
258            "2025-01-10T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
259        );
260        assert_eq!(result.expiration_date, None);
261        assert_eq!(result.emails, Vec::<String>::new());
262        assert_eq!(result.auth_type, AuthType::None);
263        assert_eq!(
264            result.revision_date,
265            "2025-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
266        );
267
268        // Confirm the send was stored in the repository
269        assert_eq!(
270            store
271                .decrypt::<SymmetricKeySlotId, Send, SendView>(
272                    &repository.get(SendId::new(send_id)).await.unwrap().unwrap()
273                )
274                .unwrap(),
275            result
276        );
277    }
278
279    #[tokio::test]
280    async fn test_create_send_http_error() {
281        let store: KeyStore<KeySlotIds> = KeyStore::default();
282        {
283            let mut ctx = store.context_mut();
284            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
285            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
286                .unwrap();
287        }
288
289        let api_client = ApiClient::new_mocked(move |mock| {
290            mock.sends_api
291                .expect_post()
292                .returning(move |_model| Err(std::io::Error::other("Simulated error").into()));
293        });
294
295        let repository = MemoryRepository::<Send>::default();
296
297        let result = create_send(
298            &store,
299            &api_client,
300            &repository,
301            SendAddRequest {
302                name: "test".to_string(),
303                notes: Some("notes".to_string()),
304                view_type: SendViewType::Text(SendTextView {
305                    text: Some("test".to_string()),
306                    hidden: false,
307                }),
308                max_access_count: None,
309                disabled: false,
310                hide_email: false,
311                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
312                expiration_date: None,
313                auth: SendAuthType::None,
314            },
315        )
316        .await;
317
318        assert!(result.is_err());
319        assert!(matches!(result.unwrap_err(), CreateSendError::Api(_)));
320    }
321}