Skip to main content

bitwarden_vault/cipher/cipher_client/
create.rs

1use bitwarden_api_api::models::{CipherCreateRequestModel, CipherRequestModel};
2use bitwarden_collections::collection::CollectionId;
3use bitwarden_core::{
4    ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId,
5    key_management::KeySlotIds, require,
6};
7use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore};
8use bitwarden_error::bitwarden_error;
9use bitwarden_state::repository::{Repository, RepositoryError};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13#[cfg(feature = "wasm")]
14use tsify::Tsify;
15#[cfg(feature = "wasm")]
16use wasm_bindgen::prelude::*;
17
18use super::CiphersClient;
19use crate::{
20    Cipher, CipherRepromptType, CipherView, FieldView, FolderId, VaultParseError,
21    cipher::cipher::{EncryptMode, PartialCipher, StrictDecrypt},
22    cipher_view_type::CipherViewType,
23};
24
25#[allow(missing_docs)]
26#[bitwarden_error(flat)]
27#[derive(Debug, Error)]
28pub enum CreateCipherError {
29    #[error(transparent)]
30    Crypto(#[from] CryptoError),
31    #[error(transparent)]
32    Api(#[from] ApiError),
33    #[error(transparent)]
34    VaultParse(#[from] VaultParseError),
35    #[error(transparent)]
36    MissingField(#[from] MissingFieldError),
37    #[error(transparent)]
38    NotAuthenticated(#[from] NotAuthenticatedError),
39    #[error(transparent)]
40    Repository(#[from] RepositoryError),
41}
42
43impl<T> From<bitwarden_api_api::apis::Error<T>> for CreateCipherError {
44    fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
45        Self::Api(val.into())
46    }
47}
48
49/// Request to add a cipher.
50#[derive(Serialize, Deserialize, Clone, Debug)]
51#[serde(rename_all = "camelCase")]
52#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
53#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
54pub struct CipherCreateRequest {
55    pub organization_id: Option<OrganizationId>,
56    pub collection_ids: Vec<CollectionId>,
57    pub folder_id: Option<FolderId>,
58    pub name: String,
59    pub notes: Option<String>,
60    pub favorite: bool,
61    pub reprompt: CipherRepromptType,
62    pub r#type: CipherViewType,
63    pub fields: Vec<FieldView>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub archived_date: Option<DateTime<Utc>>,
66}
67
68/// Internal helper to convert a [`CipherCreateRequest`] into a [`CipherView`]
69/// so the existing `CipherView` encryption pipeline can be reused.
70///
71/// This conversion is lossy and intended for use only within the internal create flow.
72/// Placeholder values are generated to satisfy the CipherView contract; they have
73/// no meaning outside of this flow.
74pub(crate) fn convert_request_to_cipher_view(r: CipherCreateRequest) -> CipherView {
75    // `creation_date` / `revision_date` are overwritten by the server on
76    // merge; `Utc::now()` is a safe placeholder.
77    let now = chrono::Utc::now();
78    CipherView {
79        id: None,
80        organization_id: r.organization_id,
81        folder_id: r.folder_id,
82        collection_ids: r.collection_ids,
83        key: None,
84        name: r.name,
85        notes: r.notes,
86        r#type: r.r#type.get_cipher_type(),
87        login: r.r#type.as_login_view().cloned(),
88        identity: r.r#type.as_identity_view().cloned(),
89        card: r.r#type.as_card_view().cloned(),
90        secure_note: r.r#type.as_secure_note_view().cloned(),
91        ssh_key: r.r#type.as_ssh_key_view().cloned(),
92        bank_account: r.r#type.as_bank_account_view().cloned(),
93        drivers_license: r.r#type.as_drivers_license_view().cloned(),
94        passport: r.r#type.as_passport_view().cloned(),
95        favorite: r.favorite,
96        reprompt: r.reprompt,
97        organization_use_totp: false,
98        edit: true,
99        permissions: None,
100        view_password: true,
101        local_data: None,
102        attachments: None,
103        attachment_decryption_failures: None,
104        fields: Some(r.fields),
105        password_history: None,
106        creation_date: now,
107        deleted_date: None,
108        revision_date: now,
109        archived_date: r.archived_date,
110    }
111}
112
113async fn create_cipher<R: Repository<Cipher> + ?Sized>(
114    key_store: &KeyStore<KeySlotIds>,
115    api_client: &bitwarden_api_api::apis::ApiClient,
116    repository: &R,
117    encrypted_for: UserId,
118    view: CipherView,
119    use_strict_decryption: bool,
120    use_blob: bool,
121) -> Result<CipherView, CreateCipherError> {
122    let collection_ids = view.collection_ids.clone();
123    let mode = if use_blob {
124        EncryptMode::Blob(view)
125    } else {
126        EncryptMode::Legacy(view)
127    };
128    let cipher: Cipher = key_store.encrypt(mode)?;
129    let mut cipher_request: CipherRequestModel = cipher.try_into()?;
130    cipher_request.encrypted_for = Some(encrypted_for.into());
131
132    let mut cipher: Cipher;
133    if !collection_ids.is_empty() {
134        cipher = api_client
135            .ciphers_api()
136            .post_create(Some(CipherCreateRequestModel {
137                collection_ids: Some(collection_ids.iter().cloned().map(Into::into).collect()),
138                cipher: Box::new(cipher_request),
139            }))
140            .await
141            .map_err(ApiError::from)?
142            .merge_with_cipher(None)?;
143        cipher.collection_ids = collection_ids;
144        repository.set(require!(cipher.id), cipher.clone()).await?;
145    } else {
146        cipher = api_client
147            .ciphers_api()
148            .post(Some(cipher_request))
149            .await
150            .map_err(ApiError::from)?
151            .merge_with_cipher(None)?;
152        repository.set(require!(cipher.id), cipher.clone()).await?;
153    }
154
155    Ok(if use_strict_decryption {
156        key_store.decrypt(&StrictDecrypt(cipher))?
157    } else {
158        key_store.decrypt(&cipher)?
159    })
160}
161
162#[allow(deprecated)]
163#[cfg_attr(feature = "wasm", wasm_bindgen)]
164impl CiphersClient {
165    /// Creates a new [Cipher] and saves it to the server.
166    pub async fn create(
167        &self,
168        request: CipherCreateRequest,
169    ) -> Result<CipherView, CreateCipherError> {
170        let key_store = self.client.internal.get_key_store();
171        let config = self.client.internal.get_api_configurations();
172        let repository = self.get_repository()?;
173
174        let user_id = self
175            .client
176            .internal
177            .get_user_id()
178            .ok_or(NotAuthenticatedError)?;
179
180        let mut view: CipherView = convert_request_to_cipher_view(request);
181
182        // TODO: Once this flag is removed, the key generation logic should
183        // be moved directly into the CompositeEncryptable implementation.
184        if self.client.flags().get().await.enable_cipher_key_encryption {
185            let key = view.key_identifier();
186            view.generate_cipher_key(&mut key_store.context(), key)?;
187        }
188
189        let use_blob = self.should_use_blob_encryption(view.organization_id);
190
191        create_cipher(
192            key_store,
193            &config.api_client,
194            repository.as_ref(),
195            user_id,
196            view,
197            self.is_strict_decrypt().await,
198            use_blob,
199        )
200        .await
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
207    use bitwarden_core::key_management::SymmetricKeySlotId;
208    use bitwarden_crypto::SymmetricKeyAlgorithm;
209    use bitwarden_test::MemoryRepository;
210    use chrono::Utc;
211
212    use super::*;
213    use crate::{CipherId, LoginView};
214
215    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
216    const TEST_COLLECTION_ID: &str = "73546b86-8802-4449-ad2a-69ea981b4ffd";
217    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
218    const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";
219
220    fn generate_test_cipher_create_request() -> CipherCreateRequest {
221        CipherCreateRequest {
222            name: "Test Login".to_string(),
223            notes: Some("Test notes".to_string()),
224            r#type: CipherViewType::Login(LoginView {
225                username: Some("[email protected]".to_string()),
226                password: Some("password123".to_string()),
227                password_revision_date: None,
228                uris: None,
229                totp: None,
230                autofill_on_page_load: None,
231                fido2_credentials: None,
232            }),
233            organization_id: Default::default(),
234            folder_id: Default::default(),
235            favorite: Default::default(),
236            reprompt: Default::default(),
237            fields: Default::default(),
238            collection_ids: vec![],
239            archived_date: None,
240        }
241    }
242
243    #[tokio::test]
244    async fn test_create_cipher() {
245        let store: KeyStore<KeySlotIds> = KeyStore::default();
246        {
247            let mut ctx = store.context_mut();
248            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
249            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
250                .unwrap();
251        }
252
253        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
254
255        let api_client = ApiClient::new_mocked(move |mock| {
256            mock.ciphers_api
257                .expect_post()
258                .returning(move |body| {
259                    let body = body.unwrap();
260                    Ok(CipherResponseModel {
261                        object: Some("cipher".to_string()),
262                        id: Some(cipher_id.into()),
263                        name: Some(body.name.clone()),
264                        r#type: body.r#type,
265                        organization_id: body
266                            .organization_id
267                            .as_ref()
268                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
269                        folder_id: body
270                            .folder_id
271                            .as_ref()
272                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
273                        favorite: body.favorite,
274                        reprompt: body.reprompt,
275                        key: body.key.clone(),
276                        notes: body.notes.clone(),
277                        view_password: Some(true),
278                        edit: Some(true),
279                        organization_use_totp: Some(true),
280                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
281                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
282                        deleted_date: None,
283                        login: body.login,
284                        card: body.card,
285                        identity: body.identity,
286                        secure_note: body.secure_note,
287                        ssh_key: body.ssh_key,
288                        bank_account: body.bank_account,
289                        drivers_license: body.drivers_license,
290                        passport: body.passport,
291                        fields: body.fields,
292                        password_history: body.password_history,
293                        attachments: None,
294                        permissions: None,
295                        data: None,
296                        archived_date: None,
297                    })
298                })
299                .once();
300        });
301
302        let repository = MemoryRepository::<Cipher>::default();
303        let request = generate_test_cipher_create_request();
304
305        let result = create_cipher(
306            &store,
307            &api_client,
308            &repository,
309            TEST_USER_ID.parse().unwrap(),
310            convert_request_to_cipher_view(request),
311            false,
312            false,
313        )
314        .await
315        .unwrap();
316
317        assert_eq!(result.id, Some(cipher_id));
318        assert_eq!(result.name, "Test Login");
319        assert_eq!(
320            result.login,
321            Some(LoginView {
322                username: Some("[email protected]".to_string()),
323                password: Some("password123".to_string()),
324                password_revision_date: None,
325                uris: None,
326                totp: None,
327                autofill_on_page_load: None,
328                fido2_credentials: None,
329            })
330        );
331
332        // Confirm the cipher was stored in the repository
333        let stored_cipher_view: CipherView = store
334            .decrypt(&repository.get(cipher_id).await.unwrap().unwrap())
335            .unwrap();
336        assert_eq!(stored_cipher_view.id, result.id);
337        assert_eq!(stored_cipher_view.name, result.name);
338        assert_eq!(stored_cipher_view.r#type, result.r#type);
339        assert!(stored_cipher_view.login.is_some());
340        assert_eq!(stored_cipher_view.favorite, result.favorite);
341    }
342
343    #[tokio::test]
344    async fn test_create_cipher_http_error() {
345        let store: KeyStore<KeySlotIds> = KeyStore::default();
346        {
347            let mut ctx = store.context_mut();
348            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
349            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
350                .unwrap();
351        }
352
353        let api_client = ApiClient::new_mocked(move |mock| {
354            mock.ciphers_api
355                .expect_post()
356                .returning(move |_body| Err(std::io::Error::other("Simulated error").into()));
357        });
358
359        let repository = MemoryRepository::<Cipher>::default();
360
361        let request = generate_test_cipher_create_request();
362
363        let result = create_cipher(
364            &store,
365            &api_client,
366            &repository,
367            TEST_USER_ID.parse().unwrap(),
368            convert_request_to_cipher_view(request),
369            false,
370            false,
371        )
372        .await;
373
374        assert!(result.is_err());
375        assert!(matches!(result.unwrap_err(), CreateCipherError::Api(_)));
376    }
377
378    #[tokio::test]
379    async fn test_create_org_cipher() {
380        let api_client = ApiClient::new_mocked(move |mock| {
381            mock.ciphers_api
382                .expect_post_create()
383                .returning(move |body| {
384                    let request_body = body.unwrap();
385
386                    Ok(CipherResponseModel {
387                        id: Some(TEST_CIPHER_ID.try_into().unwrap()),
388                        organization_id: request_body
389                            .cipher
390                            .organization_id
391                            .and_then(|id| id.parse().ok()),
392                        name: Some(request_body.cipher.name.clone()),
393                        r#type: request_body.cipher.r#type,
394                        creation_date: Some(Utc::now().to_string()),
395                        revision_date: Some(Utc::now().to_string()),
396                        ..Default::default()
397                    })
398                })
399                .once();
400        });
401
402        let store: KeyStore<KeySlotIds> = KeyStore::default();
403        {
404            let mut ctx = store.context_mut();
405            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
406            ctx.persist_symmetric_key(
407                local_key_id,
408                SymmetricKeySlotId::Organization(TEST_ORG_ID.parse().unwrap()),
409            )
410            .unwrap();
411        }
412        let repository = MemoryRepository::<Cipher>::default();
413        let request = CipherCreateRequest {
414            organization_id: Some(TEST_ORG_ID.parse().unwrap()),
415            collection_ids: vec![TEST_COLLECTION_ID.parse().unwrap()],
416            folder_id: None,
417            name: "Test Cipher".into(),
418            notes: None,
419            favorite: false,
420            reprompt: CipherRepromptType::None,
421            r#type: CipherViewType::Login(LoginView {
422                username: None,
423                password: None,
424                password_revision_date: None,
425                uris: None,
426                totp: None,
427                autofill_on_page_load: None,
428                fido2_credentials: None,
429            }),
430            fields: vec![],
431            archived_date: None,
432        };
433
434        let response = create_cipher(
435            &store,
436            &api_client,
437            &repository,
438            TEST_USER_ID.parse().unwrap(),
439            convert_request_to_cipher_view(request),
440            false,
441            false,
442        )
443        .await
444        .unwrap();
445
446        let cipher: Cipher = repository
447            .get(TEST_CIPHER_ID.parse().unwrap())
448            .await
449            .unwrap()
450            .unwrap();
451        let cipher_view: CipherView = store.decrypt(&cipher).unwrap();
452
453        assert_eq!(response.id, cipher_view.id);
454        assert_eq!(response.organization_id, cipher_view.organization_id);
455
456        assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap()));
457        assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap()));
458    }
459}