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