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