Skip to main content

bitwarden_send/
edit.rs

1use bitwarden_core::{
2    ApiError, MissingFieldError,
3    key_management::{KeyIds, SymmetricKeyId},
4};
5use bitwarden_crypto::{
6    CompositeEncryptable, CryptoError, IdentifyKey, KeyStore, KeyStoreContext, OctetStreamBytes,
7    PrimitiveEncryptable,
8};
9use bitwarden_encoding::B64Url;
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;
17use uuid::Uuid;
18#[cfg(feature = "wasm")]
19use wasm_bindgen::prelude::*;
20
21use crate::{
22    EmptyEmailListError, Send, SendAuthType, SendId, SendView, SendViewType,
23    error::{ItemNotFoundError, SendParseError},
24    send_client::SendClient,
25};
26
27#[allow(missing_docs)]
28#[bitwarden_error(flat)]
29#[derive(Debug, Error)]
30pub enum EditSendError {
31    #[error(transparent)]
32    ItemNotFound(#[from] ItemNotFoundError),
33    #[error(transparent)]
34    Crypto(#[from] CryptoError),
35    #[error(transparent)]
36    Api(#[from] ApiError),
37    #[error(transparent)]
38    EmptyEmailList(#[from] EmptyEmailListError),
39    #[error(transparent)]
40    MissingField(#[from] MissingFieldError),
41    #[error(transparent)]
42    Repository(#[from] RepositoryError),
43    #[error(transparent)]
44    Uuid(#[from] uuid::Error),
45    #[error(transparent)]
46    SendParse(#[from] SendParseError),
47    #[error("Server returned Send with ID {returned:?} but expected {expected}")]
48    IdMismatch {
49        expected: Uuid,
50        returned: Option<Uuid>,
51    },
52}
53
54/// Request model for editing an existing Send.
55#[derive(Serialize, Deserialize, Debug)]
56#[serde(rename_all = "camelCase")]
57#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
58pub struct SendEditRequest {
59    /// The name of the Send.
60    pub name: String,
61    /// Optional notes visible to the sender.
62    pub notes: Option<String>,
63
64    /// The type and content of the Send.
65    pub view_type: SendViewType,
66
67    /// Maximum number of times the Send can be accessed.
68    pub max_access_count: Option<u32>,
69    /// Whether the Send is disabled and cannot be accessed.
70    pub disabled: bool,
71    /// Whether to hide the sender's email from recipients.
72    pub hide_email: bool,
73
74    /// Date and time when the Send will be permanently deleted.
75    pub deletion_date: DateTime<Utc>,
76    /// Optional date and time when the Send expires and can no longer be accessed.
77    pub expiration_date: Option<DateTime<Utc>>,
78
79    /// Authentication method for accessing this Send.
80    pub auth: SendAuthType,
81}
82
83/// Internal helper struct that includes the send key for encryption.
84/// The key is retrieved from state during edit operations.
85#[derive(Debug)]
86struct SendEditRequestWithKey {
87    request: SendEditRequest,
88    send_key: String,
89}
90
91impl CompositeEncryptable<KeyIds, SymmetricKeyId, bitwarden_api_api::models::SendRequestModel>
92    for SendEditRequestWithKey
93{
94    fn encrypt_composite(
95        &self,
96        ctx: &mut KeyStoreContext<KeyIds>,
97        key: SymmetricKeyId,
98    ) -> Result<bitwarden_api_api::models::SendRequestModel, CryptoError> {
99        // Decode the send key from the existing send
100        let k = B64Url::try_from(self.send_key.as_str())
101            .map_err(|_| CryptoError::InvalidKey)?
102            .as_bytes()
103            .to_vec();
104
105        let send_key = Send::derive_shareable_key(ctx, &k)?;
106
107        let (send_type, file, text) = self
108            .request
109            .view_type
110            .clone()
111            .encrypt_composite(ctx, send_key)?;
112
113        let (password, emails) = self.request.auth.auth_data(&k);
114
115        Ok(bitwarden_api_api::models::SendRequestModel {
116            r#type: Some(send_type),
117            auth_type: Some(self.request.auth.auth_type().into()),
118            file_length: None,
119            name: Some(self.request.name.encrypt(ctx, send_key)?.to_string()),
120            notes: self
121                .request
122                .notes
123                .as_ref()
124                .map(|n| n.encrypt(ctx, send_key))
125                .transpose()?
126                .map(|e| e.to_string()),
127            // Encrypt the send key itself with the user key
128            key: OctetStreamBytes::from(k).encrypt(ctx, key)?.to_string(),
129            max_access_count: self.request.max_access_count.map(|c| c as i32),
130            expiration_date: self.request.expiration_date.map(|d| d.to_rfc3339()),
131            deletion_date: self.request.deletion_date.to_rfc3339(),
132            file,
133            text,
134            password,
135            emails,
136            disabled: self.request.disabled,
137            hide_email: Some(self.request.hide_email),
138        })
139    }
140}
141
142impl IdentifyKey<SymmetricKeyId> for SendEditRequestWithKey {
143    fn key_identifier(&self) -> SymmetricKeyId {
144        SymmetricKeyId::User
145    }
146}
147
148async fn edit_send<R: Repository<Send> + ?Sized>(
149    key_store: &KeyStore<KeyIds>,
150    api_client: &bitwarden_api_api::apis::ApiClient,
151    repository: &R,
152    send_id: SendId,
153    request: SendEditRequest,
154) -> Result<SendView, EditSendError> {
155    request.auth.validate()?;
156
157    let id = send_id.to_string();
158
159    // Retrieve the existing send to get its key (keys cannot be modified during edit)
160    let existing_send = repository.get(send_id).await?.ok_or(ItemNotFoundError)?;
161
162    // Decrypt to get the key - we only need the key field
163    let existing_send_view: SendView = key_store.decrypt(&existing_send)?;
164    let send_key = existing_send_view.key.ok_or(MissingFieldError("key"))?;
165
166    // Create the wrapper with the key from the existing send
167    let request_with_key = SendEditRequestWithKey { request, send_key };
168
169    let send_request = key_store.encrypt(request_with_key)?;
170
171    let resp = api_client
172        .sends_api()
173        .put(&id, Some(send_request))
174        .await
175        .map_err(ApiError::from)?;
176
177    let send: Send = resp.try_into()?;
178
179    // Verify the server returned the correct send ID
180    if send.id != Some(send_id) {
181        return Err(EditSendError::IdMismatch {
182            expected: send_id.into(),
183            returned: send.id.map(Into::into),
184        });
185    }
186
187    repository.set(send_id, send.clone()).await?;
188
189    Ok(key_store.decrypt(&send)?)
190}
191
192#[cfg_attr(feature = "wasm", wasm_bindgen)]
193impl SendClient {
194    /// Edit the [Send] and save it to the server.
195    pub async fn edit(
196        &self,
197        send_id: SendId,
198        request: SendEditRequest,
199    ) -> Result<SendView, EditSendError> {
200        let key_store = self.client.internal.get_key_store();
201        let config = self.client.internal.get_api_configurations();
202        let repository = self.get_repository()?;
203
204        edit_send(
205            key_store,
206            &config.api_client,
207            repository.as_ref(),
208            send_id,
209            request,
210        )
211        .await
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use bitwarden_api_api::{apis::ApiClient, models::SendResponseModel};
218    use bitwarden_core::key_management::SymmetricKeyId;
219    use bitwarden_crypto::SymmetricKeyAlgorithm;
220    use bitwarden_test::MemoryRepository;
221    use chrono::{DateTime, Utc};
222    use uuid::uuid;
223
224    use super::*;
225    use crate::{AuthType, SendTextView, SendType, SendViewType};
226
227    #[tokio::test]
228    async fn test_edit_send() {
229        let store: KeyStore<KeyIds> = KeyStore::default();
230        {
231            let mut ctx = store.context_mut();
232            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
233            ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User)
234                .unwrap();
235        }
236
237        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
238
239        // Pre-populate the repository with an existing send by encrypting a SendView
240        let repository = MemoryRepository::<Send>::default();
241        let existing_send_view = SendView {
242            id: None, // No ID initially to allow key generation
243            access_id: None,
244            name: "original".to_string(),
245            notes: Some("original notes".to_string()),
246            key: None, // Generates a new key when first encrypted
247            new_password: None,
248            has_password: false,
249            r#type: SendType::Text,
250            file: None,
251            text: Some(SendTextView {
252                text: Some("original text".to_string()),
253                hidden: false,
254            }),
255            max_access_count: None,
256            access_count: 0,
257            disabled: false,
258            hide_email: false,
259            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
260            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
261            expiration_date: None,
262            emails: Vec::new(),
263            auth_type: AuthType::None,
264        };
265        let mut existing_send = store.encrypt(existing_send_view).unwrap();
266        existing_send.id = Some(crate::send::SendId::new(send_id)); // Set the ID after encryption
267        repository
268            .set(SendId::new(send_id), existing_send)
269            .await
270            .unwrap();
271
272        let api_client = ApiClient::new_mocked(move |mock| {
273            mock.sends_api
274                .expect_put()
275                .returning(move |_id, model| {
276                    let model = model.unwrap();
277                    Ok(SendResponseModel {
278                        id: Some(send_id),
279                        name: model.name,
280                        revision_date: Some("2025-01-02T00:00:00Z".to_string()),
281                        object: Some("send".to_string()),
282                        access_id: None,
283                        r#type: model.r#type,
284                        auth_type: model.auth_type,
285                        notes: model.notes,
286                        file: model.file,
287                        text: model.text,
288                        key: Some(model.key),
289                        max_access_count: model.max_access_count,
290                        access_count: Some(0),
291                        password: model.password,
292                        emails: model.emails,
293                        disabled: Some(model.disabled),
294                        expiration_date: model.expiration_date,
295                        deletion_date: Some(model.deletion_date),
296                        hide_email: model.hide_email,
297                    })
298                })
299                .once();
300        });
301
302        let result = edit_send(
303            &store,
304            &api_client,
305            &repository,
306            SendId::new(send_id),
307            SendEditRequest {
308                name: "updated".to_string(),
309                notes: Some("updated notes".to_string()),
310                view_type: SendViewType::Text(SendTextView {
311                    text: Some("updated text".to_string()),
312                    hidden: false,
313                }),
314                max_access_count: None,
315                disabled: false,
316                hide_email: false,
317                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
318                expiration_date: None,
319                auth: SendAuthType::None,
320            },
321        )
322        .await
323        .unwrap();
324
325        // Verify the result
326        assert_eq!(result.id, Some(crate::send::SendId::new(send_id)));
327        assert_eq!(result.name, "updated");
328        assert_eq!(result.notes, Some("updated notes".to_string()));
329        assert!(result.key.is_some(), "Expected a key");
330        assert_eq!(
331            result.revision_date,
332            "2025-01-02T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
333        );
334
335        // Confirm the send was updated in the repository
336        let stored = repository.get(SendId::new(send_id)).await.unwrap().unwrap();
337        assert_eq!(
338            store
339                .decrypt::<SymmetricKeyId, Send, SendView>(&stored)
340                .unwrap()
341                .name,
342            "updated"
343        );
344    }
345
346    #[tokio::test]
347    async fn test_edit_send_not_found() {
348        let store: KeyStore<KeyIds> = KeyStore::default();
349        {
350            let mut ctx = store.context_mut();
351            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
352            ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User)
353                .unwrap();
354        }
355
356        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
357        let repository = MemoryRepository::<Send>::default();
358        let api_client = ApiClient::new_mocked(move |_mock| {});
359
360        let result = edit_send(
361            &store,
362            &api_client,
363            &repository,
364            SendId::new(send_id),
365            SendEditRequest {
366                name: "test".to_string(),
367                notes: None,
368                view_type: SendViewType::Text(SendTextView {
369                    text: Some("test".to_string()),
370                    hidden: false,
371                }),
372                max_access_count: None,
373                disabled: false,
374                hide_email: false,
375                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
376                expiration_date: None,
377                auth: SendAuthType::None,
378            },
379        )
380        .await;
381
382        assert!(result.is_err());
383        assert!(matches!(
384            result.unwrap_err(),
385            EditSendError::ItemNotFound(_)
386        ));
387    }
388
389    #[tokio::test]
390    async fn test_edit_send_http_error() {
391        let store: KeyStore<KeyIds> = KeyStore::default();
392        {
393            let mut ctx = store.context_mut();
394            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
395            ctx.persist_symmetric_key(local_key_id, SymmetricKeyId::User)
396                .unwrap();
397        }
398
399        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
400
401        // Pre-populate the repository with an existing send by encrypting a SendView
402        let repository = MemoryRepository::<Send>::default();
403        let existing_send_view = SendView {
404            id: None, // No ID initially to allow key generation
405            access_id: None,
406            name: "original".to_string(),
407            notes: Some("original notes".to_string()),
408            key: None, // Generates a new key when first encrypted
409            new_password: None,
410            has_password: false,
411            r#type: SendType::Text,
412            file: None,
413            text: Some(SendTextView {
414                text: Some("original text".to_string()),
415                hidden: false,
416            }),
417            max_access_count: None,
418            access_count: 0,
419            disabled: false,
420            hide_email: false,
421            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
422            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
423            expiration_date: None,
424            emails: Vec::new(),
425            auth_type: AuthType::None,
426        };
427        let mut existing_send = store.encrypt(existing_send_view).unwrap();
428        existing_send.id = Some(crate::send::SendId::new(send_id)); // Set the ID after encryption
429        repository
430            .set(SendId::new(send_id), existing_send)
431            .await
432            .unwrap();
433
434        let api_client = ApiClient::new_mocked(move |mock| {
435            mock.sends_api.expect_put().returning(move |_id, _model| {
436                Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
437                    "Simulated error",
438                )))
439            });
440        });
441
442        let result = edit_send(
443            &store,
444            &api_client,
445            &repository,
446            SendId::new(send_id),
447            SendEditRequest {
448                name: "test".to_string(),
449                notes: None,
450                view_type: SendViewType::Text(SendTextView {
451                    text: Some("test".to_string()),
452                    hidden: false,
453                }),
454                max_access_count: None,
455                disabled: false,
456                hide_email: false,
457                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
458                expiration_date: None,
459                auth: SendAuthType::None,
460            },
461        )
462        .await;
463
464        assert!(result.is_err());
465        assert!(matches!(result.unwrap_err(), EditSendError::Api(_)));
466    }
467}