Skip to main content

bitwarden_vault/cipher/cipher_client/admin/
edit.rs

1use bitwarden_api_api::{apis::ApiClient, models::CipherCollectionsRequestModel};
2use bitwarden_collections::collection::CollectionId;
3use bitwarden_core::{
4    ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeySlotIds,
5};
6use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore};
7use bitwarden_error::bitwarden_error;
8use bitwarden_state::repository::RepositoryError;
9use thiserror::Error;
10#[cfg(feature = "wasm")]
11use wasm_bindgen::prelude::*;
12
13use super::CipherAdminClient;
14use crate::{
15    Cipher, CipherId, CipherView, DecryptError, ItemNotFoundError, VaultParseError,
16    cipher::cipher::{PartialCipher, StrictDecrypt},
17    cipher_client::edit::{CipherEditRequest, CipherEditRequestInternal},
18};
19
20#[allow(missing_docs)]
21#[bitwarden_error(flat)]
22#[derive(Debug, Error)]
23pub enum EditCipherAdminError {
24    #[error(transparent)]
25    ItemNotFound(#[from] ItemNotFoundError),
26    #[error(transparent)]
27    Crypto(#[from] CryptoError),
28    #[error(transparent)]
29    Api(#[from] ApiError),
30    #[error(transparent)]
31    VaultParse(#[from] VaultParseError),
32    #[error(transparent)]
33    MissingField(#[from] MissingFieldError),
34    #[error(transparent)]
35    NotAuthenticated(#[from] NotAuthenticatedError),
36    #[error(transparent)]
37    Repository(#[from] RepositoryError),
38    #[error(transparent)]
39    Uuid(#[from] uuid::Error),
40    #[error(transparent)]
41    Decrypt(#[from] DecryptError),
42}
43
44impl<T> From<bitwarden_api_api::apis::Error<T>> for EditCipherAdminError {
45    fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
46        Self::Api(val.into())
47    }
48}
49
50async fn edit_cipher(
51    key_store: &KeyStore<KeySlotIds>,
52    api_client: &bitwarden_api_api::apis::ApiClient,
53    encrypted_for: UserId,
54    original_cipher_view: CipherView,
55    request: CipherEditRequest,
56    use_strict_decryption: bool,
57) -> Result<CipherView, EditCipherAdminError> {
58    let cipher_id = request.id;
59    // CipherMiniResponseModel does not include folder_id or favorite — save them from the
60    // request before it is consumed so they can be applied to the merged result.
61    let folder_id = request.folder_id;
62    let favorite = request.favorite;
63    let request = CipherEditRequestInternal::new(request, &original_cipher_view);
64
65    let mut cipher_request = key_store.encrypt(request)?;
66    cipher_request.encrypted_for = Some(encrypted_for.into());
67
68    let orig_cipher = key_store.encrypt(original_cipher_view)?;
69
70    let mut cipher: Cipher = api_client
71        .ciphers_api()
72        .put_admin(cipher_id.into(), Some(cipher_request))
73        .await
74        .map_err(ApiError::from)?
75        .merge_with_cipher(Some(orig_cipher))?;
76
77    cipher.folder_id = folder_id;
78    cipher.favorite = favorite;
79
80    if use_strict_decryption {
81        Ok(key_store.decrypt(&StrictDecrypt(cipher))?)
82    } else {
83        Ok(key_store.decrypt(&cipher)?)
84    }
85}
86
87/// Adds the cipher matched by [CipherId] to any number of collections on the server.
88pub async fn add_to_collections(
89    cipher_id: CipherId,
90    collection_ids: Vec<CollectionId>,
91    api_client: &ApiClient,
92    key_store: &KeyStore<KeySlotIds>,
93    use_strict_decryption: bool,
94) -> Result<CipherView, EditCipherAdminError> {
95    let req = CipherCollectionsRequestModel {
96        collection_ids: collection_ids
97            .into_iter()
98            .map(|id| id.to_string())
99            .collect(),
100    };
101
102    let api = api_client.ciphers_api();
103    let cipher: Cipher = api
104        .put_collections_admin(&cipher_id.to_string(), Some(req))
105        .await?
106        .merge_with_cipher(None)?;
107
108    if use_strict_decryption {
109        Ok(key_store.decrypt(&StrictDecrypt(cipher))?)
110    } else {
111        Ok(key_store.decrypt(&cipher)?)
112    }
113}
114
115#[cfg_attr(feature = "wasm", wasm_bindgen)]
116impl CipherAdminClient {
117    /// Edit an existing [Cipher] and save it to the server.
118    pub async fn edit(
119        &self,
120        mut request: CipherEditRequest,
121        original_cipher_view: CipherView,
122    ) -> Result<CipherView, EditCipherAdminError> {
123        let key_store = self.client.internal.get_key_store();
124        let config = self.client.internal.get_api_configurations();
125
126        let user_id = self
127            .client
128            .internal
129            .get_user_id()
130            .ok_or(NotAuthenticatedError)?;
131
132        // TODO: Once this flag is removed, the key generation logic should
133        // be moved closer to the actual encryption logic.
134        if request.key.is_none()
135            && self
136                .client
137                .internal
138                .get_flags()
139                .await
140                .enable_cipher_key_encryption
141        {
142            let key = request.key_identifier();
143            request.generate_cipher_key(&mut key_store.context(), key)?;
144        }
145
146        edit_cipher(
147            key_store,
148            &config.api_client,
149            user_id,
150            original_cipher_view,
151            request,
152            self.is_strict_decrypt().await,
153        )
154        .await
155    }
156
157    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
158    pub async fn update_collection(
159        &self,
160        cipher_id: CipherId,
161        collection_ids: Vec<CollectionId>,
162    ) -> Result<CipherView, EditCipherAdminError> {
163        add_to_collections(
164            cipher_id,
165            collection_ids,
166            &self.client.internal.get_api_configurations().api_client,
167            self.client.internal.get_key_store(),
168            self.is_strict_decrypt().await,
169        )
170        .await
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use bitwarden_api_api::{apis::ApiClient, models::CipherMiniResponseModel};
177    use bitwarden_core::key_management::SymmetricKeySlotId;
178    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey};
179
180    use super::*;
181    use crate::{CipherId, CipherRepromptType, CipherType, LoginView};
182
183    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
184    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
185
186    fn generate_test_cipher() -> CipherView {
187        CipherView {
188            id: Some(TEST_CIPHER_ID.parse().unwrap()),
189            organization_id: None,
190            folder_id: None,
191            collection_ids: vec![],
192            key: None,
193            name: "Test Login".to_string(),
194            notes: None,
195            r#type: CipherType::Login,
196            login: Some(LoginView {
197                username: Some("[email protected]".to_string()),
198                password: Some("password123".to_string()),
199                password_revision_date: None,
200                uris: None,
201                totp: None,
202                autofill_on_page_load: None,
203                fido2_credentials: None,
204            }),
205            identity: None,
206            card: None,
207            secure_note: None,
208            ssh_key: None,
209            bank_account: None,
210            favorite: false,
211            reprompt: CipherRepromptType::None,
212            organization_use_totp: true,
213            edit: true,
214            permissions: None,
215            view_password: true,
216            local_data: None,
217            attachments: None,
218            attachment_decryption_failures: None,
219            fields: None,
220            password_history: None,
221            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
222            deleted_date: None,
223            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
224            archived_date: None,
225        }
226    }
227
228    #[tokio::test]
229    async fn test_edit_cipher() {
230        let store: KeyStore<KeySlotIds> = KeyStore::default();
231        #[allow(deprecated)]
232        let _ = store.context_mut().set_symmetric_key(
233            SymmetricKeySlotId::User,
234            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
235        );
236
237        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
238
239        let api_client = ApiClient::new_mocked(move |mock| {
240            mock.ciphers_api
241                .expect_put_admin()
242                .returning(move |_id, body| {
243                    let body = body.unwrap();
244                    Ok(CipherMiniResponseModel {
245                        object: Some("cipher".to_string()),
246                        id: Some(cipher_id.into()),
247                        name: Some(body.name),
248                        r#type: body.r#type,
249                        organization_id: body
250                            .organization_id
251                            .as_ref()
252                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
253                        reprompt: body.reprompt,
254                        key: body.key,
255                        notes: body.notes,
256                        organization_use_totp: Some(true),
257                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
258                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
259                        deleted_date: None,
260                        login: body.login,
261                        card: body.card,
262                        identity: body.identity,
263                        secure_note: body.secure_note,
264                        ssh_key: body.ssh_key,
265                        bank_account: body.bank_account,
266                        fields: body.fields,
267                        password_history: body.password_history,
268                        attachments: None,
269                        data: None,
270                    })
271                })
272                .once();
273        });
274
275        let folder_a: crate::FolderId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
276        let folder_b: crate::FolderId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
277
278        let mut original_cipher_view = generate_test_cipher();
279        original_cipher_view.folder_id = Some(folder_a);
280        let mut cipher_view = original_cipher_view.clone();
281        cipher_view.name = "New Cipher Name".to_string();
282        // Change folder: request carries folder_b, original has folder_a.
283        cipher_view.folder_id = Some(folder_b);
284
285        let request: CipherEditRequest = cipher_view.try_into().unwrap();
286
287        let result = edit_cipher(
288            &store,
289            &api_client,
290            TEST_USER_ID.parse().unwrap(),
291            original_cipher_view,
292            request,
293            false,
294        )
295        .await
296        .unwrap();
297
298        assert_eq!(result.id, Some(cipher_id));
299        assert_eq!(result.name, "New Cipher Name");
300        // folder_id must come from the request, not from the original cipher.
301        assert_eq!(result.folder_id, Some(folder_b));
302    }
303
304    #[tokio::test]
305    async fn test_edit_cipher_http_error() {
306        let store: KeyStore<KeySlotIds> = KeyStore::default();
307        #[allow(deprecated)]
308        let _ = store.context_mut().set_symmetric_key(
309            SymmetricKeySlotId::User,
310            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
311        );
312
313        let api_client = ApiClient::new_mocked(move |mock| {
314            mock.ciphers_api
315                .expect_put_admin()
316                .returning(move |_id, _body| {
317                    Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
318                        "Simulated error",
319                    )))
320                });
321        });
322        let orig_cipher_view = generate_test_cipher();
323        let cipher_view = orig_cipher_view.clone();
324        let request: CipherEditRequest = cipher_view.try_into().unwrap();
325        let result = edit_cipher(
326            &store,
327            &api_client,
328            TEST_USER_ID.parse().unwrap(),
329            orig_cipher_view,
330            request,
331            false,
332        )
333        .await;
334
335        assert!(result.is_err());
336        assert!(matches!(result.unwrap_err(), EditCipherAdminError::Api(_)));
337    }
338}