bitwarden_vault/cipher/cipher_client/
create.rs

1use bitwarden_api_api::models::CipherRequestModel;
2use bitwarden_core::{
3    ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId,
4    key_management::{KeyIds, SymmetricKeyId},
5    require,
6};
7use bitwarden_crypto::{
8    CompositeEncryptable, CryptoError, EncString, IdentifyKey, KeyStore, KeyStoreContext,
9    PrimitiveEncryptable,
10};
11use bitwarden_error::bitwarden_error;
12use bitwarden_state::repository::{Repository, RepositoryError};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15#[cfg(feature = "wasm")]
16use tsify::Tsify;
17#[cfg(feature = "wasm")]
18use wasm_bindgen::prelude::*;
19
20use super::CiphersClient;
21use crate::{
22    Cipher, CipherRepromptType, CipherView, FieldView, FolderId, VaultParseError,
23    cipher_view_type::CipherViewType,
24};
25
26#[allow(missing_docs)]
27#[bitwarden_error(flat)]
28#[derive(Debug, Error)]
29pub enum CreateCipherError {
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}
43
44/// Request to add a cipher.
45#[derive(Serialize, Deserialize, Clone, Debug)]
46#[serde(rename_all = "camelCase")]
47#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
48#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
49pub struct CipherCreateRequest {
50    pub organization_id: Option<OrganizationId>,
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}
59
60/// Used as an intermediary between the public-facing [CipherCreateRequest], and the encrypted
61/// value. This allows us to manage the cipher key creation internally.
62#[derive(Clone, Debug)]
63struct CipherCreateRequestInternal {
64    create_request: CipherCreateRequest,
65    key: Option<EncString>,
66}
67
68impl From<CipherCreateRequest> for CipherCreateRequestInternal {
69    fn from(create_request: CipherCreateRequest) -> Self {
70        Self {
71            create_request,
72            key: None,
73        }
74    }
75}
76
77impl CipherCreateRequestInternal {
78    /// Generate a new key for the cipher, re-encrypting internal data, if necessary, and stores the
79    /// encrypted key to the cipher data.
80    fn generate_cipher_key(
81        &mut self,
82        ctx: &mut KeyStoreContext<KeyIds>,
83        key: SymmetricKeyId,
84    ) -> Result<(), CryptoError> {
85        let old_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
86
87        let new_key = ctx.generate_symmetric_key();
88        self.create_request
89            .r#type
90            .as_login_view_mut()
91            .map(|l| l.reencrypt_fido2_credentials(ctx, old_key, new_key))
92            .transpose()?;
93
94        self.key = Some(ctx.wrap_symmetric_key(key, new_key)?);
95        Ok(())
96    }
97
98    fn generate_checksums(&mut self) {
99        if let Some(login) = &mut self.create_request.r#type.as_login_view_mut() {
100            login.generate_checksums();
101        }
102    }
103}
104
105impl CompositeEncryptable<KeyIds, SymmetricKeyId, CipherRequestModel>
106    for CipherCreateRequestInternal
107{
108    fn encrypt_composite(
109        &self,
110        ctx: &mut KeyStoreContext<KeyIds>,
111        key: SymmetricKeyId,
112    ) -> Result<CipherRequestModel, CryptoError> {
113        // Clone self so we can generating the checksums before encrypting.
114        let mut cipher_data = (*self).clone();
115        cipher_data.generate_checksums();
116
117        let cipher_key = Cipher::decrypt_cipher_key(ctx, key, &cipher_data.key)?;
118
119        let cipher_request = CipherRequestModel {
120            encrypted_for: None,
121            r#type: Some(cipher_data.create_request.r#type.get_cipher_type().into()),
122            organization_id: cipher_data
123                .create_request
124                .organization_id
125                .map(|id| id.to_string()),
126            folder_id: cipher_data
127                .create_request
128                .folder_id
129                .map(|id| id.to_string()),
130            favorite: Some(cipher_data.create_request.favorite),
131            reprompt: Some(cipher_data.create_request.reprompt.into()),
132            key: cipher_data.key.map(|k| k.to_string()),
133            name: cipher_data
134                .create_request
135                .name
136                .encrypt(ctx, cipher_key)?
137                .to_string(),
138            notes: cipher_data
139                .create_request
140                .notes
141                .as_ref()
142                .map(|n| n.encrypt(ctx, cipher_key))
143                .transpose()?
144                .map(|n| n.to_string()),
145            login: cipher_data
146                .create_request
147                .r#type
148                .as_login_view()
149                .as_ref()
150                .map(|l| l.encrypt_composite(ctx, cipher_key))
151                .transpose()?
152                .map(|l| Box::new(l.into())),
153            card: cipher_data
154                .create_request
155                .r#type
156                .as_card_view()
157                .as_ref()
158                .map(|c| c.encrypt_composite(ctx, cipher_key))
159                .transpose()?
160                .map(|c| Box::new(c.into())),
161            identity: cipher_data
162                .create_request
163                .r#type
164                .as_identity_view()
165                .as_ref()
166                .map(|i| i.encrypt_composite(ctx, cipher_key))
167                .transpose()?
168                .map(|i| Box::new(i.into())),
169            secure_note: cipher_data
170                .create_request
171                .r#type
172                .as_secure_note_view()
173                .as_ref()
174                .map(|s| s.encrypt_composite(ctx, cipher_key))
175                .transpose()?
176                .map(|s| Box::new(s.into())),
177            ssh_key: cipher_data
178                .create_request
179                .r#type
180                .as_ssh_key_view()
181                .as_ref()
182                .map(|s| s.encrypt_composite(ctx, cipher_key))
183                .transpose()?
184                .map(|s| Box::new(s.into())),
185            fields: Some(
186                cipher_data
187                    .create_request
188                    .fields
189                    .iter()
190                    .map(|f| f.encrypt_composite(ctx, cipher_key))
191                    .map(|f| f.map(|f| f.into()))
192                    .collect::<Result<Vec<_>, _>>()?,
193            ),
194            password_history: None,
195            attachments: None,
196            attachments2: None,
197            last_known_revision_date: None,
198            archived_date: None,
199            data: None,
200        };
201
202        Ok(cipher_request)
203    }
204}
205
206impl IdentifyKey<SymmetricKeyId> for CipherCreateRequestInternal {
207    fn key_identifier(&self) -> SymmetricKeyId {
208        match self.create_request.organization_id {
209            Some(organization_id) => SymmetricKeyId::Organization(organization_id),
210            None => SymmetricKeyId::User,
211        }
212    }
213}
214
215async fn create_cipher<R: Repository<Cipher> + ?Sized>(
216    key_store: &KeyStore<KeyIds>,
217    api_client: &bitwarden_api_api::apis::ApiClient,
218    repository: &R,
219    encrypted_for: UserId,
220    request: CipherCreateRequestInternal,
221) -> Result<CipherView, CreateCipherError> {
222    let mut cipher_request = key_store.encrypt(request)?;
223    cipher_request.encrypted_for = Some(encrypted_for.into());
224
225    let resp = api_client
226        .ciphers_api()
227        .post(Some(cipher_request))
228        .await
229        .map_err(ApiError::from)?;
230    let cipher: Cipher = resp.try_into()?;
231    repository
232        .set(require!(cipher.id).to_string(), cipher.clone())
233        .await?;
234    Ok(key_store.decrypt(&cipher)?)
235}
236
237#[cfg_attr(feature = "wasm", wasm_bindgen)]
238impl CiphersClient {
239    /// Create a new [Cipher] and save it to the server.
240    pub async fn create(
241        &self,
242        request: CipherCreateRequest,
243    ) -> Result<CipherView, CreateCipherError> {
244        let key_store = self.client.internal.get_key_store();
245        let config = self.client.internal.get_api_configurations().await;
246        let repository = self.get_repository()?;
247        let mut internal_request: CipherCreateRequestInternal = request.into();
248
249        let user_id = self
250            .client
251            .internal
252            .get_user_id()
253            .ok_or(NotAuthenticatedError)?;
254
255        // TODO: Once this flag is removed, the key generation logic should
256        // be moved closer to the actual encryption logic.
257        if self
258            .client
259            .internal
260            .get_flags()
261            .enable_cipher_key_encryption
262        {
263            let key = internal_request.key_identifier();
264            internal_request.generate_cipher_key(&mut key_store.context(), key)?;
265        }
266
267        create_cipher(
268            key_store,
269            &config.api_client,
270            repository.as_ref(),
271            user_id,
272            internal_request,
273        )
274        .await
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
281    use bitwarden_crypto::SymmetricCryptoKey;
282    use bitwarden_test::MemoryRepository;
283
284    use super::*;
285    use crate::{CipherId, LoginView};
286
287    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
288    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
289
290    fn generate_test_cipher_create_request() -> CipherCreateRequest {
291        CipherCreateRequest {
292            name: "Test Login".to_string(),
293            notes: Some("Test notes".to_string()),
294            r#type: CipherViewType::Login(LoginView {
295                username: Some("[email protected]".to_string()),
296                password: Some("password123".to_string()),
297                password_revision_date: None,
298                uris: None,
299                totp: None,
300                autofill_on_page_load: None,
301                fido2_credentials: None,
302            }),
303            organization_id: Default::default(),
304            folder_id: Default::default(),
305            favorite: Default::default(),
306            reprompt: Default::default(),
307            fields: Default::default(),
308        }
309    }
310
311    #[tokio::test]
312    async fn test_create_cipher() {
313        let store: KeyStore<KeyIds> = KeyStore::default();
314        #[allow(deprecated)]
315        let _ = store.context_mut().set_symmetric_key(
316            SymmetricKeyId::User,
317            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
318        );
319
320        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
321
322        let api_client = ApiClient::new_mocked(move |mock| {
323            mock.ciphers_api
324                .expect_post()
325                .returning(move |body| {
326                    let body = body.unwrap();
327                    Ok(CipherResponseModel {
328                        object: Some("cipher".to_string()),
329                        id: Some(cipher_id.into()),
330                        name: Some(body.name.clone()),
331                        r#type: body.r#type,
332                        organization_id: body
333                            .organization_id
334                            .as_ref()
335                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
336                        folder_id: body
337                            .folder_id
338                            .as_ref()
339                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
340                        favorite: body.favorite,
341                        reprompt: body.reprompt,
342                        key: body.key.clone(),
343                        notes: body.notes.clone(),
344                        view_password: Some(true),
345                        edit: Some(true),
346                        organization_use_totp: Some(true),
347                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
348                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
349                        deleted_date: None,
350                        login: body.login,
351                        card: body.card,
352                        identity: body.identity,
353                        secure_note: body.secure_note,
354                        ssh_key: body.ssh_key,
355                        fields: body.fields,
356                        password_history: body.password_history,
357                        attachments: None,
358                        permissions: None,
359                        data: None,
360                        archived_date: None,
361                    })
362                })
363                .once();
364        });
365
366        let repository = MemoryRepository::<Cipher>::default();
367        let request = generate_test_cipher_create_request();
368
369        let result = create_cipher(
370            &store,
371            &api_client,
372            &repository,
373            TEST_USER_ID.parse().unwrap(),
374            request.into(),
375        )
376        .await
377        .unwrap();
378
379        assert_eq!(result.id, Some(cipher_id));
380        assert_eq!(result.name, "Test Login");
381        assert_eq!(
382            result.login,
383            Some(LoginView {
384                username: Some("[email protected]".to_string()),
385                password: Some("password123".to_string()),
386                password_revision_date: None,
387                uris: None,
388                totp: None,
389                autofill_on_page_load: None,
390                fido2_credentials: None,
391            })
392        );
393
394        // Confirm the cipher was stored in the repository
395        let stored_cipher_view: CipherView = store
396            .decrypt(
397                &repository
398                    .get(cipher_id.to_string())
399                    .await
400                    .unwrap()
401                    .unwrap(),
402            )
403            .unwrap();
404        assert_eq!(stored_cipher_view.id, result.id);
405        assert_eq!(stored_cipher_view.name, result.name);
406        assert_eq!(stored_cipher_view.r#type, result.r#type);
407        assert!(stored_cipher_view.login.is_some());
408        assert_eq!(stored_cipher_view.favorite, result.favorite);
409    }
410
411    #[tokio::test]
412    async fn test_create_cipher_http_error() {
413        let store: KeyStore<KeyIds> = KeyStore::default();
414        #[allow(deprecated)]
415        let _ = store.context_mut().set_symmetric_key(
416            SymmetricKeyId::User,
417            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
418        );
419
420        let api_client = ApiClient::new_mocked(move |mock| {
421            mock.ciphers_api.expect_post().returning(move |_body| {
422                Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
423                    "Simulated error",
424                )))
425            });
426        });
427
428        let repository = MemoryRepository::<Cipher>::default();
429
430        let request = generate_test_cipher_create_request();
431
432        let result = create_cipher(
433            &store,
434            &api_client,
435            &repository,
436            TEST_USER_ID.parse().unwrap(),
437            request.into(),
438        )
439        .await;
440
441        assert!(result.is_err());
442        assert!(matches!(result.unwrap_err(), CreateCipherError::Api(_)));
443    }
444}