diff --git a/Cargo.lock b/Cargo.lock index 14943a298..9f82acb7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -504,6 +504,7 @@ dependencies = [ "bitwarden-crypto", "bitwarden-error", "bitwarden-fido", + "bitwarden-ssh", "bitwarden-vault", "chrono", "credential-exchange-format", @@ -1166,9 +1167,8 @@ dependencies = [ [[package]] name = "credential-exchange-format" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5209d9b20c3eb8d2b96f3b06133b27d79168f312c09a8035f46c0fb18053aefc" +version = "0.2.0" +source = "git+https://github.com/bitwarden/credential-exchange?rev=38e8a013c13644f832c457555baaa536fe481b77#38e8a013c13644f832c457555baaa536fe481b77" dependencies = [ "chrono", "data-encoding", diff --git a/crates/bitwarden-exporters/Cargo.toml b/crates/bitwarden-exporters/Cargo.toml index 51fc4d05e..69734a510 100644 --- a/crates/bitwarden-exporters/Cargo.toml +++ b/crates/bitwarden-exporters/Cargo.toml @@ -30,9 +30,10 @@ bitwarden-core = { workspace = true } bitwarden-crypto = { workspace = true } bitwarden-error = { workspace = true } bitwarden-fido = { workspace = true } +bitwarden-ssh = { workspace = true } bitwarden-vault = { workspace = true } chrono = { workspace = true, features = ["std"] } -credential-exchange-format = ">=0.1, <0.2" +credential-exchange-format = { git = "https://github.com/bitwarden/credential-exchange", rev = "38e8a013c13644f832c457555baaa536fe481b77" } csv = "1.3.0" num-traits = ">=0.2, <0.3" serde = { workspace = true } diff --git a/crates/bitwarden-exporters/resources/cxf_example.json b/crates/bitwarden-exporters/resources/cxf_example.json new file mode 100644 index 000000000..727164d5e --- /dev/null +++ b/crates/bitwarden-exporters/resources/cxf_example.json @@ -0,0 +1,599 @@ +{ + "version": { + "major": 1, + "minor": 0 + }, + "exporterRpId": "exporter.example.com", + "exporterDisplayName": "Exporter app", + "timestamp": 1705228800, + "accounts": [ + { + "id": "DZSXp7iBQY-Fg-OofakQtQ", + "username": "jane_smith", + "email": "jane.smith@example.com", + "fullName": "Jane Smith", + "items": [ + { + "id": "9OF-QjVDQo2Wp2xWPw6ZhA", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "GitHub Login", + "subtitle": "Work GitHub account", + "scope": { + "urls": ["https://github.com"], + "androidApps": [] + }, + "credentials": [ + { + "type": "basic-auth", + "username": { + "id": "-eZX0Gw-TzOsBFwt67N7ZA", + "fieldType": "string", + "value": "johndoe", + "label": "Username field" + }, + "password": { + "id": "wgu3wTcXSYawrGMWMtaANg", + "fieldType": "concealed-string", + "value": "securepassword123", + "label": "Password field" + } + }, + { + "type": "totp", + "secret": "JBSWY3DPEHPK3PXP", + "period": 30, + "digits": 6, + "issuer": "Google", + "algorithm": "sha256", + "username": "jane.smith@example.com" + } + ], + "tags": ["development", "git", "work"] + }, + { + "id": "akKA3Y0jQRuK7sKplB0Y9w", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "WebAuthn.io", + "subtitle": "johndoe", + "credentials": [ + { + "type": "passkey", + "credentialId": "Y3JlZGVudGlhbElkRXhhbXBsZQ", + "rpId": "webauthn.io", + "username": "johndoe", + "userDisplayName": "John Doe", + "userHandle": "cnEzaNHWcYK3coWZjvoaV1Hj9gnI12mKe2dL2HZVFlY", + "key": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgARu_0sCt20EpgVxb4Puq3Ga5VVLpuTY75ngvZlyq3X6hRANCAASmdk1xLsK0oOlhxIPp0d1ZuS0sT9nf6BZtSelhqvLBW0fOL33l_bXgsr_STUHjCLn8l6gcRJwe7OQvbQubZ1dY", + "fido2Extensions": { + "hmacSecret": { + "algorithm": "HS256", + "secret": "c2VjcmV0X2tleV9kYXRh" + } + } + } + ] + }, + { + "id": "iz0Q6JWoQ_CbDRboCPJ1Tg", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "Visa Credit Card", + "subtitle": "Personal Visa card", + "credentials": [ + { + "type": "credit-card", + "number": { + "id": "MTIz", + "fieldType": "concealed-string", + "value": "4111111111111111", + "label": "Card Number" + }, + "fullName": { + "fieldType": "string", + "value": "John Doe", + "label": "Cardholder Name" + }, + "cardType": { + "fieldType": "string", + "value": "Visa", + "label": "Card Type" + }, + "verificationNumber": { + "fieldType": "concealed-string", + "value": "123", + "label": "CVV" + }, + "pin": { + "fieldType": "concealed-string", + "value": "0000", + "label": "PIN" + }, + "expiryDate": { + "fieldType": "year-month", + "value": "2027-08", + "label": "Expiry Date" + }, + "validFrom": { + "fieldType": "year-month", + "value": "2024-02", + "label": "Valid From" + } + } + ], + "tags": ["finance", "credit card", "personal"] + }, + { + "id": "2cGy6PNOSQ2cW43NVxjGSg", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "Wifi", + "subtitle": "Home Wifi", + "credentials": [ + { + "type": "wifi", + "ssid": { + "fieldType": "string", + "value": "Home_Network", + "label": "Wi-Fi SSID" + }, + "networkSecurityType": { + "fieldType": "wifi-network-security-type", + "value": "WPA2", + "label": "Security Type" + }, + "passphrase": { + "fieldType": "concealed-string", + "value": "mypassword123", + "label": "Wi-Fi Password" + }, + "hidden": { + "fieldType": "boolean", + "value": "false", + "label": "Hidden Network" + } + } + ] + }, + { + "id": "s4TK1UNTRhG4j1DQawUz8g", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "Home alarm", + "subtitle": "instructions", + "credentials": [ + { + "type": "note", + "content": { + "fieldType": "string", + "value": "some instructionts to enable/disable the alarm", + "label": "alarm" + } + } + ] + }, + { + "id": "BQzS9Ws3RnOabLzFuyOu7Q", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "Driver License", + "subtitle": "US", + "credentials": [ + { + "type": "drivers-license", + "fullName": { + "fieldType": "string", + "value": "John Doe", + "label": "Full Name" + }, + "birthDate": { + "fieldType": "date", + "value": "1990-05-15", + "label": "Date of Birth" + }, + "issueDate": { + "fieldType": "date", + "value": "2020-06-01", + "label": "Issue Date" + }, + "expiryDate": { + "fieldType": "date", + "value": "2030-06-01", + "label": "Expiry Date" + }, + "issuingAuthority": { + "fieldType": "string", + "value": "Department of Motor Vehicles", + "label": "Issuing Authority" + }, + "territory": { + "fieldType": "subdivision-code", + "value": "CA", + "label": "Territory" + }, + "country": { + "fieldType": "country-code", + "value": "US", + "label": "Country" + }, + "licenseNumber": { + "fieldType": "string", + "value": "D12345678", + "label": "License Number" + }, + "licenseClass": { + "fieldType": "string", + "value": "C", + "label": "License Class" + } + } + ] + }, + { + "id": "HHl63ybfQG6GBRHlyrvKfg", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "House Address", + "subtitle": "US", + "credentials": [ + { + "type": "address", + "streetAddress": { + "fieldType": "string", + "value": "123 Main Street", + "label": "Street Address" + }, + "postalCode": { + "fieldType": "string", + "value": "12345", + "label": "Postal Code" + }, + "city": { + "fieldType": "string", + "value": "Springfield", + "label": "City" + }, + "territory": { + "fieldType": "subdivision-code", + "value": "CA", + "label": "State" + }, + "country": { + "fieldType": "country-code", + "value": "US", + "label": "Country" + }, + "tel": { + "fieldType": "string", + "value": "+1-555-123-4567", + "label": "Telephone" + } + } + ] + }, + { + "id": "Z4cFmc21Q5-vCVwd1wJx1g", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "SSH Key", + "subtitle": "GitHub", + "credentials": [ + { + "type": "ssh-key", + "keyType": "ssh-rsa", + "privateKey": "MC4CAQAwBQYDK2VwBCIEID-U9VakauO4Fsv4b_znpDHcdYg74U68siZjnWLPn7Q1", + "keyComment": "Work SSH Key", + "creationDate": { + "fieldType": "date", + "value": "2023-01-01", + "label": "Creation Date" + }, + "expiryDate": { + "fieldType": "date", + "value": "2025-01-01", + "label": "Expiry Date", + "extensions": [] + }, + "keyGenerationSource": { + "fieldType": "string", + "value": "Generated using OpenSSH", + "label": "Key Generation Source" + } + } + ] + }, + { + "id": "EWM-4m3pSEi0ZBQbFVB92g", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "ID card", + "subtitle": "US", + "credentials": [ + { + "type": "file", + "id": "VGVzdEZpbGVJRA", + "name": "example-document.pdf", + "decryptedSize": 2048576, + "integrityHash": "dGhpcyBpcyBhIHNhbXBsZSBpbnRlZ3JpdHkgaGFzaA" + } + ] + }, + { + "id": "U9TPhd80SsWKKUtx3HxVsA", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "ID card", + "subtitle": "US", + "credentials": [ + { + "type": "identity-document", + "issuingCountry": { + "fieldType": "country-code", + "value": "US", + "label": "Issuing Country" + }, + "documentNumber": { + "fieldType": "string", + "value": "123456789", + "label": "Document Number" + }, + "identificationNumber": { + "fieldType": "string", + "value": "ID123456789", + "label": "Identification Number" + }, + "nationality": { + "fieldType": "string", + "value": "American", + "label": "Nationality" + }, + "fullName": { + "fieldType": "string", + "value": "Jane Doe", + "label": "Full Name" + }, + "birthDate": { + "fieldType": "date", + "value": "1990-04-15", + "label": "Birth Date" + }, + "birthPlace": { + "fieldType": "string", + "value": "New York, USA", + "label": "Birth Place" + }, + "sex": { + "fieldType": "string", + "value": "F", + "label": "Sex" + }, + "issueDate": { + "fieldType": "date", + "value": "2020-01-01", + "label": "Issue Date" + }, + "expiryDate": { + "fieldType": "date", + "value": "2030-01-01", + "label": "Expiry Date" + }, + "issuingAuthority": { + "fieldType": "string", + "value": "Department of State", + "label": "Issuing Authority" + } + } + ] + }, + { + "id": "K4BBlNWWTS21ZqzTUn0H6Q", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "Passport", + "subtitle": "US", + "credentials": [ + { + "type": "passport", + "issuingCountry": { + "fieldType": "country-code", + "value": "US", + "label": "Issuing Country" + }, + "passportType": { + "fieldType": "string", + "value": "Regular", + "label": "Passport Type" + }, + "passportNumber": { + "fieldType": "string", + "value": "A12345678", + "label": "Passport Number" + }, + "nationalIdentificationNumber": { + "fieldType": "string", + "value": "ID123456789", + "label": "National Identification Number" + }, + "nationality": { + "fieldType": "string", + "value": "American", + "label": "Nationality" + }, + "fullName": { + "fieldType": "string", + "value": "John Doe", + "label": "Full Name" + }, + "birthDate": { + "fieldType": "date", + "value": "1990-01-01", + "label": "Birth Date" + }, + "birthPlace": { + "fieldType": "string", + "value": "Los Angeles, USA", + "label": "Birth Place" + }, + "sex": { + "fieldType": "string", + "value": "M", + "label": "Sex" + }, + "issueDate": { + "fieldType": "date", + "value": "2015-06-15", + "label": "Issue Date" + }, + "expiryDate": { + "fieldType": "date", + "value": "2025-06-15", + "label": "Expiry Date" + }, + "issuingAuthority": { + "fieldType": "string", + "value": "U.S. Department of State", + "label": "Issuing Authority" + } + } + ] + }, + { + "id": "LmInpZjdRwKIKZFdbBz19g", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "John Doe", + "subtitle": "personal name", + "credentials": [ + { + "type": "person-name", + "title": { + "fieldType": "string", + "value": "Dr.", + "label": "Title" + }, + "given": { + "fieldType": "string", + "value": "John", + "label": "Given Name" + }, + "givenInformal": { + "fieldType": "string", + "value": "Johnny", + "label": "Informal Given Name" + }, + "given2": { + "fieldType": "string", + "value": "Michael", + "label": "Second Given Name" + }, + "surnamePrefix": { + "fieldType": "string", + "value": "van", + "label": "Surname Prefix" + }, + "surname": { + "fieldType": "string", + "value": "Doe", + "label": "Surname" + }, + "surname2": { + "fieldType": "string", + "value": "Smith", + "label": "Second Surname" + }, + "credentials": { + "fieldType": "string", + "value": "PhD", + "label": "Credentials" + }, + "generation": { + "fieldType": "string", + "value": "III", + "label": "Generation" + } + } + ] + }, + { + "id": "TMrjj3uIRtitVmIpiwXmyg", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "API key", + "subtitle": "john_doe", + "credentials": [ + { + "type": "api-key", + "key": { + "fieldType": "concealed-string", + "value": "AIzaSyAyRofL-VJHZofHc-qOSkqVOdhvgQoJADk", + "label": "API Key" + }, + "username": { + "fieldType": "string", + "value": "john_doe", + "label": "Username" + }, + "keyType": { + "fieldType": "string", + "value": "Bearer", + "label": "Key Type" + }, + "url": { + "fieldType": "string", + "value": "https://api.example.com", + "label": "API URL" + }, + "validFrom": { + "fieldType": "date", + "value": "2025-01-01", + "label": "Valid From" + }, + "expiryDate": { + "fieldType": "date", + "value": "2026-01-01", + "label": "Expiry Date" + } + } + ] + }, + { + "id": "QtvgfXSgS8O6ukLNZZKMlw", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "Generated Password", + "subtitle": "john_doe", + "credentials": [ + { + "type": "generated-password", + "password": "KozyS!cf#Nc9C799" + } + ] + } + ], + "collections": [ + { + "id": "0dimBl7dRRyPLGKGxEEm5Q", + "creationAt": 1705228800, + "modifiedAt": 1705315200, + "title": "Work Accounts", + "subtitle": "A collection of pro accounts for various services", + "items": [ + { + "item": "TMrjj3uIRtitVmIpiwXmyg", + "account": "DZSXp7iBQY-Fg-OofakQtQ" + }, + { + "item": "Z4cFmc21Q5-vCVwd1wJx1g", + "account": "DZSXp7iBQY-Fg-OofakQtQ" + }, + { + "item": "9OF-QjVDQo2Wp2xWPw6ZhA", + "account": "DZSXp7iBQY-Fg-OofakQtQ" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/crates/bitwarden-exporters/src/cxf/api_key.rs b/crates/bitwarden-exporters/src/cxf/api_key.rs index b234ef2d9..04c3af3da 100644 --- a/crates/bitwarden-exporters/src/cxf/api_key.rs +++ b/crates/bitwarden-exporters/src/cxf/api_key.rs @@ -3,7 +3,7 @@ use credential_exchange_format::ApiKeyCredential; use crate::{cxf::editable_field::create_field, Field}; /// Convert API key credentials to custom fields -pub fn api_key_to_fields(api_key: &ApiKeyCredential) -> Vec { +pub(super) fn api_key_to_fields(api_key: &ApiKeyCredential) -> Vec { [ api_key.key.as_ref().map(|key| create_field("API Key", key)), api_key diff --git a/crates/bitwarden-exporters/src/cxf/card.rs b/crates/bitwarden-exporters/src/cxf/card.rs index 0498037e8..22e6c99d8 100644 --- a/crates/bitwarden-exporters/src/cxf/card.rs +++ b/crates/bitwarden-exporters/src/cxf/card.rs @@ -7,7 +7,7 @@ use chrono::Month; use credential_exchange_format::{Credential, CreditCardCredential, EditableFieldYearMonth}; use num_traits::FromPrimitive; -use crate::Card; +use crate::{cxf::editable_field::create_field, Card, Field}; impl From for Vec { fn from(value: Card) -> Self { @@ -57,6 +57,23 @@ impl From<&CreditCardCredential> for Card { } } +pub(super) fn to_card(credential: &CreditCardCredential) -> (Card, Vec) { + let card = credential.into(); + + let fields = [ + credential.pin.as_ref().map(|v| create_field("PIN", v)), + credential + .valid_from + .as_ref() + .map(|v| create_field("Valid From", v)), + ] + .into_iter() + .flatten() + .collect(); + + (card, fields) +} + /// Sanitize credit card brand /// /// Performs a fuzzy match on the string to find a matching brand. By converting to lowercase and @@ -83,6 +100,7 @@ fn sanitize_brand(value: &str) -> Option { #[cfg(test)] mod tests { + use bitwarden_vault::FieldType; use chrono::Month; use credential_exchange_format::EditableFieldYearMonth; @@ -150,7 +168,7 @@ mod tests { full_name: Some("John Doe".to_string().into()), card_type: Some("Visa".to_string().into()), verification_number: Some("123".to_string().into()), - pin: None, + pin: Some("4567".to_string().into()), expiry_date: Some( EditableFieldYearMonth { year: 2025, @@ -158,15 +176,39 @@ mod tests { } .into(), ), - valid_from: None, + valid_from: Some( + EditableFieldYearMonth { + year: 2024, + month: Month::January, + } + .into(), + ), }; - let card: Card = (&credit_card).into(); + let (card, fields) = to_card(&credit_card); assert_eq!(card.cardholder_name, Some("John Doe".to_string())); assert_eq!(card.exp_month, Some("12".to_string())); assert_eq!(card.exp_year, Some("2025".to_string())); assert_eq!(card.code, Some("123".to_string())); assert_eq!(card.brand, Some("Visa".to_string())); assert_eq!(card.number, Some("4111111111111111".to_string())); + + assert_eq!( + fields, + vec![ + Field { + name: Some("PIN".to_string()), + value: Some("4567".to_string()), + r#type: FieldType::Hidden as u8, + linked_id: None, + }, + Field { + name: Some("Valid From".to_string()), + value: Some("2024-01".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + ] + ) } } diff --git a/crates/bitwarden-exporters/src/cxf/editable_field.rs b/crates/bitwarden-exporters/src/cxf/editable_field.rs index f611d2810..db1fe1f6b 100644 --- a/crates/bitwarden-exporters/src/cxf/editable_field.rs +++ b/crates/bitwarden-exporters/src/cxf/editable_field.rs @@ -1,7 +1,8 @@ use bitwarden_vault::FieldType; use credential_exchange_format::{ - EditableField, EditableFieldBoolean, EditableFieldConcealedString, EditableFieldDate, - EditableFieldString, EditableFieldWifiNetworkSecurityType, + EditableField, EditableFieldBoolean, EditableFieldConcealedString, EditableFieldCountryCode, + EditableFieldDate, EditableFieldString, EditableFieldWifiNetworkSecurityType, + EditableFieldYearMonth, }; use crate::Field; @@ -58,6 +59,14 @@ impl EditableFieldToField for EditableField { + const FIELD_TYPE: FieldType = FieldType::Text; + + fn field_value(&self) -> String { + self.value.0.clone() + } +} + impl EditableFieldToField for EditableField { const FIELD_TYPE: FieldType = FieldType::Text; @@ -66,6 +75,18 @@ impl EditableFieldToField for EditableField { } } +impl EditableFieldToField for EditableField { + const FIELD_TYPE: FieldType = FieldType::Text; + + fn field_value(&self) -> String { + format!( + "{:04}-{:02}", + self.value.year, + self.value.month.number_from_month() + ) + } +} + /// Convert WiFi security type enum to human-readable string fn security_type_to_string(security_type: &EditableFieldWifiNetworkSecurityType) -> &str { use EditableFieldWifiNetworkSecurityType::*; @@ -76,6 +97,7 @@ fn security_type_to_string(security_type: &EditableFieldWifiNetworkSecurityType) Wpa3Personal => "WPA3 Personal", Wep => "WEP", Other(s) => s, + _ => "Unknown", } } @@ -248,4 +270,31 @@ mod tests { } ); } + + #[test] + fn test_create_field_year_month() { + use chrono::Month; + + let editable_field = EditableField { + id: None, + label: None, + value: EditableFieldYearMonth { + year: 2025, + month: Month::December, + }, + extensions: None, + }; + + let field = create_field("Card Expiry", &editable_field); + + assert_eq!( + field, + Field { + name: Some("Card Expiry".to_string()), + value: Some("2025-12".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + } + ); + } } diff --git a/crates/bitwarden-exporters/src/cxf/identity.rs b/crates/bitwarden-exporters/src/cxf/identity.rs new file mode 100644 index 000000000..f0c027dd7 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/identity.rs @@ -0,0 +1,418 @@ +use credential_exchange_format::{ + AddressCredential, DriversLicenseCredential, EditableField, EditableFieldString, + IdentityDocumentCredential, PassportCredential, PersonNameCredential, +}; + +use crate::{cxf::editable_field::create_field, Field, Identity}; + +/// 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(super) fn address_to_identity(address: AddressCredential) -> (Identity, Vec) { + let identity = Identity { + address1: address.street_address.map(Into::into), + city: address.city.map(Into::into), + state: address.territory.map(Into::into), + postal_code: address.postal_code.map(Into::into), + country: address.country.map(Into::into), + phone: address.tel.map(Into::into), + ..Default::default() + }; + + (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(super) fn passport_to_identity(passport: PassportCredential) -> (Identity, Vec) { + // Split full name into first and last name if available + let (first_name, last_name) = split_name(&passport.full_name); + + let identity = Identity { + first_name, + last_name, + // Map nationalIdentificationNumber to ssn as closest available field + ssn: passport.national_identification_number.map(Into::into), + passport_number: passport.passport_number.map(Into::into), + ..Default::default() + }; + + // Create custom fields for unmapped data according to CXF mapping document + let custom_fields = [ + passport + .issuing_country + .map(|issuing_country| create_field("Issuing Country", &issuing_country)), + passport + .nationality + .map(|nationality| create_field("Nationality", &nationality)), + passport + .birth_date + .map(|birth_date| create_field("Birth Date", &birth_date)), + passport + .birth_place + .map(|birth_place| create_field("Birth Place", &birth_place)), + passport.sex.map(|sex| create_field("Sex", &sex)), + passport + .issue_date + .map(|issue_date| create_field("Issue Date", &issue_date)), + passport + .expiry_date + .map(|expiry_date| create_field("Expiry Date", &expiry_date)), + passport + .issuing_authority + .map(|issuing_authority| create_field("Issuing Authority", &issuing_authority)), + passport + .passport_type + .map(|passport_type| create_field("Passport Type", &passport_type)), + ] + .into_iter() + .flatten() + .collect(); + + (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(super) fn person_name_to_identity(person_name: PersonNameCredential) -> (Identity, Vec) { + // Construct complete last name from surnamePrefix, surname, and surname2 + let last_name = [ + person_name.surname_prefix.as_ref(), + person_name.surname.as_ref(), + person_name.surname2.as_ref(), + ] + .into_iter() + .flatten() + .map(|field| field.value.0.clone()) + .collect::>() + .into_iter() + .reduce(|acc, part| format!("{acc} {part}")); + + let identity = Identity { + title: person_name.title.map(Into::into), + first_name: person_name.given.map(Into::into), + middle_name: person_name.given2.map(Into::into), + last_name, + // Map credentials (e.g., "PhD") to company field as professional qualifications + company: person_name.credentials.map(Into::into), + ..Default::default() + }; + + // Create custom fields for unmapped data + let custom_fields = [ + person_name + .given_informal + .map(|given_informal| create_field("Informal Given Name", &given_informal)), + person_name + .generation + .map(|generation| create_field("Generation", &generation)), + ] + .into_iter() + .flatten() + .collect(); + + (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(super) 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) = split_name(&drivers_license.full_name); + + let identity = Identity { + first_name, + last_name, + // Map territory (state/province) to state field + state: drivers_license.territory.map(Into::into), + // Map country to country field + country: drivers_license.country.map(Into::into), + license_number: drivers_license.license_number.map(Into::into), + ..Default::default() + }; + + // Create custom fields for unmapped data according to CXF mapping document + let custom_fields = [ + drivers_license + .birth_date + .map(|birth_date| create_field("Birth Date", &birth_date)), + drivers_license + .issue_date + .map(|issue_date| create_field("Issue Date", &issue_date)), + drivers_license + .expiry_date + .map(|expiry_date| create_field("Expiry Date", &expiry_date)), + drivers_license + .issuing_authority + .map(|issuing_authority| create_field("Issuing Authority", &issuing_authority)), + drivers_license + .license_class + .map(|license_class| create_field("License Class", &license_class)), + ] + .into_iter() + .flatten() + .collect(); + + (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(super) 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) = split_name(&identity_document.full_name); + + let identity = Identity { + first_name, + last_name, + // Map identificationNumber to ssn + ssn: identity_document.identification_number.map(Into::into), + // Map documentNumber to passport_number (reusing for document number) + passport_number: identity_document.document_number.map(Into::into), + ..Default::default() + }; + + // Create custom fields for unmapped data according to CXF mapping document + let custom_fields = [ + identity_document + .issuing_country + .map(|issuing_country| create_field("Issuing Country", &issuing_country)), + identity_document + .nationality + .map(|nationality| create_field("Nationality", &nationality)), + identity_document + .birth_date + .map(|birth_date| create_field("Birth Date", &birth_date)), + identity_document + .birth_place + .map(|birth_place| create_field("Birth Place", &birth_place)), + identity_document.sex.map(|sex| create_field("Sex", &sex)), + identity_document + .issue_date + .map(|issue_date| create_field("Issue Date", &issue_date)), + identity_document + .expiry_date + .map(|expiry_date| create_field("Expiry Date", &expiry_date)), + identity_document + .issuing_authority + .map(|issuing_authority| create_field("Issuing Authority", &issuing_authority)), + ] + .into_iter() + .flatten() + .collect(); + + (identity, custom_fields) +} + +fn split_name( + full_name: &Option>, +) -> (Option, Option) { + full_name.as_ref().map_or((None, None), |name| { + let parts: Vec<&str> = name.value.0.split_whitespace().collect(); + match parts.as_slice() { + [] => (None, None), + [first] => (Some(first.to_string()), None), + [first, rest @ ..] => (Some(first.to_string()), Some(rest.join(" "))), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_name_none() { + let full_name = None; + let (first, last) = split_name(&full_name); + assert_eq!(first, None); + assert_eq!(last, None); + } + + #[test] + fn test_split_name_empty_string() { + let full_name = Some(EditableField { + value: EditableFieldString("".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, None); + assert_eq!(last, None); + } + + #[test] + fn test_split_name_whitespace_only() { + let full_name = Some(EditableField { + value: EditableFieldString(" \t\n ".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, None); + assert_eq!(last, None); + } + + #[test] + fn test_split_name_single_name() { + let full_name = Some(EditableField { + value: EditableFieldString("John".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("John".to_string())); + assert_eq!(last, None); + } + + #[test] + fn test_split_name_single_name_with_whitespace() { + let full_name = Some(EditableField { + value: EditableFieldString(" John ".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("John".to_string())); + assert_eq!(last, None); + } + + #[test] + fn test_split_name_first_last() { + let full_name = Some(EditableField { + value: EditableFieldString("John Doe".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("John".to_string())); + assert_eq!(last, Some("Doe".to_string())); + } + + #[test] + fn test_split_name_first_middle_last() { + let full_name = Some(EditableField { + value: EditableFieldString("John Michael Doe".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("John".to_string())); + assert_eq!(last, Some("Michael Doe".to_string())); + } + + #[test] + fn test_split_name_multiple_middle_names() { + let full_name = Some(EditableField { + value: EditableFieldString("John Michael Andrew Doe".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("John".to_string())); + assert_eq!(last, Some("Michael Andrew Doe".to_string())); + } + + #[test] + fn test_split_name_complex_surname() { + let full_name = Some(EditableField { + value: EditableFieldString("Jane van der Berg".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("Jane".to_string())); + assert_eq!(last, Some("van der Berg".to_string())); + } + + #[test] + fn test_split_name_hyphenated_surname() { + let full_name = Some(EditableField { + value: EditableFieldString("Mary Smith-Johnson".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("Mary".to_string())); + assert_eq!(last, Some("Smith-Johnson".to_string())); + } + + #[test] + fn test_split_name_extra_whitespace() { + let full_name = Some(EditableField { + value: EditableFieldString(" John Michael Doe ".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("John".to_string())); + assert_eq!(last, Some("Michael Doe".to_string())); + } + + #[test] + fn test_split_name_special_characters() { + let full_name = Some(EditableField { + value: EditableFieldString("José María González".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("José".to_string())); + assert_eq!(last, Some("María González".to_string())); + } + + #[test] + fn test_split_name_single_character_names() { + let full_name = Some(EditableField { + value: EditableFieldString("A B C".to_string()), + label: None, + id: None, + extensions: None, + }); + let (first, last) = split_name(&full_name); + assert_eq!(first, Some("A".to_string())); + assert_eq!(last, Some("B C".to_string())); + } +} diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 7d420f72f..c80eabff5 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -1,19 +1,31 @@ use chrono::{DateTime, Utc}; use credential_exchange_format::{ - Account as CxfAccount, ApiKeyCredential, BasicAuthCredential, Credential, CreditCardCredential, - Item, PasskeyCredential, WifiCredential, + Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential, + CreditCardCredential, DriversLicenseCredential, IdentityDocumentCredential, Item, + NoteCredential, PasskeyCredential, PassportCredential, PersonNameCredential, SshKeyCredential, + TotpCredential, WifiCredential, }; use crate::{ cxf::{ api_key::api_key_to_fields, - login::{to_fields, to_login}, + card::to_card, + identity::{ + address_to_identity, drivers_license_to_identity, identity_document_to_identity, + passport_to_identity, person_name_to_identity, + }, + login::to_login, + note::extract_note_content, + ssh::to_ssh, wifi::wifi_to_fields, CxfError, }, - CipherType, ImportingCipher, SecureNote, SecureNoteType, + CipherType, Field, ImportingCipher, SecureNote, SecureNoteType, }; +/** + * Parse CXF payload in the format compatible with Apple (At the Account-level) + */ pub(crate) fn parse_cxf(payload: String) -> Result, CxfError> { let account: CxfAccount = serde_json::from_str(&payload)?; @@ -30,7 +42,7 @@ fn convert_date(ts: Option) -> DateTime { .unwrap_or(Utc::now()) } -fn parse_item(value: Item) -> Vec { +pub(super) fn parse_item(value: Item) -> Vec { let grouped = group_credentials_by_type(value.credentials); let creation_date = convert_date(value.creation_at); @@ -40,85 +52,115 @@ fn parse_item(value: Item) -> Vec { let scope = value.scope.as_ref(); - // Login credentials - if !grouped.basic_auth.is_empty() || !grouped.passkey.is_empty() { - let basic_auth = grouped.basic_auth.first(); - let passkey = grouped.passkey.first(); - - let login = to_login(creation_date, basic_auth, passkey, scope); + // Extract note content if present (to be added to parent cipher) + let note_content = grouped.note.first().map(extract_note_content); + // Helper to add ciphers with consistent boilerplate + let mut add_item = |t: CipherType, fields: Vec| { output.push(ImportingCipher { folder_id: None, // TODO: Handle folders name: value.title.clone(), - notes: None, - r#type: CipherType::Login(Box::new(login)), + notes: note_content.clone(), + r#type: t, favorite: false, reprompt: 0, - fields: vec![], + fields, revision_date, creation_date, deleted_date: None, }) + }; + + // Login credentials + if !grouped.basic_auth.is_empty() || !grouped.passkey.is_empty() || !grouped.totp.is_empty() { + let basic_auth = grouped.basic_auth.first(); + let passkey = grouped.passkey.first(); + let totp = grouped.totp.first(); + + let login = to_login(creation_date, basic_auth, passkey, totp, scope); + add_item(CipherType::Login(Box::new(login)), vec![]); } - if !grouped.credit_card.is_empty() { - let credit_card = grouped - .credit_card - .first() - .expect("Credit card is not empty"); + // Credit Card credentials + if let Some(credit_card) = grouped.credit_card.first() { + let (card, fields) = to_card(credit_card); - output.push(ImportingCipher { - folder_id: None, // TODO: Handle folders - name: value.title.clone(), - notes: None, - r#type: CipherType::Card(Box::new(credit_card.into())), - favorite: false, - reprompt: 0, - fields: scope.map(to_fields).unwrap_or_default(), - revision_date, - creation_date, - deleted_date: None, - }) + add_item(CipherType::Card(Box::new(card)), fields); } + // Helper for creating SecureNote cipher type + let secure_note_type = || { + CipherType::SecureNote(Box::new(SecureNote { + r#type: SecureNoteType::Generic, + })) + }; + // API Key credentials -> Secure Note if let Some(api_key) = grouped.api_key.first() { let fields = api_key_to_fields(api_key); - - output.push(ImportingCipher { - folder_id: None, // TODO: Handle folders - name: value.title.clone(), - notes: None, - r#type: CipherType::SecureNote(Box::new(SecureNote { - r#type: SecureNoteType::Generic, - })), - favorite: false, - reprompt: 0, - fields, - revision_date, - creation_date, - deleted_date: None, - }) + add_item(secure_note_type(), fields); } // WiFi credentials -> Secure Note if let Some(wifi) = grouped.wifi.first() { let fields = wifi_to_fields(wifi); + add_item(secure_note_type(), fields); + } + // Identity credentials (address, passport, person name, drivers license, identity document) + [ + grouped + .address + .first() + .map(|a| address_to_identity(a.clone())), + grouped + .passport + .first() + .map(|p| passport_to_identity(p.clone())), + grouped + .person_name + .first() + .map(|p| person_name_to_identity(p.clone())), + grouped + .drivers_license + .first() + .map(|d| drivers_license_to_identity(d.clone())), + grouped + .identity_document + .first() + .map(|i| identity_document_to_identity(i.clone())), + ] + .into_iter() + .flatten() + .for_each(|(identity, custom_fields)| { + add_item(CipherType::Identity(Box::new(identity)), custom_fields); + }); + + // SSH Key credentials + if let Some(ssh) = grouped.ssh.first() { + match to_ssh(ssh) { + Ok((ssh_key, fields)) => add_item(CipherType::SshKey(Box::new(ssh_key)), fields), + Err(_) => { + // Include information about the failed items, or import as note? + } + } + } + + // Standalone Note credentials -> Secure Note (only if no other credentials exist) + if !grouped.note.is_empty() && output.is_empty() { + let standalone_note_content = grouped.note.first().map(extract_note_content); output.push(ImportingCipher { folder_id: None, // TODO: Handle folders name: value.title.clone(), - notes: None, - r#type: CipherType::SecureNote(Box::new(SecureNote { - r#type: SecureNoteType::Generic, - })), + notes: standalone_note_content, + r#type: secure_note_type(), favorite: false, reprompt: 0, - fields, + fields: vec![], revision_date, creation_date, deleted_date: None, - }) + }); } output @@ -150,26 +192,66 @@ fn group_credentials_by_type(credentials: Vec) -> GroupedCredentials Credential::BasicAuth(basic_auth) => Some(basic_auth.as_ref()), _ => None, }), + credit_card: filter_credentials(&credentials, |c| match c { + Credential::CreditCard(credit_card) => Some(credit_card.as_ref()), + _ => None, + }), passkey: filter_credentials(&credentials, |c| match c { Credential::Passkey(passkey) => Some(passkey.as_ref()), _ => None, }), - credit_card: filter_credentials(&credentials, |c| match c { - Credential::CreditCard(credit_card) => Some(credit_card.as_ref()), + ssh: filter_credentials(&credentials, |c| match c { + Credential::SshKey(ssh) => Some(ssh.as_ref()), + _ => None, + }), + totp: filter_credentials(&credentials, |c| match c { + Credential::Totp(totp) => Some(totp.as_ref()), _ => None, }), wifi: filter_credentials(&credentials, |c| match c { Credential::Wifi(wifi) => Some(wifi.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, + }), + note: filter_credentials(&credentials, |c| match c { + Credential::Note(note) => Some(note.as_ref()), + _ => None, + }), } } struct GroupedCredentials { + address: Vec, api_key: Vec, basic_auth: Vec, - passkey: Vec, credit_card: Vec, + drivers_license: Vec, + identity_document: Vec, + note: Vec, + passkey: Vec, + passport: Vec, + person_name: Vec, + ssh: Vec, + totp: Vec, wifi: Vec, } @@ -346,4 +428,363 @@ mod tests { assert_eq!(card.brand, Some("Mastercard".to_string())); assert_eq!(card.number, Some("1234 5678 9012 3456".to_string())); } + + #[test] + fn test_totp() { + use credential_exchange_format::{OTPHashAlgorithm, TotpCredential}; + + let item = Item { + id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(), + creation_at: Some(1706613834), + modified_at: Some(1706623773), + title: "My TOTP".to_string(), + subtitle: None, + favorite: None, + credentials: vec![Credential::Totp(Box::new(TotpCredential { + secret: "Hello World!".as_bytes().to_vec().into(), + period: 30, + digits: 6, + username: Some("test@example.com".to_string()), + algorithm: OTPHashAlgorithm::Sha1, + issuer: Some("Example Service".to_string()), + }))], + tags: None, + extensions: None, + scope: None, + }; + + let ciphers: Vec = parse_item(item); + assert_eq!(ciphers.len(), 1); + let cipher = ciphers.first().unwrap(); + + assert_eq!(cipher.folder_id, None); + assert_eq!(cipher.name, "My TOTP"); + assert_eq!(cipher.notes, None); + assert!(!cipher.favorite); + assert_eq!(cipher.reprompt, 0); + assert_eq!(cipher.fields, vec![]); + + let login = match &cipher.r#type { + CipherType::Login(login) => login, + _ => panic!("Expected login cipher for TOTP"), + }; + + // TOTP should be mapped to login.totp as otpauth URI + assert!(login.totp.is_some()); + let otpauth = login.totp.as_ref().unwrap(); + + // Verify the otpauth URI format and content + assert!( + otpauth.starts_with("otpauth://totp/Example%20Service:test%40example%2Ecom?secret=") + ); + assert!(otpauth.contains("&issuer=Example%20Service")); + + // Default values should not be present in URI + assert!(!otpauth.contains("&period=30")); + assert!(!otpauth.contains("&digits=6")); + assert!(!otpauth.contains("&algorithm=SHA1")); + + // Other login fields should be None since only TOTP was provided + assert_eq!(login.username, None); + assert_eq!(login.password, None); + assert_eq!(login.login_uris, vec![]); + } + + #[test] + fn test_totp_combined_with_basic_auth() { + use credential_exchange_format::{BasicAuthCredential, OTPHashAlgorithm, TotpCredential}; + + let item = Item { + id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(), + creation_at: Some(1706613834), + modified_at: Some(1706623773), + title: "Login with TOTP".to_string(), + subtitle: None, + favorite: None, + credentials: vec![ + Credential::BasicAuth(Box::new(BasicAuthCredential { + username: Some("myuser".to_string().into()), + password: Some("mypass".to_string().into()), + })), + Credential::Totp(Box::new(TotpCredential { + secret: "totpkey".as_bytes().to_vec().into(), + period: 30, + digits: 6, + username: Some("totpuser".to_string()), + algorithm: OTPHashAlgorithm::Sha1, + issuer: Some("Service".to_string()), + })), + ], + tags: None, + extensions: None, + scope: None, + }; + + let ciphers: Vec = parse_item(item); + assert_eq!(ciphers.len(), 1); + let cipher = ciphers.first().unwrap(); + + let login = match &cipher.r#type { + CipherType::Login(login) => login, + _ => panic!("Expected login cipher"), + }; + + // Should have both basic auth and TOTP + assert_eq!(login.username, Some("myuser".to_string())); + assert_eq!(login.password, Some("mypass".to_string())); + assert!(login.totp.is_some()); + + let otpauth = login.totp.as_ref().unwrap(); + assert!(otpauth.starts_with("otpauth://totp/Service:totpuser?secret=")); + assert!(otpauth.contains("&issuer=Service")); + } + + // Note integration tests + + #[test] + fn test_note_as_part_of_login() { + use credential_exchange_format::{BasicAuthCredential, Credential, Item, NoteCredential}; + + let item = Item { + id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(), + creation_at: Some(1706613834), + modified_at: Some(1706623773), + title: "Login with Note".to_string(), + subtitle: None, + favorite: None, + credentials: vec![ + Credential::BasicAuth(Box::new(BasicAuthCredential { + username: Some("testuser".to_string().into()), + password: Some("testpass".to_string().into()), + })), + Credential::Note(Box::new(NoteCredential { + content: "This note should be added to the login cipher." + .to_string() + .into(), + })), + ], + tags: None, + extensions: None, + scope: None, + }; + + let ciphers: Vec = parse_item(item); + assert_eq!(ciphers.len(), 1); // Should create only one cipher (Login with note content) + let cipher = ciphers.first().unwrap(); + + assert_eq!(cipher.name, "Login with Note"); + assert_eq!( + cipher.notes, + Some("This note should be added to the login cipher.".to_string()) + ); + + match &cipher.r#type { + CipherType::Login(_) => (), // Should be a Login cipher + _ => panic!("Expected Login cipher with note content"), + }; + } + + #[test] + fn test_note_as_part_of_api_key() { + use credential_exchange_format::{ApiKeyCredential, Credential, Item, NoteCredential}; + + let item = Item { + id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(), + creation_at: Some(1706613834), + modified_at: Some(1706623773), + title: "API Key with Note".to_string(), + subtitle: None, + favorite: None, + credentials: vec![ + Credential::ApiKey(Box::new(ApiKeyCredential { + key: Some("api-key-12345".to_string().into()), + username: Some("api-user".to_string().into()), + key_type: Some("Bearer".to_string().into()), + url: None, + valid_from: None, + expiry_date: None, + })), + Credential::Note(Box::new(NoteCredential { + content: "This note should be added to the API key cipher." + .to_string() + .into(), + })), + ], + tags: None, + extensions: None, + scope: None, + }; + + let ciphers: Vec = parse_item(item); + assert_eq!(ciphers.len(), 1); // Should create only one cipher (SecureNote with note content) + let cipher = ciphers.first().unwrap(); + + assert_eq!(cipher.name, "API Key with Note"); + assert_eq!( + cipher.notes, + Some("This note should be added to the API key cipher.".to_string()) + ); + + match &cipher.r#type { + CipherType::SecureNote(_) => (), // Should be a SecureNote cipher + _ => panic!("Expected SecureNote cipher with note content"), + }; + + // Should have API key fields + assert!(!cipher.fields.is_empty()); + } + + #[test] + fn test_note_as_part_of_credit_card() { + use chrono::Month; + use credential_exchange_format::{Credential, CreditCardCredential, Item, NoteCredential}; + + let item = Item { + id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(), + creation_at: Some(1706613834), + modified_at: Some(1706623773), + title: "Credit Card with Note".to_string(), + subtitle: None, + favorite: None, + credentials: vec![ + Credential::CreditCard(Box::new(CreditCardCredential { + number: Some("1234 5678 9012 3456".to_string().into()), + full_name: Some("John Doe".to_string().into()), + card_type: Some("Visa".to_string().into()), + verification_number: Some("123".to_string().into()), + pin: None, + expiry_date: Some( + credential_exchange_format::EditableFieldYearMonth { + year: 2026, + month: Month::December, + } + .into(), + ), + valid_from: None, + })), + Credential::Note(Box::new(NoteCredential { + content: "This note should be added to the credit card cipher." + .to_string() + .into(), + })), + ], + tags: None, + extensions: None, + scope: None, + }; + + let ciphers: Vec = parse_item(item); + assert_eq!(ciphers.len(), 1); // Should create only one cipher (Card with note content) + let cipher = ciphers.first().unwrap(); + + assert_eq!(cipher.name, "Credit Card with Note"); + assert_eq!( + cipher.notes, + Some("This note should be added to the credit card cipher.".to_string()) + ); + + match &cipher.r#type { + CipherType::Card(_) => (), // Should be a Card cipher + _ => panic!("Expected Card cipher with note content"), + }; + } + + #[test] + fn test_note_as_part_of_wifi() { + use credential_exchange_format::{ + Credential, EditableFieldWifiNetworkSecurityType, Item, NoteCredential, WifiCredential, + }; + + let item = Item { + id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(), + creation_at: Some(1706613834), + modified_at: Some(1706623773), + title: "WiFi with Note".to_string(), + subtitle: None, + favorite: None, + credentials: vec![ + Credential::Wifi(Box::new(WifiCredential { + ssid: Some("MyNetwork".to_string().into()), + passphrase: Some("password123".to_string().into()), + network_security_type: Some( + EditableFieldWifiNetworkSecurityType::Wpa3Personal.into(), + ), + hidden: Some(false.into()), + })), + Credential::Note(Box::new(NoteCredential { + content: "This note should be added to the WiFi cipher." + .to_string() + .into(), + })), + ], + tags: None, + extensions: None, + scope: None, + }; + + let ciphers: Vec = parse_item(item); + assert_eq!(ciphers.len(), 1); // Should create only one cipher (SecureNote with note content) + let cipher = ciphers.first().unwrap(); + + assert_eq!(cipher.name, "WiFi with Note"); + assert_eq!( + cipher.notes, + Some("This note should be added to the WiFi cipher.".to_string()) + ); + + match &cipher.r#type { + CipherType::SecureNote(_) => (), // Should be a SecureNote cipher + _ => panic!("Expected SecureNote cipher with note content"), + }; + + // Should have WiFi fields + assert!(!cipher.fields.is_empty()); + } + + #[test] + fn test_note_as_part_of_identity() { + use credential_exchange_format::{AddressCredential, Credential, Item, NoteCredential}; + + let item = Item { + id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(), + creation_at: Some(1706613834), + modified_at: Some(1706623773), + title: "Address with Note".to_string(), + subtitle: None, + favorite: None, + credentials: vec![ + Credential::Address(Box::new(AddressCredential { + street_address: Some("123 Main St".to_string().into()), + city: Some("Springfield".to_string().into()), + territory: Some("CA".to_string().into()), + postal_code: Some("12345".to_string().into()), + country: Some("US".to_string().into()), + tel: Some("+1-555-123-4567".to_string().into()), + })), + Credential::Note(Box::new(NoteCredential { + content: "This note should be added to the address identity cipher." + .to_string() + .into(), + })), + ], + tags: None, + extensions: None, + scope: None, + }; + + let ciphers: Vec = parse_item(item); + assert_eq!(ciphers.len(), 1); // Should create only one cipher (Identity with note content) + let cipher = ciphers.first().unwrap(); + + assert_eq!(cipher.name, "Address with Note"); + assert_eq!( + cipher.notes, + Some("This note should be added to the address identity cipher.".to_string()) + ); + + match &cipher.r#type { + CipherType::Identity(_) => (), // Should be an Identity cipher + _ => panic!("Expected Identity cipher"), + }; + } } diff --git a/crates/bitwarden-exporters/src/cxf/import_sample_tests.rs b/crates/bitwarden-exporters/src/cxf/import_sample_tests.rs new file mode 100644 index 000000000..054dc226e --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/import_sample_tests.rs @@ -0,0 +1,458 @@ +//! Sample file integration tests for CXF import functionality +//! +//! These tests validate the parsing of real CXF sample files against the specification. + +use super::import::parse_item; +use crate::{cxf::CxfError, CipherType, ImportingCipher}; + +/// Parse CXF payload in the format compatible with the CXF specification (At the +/// Header-level). +fn parse_cxf_spec(payload: String) -> Result, CxfError> { + use credential_exchange_format::Header; + + let header: Header = serde_json::from_str(&payload)?; + + let items: Vec = header + .accounts + .into_iter() + .flat_map(|account| account.items.into_iter().flat_map(parse_item)) + .collect(); + + Ok(items) +} + +fn load_sample_cxf() -> Result, CxfError> { + use std::fs; + + // 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"); + + let items = parse_cxf_spec(cxf_data)?; + + Ok(items) +} + +#[cfg(test)] +mod tests { + use bitwarden_vault::FieldType; + + use super::*; + use crate::{Field, Identity}; + + #[test] + fn test_load_cxf_example_without_crashing() { + let result = load_sample_cxf(); + assert!(result.is_ok()); + } + + #[test] + fn test_cxf_sample_totp_mapping() { + let items = load_sample_cxf().expect("Should load sample CXF data"); + + // Find the item with TOTP - should be the "GitHub Login" item + let totp_item = items + .iter() + .find(|item| item.name == "GitHub Login") + .expect("Should find GitHub Login item"); + + // Verify it's a Login type with TOTP + match &totp_item.r#type { + CipherType::Login(login) => { + // Verify the TOTP field is properly mapped + assert!(login.totp.is_some()); + let totp_uri = login.totp.as_ref().unwrap(); + + // Verify it's a proper otpauth URI + assert!(totp_uri.starts_with("otpauth://totp/")); + + // Verify it contains the expected components from the CXF sample: + // - secret: "JBSWY3DPEHPK3PXP" + // - issuer: "Google" + // - algorithm: "sha256" (non-default, should appear as SHA256) + // - username: "jane.smith@example.com" (in the URI label) + // - period: 30 (default, so should NOT appear in URI) + // - digits: 6 (default, so should NOT appear in URI) + assert!(totp_uri.contains("secret=JBSWY3DPEHPK3PXP")); + assert!(totp_uri.contains("issuer=Google")); + assert!(totp_uri.contains("algorithm=SHA256")); + assert!(totp_uri.contains("Google:jane%2Esmith%40example%2Ecom")); + + // Should NOT contain default values + assert!(!totp_uri.contains("period=30")); + assert!(!totp_uri.contains("digits=6")); + + // Verify the Login structure is complete + assert!(login.username.is_some()); // From basic auth credential + assert!(login.password.is_some()); // From basic auth credential + assert!(!login.login_uris.is_empty()); // From item scope + assert!(login.totp.is_some()); // From TOTP credential + + // Expected URI format using official Bitwarden TOTP implementation: + // otpauth://totp/Google:jane%2Esmith%40example%2Ecom?secret=JBSWY3DPEHPK3PXP& + // issuer=Google&algorithm=SHA256 + } + _ => panic!("GitHub Login item should be a Login type"), + } + } + + #[test] + fn test_cxf_sample_note_integration() { + let items = load_sample_cxf().expect("Should load sample CXF data"); + + // Find the note item (Home alarm) + let note_cipher = items + .iter() + .find(|cipher| cipher.name == "Home alarm") + .expect("Should find Home alarm note item"); + + // Validate it's a SecureNote cipher + match ¬e_cipher.r#type { + CipherType::SecureNote(_) => (), // Successfully identified as SecureNote + _ => panic!("Expected SecureNote for standalone note credential"), + } + + // Validate the note content + assert_eq!( + note_cipher.notes, + Some("some instructionts to enable/disable the alarm".to_string()) + ); + + // Should have no custom fields since it's a standalone note + assert_eq!(note_cipher.fields.len(), 0); + + // Validate basic properties + assert_eq!(note_cipher.name, "Home alarm"); + assert_eq!(note_cipher.folder_id, None); + assert!(!note_cipher.favorite); + } + + #[test] + fn test_cxf_sample_address_complete_mapping() { + let ciphers = load_sample_cxf().expect("Should load sample CXF data"); + + // 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 = if let CipherType::Identity(identity) = &address_cipher.r#type { + identity + } else { + panic!("Expected Identity cipher for address") + }; + + // Verify all address field mappings from cxf_example.json + let expected_identity = Identity { + address1: Some("123 Main Street".to_string()), + city: Some("Springfield".to_string()), + state: Some("CA".to_string()), + country: Some("US".to_string()), + phone: Some("+1-555-123-4567".to_string()), + postal_code: Some("12345".to_string()), + ..Default::default() + }; + + assert_eq!(**identity, expected_identity); + + // Verify no unmapped fields (address has no custom fields) + assert_eq!(address_cipher.fields.len(), 0); + } + + #[test] + fn test_cxf_sample_passport_complete_mapping() { + let ciphers = load_sample_cxf().expect("Should load sample CXF data"); + + // 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 = if let CipherType::Identity(identity) = &passport_cipher.r#type { + identity + } else { + panic!("Expected Identity cipher for passport") + }; + + // Verify Identity field mappings from cxf_example.json + let expected_identity = Identity { + passport_number: Some("A12345678".to_string()), + first_name: Some("John".to_string()), + last_name: Some("Doe".to_string()), + ssn: Some("ID123456789".to_string()), + country: None, + ..Default::default() + }; + + assert_eq!(**identity, expected_identity); + + // Verify custom fields preserve unmapped data + let expected_fields = vec![ + Field { + name: Some("Issuing Country".to_string()), + value: Some("US".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Nationality".to_string()), + value: Some("American".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Birth Date".to_string()), + value: Some("1990-01-01".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Birth Place".to_string()), + value: Some("Los Angeles, USA".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Sex".to_string()), + value: Some("M".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Issue Date".to_string()), + value: Some("2015-06-15".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Expiry Date".to_string()), + value: Some("2025-06-15".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Issuing Authority".to_string()), + value: Some("U.S. Department of State".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Passport Type".to_string()), + value: Some("Regular".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + ]; + + assert_eq!(passport_cipher.fields, expected_fields); + } + + #[test] + fn test_cxf_sample_person_name_complete_mapping() { + let ciphers = load_sample_cxf().expect("Should load sample CXF data"); + + // 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 = if let CipherType::Identity(identity) = &person_name_cipher.r#type { + identity + } else { + panic!("Expected Identity cipher for person name") + }; + + // Verify Identity field mappings from cxf_example.json + let expected_identity = Identity { + title: Some("Dr.".to_string()), + first_name: Some("John".to_string()), + middle_name: Some("Michael".to_string()), + last_name: Some("van Doe Smith".to_string()), // Combined surname + company: Some("PhD".to_string()), // credentials → company + ..Default::default() + }; + + assert_eq!(**identity, expected_identity); + + // Verify custom fields preserve unmapped data + let expected_fields = vec![ + Field { + name: Some("Informal Given Name".to_string()), + value: Some("Johnny".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Generation".to_string()), + value: Some("III".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + ]; + + assert_eq!(person_name_cipher.fields, expected_fields); + } + + #[test] + fn test_cxf_sample_drivers_license_complete_mapping() { + let ciphers = load_sample_cxf().expect("Should load sample CXF data"); + + // 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 = if let CipherType::Identity(identity) = &drivers_license_cipher.r#type { + identity + } else { + panic!("Expected Identity cipher for drivers license") + }; + + // Verify Identity field mappings from cxf_example.json + let expected_identity = Identity { + license_number: Some("D12345678".to_string()), + first_name: Some("John".to_string()), + last_name: Some("Doe".to_string()), + state: Some("CA".to_string()), + country: Some("US".to_string()), + company: None, // issuingAuthority is now custom field + ..Default::default() + }; + + assert_eq!(**identity, expected_identity); + + // Verify custom fields preserve unmapped data + let expected_fields = vec![ + Field { + name: Some("Birth Date".to_string()), + value: Some("1990-05-15".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Issue Date".to_string()), + value: Some("2020-06-01".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Expiry Date".to_string()), + value: Some("2030-06-01".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Issuing Authority".to_string()), + value: Some("Department of Motor Vehicles".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("License Class".to_string()), + value: Some("C".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + ]; + + assert_eq!(drivers_license_cipher.fields, expected_fields); + } + + #[test] + fn test_cxf_sample_identity_document_complete_mapping() { + let ciphers = load_sample_cxf().expect("Should load sample CXF data"); + + // 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 = if let CipherType::Identity(identity) = &identity_document_cipher.r#type { + identity + } else { + panic!("Expected Identity cipher for identity document") + }; + + // Verify Identity field mappings from cxf_example.json + let expected_identity = Identity { + passport_number: Some("123456789".to_string()), // documentNumber → passport_number + first_name: Some("Jane".to_string()), // fullName split + last_name: Some("Doe".to_string()), // fullName split + ssn: Some("ID123456789".to_string()), // identificationNumber → ssn + country: None, // issuingCountry goes to custom fields + ..Default::default() // All other fields should remain None + }; + + assert_eq!(**identity, expected_identity); + + // Verify custom fields preserve unmapped data + assert!( + identity_document_cipher.fields.len() >= 6, + "Should have multiple custom fields" + ); + + // Verify custom fields preserve unmapped data + let expected_fields = vec![ + Field { + name: Some("Issuing Country".to_string()), + value: Some("US".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Nationality".to_string()), + value: Some("American".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Birth Date".to_string()), + value: Some("1990-04-15".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Birth Place".to_string()), + value: Some("New York, USA".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Sex".to_string()), + value: Some("F".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Issue Date".to_string()), + value: Some("2020-01-01".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Expiry Date".to_string()), + value: Some("2030-01-01".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + Field { + name: Some("Issuing Authority".to_string()), + value: Some("Department of State".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + }, + ]; + + assert_eq!(identity_document_cipher.fields, expected_fields); + } +} diff --git a/crates/bitwarden-exporters/src/cxf/login.rs b/crates/bitwarden-exporters/src/cxf/login.rs index 74c9259b1..fa8106c74 100644 --- a/crates/bitwarden-exporters/src/cxf/login.rs +++ b/crates/bitwarden-exporters/src/cxf/login.rs @@ -6,10 +6,11 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use bitwarden_core::MissingFieldError; use bitwarden_fido::{string_to_guid_bytes, InvalidGuid}; -use bitwarden_vault::FieldType; +use bitwarden_vault::{FieldType, Totp, TotpAlgorithm}; use chrono::{DateTime, Utc}; use credential_exchange_format::{ - AndroidAppIdCredential, BasicAuthCredential, CredentialScope, PasskeyCredential, + AndroidAppIdCredential, BasicAuthCredential, CredentialScope, OTPHashAlgorithm, + PasskeyCredential, TotpCredential, }; use thiserror::Error; @@ -18,17 +19,48 @@ use crate::{Fido2Credential, Field, Login, LoginUri}; /// Prefix that indicates the URL is an Android app scheme. const ANDROID_APP_SCHEME: &str = "androidapp://"; +/// Convert CXF OTPHashAlgorithm to Bitwarden's TotpAlgorithm +/// Handles standard algorithms and special cases like Steam +fn convert_otp_algorithm(algorithm: &OTPHashAlgorithm) -> TotpAlgorithm { + match algorithm { + OTPHashAlgorithm::Sha1 => TotpAlgorithm::Sha1, + OTPHashAlgorithm::Sha256 => TotpAlgorithm::Sha256, + OTPHashAlgorithm::Sha512 => TotpAlgorithm::Sha512, + OTPHashAlgorithm::Unknown(ref algo) if algo == "steam" => TotpAlgorithm::Steam, + OTPHashAlgorithm::Unknown(_) | _ => TotpAlgorithm::Sha1, /* Default to SHA1 for unknown + * algorithms */ + } +} + +/// Convert CXF TotpCredential to Bitwarden's Totp struct +/// This ensures we use the exact same encoding and formatting as Bitwarden's core implementation +fn totp_credential_to_totp(cxf_totp: &TotpCredential) -> Totp { + let algorithm = convert_otp_algorithm(&cxf_totp.algorithm); + + let secret_bytes: Vec = cxf_totp.secret.clone().into(); + + Totp { + account: cxf_totp.username.clone(), + algorithm, + digits: cxf_totp.digits as u32, + issuer: cxf_totp.issuer.clone(), + period: cxf_totp.period as u32, + secret: secret_bytes, + } +} + pub(super) fn to_login( creation_date: DateTime, basic_auth: Option<&BasicAuthCredential>, passkey: Option<&PasskeyCredential>, + totp: Option<&TotpCredential>, scope: Option<&CredentialScope>, ) -> Login { Login { username: basic_auth.and_then(|v| v.username.clone().map(|v| v.into())), password: basic_auth.and_then(|v| v.password.clone().map(|u| u.into())), login_uris: scope.map(to_uris).unwrap_or_default(), - totp: None, + totp: totp.map(|t| totp_credential_to_totp(t).to_string()), fido2_credentials: passkey.map(|p| { vec![Fido2Credential { credential_id: format!("b64.{}", p.credential_id), @@ -69,7 +101,8 @@ fn to_uris(scope: &CredentialScope) -> Vec { /// Converts a `CredentialScope` to a vector of `Field` objects. /// /// This is used for non-login credentials. -pub(crate) fn to_fields(scope: &CredentialScope) -> Vec { +#[allow(unused)] +pub(super) fn to_fields(scope: &CredentialScope) -> Vec { let urls = scope.urls.iter().enumerate().map(|(i, u)| Field { name: Some(format!("Url {}", i + 1)), value: Some(u.clone()), @@ -473,4 +506,100 @@ mod tests { ] ); } + + // TOTP tests + #[test] + fn test_totp_credential_to_totp_basic() { + let totp = TotpCredential { + secret: "Hello World!".as_bytes().to_vec().into(), + period: 30, + digits: 6, + username: Some("test@example.com".to_string()), + algorithm: OTPHashAlgorithm::Sha1, + issuer: Some("Example".to_string()), + }; + + let bitwarden_totp = totp_credential_to_totp(&totp); + let otpauth = bitwarden_totp.to_string(); + + assert!(otpauth.starts_with("otpauth://totp/Example:test%40example%2Ecom?secret=")); + assert!(otpauth.contains("&issuer=Example")); + // Default period (30) and digits (6) and algorithm (SHA1) should not be included + assert!(!otpauth.contains("&period=30")); + assert!(!otpauth.contains("&digits=6")); + assert!(!otpauth.contains("&algorithm=SHA1")); + } + + #[test] + fn test_totp_credential_to_totp_custom_parameters() { + let totp = TotpCredential { + secret: "Hello World!".as_bytes().to_vec().into(), + period: 60, + digits: 8, + username: Some("user".to_string()), + algorithm: OTPHashAlgorithm::Sha256, + issuer: Some("Custom Issuer".to_string()), + }; + + let bitwarden_totp = totp_credential_to_totp(&totp); + let otpauth = bitwarden_totp.to_string(); + + assert!(otpauth.contains("Custom%20Issuer:user")); + assert!(otpauth.contains("&issuer=Custom%20Issuer")); + assert!(otpauth.contains("&period=60")); + assert!(otpauth.contains("&digits=8")); + assert!(otpauth.contains("&algorithm=SHA256")); + } + + // Algorithm conversion tests + #[test] + fn test_convert_otp_algorithm_sha1() { + let result = convert_otp_algorithm(&OTPHashAlgorithm::Sha1); + assert_eq!(result, TotpAlgorithm::Sha1); + } + + #[test] + fn test_convert_otp_algorithm_sha256() { + let result = convert_otp_algorithm(&OTPHashAlgorithm::Sha256); + assert_eq!(result, TotpAlgorithm::Sha256); + } + + #[test] + fn test_convert_otp_algorithm_sha512() { + let result = convert_otp_algorithm(&OTPHashAlgorithm::Sha512); + assert_eq!(result, TotpAlgorithm::Sha512); + } + + #[test] + fn test_convert_otp_algorithm_steam() { + let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("steam".to_string())); + assert_eq!(result, TotpAlgorithm::Steam); + } + + #[test] + fn test_convert_otp_algorithm_steam_case_sensitive() { + // Test that "steam" is case-sensitive + let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("Steam".to_string())); + assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1 + } + + #[test] + fn test_convert_otp_algorithm_unknown_empty() { + let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("".to_string())); + assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1 + } + + #[test] + fn test_convert_otp_algorithm_unknown_md5() { + // Test an algorithm that might exist in other systems but isn't supported + let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("md5".to_string())); + assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1 + } + + #[test] + fn test_convert_otp_algorithm_unknown_whitespace() { + // Test steam with whitespace (will not match) + let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown(" steam ".to_string())); + assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1 + } } diff --git a/crates/bitwarden-exporters/src/cxf/mod.rs b/crates/bitwarden-exporters/src/cxf/mod.rs index dcd558ead..0ffe2844c 100644 --- a/crates/bitwarden-exporters/src/cxf/mod.rs +++ b/crates/bitwarden-exporters/src/cxf/mod.rs @@ -15,5 +15,10 @@ pub(crate) use import::parse_cxf; mod api_key; mod card; mod editable_field; +mod identity; +#[cfg(test)] +mod import_sample_tests; mod login; +mod note; +mod ssh; mod wifi; diff --git a/crates/bitwarden-exporters/src/cxf/note.rs b/crates/bitwarden-exporters/src/cxf/note.rs new file mode 100644 index 000000000..55686afb2 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/note.rs @@ -0,0 +1,123 @@ +use credential_exchange_format::NoteCredential; + +/// Extract note content from a CXF Note credential +/// The way notes are handled (in import.rs) depends on their context: +/// - If part of an item, use parent type and map content to Cipher::notes +/// - If standalone, map to SecureNote +/// +/// That's why we only have this small utility function and tests here. +pub(super) fn extract_note_content(note: &NoteCredential) -> String { + note.content.value.0.clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_note_content_with_content() { + let note = NoteCredential { + content: "This is a test note with important information." + .to_owned() + .into(), + }; + + let content = extract_note_content(¬e); + assert_eq!( + content, + "This is a test note with important information.".to_string() + ); + } + + #[test] + fn test_extract_note_content_empty_string() { + let note = NoteCredential { + content: "".to_owned().into(), + }; + + let content = extract_note_content(¬e); + assert_eq!(content, "".to_string()); + } + + #[test] + fn test_extract_note_content_multiline() { + let note = NoteCredential { + content: "Line 1\nLine 2\nLine 3".to_owned().into(), + }; + + let content = extract_note_content(¬e); + assert_eq!(content, "Line 1\nLine 2\nLine 3".to_string()); + } + + #[test] + fn test_extract_note_content_special_characters() { + let note = NoteCredential { + content: "Note with emojis 🔐 and special chars: @#$%^&*()" + .to_owned() + .into(), + }; + + let content = extract_note_content(¬e); + assert_eq!( + content, + "Note with emojis 🔐 and special chars: @#$%^&*()".to_string() + ); + } + + #[test] + fn test_extract_note_content_very_long() { + let long_content = "A".repeat(10000); + let note = NoteCredential { + content: long_content.clone().into(), + }; + + let content = extract_note_content(¬e); + assert_eq!(content, long_content); + } + + #[test] + fn test_standalone_note_credential() { + use credential_exchange_format::{Credential, Item}; + + use crate::{cxf::import::parse_item, CipherType, ImportingCipher}; + + let item = Item { + id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(), + creation_at: Some(1706613834), + modified_at: Some(1706623773), + title: "My Important Note".to_string(), + subtitle: None, + favorite: None, + credentials: vec![Credential::Note(Box::new(NoteCredential { + content: + "This is a standalone secure note with important information.\nLine 2\nLine 3" + .to_string() + .into(), + }))], + tags: None, + extensions: None, + scope: None, + }; + + let ciphers: Vec = parse_item(item); + assert_eq!(ciphers.len(), 1); + let cipher = ciphers.first().unwrap(); + + assert_eq!(cipher.folder_id, None); + assert_eq!(cipher.name, "My Important Note"); + assert_eq!( + cipher.notes, + Some( + "This is a standalone secure note with important information.\nLine 2\nLine 3" + .to_string() + ) + ); + + match &cipher.r#type { + CipherType::SecureNote(_) => (), // Successfully created a SecureNote + _ => panic!("Expected SecureNote"), + }; + + assert_eq!(cipher.fields.len(), 0); // Notes don't have custom fields + } +} diff --git a/crates/bitwarden-exporters/src/cxf/ssh.rs b/crates/bitwarden-exporters/src/cxf/ssh.rs new file mode 100644 index 000000000..436e0eba0 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/ssh.rs @@ -0,0 +1,99 @@ +use bitwarden_ssh::{error::SshKeyImportError, import::import_pkcs8_der_key}; +use bitwarden_vault::FieldType; +use credential_exchange_format::SshKeyCredential; + +use crate::{cxf::editable_field::create_field, Field, SshKey}; + +/// Convert SSH key credentials to SshKey and custom fields +pub(super) fn to_ssh( + credential: &SshKeyCredential, +) -> Result<(SshKey, Vec), SshKeyImportError> { + // Convert to OpenSSH format + let encoded_key: Vec = credential.private_key.as_ref().into(); + let encoded_key = import_pkcs8_der_key(&encoded_key)?; + + let ssh = SshKey { + private_key: encoded_key.private_key, + public_key: encoded_key.public_key, + fingerprint: encoded_key.fingerprint, + }; + + let fields = [ + credential.key_comment.as_ref().map(|comment| Field { + name: Some("Key Comment".into()), + value: Some(comment.into()), + r#type: FieldType::Text as u8, + linked_id: None, + }), + credential + .creation_date + .as_ref() + .map(|date| create_field("Creation Date", date)), + credential + .expiry_date + .as_ref() + .map(|date| create_field("Expiry Date", date)), + credential + .key_generation_source + .as_ref() + .map(|source| create_field("Key Generation Source", source)), + ] + .into_iter() + .flatten() + .collect(); + + Ok((ssh, fields)) +} + +#[cfg(test)] +mod tests { + use bitwarden_vault::FieldType; + use chrono::NaiveDate; + use credential_exchange_format::EditableFieldDate; + + use super::*; + + #[test] + fn test_to_ssh() { + let credential = SshKeyCredential { + key_type: "ssh-ed25519".into(), + private_key: "MIIG_QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQCn4-QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN-mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn-vtb49xPzIv-M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl-s4mUyr_qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ-EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT_1chb8GCTDT-2DCBRApbsIg6TOBVS-PR6emAQ3eZzUW0-3_oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo_s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe-1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt-ys__5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0CAwEAAQKCAYAA2SDMf7OBHw1OGM9OQa1ZS4u-ktfQHhn31-FxbrhWGp-lDt8gYABVf6Y4dKN6rMtn7D9gVSAlZCAn3Hx8aWAvcXHaspxe9YXiZDTh-Kd8EIXxBQn-TiDA5LH0dryABqmMp20vYKtR7OS3lIIXfFBSrBMwdunKzLwmKwZLWq0SWf6vVbwpxRyR9CyByodF6DjmZK3QB2qQ3jqlL1HWXL0VnyArY7HLvUvfLLK4vMPqnsSH-FdHvhcEhwqMlWT44g-fhqWtCJNnjDgLK3FPbI8Pz9TF8dWJvOmp5Q6iSBua1e9x2LizVuNSqiFc7ZTLeoG4nDj7T2BtqB0E1rNUDEN1aBo-UZmHJK7LrzfW_B-ssi2WwIpfxYa1lO6HFod5_YQiXV1GunyH1chCsbvOFtXvAHASO4HTKlJNbWhRF1GXqnKpAaHDPCVuwp3eq6Yf0oLbXrL3KFZ3jwWiWbpQXRVvpqzaJwZn3CN1yQgYS9j17a9wrPky-BoJxXjZ_oImWLECgcEA0lkLwiHvmTYFTCC7PN938Agk9_NQs5PQ18MRn9OJmyfSpYqf_gNp-Md7xUgtF_MTif7uelp2J7DYf6fj9EYf9g4EuW-SQgFP4pfiJn1-zGFeTQq1ISvwjsA4E8ZSt-GIumjZTg6YiL1_A79u4wm24swt7iqnVViOPtPGOM34S1tAamjZzq2eZDmAF6pAfmuTMdinCMR1E1kNJYbxeqLiqQCXuwBBnHOOOJofN3AkvzjRUBB9udvniqYxH3PQcxPxAoHBAMxT5KwBhZhnJedYN87Kkcpl7xdMkpU8b-aXeZoNykCeoC-wgIQexnSWmFk4HPkCNxvCWlbkOT1MHrTAKFnaOww23Ob-Vi6A9n0rozo9vtoJig114GB0gUqEmtfLhO1P5AE8yzogE-ILHyp0BqXt8vGIfzpDnCkN-GKl8gOOMPrR4NAcLO-Rshc5nLs7BGB4SEi126Y6mSfp85m0--1QhWMz9HzqJEHCWKVcZYdCdEONP9js04EUnK33KtlJIWzZTQKBwAT0pBpGwmZRp35Lpx2gBitZhcVxrg0NBnaO2fNyAGPvZD8SLQLHAdAiov_a23Uc_PDbWLL5Pp9gwzj-s5glrssVOXdE8aUscr1b5rARdNNL1_Tos6u8ZUZ3sNqGaZx7a8U4gyYboexWyo9EC1C-AdkGBm7-AkM4euFwC9N6xsa_t5zKK5d676hc0m-8SxivYCBkgkrqlfeGuZCQxU-mVsC0it6U-va8ojUjLGkZ80OuCwBf4xZl3-acU7vx9o8_gQKBwB7BrhU6MWrsc-cr_1KQaXum9mNyckomi82RFYvb8Yrilcg38FBy9XqNRKeBa9MLw1HZYpHbzsXsVF7u4eQMloDTLVNUC5L6dKAI1owoyTa24uH90WWTg_a8mTZMe1jhgrew-AJq27NV6z4PswR9GenDmyshDDudz7rBsflZCQRoXUfWRelV7BHU6UPBsXn4ASF4xnRyM6WvcKy9coKZcUqqgm3fLM_9OizCCMJgfXHBrE-x7nBqst746qlEedSRrQKBwQCVYwwKCHNlZxl0_NMkDJ-hp7_InHF6mz_3VO58iCb19TLDVUC2dDGPXNYwWTT9PclefwV5HNBHcAfTzgB4dpQyNiDyV914HL7DFEGduoPnwBYjeFre54v0YjjnskjJO7myircdbdX__i-7LMUw5aZZXCC8a5BD_rdV6IKJWJG5QBXbe5fVf1XwOjBTzlhIPIqhNFfSu-mFikp5BRwHGBqsKMju6inYmW6YADeY_SvOQjDEB37RqGZxqyIx8V2ZYwU" + .try_into() + .unwrap(), + key_comment: Some("Work SSH Key".into()), + creation_date: Some( + EditableFieldDate(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()).into(), + ), + expiry_date: Some( + EditableFieldDate(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()).into(), + ), + key_generation_source: Some("Generated using OpenSSH".to_owned().into()), + }; + + let (ssh, fields) = to_ssh(&credential).unwrap(); + + assert_eq!(ssh.private_key, "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAYEAp+PkIiaI2fZoHPSmCSLw1hms+KhYX9AgbrYOi/MxKBysLojfpqnB\nHIggjmvQTYiZpvDU03oWb664JRhufcyG6mLQwB41Z/r7W+PcT8yL/jOudWKo9PJSG0fbRC\nBygqu2h/BpQ5vDYBvRMyRo5S+6JEMpuEx9TB5frOJlMq/6m9rp5WQQ4sQELKYVPIgi37bp\nFgiJJkIOAko9Rod1mwZWfhF+XltBF7gMOF7EmwU1lfQh0aMFSKWKj7xGEqR7KAoxHHffPK\ndfYaYhpJwNCeM+yX1JTYMk/9XIW/Bgkw0/tgwgUQKW7CIOkzgVUvj0enpgEN3mc1FtPt/6\nETOIqdLo5bUMvLlOoL2CAKsecFxjE4VaWXFIGoSokrFaP7OG/OIjHuAxt1vI8+Koussjb9\nFXnRMz1GdZV3dRiRaMvbijbkk3vtZLInAHJbmTei7jbDx3D8RQsznJqDZ1VxCgFyJ7fsrP\n/+Qg1WrK6GUgB2Zw41A+KDbCyN1SDpxsYF65MEh9AAAFeFAMoMtQDKDLAAAAB3NzaC1yc2\nEAAAGBAKfj5CImiNn2aBz0pgki8NYZrPioWF/QIG62DovzMSgcrC6I36apwRyIII5r0E2I\nmabw1NN6Fm+uuCUYbn3Mhupi0MAeNWf6+1vj3E/Mi/4zrnViqPTyUhtH20QgcoKrtofwaU\nObw2Ab0TMkaOUvuiRDKbhMfUweX6ziZTKv+pva6eVkEOLEBCymFTyIIt+26RYIiSZCDgJK\nPUaHdZsGVn4Rfl5bQRe4DDhexJsFNZX0IdGjBUilio+8RhKkeygKMRx33zynX2GmIaScDQ\nnjPsl9SU2DJP/VyFvwYJMNP7YMIFECluwiDpM4FVL49Hp6YBDd5nNRbT7f+hEziKnS6OW1\nDLy5TqC9ggCrHnBcYxOFWllxSBqEqJKxWj+zhvziIx7gMbdbyPPiqLrLI2/RV50TM9RnWV\nd3UYkWjL24o25JN77WSyJwByW5k3ou42w8dw/EULM5yag2dVcQoBcie37Kz//kINVqyuhl\nIAdmcONQPig2wsjdUg6cbGBeuTBIfQAAAAMBAAEAAAGAANkgzH+zgR8NThjPTkGtWUuLvp\nLX0B4Z99fhcW64VhqfpQ7fIGAAVX+mOHSjeqzLZ+w/YFUgJWQgJ9x8fGlgL3Fx2rKcXvWF\n4mQ04finfBCF8QUJ/k4gwOSx9Ha8gAapjKdtL2CrUezkt5SCF3xQUqwTMHbpysy8JisGS1\nqtEln+r1W8KcUckfQsgcqHReg45mSt0AdqkN46pS9R1ly9FZ8gK2Oxy71L3yyyuLzD6p7E\nh/hXR74XBIcKjJVk+OIPn4alrQiTZ4w4CytxT2yPD8/UxfHVibzpqeUOokgbmtXvcdi4s1\nbjUqohXO2Uy3qBuJw4+09gbagdBNazVAxDdWgaPlGZhySuy6831vwfrLItlsCKX8WGtZTu\nhxaHef2EIl1dRrp8h9XIQrG7zhbV7wBwEjuB0ypSTW1oURdRl6pyqQGhwzwlbsKd3qumH9\nKC216y9yhWd48Folm6UF0Vb6as2icGZ9wjdckIGEvY9e2vcKz5MvgaCcV42f6CJlixAAAA\nwQCVYwwKCHNlZxl0/NMkDJ+hp7/InHF6mz/3VO58iCb19TLDVUC2dDGPXNYwWTT9Pclefw\nV5HNBHcAfTzgB4dpQyNiDyV914HL7DFEGduoPnwBYjeFre54v0YjjnskjJO7myircdbdX/\n/i+7LMUw5aZZXCC8a5BD/rdV6IKJWJG5QBXbe5fVf1XwOjBTzlhIPIqhNFfSu+mFikp5BR\nwHGBqsKMju6inYmW6YADeY/SvOQjDEB37RqGZxqyIx8V2ZYwUAAADBANJZC8Ih75k2BUwg\nuzzfd/AIJPfzULOT0NfDEZ/TiZsn0qWKn/4DafjHe8VILRfzE4n+7npadiew2H+n4/RGH/\nYOBLlvkkIBT+KX4iZ9fsxhXk0KtSEr8I7AOBPGUrfhiLpo2U4OmIi9fwO/buMJtuLMLe4q\np1VYjj7TxjjN+EtbQGpo2c6tnmQ5gBeqQH5rkzHYpwjEdRNZDSWG8Xqi4qkAl7sAQZxzjj\niaHzdwJL840VAQfbnb54qmMR9z0HMT8QAAAMEAzFPkrAGFmGcl51g3zsqRymXvF0ySlTxv\n5pd5mg3KQJ6gL7CAhB7GdJaYWTgc+QI3G8JaVuQ5PUwetMAoWdo7DDbc5v5WLoD2fSujOj\n2+2gmKDXXgYHSBSoSa18uE7U/kATzLOiAT4gsfKnQGpe3y8Yh/OkOcKQ34YqXyA44w+tHg\n0Bws75GyFzmcuzsEYHhISLXbpjqZJ+nzmbT77VCFYzP0fOokQcJYpVxlh0J0Q40/2OzTgR\nScrfcq2UkhbNlNAAAAAAEC\n-----END OPENSSH PRIVATE KEY-----\n"); + assert_eq!( + ssh.public_key, + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn4+QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0=" + ); + assert_eq!( + ssh.fingerprint, + "SHA256:vWqZh87vgxDk0eDx0VqWR001mXyFGTdRF4Q2JVW/Q9w" + ); + + assert_eq!(fields.len(), 4); + assert_eq!( + fields[0], + Field { + name: Some("Key Comment".to_string()), + value: Some("Work SSH Key".to_string()), + r#type: FieldType::Text as u8, + linked_id: None, + } + ); + assert_eq!(fields[1].value.as_deref(), Some("2023-01-01")); + assert_eq!(fields[2].value.as_deref(), Some("2025-01-01")); + assert_eq!(fields[3].value.as_deref(), Some("Generated using OpenSSH")); + } +} diff --git a/crates/bitwarden-exporters/src/cxf/wifi.rs b/crates/bitwarden-exporters/src/cxf/wifi.rs index 910d630be..f25f7265b 100644 --- a/crates/bitwarden-exporters/src/cxf/wifi.rs +++ b/crates/bitwarden-exporters/src/cxf/wifi.rs @@ -3,7 +3,7 @@ use credential_exchange_format::WifiCredential; use crate::{cxf::editable_field::create_field, Field}; /// Convert WiFi credentials to custom fields following the CXF mapping convention -pub fn wifi_to_fields(wifi: &WifiCredential) -> Vec { +pub(super) fn wifi_to_fields(wifi: &WifiCredential) -> Vec { [ // SSID: Text field wifi.ssid.as_ref().map(|ssid| create_field("SSID", ssid)), diff --git a/crates/bitwarden-exporters/src/lib.rs b/crates/bitwarden-exporters/src/lib.rs index 4466460d8..80965a551 100644 --- a/crates/bitwarden-exporters/src/lib.rs +++ b/crates/bitwarden-exporters/src/lib.rs @@ -280,7 +280,7 @@ pub enum SecureNoteType { } #[allow(missing_docs)] -#[derive(Clone)] +#[derive(Clone, Default, Debug, PartialEq)] pub struct Identity { pub title: Option, pub first_name: Option, diff --git a/crates/bitwarden-ssh/src/import.rs b/crates/bitwarden-ssh/src/import.rs index 04c75caa5..e586eb99e 100644 --- a/crates/bitwarden-ssh/src/import.rs +++ b/crates/bitwarden-ssh/src/import.rs @@ -49,8 +49,14 @@ fn import_pkcs8_key( SecretDocument::from_pkcs8_pem(&encoded_key).map_err(|_| SshKeyImportError::ParsingError)? }; + import_pkcs8_der_key(doc.as_bytes()) +} + +/// Import a DER encoded private key, and returns a decoded [SshKeyView]. This is primarily used for +/// importing SSH keys from other Credential Managers through Credential Exchange. +pub fn import_pkcs8_der_key(encoded_key: &[u8]) -> Result { let private_key_info = - PrivateKeyInfo::from_der(doc.as_bytes()).map_err(|_| SshKeyImportError::ParsingError)?; + PrivateKeyInfo::from_der(encoded_key).map_err(|_| SshKeyImportError::ParsingError)?; let private_key = match private_key_info.algorithm.oid { ed25519::pkcs8::ALGORITHM_OID => {