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::KeyIds,
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,
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<KeyIds>,
52    api_client: &bitwarden_api_api::apis::ApiClient,
53    encrypted_for: UserId,
54    original_cipher_view: CipherView,
55    request: CipherEditRequest,
56) -> Result<CipherView, EditCipherAdminError> {
57    let cipher_id = request.id;
58    let request = CipherEditRequestInternal::new(request, &original_cipher_view);
59
60    let mut cipher_request = key_store.encrypt(request)?;
61    cipher_request.encrypted_for = Some(encrypted_for.into());
62
63    let orig_cipher = key_store.encrypt(original_cipher_view)?;
64
65    let cipher: Cipher = api_client
66        .ciphers_api()
67        .put_admin(cipher_id.into(), Some(cipher_request))
68        .await
69        .map_err(ApiError::from)?
70        .merge_with_cipher(Some(orig_cipher))?;
71
72    Ok(key_store.decrypt(&cipher)?)
73}
74
75/// Adds the cipher matched by [CipherId] to any number of collections on the server.
76pub async fn add_to_collections(
77    cipher_id: CipherId,
78    collection_ids: Vec<CollectionId>,
79    api_client: &ApiClient,
80    key_store: &KeyStore<KeyIds>,
81) -> Result<CipherView, EditCipherAdminError> {
82    let req = CipherCollectionsRequestModel {
83        collection_ids: collection_ids
84            .into_iter()
85            .map(|id| id.to_string())
86            .collect(),
87    };
88
89    let api = api_client.ciphers_api();
90    let cipher: Cipher = api
91        .put_collections_admin(&cipher_id.to_string(), Some(req))
92        .await?
93        .merge_with_cipher(None)?;
94
95    Ok(key_store.decrypt(&cipher)?)
96}
97
98#[cfg_attr(feature = "wasm", wasm_bindgen)]
99impl CipherAdminClient {
100    /// Edit an existing [Cipher] and save it to the server.
101    pub async fn edit(
102        &self,
103        mut request: CipherEditRequest,
104        original_cipher_view: CipherView,
105    ) -> Result<CipherView, EditCipherAdminError> {
106        let key_store = self.client.internal.get_key_store();
107        let config = self.client.internal.get_api_configurations();
108
109        let user_id = self
110            .client
111            .internal
112            .get_user_id()
113            .ok_or(NotAuthenticatedError)?;
114
115        // TODO: Once this flag is removed, the key generation logic should
116        // be moved closer to the actual encryption logic.
117        if request.key.is_none()
118            && self
119                .client
120                .internal
121                .get_flags()
122                .enable_cipher_key_encryption
123        {
124            let key = request.key_identifier();
125            request.generate_cipher_key(&mut key_store.context(), key)?;
126        }
127
128        edit_cipher(
129            key_store,
130            &config.api_client,
131            user_id,
132            original_cipher_view,
133            request,
134        )
135        .await
136    }
137
138    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
139    pub async fn update_collection(
140        &self,
141        cipher_id: CipherId,
142        collection_ids: Vec<CollectionId>,
143    ) -> Result<CipherView, EditCipherAdminError> {
144        add_to_collections(
145            cipher_id,
146            collection_ids,
147            &self.client.internal.get_api_configurations().api_client,
148            self.client.internal.get_key_store(),
149        )
150        .await
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use bitwarden_api_api::{apis::ApiClient, models::CipherMiniResponseModel};
157    use bitwarden_core::key_management::SymmetricKeyId;
158    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey};
159
160    use super::*;
161    use crate::{CipherId, CipherRepromptType, CipherType, LoginView};
162
163    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
164    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
165
166    fn generate_test_cipher() -> CipherView {
167        CipherView {
168            id: Some(TEST_CIPHER_ID.parse().unwrap()),
169            organization_id: None,
170            folder_id: None,
171            collection_ids: vec![],
172            key: None,
173            name: "Test Login".to_string(),
174            notes: None,
175            r#type: CipherType::Login,
176            login: Some(LoginView {
177                username: Some("[email protected]".to_string()),
178                password: Some("password123".to_string()),
179                password_revision_date: None,
180                uris: None,
181                totp: None,
182                autofill_on_page_load: None,
183                fido2_credentials: None,
184            }),
185            identity: None,
186            card: None,
187            secure_note: None,
188            ssh_key: None,
189            favorite: false,
190            reprompt: CipherRepromptType::None,
191            organization_use_totp: true,
192            edit: true,
193            permissions: None,
194            view_password: true,
195            local_data: None,
196            attachments: None,
197            attachment_decryption_failures: None,
198            fields: None,
199            password_history: None,
200            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
201            deleted_date: None,
202            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
203            archived_date: None,
204        }
205    }
206
207    #[tokio::test]
208    async fn test_edit_cipher() {
209        let store: KeyStore<KeyIds> = KeyStore::default();
210        #[allow(deprecated)]
211        let _ = store.context_mut().set_symmetric_key(
212            SymmetricKeyId::User,
213            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
214        );
215
216        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
217
218        let api_client = ApiClient::new_mocked(move |mock| {
219            mock.ciphers_api
220                .expect_put_admin()
221                .returning(move |_id, body| {
222                    let body = body.unwrap();
223                    Ok(CipherMiniResponseModel {
224                        object: Some("cipher".to_string()),
225                        id: Some(cipher_id.into()),
226                        name: Some(body.name),
227                        r#type: body.r#type,
228                        organization_id: body
229                            .organization_id
230                            .as_ref()
231                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
232                        reprompt: body.reprompt,
233                        key: body.key,
234                        notes: body.notes,
235                        organization_use_totp: Some(true),
236                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
237                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
238                        deleted_date: None,
239                        login: body.login,
240                        card: body.card,
241                        identity: body.identity,
242                        secure_note: body.secure_note,
243                        ssh_key: body.ssh_key,
244                        fields: body.fields,
245                        password_history: body.password_history,
246                        attachments: None,
247                        data: None,
248                    })
249                })
250                .once();
251        });
252
253        let original_cipher_view = generate_test_cipher();
254        let mut cipher_view = original_cipher_view.clone();
255        cipher_view.name = "New Cipher Name".to_string();
256
257        let request: CipherEditRequest = cipher_view.try_into().unwrap();
258
259        let result = edit_cipher(
260            &store,
261            &api_client,
262            TEST_USER_ID.parse().unwrap(),
263            original_cipher_view,
264            request,
265        )
266        .await
267        .unwrap();
268
269        assert_eq!(result.id, Some(cipher_id));
270        assert_eq!(result.name, "New Cipher Name");
271    }
272
273    #[tokio::test]
274    async fn test_edit_cipher_http_error() {
275        let store: KeyStore<KeyIds> = KeyStore::default();
276        #[allow(deprecated)]
277        let _ = store.context_mut().set_symmetric_key(
278            SymmetricKeyId::User,
279            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
280        );
281
282        let api_client = ApiClient::new_mocked(move |mock| {
283            mock.ciphers_api
284                .expect_put_admin()
285                .returning(move |_id, _body| {
286                    Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
287                        "Simulated error",
288                    )))
289                });
290        });
291        let orig_cipher_view = generate_test_cipher();
292        let cipher_view = orig_cipher_view.clone();
293        let request: CipherEditRequest = cipher_view.try_into().unwrap();
294        let result = edit_cipher(
295            &store,
296            &api_client,
297            TEST_USER_ID.parse().unwrap(),
298            orig_cipher_view,
299            request,
300        )
301        .await;
302
303        assert!(result.is_err());
304        assert!(matches!(result.unwrap_err(), EditCipherAdminError::Api(_)));
305    }
306}