Skip to main content

bitwarden_vault/cipher/cipher_client/
restore.rs

1use bitwarden_api_api::{apis::ApiClient, models::CipherBulkRestoreRequestModel};
2use bitwarden_core::{ApiError, key_management::KeySlotIds};
3use bitwarden_crypto::{CryptoError, KeyStore};
4use bitwarden_error::bitwarden_error;
5use bitwarden_state::repository::{Repository, RepositoryError};
6use futures::future::OptionFuture;
7use thiserror::Error;
8#[cfg(feature = "wasm")]
9use wasm_bindgen::prelude::wasm_bindgen;
10
11use crate::{
12    Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, VaultParseError,
13    cipher::cipher::{PartialCipher, StrictDecrypt},
14};
15
16#[allow(missing_docs)]
17#[bitwarden_error(flat)]
18#[derive(Debug, Error)]
19pub enum RestoreCipherError {
20    #[error(transparent)]
21    Api(#[from] ApiError),
22    #[error(transparent)]
23    VaultParse(#[from] VaultParseError),
24    #[error(transparent)]
25    Repository(#[from] RepositoryError),
26    #[error(transparent)]
27    Crypto(#[from] CryptoError),
28}
29
30impl<T> From<bitwarden_api_api::apis::Error<T>> for RestoreCipherError {
31    fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
32        Self::Api(val.into())
33    }
34}
35
36/// Restores a soft-deleted cipher on the server.
37pub async fn restore<R: Repository<Cipher> + ?Sized>(
38    cipher_id: CipherId,
39    api_client: &ApiClient,
40    repository: &R,
41    key_store: &KeyStore<KeySlotIds>,
42    use_strict_decryption: bool,
43) -> Result<CipherView, RestoreCipherError> {
44    let api = api_client.ciphers_api();
45
46    let existing_cipher = repository.get(cipher_id).await?;
47    let cipher: Cipher = api
48        .put_restore(cipher_id.into())
49        .await?
50        .merge_with_cipher(existing_cipher)?;
51    repository.set(cipher_id, cipher.clone()).await?;
52
53    Ok(if use_strict_decryption {
54        key_store.decrypt(&StrictDecrypt(cipher))?
55    } else {
56        key_store.decrypt(&cipher)?
57    })
58}
59
60/// Restores multiple soft-deleted ciphers on the server.
61pub async fn restore_many<R: Repository<Cipher> + ?Sized>(
62    cipher_ids: Vec<CipherId>,
63    api_client: &ApiClient,
64    repository: &R,
65    key_store: &KeyStore<KeySlotIds>,
66    use_strict_decryption: bool,
67) -> Result<DecryptCipherListResult, RestoreCipherError> {
68    let api = api_client.ciphers_api();
69
70    let response_models: Vec<_> = api
71        .put_restore_many(Some(CipherBulkRestoreRequestModel {
72            ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(),
73            organization_id: None,
74        }))
75        .await?
76        .data
77        .into_iter()
78        .flatten()
79        .collect();
80
81    let mut ciphers = Vec::with_capacity(response_models.len());
82    for model in response_models {
83        let existing = OptionFuture::from(model.id.map(|id| repository.get(CipherId::new(id))))
84            .await
85            .transpose()?
86            .flatten();
87        ciphers.push(model.merge_with_cipher(existing)?);
88    }
89
90    for cipher in &ciphers {
91        if let Some(id) = cipher.id {
92            repository.set(id, cipher.clone()).await?;
93        }
94    }
95
96    Ok(if use_strict_decryption {
97        let wrapped: Vec<StrictDecrypt<Cipher>> = ciphers.into_iter().map(StrictDecrypt).collect();
98        let (successes, failures) = key_store.decrypt_list_with_failures(&wrapped);
99        DecryptCipherListResult {
100            successes,
101            failures: failures.into_iter().map(|f| f.0.clone()).collect(),
102        }
103    } else {
104        let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers);
105        DecryptCipherListResult {
106            successes,
107            failures: failures.into_iter().cloned().collect(),
108        }
109    })
110}
111
112#[allow(deprecated)]
113#[cfg_attr(feature = "wasm", wasm_bindgen)]
114impl CiphersClient {
115    /// Restores a soft-deleted cipher on the server.
116    pub async fn restore(&self, cipher_id: CipherId) -> Result<CipherView, RestoreCipherError> {
117        let api_client = &self.client.internal.get_api_configurations().api_client;
118        let key_store = self.client.internal.get_key_store();
119
120        restore(
121            cipher_id,
122            api_client,
123            &*self.get_repository()?,
124            key_store,
125            self.is_strict_decrypt().await,
126        )
127        .await
128    }
129
130    /// Restores multiple soft-deleted ciphers on the server.
131    pub async fn restore_many(
132        &self,
133        cipher_ids: Vec<CipherId>,
134    ) -> Result<DecryptCipherListResult, RestoreCipherError> {
135        let api_client = &self.client.internal.get_api_configurations().api_client;
136        let key_store = self.client.internal.get_key_store();
137        let repository = &*self.get_repository()?;
138
139        restore_many(
140            cipher_ids,
141            api_client,
142            repository,
143            key_store,
144            self.is_strict_decrypt().await,
145        )
146        .await
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use bitwarden_api_api::{
153        apis::ApiClient,
154        models::{
155            CipherMiniResponseModel, CipherMiniResponseModelListResponseModel, CipherResponseModel,
156        },
157    };
158    use bitwarden_collections::collection::CollectionId;
159    use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
160    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey, SymmetricKeyAlgorithm};
161    use bitwarden_state::repository::Repository;
162    use bitwarden_test::MemoryRepository;
163    use chrono::Utc;
164
165    use super::*;
166    use crate::{Cipher, CipherId, Login};
167
168    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
169    const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098";
170
171    fn setup_key_store() -> KeyStore<KeySlotIds> {
172        let store: KeyStore<KeySlotIds> = KeyStore::default();
173        #[allow(deprecated)]
174        let _ = store.context_mut().set_symmetric_key(
175            SymmetricKeySlotId::User,
176            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
177        );
178        store
179    }
180
181    fn generate_test_cipher() -> Cipher {
182        Cipher {
183            id: TEST_CIPHER_ID.parse().ok(),
184            name: Some("2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap()),
185            r#type: crate::CipherType::Login,
186            notes: Default::default(),
187            organization_id: Default::default(),
188            folder_id: Default::default(),
189            favorite: Default::default(),
190            reprompt: Default::default(),
191            fields: Default::default(),
192            collection_ids: Default::default(),
193            key: Default::default(),
194            login: Some(Login{
195                username: None,
196                password: None,
197                password_revision_date: None,
198                uris: None, totp: None,
199                autofill_on_page_load: None,
200                fido2_credentials: None,
201            }),
202            identity: Default::default(),
203            card: Default::default(),
204            secure_note: Default::default(),
205            ssh_key: Default::default(),
206            bank_account: Default::default(),
207            drivers_license: Default::default(),
208            passport: Default::default(),
209            organization_use_totp: Default::default(),
210            edit: Default::default(),
211            permissions: Default::default(),
212            view_password: Default::default(),
213            local_data: Default::default(),
214            attachments: Default::default(),
215            password_history: Default::default(),
216            creation_date: Default::default(),
217            deleted_date: Default::default(),
218            revision_date: Default::default(),
219            archived_date: Default::default(),
220            data: Default::default(),
221        }
222    }
223
224    #[tokio::test]
225    async fn test_restore() {
226        // Set up test ciphers in the repository.
227        let mut cipher_1 = generate_test_cipher();
228        cipher_1.deleted_date = Some(Utc::now());
229
230        let api_client = ApiClient::new_mocked(move |mock| {
231            mock.ciphers_api
232                .expect_put_restore()
233                .returning(move |_model| {
234                    Ok(CipherResponseModel {
235                        id: Some(TEST_CIPHER_ID.try_into().unwrap()),
236                        name: cipher_1.name.as_ref().map(ToString::to_string),
237                        r#type: Some(cipher_1.r#type.into()),
238                        creation_date: Some(cipher_1.creation_date.to_string()),
239                        revision_date: Some(Utc::now().to_string()),
240                        ..Default::default()
241                    })
242                });
243        });
244
245        let repository: MemoryRepository<Cipher> = Default::default();
246        let store: KeyStore<KeySlotIds> = KeyStore::default();
247        #[allow(deprecated)]
248        let _ = store.context_mut().set_symmetric_key(
249            SymmetricKeySlotId::User,
250            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
251        );
252
253        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
254        let mut cipher = generate_test_cipher();
255        cipher.deleted_date = Some(Utc::now());
256        cipher.collection_ids = vec![collection_id];
257
258        repository
259            .set(TEST_CIPHER_ID.parse().unwrap(), cipher)
260            .await
261            .unwrap();
262
263        let start_time = Utc::now();
264        let updated_cipher = restore(
265            TEST_CIPHER_ID.parse().unwrap(),
266            &api_client,
267            &repository,
268            &store,
269            false,
270        )
271        .await
272        .unwrap();
273
274        let end_time = Utc::now();
275        assert!(updated_cipher.deleted_date.is_none());
276        assert!(
277            updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time
278        );
279        // collection_ids are not returned by the server's restore response — they must be
280        // preserved.
281        assert_eq!(updated_cipher.collection_ids, vec![collection_id]);
282
283        let repo_cipher = repository
284            .get(TEST_CIPHER_ID.parse().unwrap())
285            .await
286            .unwrap()
287            .unwrap();
288        assert!(repo_cipher.deleted_date.is_none());
289        assert!(
290            repo_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time
291        );
292    }
293
294    #[tokio::test]
295    async fn test_restore_many() {
296        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
297        let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap();
298        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
299        let collection_id_2: CollectionId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
300        let mut cipher_1 = generate_test_cipher();
301        cipher_1.deleted_date = Some(Utc::now());
302        cipher_1.collection_ids = vec![collection_id];
303        let mut cipher_2 = generate_test_cipher();
304        cipher_2.deleted_date = Some(Utc::now());
305        cipher_2.id = Some(cipher_id_2);
306        cipher_2.collection_ids = vec![collection_id_2];
307
308        let api_client = {
309            let cipher_1 = cipher_1.clone();
310            let cipher_2 = cipher_2.clone();
311            ApiClient::new_mocked(move |mock| {
312                mock.ciphers_api.expect_put_restore_many().returning({
313                    move |_model| {
314                        Ok(CipherMiniResponseModelListResponseModel {
315                            object: None,
316                            data: Some(vec![
317                                CipherMiniResponseModel {
318                                    id: cipher_1.id.map(|id| id.into()),
319                                    name: cipher_1.name.as_ref().map(ToString::to_string),
320                                    r#type: Some(cipher_1.r#type.into()),
321                                    login: cipher_1.login.clone().map(|l| Box::new(l.into())),
322                                    creation_date: cipher_1.creation_date.to_string().into(),
323                                    deleted_date: None,
324                                    revision_date: Some(Utc::now().to_string()),
325                                    ..Default::default()
326                                },
327                                CipherMiniResponseModel {
328                                    id: cipher_2.id.map(|id| id.into()),
329                                    name: cipher_2.name.as_ref().map(ToString::to_string),
330                                    r#type: Some(cipher_2.r#type.into()),
331                                    login: cipher_2.login.clone().map(|l| Box::new(l.into())),
332                                    creation_date: cipher_2.creation_date.to_string().into(),
333                                    deleted_date: None,
334                                    revision_date: Some(Utc::now().to_string()),
335                                    ..Default::default()
336                                },
337                            ]),
338                            continuation_token: None,
339                        })
340                    }
341                });
342            })
343        };
344
345        let repository: MemoryRepository<Cipher> = Default::default();
346        let store: KeyStore<KeySlotIds> = KeyStore::default();
347        #[allow(deprecated)]
348        let _ = store.context_mut().set_symmetric_key(
349            SymmetricKeySlotId::User,
350            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
351        );
352
353        repository.set(cipher_id, cipher_1).await.unwrap();
354        repository.set(cipher_id_2, cipher_2).await.unwrap();
355
356        let start_time = Utc::now();
357        let ciphers = restore_many(
358            vec![cipher_id, cipher_id_2],
359            &api_client,
360            &repository,
361            &store,
362            false,
363        )
364        .await
365        .unwrap();
366        let end_time = Utc::now();
367
368        assert_eq!(ciphers.successes.len(), 2,);
369        assert_eq!(ciphers.failures.len(), 0,);
370        assert_eq!(ciphers.successes[0].deleted_date, None,);
371        assert_eq!(ciphers.successes[1].deleted_date, None,);
372
373        // Confirm repository was updated
374        let cipher_1 = repository.get(cipher_id).await.unwrap().unwrap();
375        let cipher_2 = repository.get(cipher_id_2).await.unwrap().unwrap();
376        assert!(cipher_1.deleted_date.is_none());
377        assert!(cipher_2.deleted_date.is_none());
378        assert!(cipher_1.revision_date >= start_time && cipher_1.revision_date <= end_time);
379        assert!(cipher_2.revision_date >= start_time && cipher_2.revision_date <= end_time);
380    }
381
382    #[tokio::test]
383    async fn test_restore_preserves_collection_ids() {
384        let store = setup_key_store();
385        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
386
387        let mut cipher = generate_test_cipher();
388        cipher.deleted_date = Some(Utc::now());
389        cipher.collection_ids = vec![collection_id];
390
391        let cipher_name = cipher
392            .name
393            .as_ref()
394            .map(ToString::to_string)
395            .unwrap_or_default();
396        let cipher_type = cipher.r#type;
397
398        let api_client = ApiClient::new_mocked(move |mock| {
399            mock.ciphers_api.expect_put_restore().returning(move |_| {
400                Ok(CipherResponseModel {
401                    id: Some(TEST_CIPHER_ID.try_into().unwrap()),
402                    name: Some(cipher_name.clone()),
403                    r#type: Some(cipher_type.into()),
404                    creation_date: Some("2025-01-01T00:00:00Z".to_string()),
405                    revision_date: Some(Utc::now().to_string()),
406                    ..Default::default()
407                })
408            });
409        });
410
411        let repository: MemoryRepository<Cipher> = Default::default();
412        repository
413            .set(TEST_CIPHER_ID.parse().unwrap(), cipher)
414            .await
415            .unwrap();
416
417        let result = restore(
418            TEST_CIPHER_ID.parse().unwrap(),
419            &api_client,
420            &repository,
421            &store,
422            false,
423        )
424        .await
425        .unwrap();
426
427        // collection_ids are not returned by the server's restore response — they must
428        // be preserved from the existing cipher in the repository.
429        assert_eq!(result.collection_ids, vec![collection_id]);
430    }
431
432    #[tokio::test]
433    async fn test_restore_many_preserves_collection_ids() {
434        let store = setup_key_store();
435        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
436        let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap();
437        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
438        let collection_id_2: CollectionId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
439
440        let mut cipher_1 = generate_test_cipher();
441        cipher_1.deleted_date = Some(Utc::now());
442        cipher_1.collection_ids = vec![collection_id];
443
444        let mut cipher_2 = generate_test_cipher();
445        cipher_2.id = Some(cipher_id_2);
446        cipher_2.deleted_date = Some(Utc::now());
447        cipher_2.collection_ids = vec![collection_id_2];
448
449        let api_client = {
450            let cipher_1 = cipher_1.clone();
451            let cipher_2 = cipher_2.clone();
452            ApiClient::new_mocked(move |mock| {
453                mock.ciphers_api.expect_put_restore_many().returning({
454                    move |_| {
455                        Ok(CipherMiniResponseModelListResponseModel {
456                            object: None,
457                            data: Some(vec![
458                                CipherMiniResponseModel {
459                                    id: cipher_1.id.map(|id| id.into()),
460                                    name: cipher_1.name.as_ref().map(ToString::to_string),
461                                    r#type: Some(cipher_1.r#type.into()),
462                                    login: cipher_1.login.clone().map(|l| Box::new(l.into())),
463                                    creation_date: cipher_1.creation_date.to_string().into(),
464                                    deleted_date: None,
465                                    revision_date: Some(Utc::now().to_string()),
466                                    ..Default::default()
467                                },
468                                CipherMiniResponseModel {
469                                    id: cipher_2.id.map(|id| id.into()),
470                                    name: cipher_2.name.as_ref().map(ToString::to_string),
471                                    r#type: Some(cipher_2.r#type.into()),
472                                    login: cipher_2.login.clone().map(|l| Box::new(l.into())),
473                                    creation_date: cipher_2.creation_date.to_string().into(),
474                                    deleted_date: None,
475                                    revision_date: Some(Utc::now().to_string()),
476                                    ..Default::default()
477                                },
478                            ]),
479                            continuation_token: None,
480                        })
481                    }
482                });
483            })
484        };
485
486        let repository: MemoryRepository<Cipher> = Default::default();
487        repository.set(cipher_id, cipher_1).await.unwrap();
488        repository.set(cipher_id_2, cipher_2).await.unwrap();
489
490        let ciphers = restore_many(
491            vec![cipher_id, cipher_id_2],
492            &api_client,
493            &repository,
494            &store,
495            false,
496        )
497        .await
498        .unwrap();
499
500        assert_eq!(ciphers.successes.len(), 2);
501
502        // collection_ids are not returned by the server's restore response — they must
503        // be preserved from the existing ciphers in the repository.
504        let result_1 = ciphers
505            .successes
506            .iter()
507            .find(|c| c.id == Some(cipher_id))
508            .unwrap();
509        let result_2 = ciphers
510            .successes
511            .iter()
512            .find(|c| c.id == Some(cipher_id_2))
513            .unwrap();
514        assert_eq!(result_1.collection_ids, vec![collection_id]);
515        assert_eq!(result_2.collection_ids, vec![collection_id_2]);
516    }
517}