Skip to main content

bitwarden_vault/cipher/cipher_client/admin/
edit.rs

1use bitwarden_api_api::{
2    apis::ApiClient,
3    models::{CipherCollectionsRequestModel, CipherRequestModel},
4};
5use bitwarden_collections::collection::CollectionId;
6use bitwarden_core::{
7    ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeySlotIds,
8};
9use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore};
10use bitwarden_error::bitwarden_error;
11use bitwarden_state::repository::RepositoryError;
12use thiserror::Error;
13#[cfg(feature = "wasm")]
14use wasm_bindgen::prelude::*;
15
16use super::CipherAdminClient;
17use crate::{
18    Cipher, CipherId, CipherView, DecryptError, ItemNotFoundError, VaultParseError,
19    cipher::cipher::{EncryptMode, PartialCipher, StrictDecrypt},
20    cipher_client::{
21        edit::{CipherEditRequest, convert_request_to_cipher_view},
22        should_use_blob_encryption,
23    },
24};
25
26#[allow(missing_docs)]
27#[bitwarden_error(flat)]
28#[derive(Debug, Error)]
29pub enum EditCipherAdminError {
30    #[error(transparent)]
31    ItemNotFound(#[from] ItemNotFoundError),
32    #[error(transparent)]
33    Crypto(#[from] CryptoError),
34    #[error(transparent)]
35    Api(#[from] ApiError),
36    #[error(transparent)]
37    VaultParse(#[from] VaultParseError),
38    #[error(transparent)]
39    MissingField(#[from] MissingFieldError),
40    #[error(transparent)]
41    NotAuthenticated(#[from] NotAuthenticatedError),
42    #[error(transparent)]
43    Repository(#[from] RepositoryError),
44    #[error(transparent)]
45    Uuid(#[from] uuid::Error),
46    #[error(transparent)]
47    Decrypt(#[from] DecryptError),
48}
49
50impl<T> From<bitwarden_api_api::apis::Error<T>> for EditCipherAdminError {
51    fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
52        Self::Api(val.into())
53    }
54}
55
56// `use_strict_decryption`, `enable_cipher_key_encryption`, and `use_blob` are
57// short-lived feature-rollout flags that will be removed once their migrations
58// complete, at which point the argument count drops back under the limit.
59#[allow(clippy::too_many_arguments)]
60async fn edit_cipher(
61    key_store: &KeyStore<KeySlotIds>,
62    api_client: &bitwarden_api_api::apis::ApiClient,
63    encrypted_for: UserId,
64    original_cipher_view: CipherView,
65    request: CipherEditRequest,
66    use_strict_decryption: bool,
67    enable_cipher_key_encryption: bool,
68    use_blob: bool,
69) -> Result<CipherView, EditCipherAdminError> {
70    let cipher_id = request.id;
71    // CipherMiniResponseModel does not include folder_id or favorite — save them from the
72    // request before it is consumed so they can be applied to the merged result.
73    let folder_id = request.folder_id;
74    let favorite = request.favorite;
75
76    let mut view: CipherView = convert_request_to_cipher_view(request);
77    view.update_password_history(&original_cipher_view);
78
79    // TODO: Once this flag is removed, the key generation logic should be
80    // moved directly into the CompositeEncryptable implementation.
81    if view.key.is_none() && enable_cipher_key_encryption {
82        let key = view.key_identifier();
83        view.generate_cipher_key(&mut key_store.context(), key)?;
84    }
85
86    // Admin endpoints operate on organization-owned ciphers, which aren't
87    // expected to use blob encryption yet — `should_use_blob_encryption`
88    // returns `false` for any `Some(org)` today. Routing through the same
89    // dispatcher means org blob support (PM-32430) flips on automatically
90    // here when the helper learns to return `true` for orgs.
91    let mode = if use_blob {
92        EncryptMode::Blob(view)
93    } else {
94        EncryptMode::Legacy(view)
95    };
96    let cipher: Cipher = key_store.encrypt(mode)?;
97    let mut cipher_request: CipherRequestModel = cipher.try_into()?;
98    cipher_request.encrypted_for = Some(encrypted_for.into());
99
100    let orig_mode = if use_blob {
101        EncryptMode::Blob(original_cipher_view)
102    } else {
103        EncryptMode::Legacy(original_cipher_view)
104    };
105    let orig_cipher = key_store.encrypt(orig_mode)?;
106
107    let mut cipher: Cipher = api_client
108        .ciphers_api()
109        .put_admin(cipher_id.into(), Some(cipher_request))
110        .await
111        .map_err(ApiError::from)?
112        .merge_with_cipher(Some(orig_cipher))?;
113
114    cipher.folder_id = folder_id;
115    cipher.favorite = favorite;
116
117    Ok(if use_strict_decryption {
118        key_store.decrypt(&StrictDecrypt(cipher))?
119    } else {
120        key_store.decrypt(&cipher)?
121    })
122}
123
124/// Adds the cipher matched by [CipherId] to any number of collections on the server.
125pub async fn add_to_collections(
126    cipher_id: CipherId,
127    collection_ids: Vec<CollectionId>,
128    api_client: &ApiClient,
129    key_store: &KeyStore<KeySlotIds>,
130    use_strict_decryption: bool,
131) -> Result<CipherView, EditCipherAdminError> {
132    let req = CipherCollectionsRequestModel {
133        collection_ids: collection_ids
134            .into_iter()
135            .map(|id| id.to_string())
136            .collect(),
137    };
138
139    let api = api_client.ciphers_api();
140    let cipher: Cipher = api
141        .put_collections_admin(&cipher_id.to_string(), Some(req))
142        .await?
143        .merge_with_cipher(None)?;
144
145    Ok(if use_strict_decryption {
146        key_store.decrypt(&StrictDecrypt(cipher))?
147    } else {
148        key_store.decrypt(&cipher)?
149    })
150}
151
152#[allow(deprecated)]
153#[cfg_attr(feature = "wasm", wasm_bindgen)]
154impl CipherAdminClient {
155    /// Edit an existing [Cipher] and save it to the server.
156    pub async fn edit(
157        &self,
158        request: CipherEditRequest,
159        original_cipher_view: CipherView,
160    ) -> Result<CipherView, EditCipherAdminError> {
161        let key_store = self.client.internal.get_key_store();
162        let config = self.client.internal.get_api_configurations();
163
164        let user_id = self
165            .client
166            .internal
167            .get_user_id()
168            .ok_or(NotAuthenticatedError)?;
169
170        let enable_cipher_key_encryption =
171            self.client.flags().get().await.enable_cipher_key_encryption;
172
173        let use_blob = should_use_blob_encryption(&self.client, request.organization_id);
174
175        edit_cipher(
176            key_store,
177            &config.api_client,
178            user_id,
179            original_cipher_view,
180            request,
181            self.is_strict_decrypt().await,
182            enable_cipher_key_encryption,
183            use_blob,
184        )
185        .await
186    }
187
188    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
189    pub async fn update_collection(
190        &self,
191        cipher_id: CipherId,
192        collection_ids: Vec<CollectionId>,
193    ) -> Result<CipherView, EditCipherAdminError> {
194        add_to_collections(
195            cipher_id,
196            collection_ids,
197            &self.client.internal.get_api_configurations().api_client,
198            self.client.internal.get_key_store(),
199            self.is_strict_decrypt().await,
200        )
201        .await
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use bitwarden_api_api::{apis::ApiClient, models::CipherMiniResponseModel};
208    use bitwarden_core::key_management::SymmetricKeySlotId;
209    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey, SymmetricKeyAlgorithm};
210
211    use super::*;
212    use crate::{CipherId, CipherRepromptType, CipherType, LoginView};
213
214    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
215    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
216
217    fn generate_test_cipher() -> CipherView {
218        CipherView {
219            id: Some(TEST_CIPHER_ID.parse().unwrap()),
220            organization_id: None,
221            folder_id: None,
222            collection_ids: vec![],
223            key: None,
224            name: "Test Login".to_string(),
225            notes: None,
226            r#type: CipherType::Login,
227            login: Some(LoginView {
228                username: Some("[email protected]".to_string()),
229                password: Some("password123".to_string()),
230                password_revision_date: None,
231                uris: None,
232                totp: None,
233                autofill_on_page_load: None,
234                fido2_credentials: None,
235            }),
236            identity: None,
237            card: None,
238            secure_note: None,
239            ssh_key: None,
240            bank_account: None,
241            drivers_license: None,
242            passport: None,
243            favorite: false,
244            reprompt: CipherRepromptType::None,
245            organization_use_totp: true,
246            edit: true,
247            permissions: None,
248            view_password: true,
249            local_data: None,
250            attachments: None,
251            attachment_decryption_failures: None,
252            fields: None,
253            password_history: None,
254            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
255            deleted_date: None,
256            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
257            archived_date: None,
258        }
259    }
260
261    #[tokio::test]
262    async fn test_edit_cipher() {
263        let store: KeyStore<KeySlotIds> = KeyStore::default();
264        #[allow(deprecated)]
265        let _ = store.context_mut().set_symmetric_key(
266            SymmetricKeySlotId::User,
267            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
268        );
269
270        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
271
272        let api_client = ApiClient::new_mocked(move |mock| {
273            mock.ciphers_api
274                .expect_put_admin()
275                .returning(move |_id, body| {
276                    let body = body.unwrap();
277                    Ok(CipherMiniResponseModel {
278                        object: Some("cipher".to_string()),
279                        id: Some(cipher_id.into()),
280                        name: Some(body.name),
281                        r#type: body.r#type,
282                        organization_id: body
283                            .organization_id
284                            .as_ref()
285                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
286                        reprompt: body.reprompt,
287                        key: body.key,
288                        notes: body.notes,
289                        organization_use_totp: Some(true),
290                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
291                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
292                        deleted_date: None,
293                        login: body.login,
294                        card: body.card,
295                        identity: body.identity,
296                        secure_note: body.secure_note,
297                        ssh_key: body.ssh_key,
298                        bank_account: body.bank_account,
299                        drivers_license: body.drivers_license,
300                        passport: body.passport,
301                        fields: body.fields,
302                        password_history: body.password_history,
303                        attachments: None,
304                        data: None,
305                    })
306                })
307                .once();
308        });
309
310        let folder_a: crate::FolderId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
311        let folder_b: crate::FolderId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
312
313        let mut original_cipher_view = generate_test_cipher();
314        original_cipher_view.folder_id = Some(folder_a);
315        let mut cipher_view = original_cipher_view.clone();
316        cipher_view.name = "New Cipher Name".to_string();
317        // Change folder: request carries folder_b, original has folder_a.
318        cipher_view.folder_id = Some(folder_b);
319
320        let request: CipherEditRequest = cipher_view.try_into().unwrap();
321
322        let result = edit_cipher(
323            &store,
324            &api_client,
325            TEST_USER_ID.parse().unwrap(),
326            original_cipher_view,
327            request,
328            false,
329            false,
330            false,
331        )
332        .await
333        .unwrap();
334
335        assert_eq!(result.id, Some(cipher_id));
336        assert_eq!(result.name, "New Cipher Name");
337        // folder_id must come from the request, not from the original cipher.
338        assert_eq!(result.folder_id, Some(folder_b));
339    }
340
341    /// A blob edit must use the `data` blob the server returns, not the stale
342    /// pre-edit blob re-sealed from the original view.
343    #[tokio::test]
344    async fn test_edit_cipher_blob_uses_echoed_data() {
345        let store: KeyStore<KeySlotIds> = KeyStore::default();
346        #[allow(deprecated)]
347        let _ = store.context_mut().set_symmetric_key(
348            SymmetricKeySlotId::User,
349            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
350        );
351
352        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
353
354        // Echo the request's blob (`key` + `data`) back, as the server does.
355        let api_client = ApiClient::new_mocked(move |mock| {
356            mock.ciphers_api
357                .expect_put_admin()
358                .returning(move |_id, body| {
359                    let body = body.unwrap();
360                    Ok(CipherMiniResponseModel {
361                        id: Some(cipher_id.into()),
362                        r#type: body.r#type,
363                        key: body.key,
364                        data: body.data,
365                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
366                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
367                        ..Default::default()
368                    })
369                })
370                .once();
371        });
372
373        let original_cipher_view = generate_test_cipher();
374        let mut cipher_view = original_cipher_view.clone();
375        cipher_view.name = "New Cipher Name".to_string();
376
377        let request: CipherEditRequest = cipher_view.try_into().unwrap();
378
379        let result = edit_cipher(
380            &store,
381            &api_client,
382            TEST_USER_ID.parse().unwrap(),
383            original_cipher_view,
384            request,
385            false,
386            false,
387            true, // use_blob
388        )
389        .await
390        .unwrap();
391
392        // The edited name lives inside the blob, so recovering it proves the
393        // echoed blob was used rather than the stale original.
394        assert_eq!(result.name, "New Cipher Name");
395    }
396
397    #[tokio::test]
398    async fn test_edit_cipher_http_error() {
399        let store: KeyStore<KeySlotIds> = KeyStore::default();
400        #[allow(deprecated)]
401        let _ = store.context_mut().set_symmetric_key(
402            SymmetricKeySlotId::User,
403            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
404        );
405
406        let api_client = ApiClient::new_mocked(move |mock| {
407            mock.ciphers_api
408                .expect_put_admin()
409                .returning(move |_id, _body| Err(std::io::Error::other("Simulated error").into()));
410        });
411        let orig_cipher_view = generate_test_cipher();
412        let cipher_view = orig_cipher_view.clone();
413        let request: CipherEditRequest = cipher_view.try_into().unwrap();
414        let result = edit_cipher(
415            &store,
416            &api_client,
417            TEST_USER_ID.parse().unwrap(),
418            orig_cipher_view,
419            request,
420            false,
421            false,
422            false,
423        )
424        .await;
425
426        assert!(result.is_err());
427        assert!(matches!(result.unwrap_err(), EditCipherAdminError::Api(_)));
428    }
429}