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 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 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 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}