Skip to main content

bitwarden_exporters/cxf/
card.rs

1//! Credit card credential conversion
2//!
3//! Handles conversion between internal [Card] and credential exchange [CreditCardCredential].
4
5use bitwarden_vault::CardBrand;
6use chrono::Month;
7use credential_exchange_format::{Credential, CreditCardCredential, EditableFieldYearMonth};
8use num_traits::FromPrimitive;
9
10use crate::{Card, Field, cxf::editable_field::create_field};
11
12impl From<Card> for Vec<Credential> {
13    fn from(value: Card) -> Self {
14        let expiry_date = match (value.exp_year, value.exp_month) {
15            (Some(year), Some(month)) => {
16                let year_parsed = year.parse().ok();
17                let numeric_month: Option<u32> = month.parse().ok();
18                let month_parsed = numeric_month.and_then(Month::from_u32);
19                match (year_parsed, month_parsed) {
20                    (Some(year), Some(month)) => {
21                        Some(EditableFieldYearMonth { year, month }.into())
22                    }
23                    _ => None,
24                }
25            }
26            _ => None,
27        };
28
29        vec![Credential::CreditCard(Box::new(CreditCardCredential {
30            number: value.number.map(|v| v.into()),
31            full_name: value.cardholder_name.map(|v| v.into()),
32            card_type: value.brand.map(|v| v.into()),
33            verification_number: value.code.map(|v| v.into()),
34            pin: None,
35            expiry_date,
36            valid_from: None,
37        }))]
38    }
39}
40
41impl From<&CreditCardCredential> for Card {
42    fn from(value: &CreditCardCredential) -> Self {
43        let expiry_date = value
44            .expiry_date
45            .as_ref()
46            .and_then(|v| v.value.as_expected().ok());
47        Card {
48            cardholder_name: value.full_name.clone().map(Into::into),
49            exp_month: expiry_date.map(|v| v.month.number_from_month().to_string()),
50            exp_year: expiry_date.map(|v| v.year.to_string()),
51            code: value.verification_number.clone().map(Into::into),
52            brand: value
53                .card_type
54                .clone()
55                .and_then(|b| sanitize_brand(b.into())),
56            number: value.number.clone().map(Into::into),
57        }
58    }
59}
60
61pub(super) fn to_card(credential: &CreditCardCredential) -> (Card, Vec<Field>) {
62    let card = credential.into();
63
64    let fields = [
65        credential
66            .pin
67            .as_ref()
68            .map(|v| create_field(v, Some("PIN"))),
69        credential
70            .valid_from
71            .as_ref()
72            .map(|v| create_field(v, Some("Valid From"))),
73    ]
74    .into_iter()
75    .flatten()
76    .collect();
77
78    (card, fields)
79}
80
81/// Sanitize credit card brand
82///
83/// Performs a fuzzy match on the string to find a matching brand. By converting to lowercase and
84/// removing all whitespace.
85///
86/// - For recognized brands, the brand is normalized before being converted to a string.
87/// - For unrecognized brands, `None` is returned.
88fn sanitize_brand(value: String) -> Option<String> {
89    match value.to_lowercase().replace(" ", "").as_str() {
90        "visa" => Some(CardBrand::Visa),
91        "mastercard" => Some(CardBrand::Mastercard),
92        "amex" | "americanexpress" => Some(CardBrand::Amex),
93        "discover" => Some(CardBrand::Discover),
94        "dinersclub" => Some(CardBrand::DinersClub),
95        "jcb" => Some(CardBrand::Jcb),
96        "maestro" => Some(CardBrand::Maestro),
97        "unionpay" => Some(CardBrand::UnionPay),
98        "rupay" => Some(CardBrand::RuPay),
99        _ => None,
100    }
101    .and_then(|brand| serde_json::to_value(&brand).ok())
102    .and_then(|v| v.as_str().map(|s| s.to_string()))
103}
104
105#[cfg(test)]
106mod tests {
107    use bitwarden_vault::FieldType;
108    use chrono::Month;
109    use credential_exchange_format::EditableFieldYearMonth;
110
111    use super::*;
112
113    #[test]
114    fn test_sanitize_brand() {
115        assert_eq!(sanitize_brand("Visa".into()), Some("Visa".to_string()));
116        assert_eq!(sanitize_brand("  visa  ".into()), Some("Visa".to_string()));
117        assert_eq!(
118            sanitize_brand("MasterCard".into()),
119            Some("Mastercard".to_string())
120        );
121        assert_eq!(sanitize_brand("amex".into()), Some("Amex".to_string()));
122        assert_eq!(
123            sanitize_brand("American Express".into()),
124            Some("Amex".to_string())
125        );
126        assert_eq!(
127            sanitize_brand("DinersClub".into()),
128            Some("Diners Club".to_string())
129        );
130        assert_eq!(sanitize_brand("j c b".into()), Some("JCB".to_string()));
131        assert_eq!(sanitize_brand("Some unknown".into()), None);
132    }
133
134    #[test]
135    fn test_card_to_credentials() {
136        let card = Card {
137            cardholder_name: Some("John Doe".to_string()),
138            exp_month: Some("12".to_string()),
139            exp_year: Some("2025".to_string()),
140            code: Some("123".to_string()),
141            brand: Some("Visa".to_string()),
142            number: Some("4111111111111111".to_string()),
143        };
144
145        let credentials: Vec<Credential> = card.into();
146        assert_eq!(credentials.len(), 1);
147
148        if let Credential::CreditCard(credit_card) = &credentials[0] {
149            assert_eq!(
150                credit_card
151                    .full_name
152                    .as_ref()
153                    .unwrap()
154                    .value
155                    .as_expected()
156                    .unwrap()
157                    .0,
158                "John Doe"
159            );
160            assert_eq!(
161                credit_card
162                    .expiry_date
163                    .as_ref()
164                    .unwrap()
165                    .value
166                    .as_expected(),
167                Ok(&EditableFieldYearMonth {
168                    year: 2025,
169                    month: Month::December
170                })
171            );
172            assert_eq!(
173                credit_card
174                    .verification_number
175                    .as_ref()
176                    .unwrap()
177                    .value
178                    .as_expected()
179                    .unwrap()
180                    .0,
181                "123".to_string()
182            );
183            assert_eq!(
184                credit_card
185                    .card_type
186                    .as_ref()
187                    .unwrap()
188                    .value
189                    .as_expected()
190                    .unwrap()
191                    .0,
192                "Visa".to_string()
193            );
194            assert_eq!(
195                credit_card
196                    .number
197                    .as_ref()
198                    .unwrap()
199                    .value
200                    .as_expected()
201                    .unwrap()
202                    .0,
203                "4111111111111111"
204            );
205        } else {
206            panic!("Expected CreditCardCredential");
207        }
208    }
209
210    /// Concealed strings are unwrapped as strings
211    #[test]
212    fn test_credit_card_credential_to_card_mismatched_card_type() {
213        let credit_card: CreditCardCredential = serde_json::from_value(serde_json::json!({
214            "cardType": {
215                "fieldType": "concealed-string",
216                "value": "visa"
217            }
218        }))
219        .unwrap();
220
221        let (card, _) = to_card(&credit_card);
222        assert_eq!(card.brand.unwrap(), "Visa");
223    }
224
225    #[test]
226    fn test_credit_card_credential_to_card() {
227        let credit_card = CreditCardCredential {
228            number: Some("4111111111111111".to_string().into()),
229            full_name: Some("John Doe".to_string().into()),
230            card_type: Some("Visa".to_string().into()),
231            verification_number: Some("123".to_string().into()),
232            pin: Some("4567".to_string().into()),
233            expiry_date: Some(
234                EditableFieldYearMonth {
235                    year: 2025,
236                    month: Month::December,
237                }
238                .into(),
239            ),
240            valid_from: Some(
241                EditableFieldYearMonth {
242                    year: 2024,
243                    month: Month::January,
244                }
245                .into(),
246            ),
247        };
248
249        let (card, fields) = to_card(&credit_card);
250        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
251        assert_eq!(card.exp_month, Some("12".to_string()));
252        assert_eq!(card.exp_year, Some("2025".to_string()));
253        assert_eq!(card.code, Some("123".to_string()));
254        assert_eq!(card.brand, Some("Visa".to_string()));
255        assert_eq!(card.number, Some("4111111111111111".to_string()));
256
257        assert_eq!(
258            fields,
259            vec![
260                Field {
261                    name: Some("PIN".to_string()),
262                    value: Some("4567".to_string()),
263                    r#type: FieldType::Hidden as u8,
264                    linked_id: None,
265                },
266                Field {
267                    name: Some("Valid From".to_string()),
268                    value: Some("2024-01".to_string()),
269                    r#type: FieldType::Text as u8,
270                    linked_id: None,
271                },
272            ]
273        )
274    }
275}