1use chrono::{DateTime, Utc};
2use credential_exchange_format::{
3 Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential,
4 CreditCardCredential, CustomFieldsCredential, DriversLicenseCredential, EditableField,
5 EditableFieldString, EditableFieldValue, IdentityDocumentCredential, Item, NoteCredential,
6 PasskeyCredential, PassportCredential, PersonNameCredential, SshKeyCredential, TotpCredential,
7 WifiCredential,
8};
9
10use crate::{
11 CipherType, Field, ImportingCipher, SecureNote, SecureNoteType,
12 cxf::{
13 CxfError,
14 api_key::api_key_to_fields,
15 card::to_card,
16 editable_field::create_field,
17 identity::{
18 address_to_identity, drivers_license_to_identity, identity_document_to_identity,
19 passport_to_identity, person_name_to_identity,
20 },
21 login::to_login,
22 note::extract_note_content,
23 ssh::to_ssh,
24 wifi::wifi_to_fields,
25 },
26};
27
28pub(crate) fn parse_cxf(payload: String) -> Result<Vec<ImportingCipher>, CxfError> {
32 let sanitized = sanitize_timestamps(&payload);
33 let account: CxfAccount = serde_json::from_str(&sanitized)?;
34
35 let items: Vec<ImportingCipher> = account.items.into_iter().flat_map(parse_item).collect();
36
37 Ok(items)
38}
39
40pub(crate) fn sanitize_timestamps(payload: &str) -> std::borrow::Cow<'_, str> {
48 let Ok(mut value) = serde_json::from_str::<serde_json::Value>(payload) else {
49 return std::borrow::Cow::Borrowed(payload);
50 };
51
52 let mut modified = false;
53
54 if let Some(items) = value.get_mut("items").and_then(|v| v.as_array_mut()) {
55 for item in items {
56 clamp_timestamps(item, &mut modified);
57 }
58 }
59 if let Some(collections) = value.get_mut("collections").and_then(|v| v.as_array_mut()) {
60 for collection in collections {
61 clamp_collection_timestamps(collection, &mut modified);
62 }
63 }
64
65 if !modified {
66 return std::borrow::Cow::Borrowed(payload);
67 }
68 serde_json::to_string(&value)
69 .map(std::borrow::Cow::Owned)
70 .unwrap_or(std::borrow::Cow::Borrowed(payload))
71}
72
73fn clamp_timestamps(item: &mut serde_json::Value, modified: &mut bool) {
74 for key in ["creationAt", "modifiedAt"] {
75 if item
76 .get(key)
77 .and_then(|v| v.as_i64())
78 .is_some_and(|n| n < 0)
79 {
80 item[key] = serde_json::Value::Null;
81 *modified = true;
82 }
83 }
84}
85
86fn clamp_collection_timestamps(collection: &mut serde_json::Value, modified: &mut bool) {
87 clamp_timestamps(collection, modified);
88 if let Some(subs) = collection
89 .get_mut("subCollections")
90 .and_then(|v| v.as_array_mut())
91 {
92 for sub in subs {
93 clamp_collection_timestamps(sub, modified);
94 }
95 }
96}
97
98fn convert_date(ts: Option<u64>) -> DateTime<Utc> {
102 ts.and_then(|ts| DateTime::from_timestamp(ts as i64, 0))
103 .unwrap_or(Utc::now())
104}
105
106fn custom_fields_to_fields(custom_fields: &CustomFieldsCredential) -> Vec<Field> {
109 custom_fields
110 .fields
111 .iter()
112 .map(|field_value| match field_value {
113 EditableFieldValue::String(field) => create_field(field, None::<String>),
114 EditableFieldValue::ConcealedString(field) => create_field(field, None::<String>),
115 EditableFieldValue::Boolean(field) => create_field(field, None::<String>),
116 EditableFieldValue::Date(field) => create_field(field, None::<String>),
117 EditableFieldValue::YearMonth(field) => create_field(field, None::<String>),
118 EditableFieldValue::SubdivisionCode(field) => create_field(field, None::<String>),
119 EditableFieldValue::CountryCode(field) => create_field(field, None::<String>),
120 EditableFieldValue::Email(field) => create_field(field, None::<String>),
121 EditableFieldValue::Number(field) => create_field(field, None::<String>),
122 EditableFieldValue::WifiNetworkSecurityType(field) => {
123 create_field(field, None::<String>)
124 }
125 _ => create_field(
126 &EditableField {
127 id: None,
128 label: Some("Unknown Field".to_string()),
129 value: EditableFieldString("".to_string()).into(),
130 extensions: None,
131 },
132 None::<String>,
133 ),
134 })
135 .collect()
136}
137
138pub(super) fn parse_item(value: Item) -> Vec<ImportingCipher> {
139 let grouped = group_credentials_by_type(value.credentials);
140
141 let creation_date = convert_date(value.creation_at);
142 let revision_date = convert_date(value.modified_at);
143
144 let mut output = vec![];
145
146 let scope = value.scope.as_ref();
147
148 let note_content = grouped.note.first().map(extract_note_content);
150
151 let mut add_item = |t: CipherType, fields: Vec<Field>, fallback_name: Option<String>| {
153 let name = match fallback_name {
154 Some(fallback) if value.title.trim().is_empty() => fallback,
155 _ => value.title.clone(),
156 };
157 output.push(ImportingCipher {
158 folder_id: None, name,
160 notes: note_content.clone(),
161 r#type: t,
162 favorite: false,
163 reprompt: 0,
164 fields,
165 revision_date,
166 creation_date,
167 deleted_date: None,
168 })
169 };
170
171 if !grouped.basic_auth.is_empty() || !grouped.passkey.is_empty() || !grouped.totp.is_empty() {
173 let basic_auth = grouped.basic_auth.first();
174 let passkey = grouped.passkey.first();
175 let totp = grouped.totp.first();
176
177 let login = to_login(creation_date, basic_auth, passkey, totp, scope);
178 add_item(CipherType::Login(Box::new(login)), vec![], None);
179 }
180
181 if let Some(credit_card) = grouped.credit_card.first() {
183 let (card, fields) = to_card(credit_card);
184
185 let fallback_name = card
187 .cardholder_name
188 .clone()
189 .unwrap_or_else(|| "Untitled Card".to_string());
190
191 add_item(
192 CipherType::Card(Box::new(card)),
193 fields,
194 Some(fallback_name),
195 );
196 }
197
198 let secure_note_type = || {
200 CipherType::SecureNote(Box::new(SecureNote {
201 r#type: SecureNoteType::Generic,
202 }))
203 };
204
205 if let Some(api_key) = grouped.api_key.first() {
207 let fields = api_key_to_fields(api_key);
208 add_item(secure_note_type(), fields, None);
209 }
210
211 if let Some(wifi) = grouped.wifi.first() {
213 let fields = wifi_to_fields(wifi);
214 add_item(secure_note_type(), fields, None);
215 }
216
217 [
219 grouped
220 .address
221 .first()
222 .map(|a| address_to_identity(a.clone())),
223 grouped
224 .passport
225 .first()
226 .map(|p| passport_to_identity(p.clone())),
227 grouped
228 .person_name
229 .first()
230 .map(|p| person_name_to_identity(p.clone())),
231 grouped
232 .drivers_license
233 .first()
234 .map(|d| drivers_license_to_identity(d.clone())),
235 grouped
236 .identity_document
237 .first()
238 .map(|i| identity_document_to_identity(i.clone())),
239 ]
240 .into_iter()
241 .flatten()
242 .for_each(|(identity, custom_fields)| {
243 add_item(
244 CipherType::Identity(Box::new(identity)),
245 custom_fields,
246 None,
247 );
248 });
249
250 if let Some(ssh) = grouped.ssh.first() {
252 match to_ssh(ssh) {
253 Ok((ssh_key, fields)) => add_item(CipherType::SshKey(Box::new(ssh_key)), fields, None),
254 Err(_) => {
255 }
257 }
258 }
259
260 if let Some(custom_fields) = grouped.custom_fields.first() {
263 if let Some(first_cipher) = output.first_mut() {
264 first_cipher
266 .fields
267 .extend(custom_fields_to_fields(custom_fields));
268 } else {
269 let fields = custom_fields_to_fields(custom_fields);
271 output.push(ImportingCipher {
272 folder_id: None,
273 name: value.title.clone(),
274 notes: note_content.clone(),
275 r#type: secure_note_type(),
276 favorite: false,
277 reprompt: 0,
278 fields,
279 revision_date,
280 creation_date,
281 deleted_date: None,
282 });
283 }
284 }
285
286 if !grouped.note.is_empty() && output.is_empty() {
288 let standalone_note_content = grouped.note.first().map(extract_note_content);
289 output.push(ImportingCipher {
290 folder_id: None, name: value.title.clone(),
292 notes: standalone_note_content,
293 r#type: secure_note_type(),
294 favorite: false,
295 reprompt: 0,
296 fields: vec![],
297 revision_date,
298 creation_date,
299 deleted_date: None,
300 });
301 }
302
303 output
304}
305
306fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials {
313 fn filter_credentials<T>(
314 credentials: &[Credential],
315 f: impl Fn(&Credential) -> Option<&T>,
316 ) -> Vec<T>
317 where
318 T: Clone,
319 {
320 credentials.iter().filter_map(f).cloned().collect()
321 }
322
323 macro_rules! extract_credential {
324 ($field:ident, $variant:path, $type:ty) => {
325 filter_credentials(&credentials, |c| match c {
326 &$variant(ref inner) => Some(inner.as_ref()),
327 _ => None,
328 })
329 };
330 }
331
332 GroupedCredentials {
333 api_key: extract_credential!(api_key, Credential::ApiKey, ApiKeyCredential),
334 basic_auth: extract_credential!(basic_auth, Credential::BasicAuth, BasicAuthCredential),
335 credit_card: extract_credential!(credit_card, Credential::CreditCard, CreditCardCredential),
336 custom_fields: extract_credential!(custom_fields, Credential::CustomFields, CustomFields),
337 passkey: extract_credential!(passkey, Credential::Passkey, PasskeyCredential),
338 ssh: extract_credential!(ssh, Credential::SshKey, SshKeyCredential),
339 totp: extract_credential!(totp, Credential::Totp, TotpCredential),
340 wifi: extract_credential!(wifi, Credential::Wifi, WifiCredential),
341 address: extract_credential!(address, Credential::Address, AddressCredential),
342 passport: extract_credential!(passport, Credential::Passport, PassportCredential),
343 person_name: extract_credential!(person_name, Credential::PersonName, PersonNameCredential),
344 drivers_license: extract_credential!(
345 drivers_license,
346 Credential::DriversLicense,
347 DriversLicenseCredential
348 ),
349 identity_document: extract_credential!(
350 identity_document,
351 Credential::IdentityDocument,
352 IdentityDocumentCredential
353 ),
354 note: extract_credential!(note, Credential::Note, NoteCredential),
355 }
356}
357
358struct GroupedCredentials {
359 address: Vec<AddressCredential>,
360 api_key: Vec<ApiKeyCredential>,
361 basic_auth: Vec<BasicAuthCredential>,
362 credit_card: Vec<CreditCardCredential>,
363 custom_fields: Vec<CustomFieldsCredential>,
364 drivers_license: Vec<DriversLicenseCredential>,
365 identity_document: Vec<IdentityDocumentCredential>,
366 note: Vec<NoteCredential>,
367 passkey: Vec<PasskeyCredential>,
368 passport: Vec<PassportCredential>,
369 person_name: Vec<PersonNameCredential>,
370 ssh: Vec<SshKeyCredential>,
371 totp: Vec<TotpCredential>,
372 wifi: Vec<WifiCredential>,
373}
374
375#[cfg(test)]
376mod tests {
377 use chrono::{Duration, Month};
378 use credential_exchange_format::{B64Url, CreditCardCredential, EditableFieldYearMonth};
379
380 use super::*;
381
382 #[test]
386 fn test_custom_fields_email_and_number_roundtrip() {
387 use bitwarden_vault::FieldType as BwFieldType;
388
389 let custom_fields: CustomFieldsCredential = serde_json::from_value(serde_json::json!({
390 "fields": [
391 {"fieldType": "email", "value": "[email protected]", "label": "Recovery email"},
392 {"fieldType": "number", "value": "42", "label": "Lucky number"},
393 ],
394 "extensions": [],
395 }))
396 .unwrap();
397
398 let fields = custom_fields_to_fields(&custom_fields);
399
400 assert_eq!(
401 fields,
402 vec![
403 Field {
404 name: Some("Recovery email".to_string()),
405 value: Some("[email protected]".to_string()),
406 r#type: BwFieldType::Text as u8,
407 linked_id: None,
408 },
409 Field {
410 name: Some("Lucky number".to_string()),
411 value: Some("42".to_string()),
412 r#type: BwFieldType::Text as u8,
413 linked_id: None,
414 },
415 ]
416 );
417 }
418
419 #[test]
420 fn test_convert_date() {
421 let timestamp: u64 = 1706613834;
422 let datetime = convert_date(Some(timestamp));
423 assert_eq!(
424 datetime,
425 "2024-01-30T11:23:54Z".parse::<DateTime<Utc>>().unwrap()
426 );
427 }
428
429 #[test]
430 fn test_convert_date_none() {
431 let datetime = convert_date(None);
432 assert!(datetime > Utc::now() - Duration::seconds(1));
433 assert!(datetime <= Utc::now());
434 }
435
436 #[test]
437 fn test_parse_empty_item() {
438 let item = Item {
439 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
440 creation_at: Some(1706613834),
441 modified_at: Some(1706623773),
442 title: "Bitwarden".to_string(),
443 subtitle: None,
444 favorite: None,
445 credentials: vec![],
446 tags: None,
447 extensions: None,
448 scope: None,
449 };
450
451 let ciphers: Vec<ImportingCipher> = parse_item(item);
452 assert_eq!(ciphers.len(), 0);
453 }
454
455 #[test]
456 fn test_parse_passkey() {
457 let item = Item {
458 id: B64Url::try_from("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
459 .unwrap(),
460 creation_at: Some(1732181986),
461 modified_at: Some(1732182026),
462 title: "example.com".to_string(),
463 subtitle: None,
464 favorite: None,
465 credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
466 credential_id: B64Url::try_from("6NiHiekW4ZY8vYHa-ucbvA")
467 .unwrap(),
468 rp_id: "example.com".to_string(),
469 username: "pj-fry".to_string(),
470 user_display_name: "Philip J. Fry".to_string(),
471 user_handle: B64Url::try_from("YWxleCBtdWxsZXI").unwrap(),
472 key: B64Url::try_from("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
473 .unwrap(),
474 fido2_extensions: None,
475 }))],
476 tags: None,
477 extensions: None,
478 scope: None,
479 };
480
481 let ciphers: Vec<ImportingCipher> = parse_item(item);
482 assert_eq!(ciphers.len(), 1);
483 let cipher = ciphers.first().unwrap();
484
485 assert_eq!(cipher.folder_id, None);
486 assert_eq!(cipher.name, "example.com");
487
488 let login = match &cipher.r#type {
489 CipherType::Login(login) => login,
490 _ => panic!("Expected login"),
491 };
492
493 assert_eq!(login.username, Some("pj-fry".to_string()));
494 assert_eq!(login.password, None);
495 assert_eq!(login.login_uris.len(), 1);
496 assert_eq!(
497 login.login_uris[0].uri,
498 Some("https://example.com".to_string())
499 );
500 assert_eq!(login.totp, None);
501
502 let passkey = login.fido2_credentials.as_ref().unwrap().first().unwrap();
503 assert_eq!(passkey.credential_id, "b64.6NiHiekW4ZY8vYHa-ucbvA");
504 assert_eq!(passkey.key_type, "public-key");
505 assert_eq!(passkey.key_algorithm, "ECDSA");
506 assert_eq!(passkey.key_curve, "P-256");
507 assert_eq!(
508 passkey.key_value,
509 "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl"
510 );
511 assert_eq!(passkey.rp_id, "example.com");
512 assert_eq!(
513 passkey.user_handle.as_ref().map(|h| h.to_string()).unwrap(),
514 "YWxleCBtdWxsZXI"
515 );
516 assert_eq!(passkey.user_name, Some("pj-fry".to_string()));
517 assert_eq!(passkey.counter, 0);
518 assert_eq!(passkey.rp_name, Some("example.com".to_string()));
519 assert_eq!(passkey.user_display_name, Some("Philip J. Fry".to_string()));
520 assert_eq!(passkey.discoverable, "true");
521 assert_eq!(
522 passkey.creation_date,
523 "2024-11-21T09:39:46Z".parse::<DateTime<Utc>>().unwrap()
524 );
525 }
526
527 #[test]
528 fn test_passkey_with_basic_auth_and_scope() {
529 use credential_exchange_format::{BasicAuthCredential, CredentialScope};
530
531 let item = Item {
532 id: B64Url::try_from("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
533 .unwrap(),
534 creation_at: Some(1732181986),
535 modified_at: Some(1732182026),
536 title: "Combined Login".to_string(),
537 subtitle: None,
538 favorite: None,
539 credentials: vec![
540 Credential::BasicAuth(Box::new(BasicAuthCredential {
541 username: Some("basic_username".to_string().into()),
542 password: Some("basic_password".to_string().into()),
543 })),
544 Credential::Passkey(Box::new(PasskeyCredential {
545 credential_id: B64Url::try_from("6NiHiekW4ZY8vYHa-ucbvA")
546 .unwrap(),
547 rp_id: "passkey-domain.com".to_string(),
548 username: "passkey_username".to_string(),
549 user_display_name: "Passkey User".to_string(),
550 user_handle: B64Url::try_from("YWxleCBtdWxsZXI")
551 .unwrap(),
552 key: B64Url::try_from("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
553 .unwrap(),
554 fido2_extensions: None,
555 }))
556 ],
557 tags: None,
558 extensions: None,
559 scope: Some(CredentialScope {
560 urls: vec!["https://example.com".to_string()],
561 android_apps: vec![],
562 }),
563 };
564
565 let ciphers: Vec<ImportingCipher> = parse_item(item);
566 assert_eq!(ciphers.len(), 1);
567 let cipher = ciphers.first().unwrap();
568
569 let login = match &cipher.r#type {
570 CipherType::Login(login) => login,
571 _ => panic!("Expected login"),
572 };
573
574 assert_eq!(login.username, Some("basic_username".to_string()));
576 assert_eq!(login.password, Some("basic_password".to_string()));
577
578 assert_eq!(login.login_uris.len(), 1);
580 assert_eq!(
581 login.login_uris[0].uri,
582 Some("https://example.com".to_string())
583 );
584
585 assert!(login.fido2_credentials.is_some());
587 }
588
589 #[test]
590 fn test_passkey_with_empty_username() {
591 let item = Item {
592 id: B64Url::try_from("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF").unwrap(),
593 creation_at: Some(1732181986),
594 modified_at: Some(1732182026),
595 title: "Empty Username Passkey".to_string(),
596 subtitle: None,
597 favorite: None,
598 credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
599 credential_id: B64Url::try_from("6NiHiekW4ZY8vYHa-ucbvA")
600 .unwrap(),
601 rp_id: "example.com".to_string(),
602 username: "".to_string(), user_display_name: "User Display".to_string(),
604 user_handle: B64Url::try_from("YWxleCBtdWxsZXI")
605 .unwrap(),
606 key: B64Url::try_from("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
607 .unwrap(),
608 fido2_extensions: None,
609 }))],
610 tags: None,
611 extensions: None,
612 scope: None,
613 };
614
615 let ciphers: Vec<ImportingCipher> = parse_item(item);
616 assert_eq!(ciphers.len(), 1);
617 let cipher = ciphers.first().unwrap();
618
619 let login = match &cipher.r#type {
620 CipherType::Login(login) => login,
621 _ => panic!("Expected login"),
622 };
623
624 assert_eq!(login.username, None);
626 assert_eq!(login.password, None);
627
628 assert_eq!(login.login_uris.len(), 1);
630 assert_eq!(
631 login.login_uris[0].uri,
632 Some("https://example.com".to_string())
633 );
634 }
635
636 #[test]
637 fn test_credit_card() {
638 let item = Item {
639 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
640 creation_at: Some(1706613834),
641 modified_at: Some(1706623773),
642 title: "My MasterCard".to_string(),
643 subtitle: None,
644 favorite: None,
645 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
646 number: Some("1234 5678 9012 3456".to_string().into()),
647 full_name: Some("John Doe".to_string().into()),
648 card_type: Some("MasterCard".to_string().into()),
649 verification_number: Some("123".to_string().into()),
650 pin: None,
651 expiry_date: Some(
652 EditableFieldYearMonth {
653 year: 2026,
654 month: Month::January,
655 }
656 .into(),
657 ),
658 valid_from: None,
659 }))],
660 tags: None,
661 extensions: None,
662 scope: None,
663 };
664
665 let ciphers: Vec<ImportingCipher> = parse_item(item);
666 assert_eq!(ciphers.len(), 1);
667 let cipher = ciphers.first().unwrap();
668
669 assert_eq!(cipher.folder_id, None);
670 assert_eq!(cipher.name, "My MasterCard");
671
672 let card = match &cipher.r#type {
673 CipherType::Card(card) => card,
674 _ => panic!("Expected card"),
675 };
676
677 assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
678 assert_eq!(card.exp_month, Some("1".to_string()));
679 assert_eq!(card.exp_year, Some("2026".to_string()));
680 assert_eq!(card.code, Some("123".to_string()));
681 assert_eq!(card.brand, Some("Mastercard".to_string()));
682 assert_eq!(card.number, Some("1234 5678 9012 3456".to_string()));
683 }
684
685 #[test]
686 fn test_totp() {
687 use credential_exchange_format::{OTPHashAlgorithm, TotpCredential};
688
689 let item = Item {
690 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
691 creation_at: Some(1706613834),
692 modified_at: Some(1706623773),
693 title: "My TOTP".to_string(),
694 subtitle: None,
695 favorite: None,
696 credentials: vec![Credential::Totp(Box::new(TotpCredential {
697 secret: "Hello World!".as_bytes().to_vec().into(),
698 period: 30,
699 digits: 6,
700 username: Some("[email protected]".to_string()),
701 algorithm: OTPHashAlgorithm::Sha1,
702 issuer: Some("Example Service".to_string()),
703 }))],
704 tags: None,
705 extensions: None,
706 scope: None,
707 };
708
709 let ciphers: Vec<ImportingCipher> = parse_item(item);
710 assert_eq!(ciphers.len(), 1);
711 let cipher = ciphers.first().unwrap();
712
713 assert_eq!(cipher.folder_id, None);
714 assert_eq!(cipher.name, "My TOTP");
715 assert_eq!(cipher.notes, None);
716 assert!(!cipher.favorite);
717 assert_eq!(cipher.reprompt, 0);
718 assert_eq!(cipher.fields, vec![]);
719
720 let login = match &cipher.r#type {
721 CipherType::Login(login) => login,
722 _ => panic!("Expected login cipher for TOTP"),
723 };
724
725 assert!(login.totp.is_some());
727 let otpauth = login.totp.as_ref().unwrap();
728
729 assert!(
731 otpauth.starts_with("otpauth://totp/Example%20Service:test%40example%2Ecom?secret=")
732 );
733 assert!(otpauth.contains("&issuer=Example%20Service"));
734
735 assert!(!otpauth.contains("&period=30"));
737 assert!(!otpauth.contains("&digits=6"));
738 assert!(!otpauth.contains("&algorithm=SHA1"));
739
740 assert_eq!(login.username, None);
742 assert_eq!(login.password, None);
743 assert_eq!(login.login_uris, vec![]);
744 }
745
746 #[test]
747 fn test_totp_combined_with_basic_auth() {
748 use credential_exchange_format::{BasicAuthCredential, OTPHashAlgorithm, TotpCredential};
749
750 let item = Item {
751 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
752 creation_at: Some(1706613834),
753 modified_at: Some(1706623773),
754 title: "Login with TOTP".to_string(),
755 subtitle: None,
756 favorite: None,
757 credentials: vec![
758 Credential::BasicAuth(Box::new(BasicAuthCredential {
759 username: Some("myuser".to_string().into()),
760 password: Some("mypass".to_string().into()),
761 })),
762 Credential::Totp(Box::new(TotpCredential {
763 secret: "totpkey".as_bytes().to_vec().into(),
764 period: 30,
765 digits: 6,
766 username: Some("totpuser".to_string()),
767 algorithm: OTPHashAlgorithm::Sha1,
768 issuer: Some("Service".to_string()),
769 })),
770 ],
771 tags: None,
772 extensions: None,
773 scope: None,
774 };
775
776 let ciphers: Vec<ImportingCipher> = parse_item(item);
777 assert_eq!(ciphers.len(), 1);
778 let cipher = ciphers.first().unwrap();
779
780 let login = match &cipher.r#type {
781 CipherType::Login(login) => login,
782 _ => panic!("Expected login cipher"),
783 };
784
785 assert_eq!(login.username, Some("myuser".to_string()));
787 assert_eq!(login.password, Some("mypass".to_string()));
788 assert!(login.totp.is_some());
789
790 let otpauth = login.totp.as_ref().unwrap();
791 assert!(otpauth.starts_with("otpauth://totp/Service:totpuser?secret="));
792 assert!(otpauth.contains("&issuer=Service"));
793 }
794
795 #[test]
798 fn test_note_as_part_of_login() {
799 use credential_exchange_format::{BasicAuthCredential, Credential, Item, NoteCredential};
800
801 let item = Item {
802 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
803 creation_at: Some(1706613834),
804 modified_at: Some(1706623773),
805 title: "Login with Note".to_string(),
806 subtitle: None,
807 favorite: None,
808 credentials: vec![
809 Credential::BasicAuth(Box::new(BasicAuthCredential {
810 username: Some("testuser".to_string().into()),
811 password: Some("testpass".to_string().into()),
812 })),
813 Credential::Note(Box::new(NoteCredential {
814 content: "This note should be added to the login cipher."
815 .to_string()
816 .into(),
817 })),
818 ],
819 tags: None,
820 extensions: None,
821 scope: None,
822 };
823
824 let ciphers: Vec<ImportingCipher> = parse_item(item);
825 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
827
828 assert_eq!(cipher.name, "Login with Note");
829 assert_eq!(
830 cipher.notes,
831 Some("This note should be added to the login cipher.".to_string())
832 );
833
834 match &cipher.r#type {
835 CipherType::Login(_) => (), _ => panic!("Expected Login cipher with note content"),
837 };
838 }
839
840 #[test]
841 fn test_note_as_part_of_api_key() {
842 use credential_exchange_format::{ApiKeyCredential, Credential, Item, NoteCredential};
843
844 let item = Item {
845 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
846 creation_at: Some(1706613834),
847 modified_at: Some(1706623773),
848 title: "API Key with Note".to_string(),
849 subtitle: None,
850 favorite: None,
851 credentials: vec![
852 Credential::ApiKey(Box::new(ApiKeyCredential {
853 key: Some("api-key-12345".to_string().into()),
854 username: Some("api-user".to_string().into()),
855 key_type: Some("Bearer".to_string().into()),
856 url: None,
857 valid_from: None,
858 expiry_date: None,
859 })),
860 Credential::Note(Box::new(NoteCredential {
861 content: "This note should be added to the API key cipher."
862 .to_string()
863 .into(),
864 })),
865 ],
866 tags: None,
867 extensions: None,
868 scope: None,
869 };
870
871 let ciphers: Vec<ImportingCipher> = parse_item(item);
872 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
874
875 assert_eq!(cipher.name, "API Key with Note");
876 assert_eq!(
877 cipher.notes,
878 Some("This note should be added to the API key cipher.".to_string())
879 );
880
881 match &cipher.r#type {
882 CipherType::SecureNote(_) => (), _ => panic!("Expected SecureNote cipher with note content"),
884 };
885
886 assert!(!cipher.fields.is_empty());
888 }
889
890 #[test]
891 fn test_note_as_part_of_credit_card() {
892 use chrono::Month;
893 use credential_exchange_format::{Credential, CreditCardCredential, Item, NoteCredential};
894
895 let item = Item {
896 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
897 creation_at: Some(1706613834),
898 modified_at: Some(1706623773),
899 title: "Credit Card with Note".to_string(),
900 subtitle: None,
901 favorite: None,
902 credentials: vec![
903 Credential::CreditCard(Box::new(CreditCardCredential {
904 number: Some("1234 5678 9012 3456".to_string().into()),
905 full_name: Some("John Doe".to_string().into()),
906 card_type: Some("Visa".to_string().into()),
907 verification_number: Some("123".to_string().into()),
908 pin: None,
909 expiry_date: Some(
910 credential_exchange_format::EditableFieldYearMonth {
911 year: 2026,
912 month: Month::December,
913 }
914 .into(),
915 ),
916 valid_from: None,
917 })),
918 Credential::Note(Box::new(NoteCredential {
919 content: "This note should be added to the credit card cipher."
920 .to_string()
921 .into(),
922 })),
923 ],
924 tags: None,
925 extensions: None,
926 scope: None,
927 };
928
929 let ciphers: Vec<ImportingCipher> = parse_item(item);
930 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
932
933 assert_eq!(cipher.name, "Credit Card with Note");
934 assert_eq!(
935 cipher.notes,
936 Some("This note should be added to the credit card cipher.".to_string())
937 );
938
939 match &cipher.r#type {
940 CipherType::Card(_) => (), _ => panic!("Expected Card cipher with note content"),
942 };
943 }
944
945 #[test]
946 fn test_note_as_part_of_wifi() {
947 use credential_exchange_format::{
948 Credential, EditableFieldWifiNetworkSecurityType, Item, NoteCredential, WifiCredential,
949 };
950
951 let item = Item {
952 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
953 creation_at: Some(1706613834),
954 modified_at: Some(1706623773),
955 title: "WiFi with Note".to_string(),
956 subtitle: None,
957 favorite: None,
958 credentials: vec![
959 Credential::Wifi(Box::new(WifiCredential {
960 ssid: Some("MyNetwork".to_string().into()),
961 passphrase: Some("password123".to_string().into()),
962 network_security_type: Some(
963 EditableFieldWifiNetworkSecurityType::Wpa3Personal.into(),
964 ),
965 hidden: Some(false.into()),
966 })),
967 Credential::Note(Box::new(NoteCredential {
968 content: "This note should be added to the WiFi cipher."
969 .to_string()
970 .into(),
971 })),
972 ],
973 tags: None,
974 extensions: None,
975 scope: None,
976 };
977
978 let ciphers: Vec<ImportingCipher> = parse_item(item);
979 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
981
982 assert_eq!(cipher.name, "WiFi with Note");
983 assert_eq!(
984 cipher.notes,
985 Some("This note should be added to the WiFi cipher.".to_string())
986 );
987
988 match &cipher.r#type {
989 CipherType::SecureNote(_) => (), _ => panic!("Expected SecureNote cipher with note content"),
991 };
992
993 assert!(!cipher.fields.is_empty());
995 }
996
997 #[test]
998 fn test_credit_card_empty_title_uses_cardholder_name() {
999 let item = Item {
1000 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
1001 creation_at: Some(1706613834),
1002 modified_at: Some(1706623773),
1003 title: "".to_string(), subtitle: None,
1005 favorite: None,
1006 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
1007 number: Some("1234 5678 9012 3456".to_string().into()),
1008 full_name: Some("Jane Smith".to_string().into()), card_type: Some("Visa".to_string().into()),
1010 verification_number: Some("456".to_string().into()),
1011 pin: None,
1012 expiry_date: Some(
1013 EditableFieldYearMonth {
1014 year: 2027,
1015 month: Month::March,
1016 }
1017 .into(),
1018 ),
1019 valid_from: None,
1020 }))],
1021 tags: None,
1022 extensions: None,
1023 scope: None,
1024 };
1025
1026 let ciphers: Vec<ImportingCipher> = parse_item(item);
1027 assert_eq!(ciphers.len(), 1);
1028 let cipher = ciphers.first().unwrap();
1029
1030 assert_eq!(cipher.name, "Jane Smith");
1032
1033 let card = match &cipher.r#type {
1034 CipherType::Card(card) => card,
1035 _ => panic!("Expected card"),
1036 };
1037
1038 assert_eq!(card.cardholder_name, Some("Jane Smith".to_string()));
1039 }
1040
1041 #[test]
1042 fn test_credit_card_blank_title_uses_cardholder_name() {
1043 let item = Item {
1044 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
1045 creation_at: Some(1706613834),
1046 modified_at: Some(1706623773),
1047 title: " ".to_string(), subtitle: None,
1049 favorite: None,
1050 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
1051 number: Some("1234 5678 9012 3456".to_string().into()),
1052 full_name: Some("John Doe".to_string().into()),
1053 card_type: Some("Mastercard".to_string().into()),
1054 verification_number: Some("789".to_string().into()),
1055 pin: None,
1056 expiry_date: None,
1057 valid_from: None,
1058 }))],
1059 tags: None,
1060 extensions: None,
1061 scope: None,
1062 };
1063
1064 let ciphers: Vec<ImportingCipher> = parse_item(item);
1065 assert_eq!(ciphers.len(), 1);
1066 let cipher = ciphers.first().unwrap();
1067
1068 assert_eq!(cipher.name, "John Doe");
1070 }
1071
1072 #[test]
1073 fn test_credit_card_empty_title_no_cardholder_uses_fallback() {
1074 let item = Item {
1075 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
1076 creation_at: Some(1706613834),
1077 modified_at: Some(1706623773),
1078 title: "".to_string(), subtitle: None,
1080 favorite: None,
1081 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
1082 number: Some("1234 5678 9012 3456".to_string().into()),
1083 full_name: None, card_type: Some("Visa".to_string().into()),
1085 verification_number: Some("123".to_string().into()),
1086 pin: None,
1087 expiry_date: None,
1088 valid_from: None,
1089 }))],
1090 tags: None,
1091 extensions: None,
1092 scope: None,
1093 };
1094
1095 let ciphers: Vec<ImportingCipher> = parse_item(item);
1096 assert_eq!(ciphers.len(), 1);
1097 let cipher = ciphers.first().unwrap();
1098
1099 assert_eq!(cipher.name, "Untitled Card");
1101 }
1102
1103 #[test]
1104 fn test_credit_card_with_title_ignores_cardholder_name() {
1105 let item = Item {
1106 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
1107 creation_at: Some(1706613834),
1108 modified_at: Some(1706623773),
1109 title: "My Business Card".to_string(), subtitle: None,
1111 favorite: None,
1112 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
1113 number: Some("1234 5678 9012 3456".to_string().into()),
1114 full_name: Some("Jane Smith".to_string().into()),
1115 card_type: Some("Visa".to_string().into()),
1116 verification_number: Some("456".to_string().into()),
1117 pin: None,
1118 expiry_date: None,
1119 valid_from: None,
1120 }))],
1121 tags: None,
1122 extensions: None,
1123 scope: None,
1124 };
1125
1126 let ciphers: Vec<ImportingCipher> = parse_item(item);
1127 assert_eq!(ciphers.len(), 1);
1128 let cipher = ciphers.first().unwrap();
1129
1130 assert_eq!(cipher.name, "My Business Card");
1132 }
1133
1134 #[test]
1135 fn test_note_as_part_of_identity() {
1136 use credential_exchange_format::{AddressCredential, Credential, Item, NoteCredential};
1137
1138 let item = Item {
1139 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
1140 creation_at: Some(1706613834),
1141 modified_at: Some(1706623773),
1142 title: "Address with Note".to_string(),
1143 subtitle: None,
1144 favorite: None,
1145 credentials: vec![
1146 Credential::Address(Box::new(AddressCredential {
1147 street_address: Some("123 Main St".to_string().into()),
1148 city: Some("Springfield".to_string().into()),
1149 territory: Some("CA".to_string().into()),
1150 postal_code: Some("12345".to_string().into()),
1151 country: Some("US".to_string().into()),
1152 tel: Some("+1-555-123-4567".to_string().into()),
1153 })),
1154 Credential::Note(Box::new(NoteCredential {
1155 content: "This note should be added to the address identity cipher."
1156 .to_string()
1157 .into(),
1158 })),
1159 ],
1160 tags: None,
1161 extensions: None,
1162 scope: None,
1163 };
1164
1165 let ciphers: Vec<ImportingCipher> = parse_item(item);
1166 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
1168
1169 assert_eq!(cipher.name, "Address with Note");
1170 assert_eq!(
1171 cipher.notes,
1172 Some("This note should be added to the address identity cipher.".to_string())
1173 );
1174
1175 match &cipher.r#type {
1176 CipherType::Identity(_) => (), _ => panic!("Expected Identity cipher"),
1178 };
1179 }
1180
1181 #[test]
1182 fn test_wifi_with_note_and_custom_fields() {
1183 use bitwarden_vault::FieldType;
1184 use credential_exchange_format::{
1185 Credential, CustomFieldsCredential, EditableFieldValue,
1186 EditableFieldWifiNetworkSecurityType, Item, NoteCredential, WifiCredential,
1187 };
1188
1189 let item = Item {
1190 id: vec![0, 1, 2, 3, 4, 5, 6].into(),
1191 creation_at: Some(1706613834),
1192 modified_at: Some(1706623773),
1193 title: "Wireless Router".to_string(),
1194 subtitle: None,
1195 favorite: None,
1196 credentials: vec![
1197 Credential::Wifi(Box::new(WifiCredential {
1198 ssid: Some("networker".to_string().into()),
1199 passphrase: Some("zhc6KLx9CD7Kj2RV9vPF".to_string().into()),
1200 network_security_type: Some(
1201 EditableFieldWifiNetworkSecurityType::Wpa3Personal.into(),
1202 ),
1203 hidden: None,
1204 })),
1205 Credential::Note(Box::new(NoteCredential {
1206 content: "My notes heigfkfdkkcmdwkkfkckekfkjf".to_string().into(),
1207 })),
1208 Credential::CustomFields(Box::new(CustomFieldsCredential {
1209 id: None,
1210 label: None,
1211 fields: vec![
1212 EditableFieldValue::String("My Station".to_string().into()),
1213 EditableFieldValue::ConcealedString(
1214 "hf6LW9UMmaxDg4sy6YCv".to_string().into(),
1215 ),
1216 EditableFieldValue::String("1.1.1.3".to_string().into()),
1217 EditableFieldValue::String("".to_string().into()),
1218 EditableFieldValue::ConcealedString(
1219 "kJaFcs7KwETkrmnpiQER".to_string().into(),
1220 ),
1221 ],
1222 extensions: vec![],
1223 })),
1224 ],
1225 tags: None,
1226 extensions: None,
1227 scope: None,
1228 };
1229
1230 let ciphers: Vec<ImportingCipher> = parse_item(item);
1231 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
1234 assert_eq!(cipher.name, "Wireless Router");
1235 assert_eq!(
1236 cipher.notes,
1237 Some("My notes heigfkfdkkcmdwkkfkckekfkjf".to_string())
1238 );
1239
1240 match &cipher.r#type {
1241 CipherType::SecureNote(_) => (), _ => panic!("Expected SecureNote cipher"),
1243 };
1244
1245 assert_eq!(cipher.fields.len(), 8); assert!(
1250 cipher
1251 .fields
1252 .iter()
1253 .any(|f| f.name.as_deref() == Some("SSID")
1254 && f.value.as_deref() == Some("networker"))
1255 );
1256 assert!(
1257 cipher
1258 .fields
1259 .iter()
1260 .any(|f| f.name.as_deref() == Some("Passphrase")
1261 && f.value.as_deref() == Some("zhc6KLx9CD7Kj2RV9vPF")
1262 && f.r#type == FieldType::Hidden as u8)
1263 );
1264 assert!(
1265 cipher
1266 .fields
1267 .iter()
1268 .any(|f| f.name.as_deref() == Some("Network Security Type")
1269 && f.value.as_deref() == Some("WPA3 Personal"))
1270 );
1271
1272 assert!(
1274 cipher
1275 .fields
1276 .iter()
1277 .any(|f| f.value.as_deref() == Some("My Station"))
1278 );
1279 assert!(
1280 cipher
1281 .fields
1282 .iter()
1283 .any(|f| f.value.as_deref() == Some("hf6LW9UMmaxDg4sy6YCv")
1284 && f.r#type == FieldType::Hidden as u8)
1285 );
1286 assert!(
1287 cipher
1288 .fields
1289 .iter()
1290 .any(|f| f.value.as_deref() == Some("1.1.1.3"))
1291 );
1292 }
1293}