Skip to content

Commit b18e651

Browse files
authored
PM-25311: CXF Map all custom fields to a CustomFieldsCredential (#414)
## 🎟️ Tracking https://bitwarden.atlassian.net/browse/PM-25311 <!-- Paste the link to the Jira or GitHub issue or otherwise describe / point to where this change is coming from. --> ## 📔 Objective This PR is a quick stab at mapping all custom fields on a cipher to a single CustomFieldCredential. I'm not 100% sure about how to parse this from the spec: > If the [exporting provider](https://fidoalliance.org/specs/cx/cxf-v1.0-ps-20250814.html#exporting-provider) allows custom fields to be added to items but does not have a grouping concept, it SHOULD use this object without setting the label or id fields. From: https://fidoalliance.org/specs/cx/cxf-v1.0-ps-20250814.html#dict-custom-fields <!-- Describe what the purpose of this PR is, for example what bug you're fixing or new feature you're adding. --> ## ⏰ Reminders before review - Contributor guidelines followed - All formatters and local linters executed and passed - Written new unit and / or integration tests where applicable - Protected functional changes with optionality (feature flags) - Used internationalization (i18n) for all UI strings - CI builds passed - Communicated to DevOps any deployment requirements - Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team ## 🦮 Reviewer guidelines <!-- Suggested interactions but feel free to use (or not) as you desire! --> - 👍 (`:+1:`) or similar for great changes - 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info - ❓ (`:question:`) for questions - 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion - 🎨 (`:art:`) for suggestions / improvements - ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention - 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt - ⛏ (`:pick:`) for minor or nitpick changes
1 parent 7b5d9db commit b18e651

File tree

5 files changed

+166
-12
lines changed

5 files changed

+166
-12
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/bitwarden-exporters/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ bitwarden-fido = { workspace = true }
3434
bitwarden-ssh = { workspace = true }
3535
bitwarden-vault = { workspace = true }
3636
chrono = { workspace = true, features = ["std"] }
37-
credential-exchange-format = { git = "https://github.com/bitwarden/credential-exchange", rev = "38e8a013c13644f832c457555baaa536fe481b77" }
37+
credential-exchange-format = { git = "https://github.com/bitwarden/credential-exchange", rev = "12702ad62ccc2a1e3cd9bdea40fc0f6399e1fffa" }
3838
csv = "1.3.0"
3939
num-traits = ">=0.2, <0.3"
4040
serde = { workspace = true }

crates/bitwarden-exporters/src/cxf/editable_field.rs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use bitwarden_vault::FieldType;
22
use credential_exchange_format::{
33
EditableField, EditableFieldBoolean, EditableFieldConcealedString, EditableFieldCountryCode,
4-
EditableFieldDate, EditableFieldString, EditableFieldWifiNetworkSecurityType,
5-
EditableFieldYearMonth,
4+
EditableFieldDate, EditableFieldString, EditableFieldSubdivisionCode, EditableFieldValue,
5+
EditableFieldWifiNetworkSecurityType, EditableFieldYearMonth,
66
};
77

88
use crate::Field;
@@ -24,6 +24,55 @@ where
2424
}
2525
}
2626

27+
/// Helper function to create an EditableField with common properties
28+
fn create_editable_field<T>(name: String, value: T) -> EditableField<T> {
29+
EditableField {
30+
id: None,
31+
label: Some(name),
32+
value,
33+
extensions: None,
34+
}
35+
}
36+
37+
/// Convert Bitwarden Field to CXF EditableFieldValue with proper type mapping
38+
pub(super) fn field_to_editable_field_value(field: Field) -> Option<EditableFieldValue> {
39+
let name = field.name?;
40+
41+
match field.r#type {
42+
x if x == FieldType::Text as u8 => field.value.map(|value| {
43+
EditableFieldValue::String(create_editable_field(name, EditableFieldString(value)))
44+
}),
45+
46+
x if x == FieldType::Hidden as u8 => field.value.map(|value| {
47+
EditableFieldValue::ConcealedString(create_editable_field(
48+
name,
49+
EditableFieldConcealedString(value),
50+
))
51+
}),
52+
53+
x if x == FieldType::Boolean as u8 => field.value?.parse::<bool>().ok().map(|bool_value| {
54+
EditableFieldValue::Boolean(create_editable_field(
55+
name,
56+
EditableFieldBoolean(bool_value),
57+
))
58+
}),
59+
60+
x if x == FieldType::Linked as u8 => {
61+
let value = field
62+
.value
63+
.or_else(|| field.linked_id.map(|id| id.to_string()))?;
64+
Some(EditableFieldValue::String(create_editable_field(
65+
name,
66+
EditableFieldString(value),
67+
)))
68+
}
69+
70+
_ => field.value.map(|value| {
71+
EditableFieldValue::String(create_editable_field(name, EditableFieldString(value)))
72+
}),
73+
}
74+
}
75+
2776
/// Trait to define field type and value conversion for inner field types
2877
pub(super) trait InnerFieldType {
2978
const FIELD_TYPE: FieldType;
@@ -87,6 +136,14 @@ impl InnerFieldType for EditableFieldYearMonth {
87136
}
88137
}
89138

139+
impl InnerFieldType for EditableFieldSubdivisionCode {
140+
const FIELD_TYPE: FieldType = FieldType::Text;
141+
142+
fn to_field_value(&self) -> String {
143+
self.0.clone()
144+
}
145+
}
146+
90147
/// Trait to convert CXP EditableField types to Bitwarden Field values and types
91148
pub(super) trait EditableFieldToField {
92149
const FIELD_TYPE: FieldType;

crates/bitwarden-exporters/src/cxf/export.rs

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
use bitwarden_vault::{Totp, TotpAlgorithm};
22
use credential_exchange_format::{
3-
Account as CxfAccount, Credential, Item, NoteCredential, OTPHashAlgorithm, TotpCredential,
3+
Account as CxfAccount, Credential, CustomFieldsCredential, EditableFieldValue, Item,
4+
NoteCredential, OTPHashAlgorithm, TotpCredential,
45
};
56
use uuid::Uuid;
67
#[cfg(feature = "wasm")]
78
use {tsify::Tsify, wasm_bindgen::prelude::*};
89

9-
use crate::{cxf::CxfError, Cipher, CipherType, Login};
10+
use crate::{
11+
cxf::{editable_field::field_to_editable_field_value, CxfError},
12+
Cipher, CipherType, Login,
13+
};
1014

1115
/// Temporary struct to hold metadata related to current account
1216
///
@@ -56,6 +60,24 @@ impl TryFrom<Cipher> for Item {
5660
})));
5761
}
5862

63+
// Convert Bitwarden custom fields to CustomFieldsCredential
64+
if !value.fields.is_empty() {
65+
let custom_fields: Vec<EditableFieldValue> = value
66+
.fields
67+
.into_iter()
68+
.filter_map(field_to_editable_field_value)
69+
.collect();
70+
71+
if !custom_fields.is_empty() {
72+
credentials.push(Credential::CustomFields(Box::new(CustomFieldsCredential {
73+
id: None,
74+
label: None,
75+
fields: custom_fields,
76+
extensions: vec![],
77+
})));
78+
}
79+
}
80+
5981
Ok(Self {
6082
id: value.id.as_bytes().as_slice().into(),
6183
creation_at: Some(value.creation_date.timestamp() as u64),
@@ -247,7 +269,7 @@ mod tests {
247269
);
248270
assert!(item.extensions.is_none());
249271

250-
assert_eq!(item.credentials.len(), 4);
272+
assert_eq!(item.credentials.len(), 5);
251273

252274
let credential = &item.credentials[0];
253275

@@ -299,7 +321,61 @@ mod tests {
299321
Credential::Note(n) => {
300322
assert_eq!(n.content.value.0, "My note");
301323
}
302-
_ => panic!("Expected Credential::Passkey"),
324+
_ => panic!("Expected Credential::Note"),
325+
}
326+
327+
let credential = &item.credentials[4];
328+
329+
match credential {
330+
Credential::CustomFields(custom_fields) => {
331+
assert_eq!(custom_fields.fields.len(), 5); // Text, Hidden, Boolean true, Boolean false, Linked
332+
333+
// Check Text field
334+
match &custom_fields.fields[0] {
335+
EditableFieldValue::String(field) => {
336+
assert_eq!(field.label.as_ref().unwrap(), "Text");
337+
assert_eq!(field.value.0, "A");
338+
}
339+
_ => panic!("Expected String field"),
340+
}
341+
342+
// Check Hidden field
343+
match &custom_fields.fields[1] {
344+
EditableFieldValue::ConcealedString(field) => {
345+
assert_eq!(field.label.as_ref().unwrap(), "Hidden");
346+
assert_eq!(field.value.0, "B");
347+
}
348+
_ => panic!("Expected ConcealedString field"),
349+
}
350+
351+
// Check Boolean true field
352+
match &custom_fields.fields[2] {
353+
EditableFieldValue::Boolean(field) => {
354+
assert_eq!(field.label.as_ref().unwrap(), "Boolean (true)");
355+
assert!(field.value.0);
356+
}
357+
_ => panic!("Expected Boolean field"),
358+
}
359+
360+
// Check Boolean false field
361+
match &custom_fields.fields[3] {
362+
EditableFieldValue::Boolean(field) => {
363+
assert_eq!(field.label.as_ref().unwrap(), "Boolean (false)");
364+
assert!(!field.value.0);
365+
}
366+
_ => panic!("Expected Boolean field"),
367+
}
368+
369+
// Check Linked field
370+
match &custom_fields.fields[4] {
371+
EditableFieldValue::String(field) => {
372+
assert_eq!(field.label.as_ref().unwrap(), "Linked");
373+
assert_eq!(field.value.0, "101"); // linked_id as string
374+
}
375+
_ => panic!("Expected String field for Linked"),
376+
}
377+
}
378+
_ => panic!("Expected Credential::CustomFields"),
303379
}
304380
}
305381
}

crates/bitwarden-exporters/src/cxf/import.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use chrono::{DateTime, Utc};
22
use credential_exchange_format::{
33
Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential,
4-
CreditCardCredential, CustomFieldsCredential, DriversLicenseCredential,
5-
IdentityDocumentCredential, Item, NoteCredential, PasskeyCredential, PassportCredential,
6-
PersonNameCredential, SshKeyCredential, TotpCredential, WifiCredential,
4+
CreditCardCredential, CustomFieldsCredential, DriversLicenseCredential, EditableField,
5+
EditableFieldString, EditableFieldValue, IdentityDocumentCredential, Item, NoteCredential,
6+
PasskeyCredential, PassportCredential, PersonNameCredential, SshKeyCredential, TotpCredential,
7+
WifiCredential,
78
};
89

910
use crate::{
@@ -49,7 +50,27 @@ fn custom_fields_to_fields(custom_fields: &CustomFieldsCredential) -> Vec<Field>
4950
custom_fields
5051
.fields
5152
.iter()
52-
.map(|f| create_field(f, None::<String>))
53+
.map(|field_value| match field_value {
54+
EditableFieldValue::String(field) => create_field(field, None::<String>),
55+
EditableFieldValue::ConcealedString(field) => create_field(field, None::<String>),
56+
EditableFieldValue::Boolean(field) => create_field(field, None::<String>),
57+
EditableFieldValue::Date(field) => create_field(field, None::<String>),
58+
EditableFieldValue::YearMonth(field) => create_field(field, None::<String>),
59+
EditableFieldValue::SubdivisionCode(field) => create_field(field, None::<String>),
60+
EditableFieldValue::CountryCode(field) => create_field(field, None::<String>),
61+
EditableFieldValue::WifiNetworkSecurityType(field) => {
62+
create_field(field, None::<String>)
63+
}
64+
_ => create_field(
65+
&EditableField {
66+
id: None,
67+
label: Some("Unknown Field".to_string()),
68+
value: EditableFieldString("".to_string()),
69+
extensions: None,
70+
},
71+
None::<String>,
72+
),
73+
})
5374
.collect()
5475
}
5576

0 commit comments

Comments
 (0)