Skip to main content

bitwarden_send/
edit.rs

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