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