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