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