diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs new file mode 100644 index 000000000..a80760ea1 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -0,0 +1,696 @@ +use bitwarden_vault::FieldType; +use credential_exchange_format::{ + AddressCredential, DriversLicenseCredential, EditableField, EditableFieldCountryCode, + EditableFieldDate, EditableFieldString, IdentityDocumentCredential, PassportCredential, + PersonNameCredential, +}; + +use crate::{Field, Identity}; + +/// Helper trait to extract value from various EditableField types +trait ExtractValue { + fn extract_value(&self) -> String; +} + +impl ExtractValue for EditableField { + fn extract_value(&self) -> String { + self.value.0.clone() + } +} + +impl ExtractValue for EditableField { + fn extract_value(&self) -> String { + self.value.0.clone() + } +} + +impl ExtractValue for EditableField { + fn extract_value(&self) -> String { + self.value.0.to_string() + } +} + +/// Generic helper function to create a custom field from any EditableField type +fn create_custom_field( + editable_field: Option<&T>, + field_name: &str, +) -> Option { + editable_field.map(|field| Field { + name: Some(field_name.to_string()), + value: Some(field.extract_value()), + r#type: FieldType::Text as u8, + linked_id: None, + }) +} + +/// Convert address credentials to Identity (no custom fields needed for address) +/// According to the mapping specification: +/// - streetAddress: EditableField<"string"> → Identity::address1 +/// - city: EditableField<"string"> → Identity::city +/// - territory: EditableField<"subdivision-code"> → Identity::state +/// - country: EditableField<"country-code"> → Identity::country +/// - tel: EditableField<"string"> → Identity::phone +/// - postalCode: EditableField<"string"> → Identity::postal_code +pub fn address_to_identity(address: &AddressCredential) -> (Identity, Vec) { + let identity = Identity { + title: None, + first_name: None, + middle_name: None, + last_name: None, + address1: address.street_address.as_ref().map(|s| s.value.0.clone()), + address2: None, + address3: None, + city: address.city.as_ref().map(|c| c.value.0.clone()), + state: address.territory.as_ref().map(|t| t.value.0.clone()), + postal_code: address.postal_code.as_ref().map(|p| p.value.0.clone()), + country: address.country.as_ref().map(|c| c.value.0.clone()), + company: None, + email: None, + phone: address.tel.as_ref().map(|t| t.value.0.clone()), + ssn: None, + username: None, + passport_number: None, + license_number: None, + }; + + // Address credentials don't have unmapped fields, so no custom fields needed + (identity, vec![]) +} + +/// Convert passport credentials to Identity and custom fields +/// According to CXF mapping document: +/// - passportNumber: EditableField<"string"> → Identity::passport_number +/// - nationalIdentificationNumber: EditableField<"string"> → Identity::ssn +/// - fullName: EditableField<"string"> → Identity::first_name + last_name (split) +/// - All other fields → CustomFields +pub fn passport_to_identity(passport: &PassportCredential) -> (Identity, Vec) { + // Split full name into first and last name if available + let (first_name, last_name) = if let Some(full_name) = &passport.full_name { + let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); + match name_parts.len() { + 0 => (None, None), + 1 => (Some(name_parts[0].to_string()), None), + _ => { + let first = name_parts[0].to_string(); + let last = name_parts[1..].join(" "); + (Some(first), Some(last)) + } + } + } else { + (None, None) + }; + + let identity = Identity { + title: None, + first_name, + middle_name: None, + last_name, + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, // According to mapping doc, issuingCountry should be CustomField + company: None, + email: None, + phone: None, + // Map nationalIdentificationNumber to ssn as closest available field + ssn: passport + .national_identification_number + .as_ref() + .map(|n| n.value.0.clone()), + username: None, + passport_number: passport.passport_number.as_ref().map(|p| p.value.0.clone()), + license_number: None, + }; + + // Create custom fields for unmapped data according to CXF mapping document + let mut custom_fields = Vec::new(); + + if let Some(field) = create_custom_field(passport.issuing_country.as_ref(), "Issuing Country") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(passport.nationality.as_ref(), "Nationality") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(passport.birth_date.as_ref(), "Birth Date") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(passport.birth_place.as_ref(), "Birth Place") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(passport.sex.as_ref(), "Sex") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(passport.issue_date.as_ref(), "Issue Date") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(passport.expiry_date.as_ref(), "Expiry Date") { + custom_fields.push(field); + } + if let Some(field) = + create_custom_field(passport.issuing_authority.as_ref(), "Issuing Authority") + { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(passport.passport_type.as_ref(), "Passport Type") { + custom_fields.push(field); + } + + (identity, custom_fields) +} + +/// Convert person name credentials to Identity and custom fields +/// According to CXF mapping: +/// - title: EditableField<"string"> → Identity::title +/// - given: EditableField<"string"> → Identity::first_name +/// - given2: EditableField<"string"> → Identity::middle_name +/// - surname: EditableField<"string"> → Identity::last_name +/// - surnamePrefix + surname + surname2: combine for complete last name +/// - credentials: EditableField<"string"> → Identity::company (as professional credentials) +/// - Other fields → CustomFields +pub fn person_name_to_identity(person_name: &PersonNameCredential) -> (Identity, Vec) { + // Construct complete last name from surnamePrefix, surname, and surname2 + let last_name = { + let mut parts = Vec::new(); + + if let Some(prefix) = &person_name.surname_prefix { + parts.push(prefix.value.0.clone()); + } + if let Some(surname) = &person_name.surname { + parts.push(surname.value.0.clone()); + } + if let Some(surname2) = &person_name.surname2 { + parts.push(surname2.value.0.clone()); + } + + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } + }; + + let identity = Identity { + title: person_name.title.as_ref().map(|t| t.value.0.clone()), + first_name: person_name.given.as_ref().map(|g| g.value.0.clone()), + middle_name: person_name.given2.as_ref().map(|g2| g2.value.0.clone()), + last_name, + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, + // Map credentials (e.g., "PhD") to company field as professional qualifications + company: person_name.credentials.as_ref().map(|c| c.value.0.clone()), + email: None, + phone: None, + ssn: None, + username: None, + passport_number: None, + license_number: None, + }; + + // Create custom fields for unmapped data + let mut custom_fields = Vec::new(); + + if let Some(field) = + create_custom_field(person_name.given_informal.as_ref(), "Informal Given Name") + { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(person_name.generation.as_ref(), "Generation") { + custom_fields.push(field); + } + + (identity, custom_fields) +} + +/// Convert drivers license credentials to Identity and custom fields +/// According to CXF mapping document: +/// - licenseNumber: EditableField<"string"> → Identity::license_number +/// - fullName: EditableField<"string"> → Identity::first_name + last_name (split) +/// - territory: EditableField<"subdivision-code"> → Identity::state +/// - country: EditableField<"country-code"> → Identity::country +/// - All other fields → CustomFields +pub fn drivers_license_to_identity( + drivers_license: &DriversLicenseCredential, +) -> (Identity, Vec) { + // Split full name into first and last name if available + let (first_name, last_name) = if let Some(full_name) = &drivers_license.full_name { + let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); + match name_parts.len() { + 0 => (None, None), + 1 => (Some(name_parts[0].to_string()), None), + _ => { + let first = name_parts[0].to_string(); + let last = name_parts[1..].join(" "); + (Some(first), Some(last)) + } + } + } else { + (None, None) + }; + + let identity = Identity { + title: None, + first_name, + middle_name: None, + last_name, + address1: None, + address2: None, + address3: None, + city: None, + // Map territory (state/province) to state field + state: drivers_license + .territory + .as_ref() + .map(|t| t.value.0.clone()), + postal_code: None, + // Map country to country field + country: drivers_license.country.as_ref().map(|c| c.value.0.clone()), + company: None, // According to mapping doc, issuingAuthority should be CustomField + email: None, + phone: None, + ssn: None, + username: None, + passport_number: None, + license_number: drivers_license + .license_number + .as_ref() + .map(|l| l.value.0.clone()), + }; + + // Create custom fields for unmapped data according to CXF mapping document + let mut custom_fields = Vec::new(); + + if let Some(field) = create_custom_field(drivers_license.birth_date.as_ref(), "Birth Date") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(drivers_license.issue_date.as_ref(), "Issue Date") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(drivers_license.expiry_date.as_ref(), "Expiry Date") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field( + drivers_license.issuing_authority.as_ref(), + "Issuing Authority", + ) { + custom_fields.push(field); + } + if let Some(field) = + create_custom_field(drivers_license.license_class.as_ref(), "License Class") + { + custom_fields.push(field); + } + + (identity, custom_fields) +} + +/// Convert identity document credentials to Identity and custom fields +/// According to CXF mapping document: IdentityDocument ↔︎ Identity +/// Fields are mapped similarly to passport but for general identity documents +/// - documentNumber: EditableField<"string"> → Identity::passport_number (reusing for general +/// document number) +/// - identificationNumber: EditableField<"string"> → Identity::ssn +/// - fullName: EditableField<"string"> → Identity::first_name + last_name (split) +/// - All other fields → CustomFields +pub fn identity_document_to_identity( + identity_document: &IdentityDocumentCredential, +) -> (Identity, Vec) { + // Split full name into first and last name if available + let (first_name, last_name) = if let Some(full_name) = &identity_document.full_name { + let name_parts: Vec<&str> = full_name.value.0.split_whitespace().collect(); + match name_parts.len() { + 0 => (None, None), + 1 => (Some(name_parts[0].to_string()), None), + _ => { + let first = name_parts[0].to_string(); + let last = name_parts[1..].join(" "); + (Some(first), Some(last)) + } + } + } else { + (None, None) + }; + + let identity = Identity { + title: None, + first_name, + middle_name: None, + last_name, + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, // issuingCountry goes to custom fields + company: None, + email: None, + phone: None, + // Map identificationNumber to ssn + ssn: identity_document + .identification_number + .as_ref() + .map(|n| n.value.0.clone()), + username: None, + // Map documentNumber to passport_number (reusing for document number) + passport_number: identity_document + .document_number + .as_ref() + .map(|d| d.value.0.clone()), + license_number: None, + }; + + // Create custom fields for unmapped data according to CXF mapping document + let mut custom_fields = Vec::new(); + + if let Some(field) = create_custom_field( + identity_document.issuing_country.as_ref(), + "Issuing Country", + ) { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.nationality.as_ref(), "Nationality") + { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.birth_date.as_ref(), "Birth Date") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.birth_place.as_ref(), "Birth Place") + { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.sex.as_ref(), "Sex") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.issue_date.as_ref(), "Issue Date") { + custom_fields.push(field); + } + if let Some(field) = create_custom_field(identity_document.expiry_date.as_ref(), "Expiry Date") + { + custom_fields.push(field); + } + if let Some(field) = create_custom_field( + identity_document.issuing_authority.as_ref(), + "Issuing Authority", + ) { + custom_fields.push(field); + } + // Note: identity-document doesn't have a document_type field in the CXF example + + (identity, custom_fields) +} + +#[cfg(test)] +mod tests { + use std::fs; + + // Tests only use the public parse_cxf function, no direct function imports needed + use crate::cxf::import::parse_cxf; + + fn load_sample_cxf() -> Result, crate::cxf::CxfError> { + // Read the actual CXF example file + let cxf_data = fs::read_to_string("resources/cxf_example.json") + .expect("Should be able to read cxf_example.json"); + + // Workaround for library bug: the example file has "integrityHash" but the library expects + // "integrationHash" + let fixed_cxf_data = cxf_data.replace("\"integrityHash\":", "\"integrationHash\":"); + + parse_cxf(fixed_cxf_data) + } + + #[test] + fn test_address_complete_mapping() { + // Test both unit logic and real data integration + let result = load_sample_cxf(); + assert!(result.is_ok()); + let ciphers = result.unwrap(); + + // Find the address cipher from cxf_example.json + let address_cipher = ciphers + .iter() + .find(|c| c.name == "House Address") + .expect("Should find House Address item"); + + // Verify it's an Identity cipher + let identity = match &address_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for address"), + }; + + // Verify all address field mappings from cxf_example.json + assert_eq!(identity.address1, Some("123 Main Street".to_string())); + assert_eq!(identity.city, Some("Springfield".to_string())); + assert_eq!(identity.state, Some("CA".to_string())); + assert_eq!(identity.country, Some("US".to_string())); + assert_eq!(identity.phone, Some("+1-555-123-4567".to_string())); + assert_eq!(identity.postal_code, Some("12345".to_string())); + + // Verify no unmapped fields (address has no custom fields) + assert_eq!(address_cipher.fields.len(), 0); + + // Verify unused Identity fields remain None + assert_eq!(identity.first_name, None); + assert_eq!(identity.passport_number, None); + assert_eq!(identity.license_number, None); + } + + #[test] + fn test_passport_complete_mapping() { + // Test both unit logic and real data integration + let result = load_sample_cxf(); + assert!(result.is_ok()); + let ciphers = result.unwrap(); + + // Find the passport cipher from cxf_example.json + let passport_cipher = ciphers + .iter() + .find(|c| c.name == "Passport") + .expect("Should find Passport item"); + + // Verify it's an Identity cipher + let identity = match &passport_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for passport"), + }; + + // Verify Identity field mappings from cxf_example.json + assert_eq!(identity.passport_number, Some("A12345678".to_string())); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.last_name, Some("Doe".to_string())); + assert_eq!(identity.ssn, Some("ID123456789".to_string())); + assert_eq!(identity.country, None); // Now custom field per mapping + + // Verify custom fields preserve all unmapped data + assert!( + passport_cipher.fields.len() >= 4, + "Should have multiple custom fields" + ); + + // Check specific custom fields + let issuing_country = passport_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Issuing Country")) + .expect("Should have Issuing Country"); + assert_eq!(issuing_country.value, Some("US".to_string())); + + let nationality = passport_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Nationality")) + .expect("Should have Nationality"); + assert_eq!(nationality.value, Some("American".to_string())); + + // Verify unused Identity fields remain None + assert_eq!(identity.address1, None); + assert_eq!(identity.license_number, None); + } + + #[test] + fn test_person_name_complete_mapping() { + // Test both unit logic and real data integration + let result = load_sample_cxf(); + assert!(result.is_ok()); + let ciphers = result.unwrap(); + + // Find the person name cipher from cxf_example.json + let person_name_cipher = ciphers + .iter() + .find(|c| c.name == "John Doe") + .expect("Should find John Doe item"); + + // Verify it's an Identity cipher + let identity = match &person_name_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for person name"), + }; + + // Verify Identity field mappings from cxf_example.json + assert_eq!(identity.title, Some("Dr.".to_string())); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.middle_name, Some("Michael".to_string())); + assert_eq!(identity.last_name, Some("van Doe Smith".to_string())); // Combined surname + assert_eq!(identity.company, Some("PhD".to_string())); // credentials → company + + // Verify custom fields preserve unmapped data + assert!( + person_name_cipher.fields.len() >= 2, + "Should have custom fields" + ); + + let informal_given = person_name_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Informal Given Name")) + .expect("Should have Informal Given Name"); + assert_eq!(informal_given.value, Some("Johnny".to_string())); + + let generation = person_name_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Generation")) + .expect("Should have Generation"); + assert_eq!(generation.value, Some("III".to_string())); + + // Verify unused Identity fields remain None + assert_eq!(identity.address1, None); + assert_eq!(identity.passport_number, None); + assert_eq!(identity.license_number, None); + } + + #[test] + fn test_drivers_license_complete_mapping() { + // Test both unit logic and real data integration + let result = load_sample_cxf(); + assert!(result.is_ok()); + let ciphers = result.unwrap(); + + // Find the drivers license cipher from cxf_example.json + let drivers_license_cipher = ciphers + .iter() + .find(|c| c.name == "Driver License") + .expect("Should find Driver License item"); + + // Verify it's an Identity cipher + let identity = match &drivers_license_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for drivers license"), + }; + + // Verify Identity field mappings from cxf_example.json + assert_eq!(identity.license_number, Some("D12345678".to_string())); + assert_eq!(identity.first_name, Some("John".to_string())); + assert_eq!(identity.last_name, Some("Doe".to_string())); + assert_eq!(identity.state, Some("CA".to_string())); + assert_eq!(identity.country, Some("US".to_string())); + assert_eq!(identity.company, None); // issuingAuthority is now custom field + + // Verify custom fields preserve unmapped data + assert!( + drivers_license_cipher.fields.len() >= 3, + "Should have multiple custom fields" + ); + + let issuing_authority = drivers_license_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Issuing Authority")) + .expect("Should have Issuing Authority"); + assert_eq!( + issuing_authority.value, + Some("Department of Motor Vehicles".to_string()) + ); + + let license_class = drivers_license_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("License Class")) + .expect("Should have License Class"); + assert_eq!(license_class.value, Some("C".to_string())); + + // Verify unused Identity fields remain None + assert_eq!(identity.title, None); + assert_eq!(identity.address1, None); + assert_eq!(identity.passport_number, None); + } + + #[test] + fn test_identity_document_complete_mapping() { + // Test both unit logic and real data integration + let result = load_sample_cxf(); + assert!(result.is_ok()); + let ciphers = result.unwrap(); + + // Find the identity document cipher from cxf_example.json + let identity_document_cipher = ciphers + .iter() + .find(|c| c.name == "ID card") + .expect("Should find ID card item"); + + // Verify it's an Identity cipher + let identity = match &identity_document_cipher.r#type { + crate::CipherType::Identity(identity) => identity, + _ => panic!("Expected Identity cipher for identity document"), + }; + + // Verify Identity field mappings from cxf_example.json + assert_eq!(identity.passport_number, Some("123456789".to_string())); // documentNumber → passport_number + assert_eq!(identity.first_name, Some("Jane".to_string())); // fullName split + assert_eq!(identity.last_name, Some("Doe".to_string())); // fullName split + assert_eq!(identity.ssn, Some("ID123456789".to_string())); // identificationNumber → ssn + assert_eq!(identity.country, None); // issuingCountry goes to custom fields + + // Verify custom fields preserve unmapped data + assert!( + identity_document_cipher.fields.len() >= 6, + "Should have multiple custom fields" + ); + + // Check specific custom fields + let issuing_country = identity_document_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Issuing Country")) + .expect("Should have Issuing Country"); + assert_eq!(issuing_country.value, Some("US".to_string())); + + let nationality = identity_document_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Nationality")) + .expect("Should have Nationality"); + assert_eq!(nationality.value, Some("American".to_string())); + + let birth_place = identity_document_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Birth Place")) + .expect("Should have Birth Place"); + assert_eq!(birth_place.value, Some("New York, USA".to_string())); + + let issuing_authority = identity_document_cipher + .fields + .iter() + .find(|f| f.name.as_deref() == Some("Issuing Authority")) + .expect("Should have Issuing Authority"); + assert_eq!( + issuing_authority.value, + Some("Department of State".to_string()) + ); + + // Verify unused Identity fields remain None + assert_eq!(identity.title, None); + assert_eq!(identity.address1, None); + assert_eq!(identity.license_number, None); + assert_eq!(identity.company, None); + } +} diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 82c6eb7a1..8c30fec42 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -1,11 +1,16 @@ use chrono::{DateTime, Utc}; use credential_exchange_format::{ - BasicAuthCredential, Credential, CreditCardCredential, Header, Item, PasskeyCredential, + AddressCredential, BasicAuthCredential, Credential, CreditCardCredential, + DriversLicenseCredential, Header, IdentityDocumentCredential, Item, PasskeyCredential, + PassportCredential, PersonNameCredential, }; -use serde_json; use crate::{ cxf::{ + identity::{ + address_to_identity, drivers_license_to_identity, identity_document_to_identity, + passport_to_identity, person_name_to_identity, + }, login::{to_fields, to_login}, CxfError, }, @@ -63,12 +68,7 @@ fn parse_item(value: Item) -> Vec { }) } - if !grouped.credit_card.is_empty() { - let credit_card = grouped - .credit_card - .first() - .expect("Credit card is not empty"); - + if let Some(credit_card) = grouped.credit_card.first() { output.push(ImportingCipher { folder_id: None, // TODO: Handle folders name: value.title.clone(), @@ -83,6 +83,96 @@ fn parse_item(value: Item) -> Vec { }) } + // Address credentials + if let Some(address) = grouped.address.first() { + let (identity, custom_fields) = address_to_identity(address); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: custom_fields, + revision_date, + creation_date, + deleted_date: None, + }) + } + + // Passport credentials + if let Some(passport) = grouped.passport.first() { + let (identity, custom_fields) = passport_to_identity(passport); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: custom_fields, + revision_date, + creation_date, + deleted_date: None, + }) + } + + // Person name credentials + if let Some(person_name) = grouped.person_name.first() { + let (identity, custom_fields) = person_name_to_identity(person_name); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: custom_fields, + revision_date, + creation_date, + deleted_date: None, + }) + } + + // Drivers license credentials + if let Some(drivers_license) = grouped.drivers_license.first() { + let (identity, custom_fields) = drivers_license_to_identity(drivers_license); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: custom_fields, + revision_date, + creation_date, + deleted_date: None, + }) + } + + // Identity document credentials + if let Some(identity_document) = grouped.identity_document.first() { + let (identity, custom_fields) = identity_document_to_identity(identity_document); + + output.push(ImportingCipher { + folder_id: None, // TODO: Handle folders + name: value.title.clone(), + notes: None, + r#type: CipherType::Identity(Box::new(identity)), + favorite: false, + reprompt: 0, + fields: custom_fields, + revision_date, + creation_date, + deleted_date: None, + }) + } + output } @@ -116,6 +206,26 @@ fn group_credentials_by_type(credentials: Vec) -> GroupedCredentials Credential::CreditCard(credit_card) => Some(credit_card.as_ref()), _ => None, }), + address: filter_credentials(&credentials, |c| match c { + Credential::Address(address) => Some(address.as_ref()), + _ => None, + }), + passport: filter_credentials(&credentials, |c| match c { + Credential::Passport(passport) => Some(passport.as_ref()), + _ => None, + }), + person_name: filter_credentials(&credentials, |c| match c { + Credential::PersonName(person_name) => Some(person_name.as_ref()), + _ => None, + }), + drivers_license: filter_credentials(&credentials, |c| match c { + Credential::DriversLicense(drivers_license) => Some(drivers_license.as_ref()), + _ => None, + }), + identity_document: filter_credentials(&credentials, |c| match c { + Credential::IdentityDocument(identity_document) => Some(identity_document.as_ref()), + _ => None, + }), } } @@ -123,6 +233,11 @@ struct GroupedCredentials { basic_auth: Vec, passkey: Vec, credit_card: Vec, + address: Vec, + passport: Vec, + person_name: Vec, + drivers_license: Vec, + identity_document: Vec, } #[cfg(test)] diff --git a/crates/bitwarden-exporters/src/cxf/mod.rs b/crates/bitwarden-exporters/src/cxf/mod.rs index 21f5f40b5..50636c44d 100644 --- a/crates/bitwarden-exporters/src/cxf/mod.rs +++ b/crates/bitwarden-exporters/src/cxf/mod.rs @@ -13,4 +13,5 @@ pub use export::Account; mod import; pub(crate) use import::parse_cxf; mod card; +mod identity; mod login;