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