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