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