bitwarden_exporters/cxf/
import.rs

1use chrono::{DateTime, Utc};
2use credential_exchange_format::{
3    Account as CxfAccount, BasicAuthCredential, Credential, CreditCardCredential, Item,
4    PasskeyCredential,
5};
6
7use crate::{
8    cxf::{login::to_login, CxfError},
9    CipherType, ImportingCipher,
10};
11
12pub(crate) fn parse_cxf(payload: String) -> Result<Vec<ImportingCipher>, CxfError> {
13    let account: CxfAccount = serde_json::from_str(&payload)?;
14
15    let items: Vec<ImportingCipher> = account.items.into_iter().flat_map(parse_item).collect();
16
17    Ok(items)
18}
19
20/// Convert a CXF timestamp to a [`DateTime<Utc>`].
21///
22/// If the timestamp is None, the current time is used.
23fn convert_date(ts: Option<u64>) -> DateTime<Utc> {
24    ts.and_then(|ts| DateTime::from_timestamp(ts as i64, 0))
25        .unwrap_or(Utc::now())
26}
27
28fn parse_item(value: Item) -> Vec<ImportingCipher> {
29    let grouped = group_credentials_by_type(value.credentials);
30
31    let creation_date = convert_date(value.creation_at);
32    let revision_date = convert_date(value.modified_at);
33
34    let mut output = vec![];
35
36    // Login credentials
37    if !grouped.basic_auth.is_empty() || !grouped.passkey.is_empty() {
38        let basic_auth = grouped.basic_auth.first();
39        let passkey = grouped.passkey.first();
40
41        let login = to_login(creation_date, basic_auth, passkey, value.scope);
42
43        output.push(ImportingCipher {
44            folder_id: None, // TODO: Handle folders
45            name: value.title.clone(),
46            notes: None,
47            r#type: CipherType::Login(Box::new(login)),
48            favorite: false,
49            reprompt: 0,
50            fields: vec![],
51            revision_date,
52            creation_date,
53            deleted_date: None,
54        })
55    }
56
57    if !grouped.credit_card.is_empty() {
58        let credit_card = grouped
59            .credit_card
60            .first()
61            .expect("Credit card is not empty");
62
63        output.push(ImportingCipher {
64            folder_id: None, // TODO: Handle folders
65            name: value.title.clone(),
66            notes: None,
67            r#type: CipherType::Card(Box::new(credit_card.into())),
68            favorite: false,
69            reprompt: 0,
70            fields: vec![],
71            revision_date,
72            creation_date,
73            deleted_date: None,
74        })
75    }
76
77    output
78}
79
80/// Group credentials by type.
81///
82/// The Credential Exchange protocol allows multiple identical credentials to be stored in a single
83/// item. Currently we only support one of each type and grouping allows an easy way to fetch the
84/// first of each type. Eventually we should add support for handling multiple credentials of the
85/// same type.
86fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials {
87    fn filter_credentials<T>(
88        credentials: &[Credential],
89        f: impl Fn(&Credential) -> Option<&T>,
90    ) -> Vec<T>
91    where
92        T: Clone,
93    {
94        credentials.iter().filter_map(f).cloned().collect()
95    }
96
97    GroupedCredentials {
98        basic_auth: filter_credentials(&credentials, |c| match c {
99            Credential::BasicAuth(basic_auth) => Some(basic_auth.as_ref()),
100            _ => None,
101        }),
102        passkey: filter_credentials(&credentials, |c| match c {
103            Credential::Passkey(passkey) => Some(passkey.as_ref()),
104            _ => None,
105        }),
106        credit_card: filter_credentials(&credentials, |c| match c {
107            Credential::CreditCard(credit_card) => Some(credit_card.as_ref()),
108            _ => None,
109        }),
110    }
111}
112
113struct GroupedCredentials {
114    basic_auth: Vec<BasicAuthCredential>,
115    passkey: Vec<PasskeyCredential>,
116    credit_card: Vec<CreditCardCredential>,
117}
118
119#[cfg(test)]
120mod tests {
121    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
122    use chrono::{Duration, Month};
123    use credential_exchange_format::{CreditCardCredential, EditableFieldYearMonth};
124
125    use super::*;
126
127    #[test]
128    fn test_convert_date() {
129        let timestamp: u64 = 1706613834;
130        let datetime = convert_date(Some(timestamp));
131        assert_eq!(
132            datetime,
133            "2024-01-30T11:23:54Z".parse::<DateTime<Utc>>().unwrap()
134        );
135    }
136
137    #[test]
138    fn test_convert_date_none() {
139        let datetime = convert_date(None);
140        assert!(datetime > Utc::now() - Duration::seconds(1));
141        assert!(datetime <= Utc::now());
142    }
143
144    #[test]
145    fn test_parse_empty_item() {
146        let item = Item {
147            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
148            creation_at: Some(1706613834),
149            modified_at: Some(1706623773),
150            title: "Bitwarden".to_string(),
151            subtitle: None,
152            favorite: None,
153            credentials: vec![],
154            tags: None,
155            extensions: None,
156            scope: None,
157        };
158
159        let ciphers: Vec<ImportingCipher> = parse_item(item);
160        assert_eq!(ciphers.len(), 0);
161    }
162
163    #[test]
164    fn test_parse_passkey() {
165        let item = Item {
166            id: URL_SAFE_NO_PAD
167                .decode("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
168                .unwrap()
169                .as_slice()
170                .into(),
171            creation_at: Some(1732181986),
172            modified_at: Some(1732182026),
173            title: "opotonniee.github.io".to_string(),
174            subtitle: None,
175            favorite: None,
176            credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
177                credential_id: URL_SAFE_NO_PAD
178                    .decode("6NiHiekW4ZY8vYHa-ucbvA")
179                    .unwrap()
180                    .as_slice()
181                    .into(),
182                rp_id: "opotonniee.github.io".to_string(),
183                username: "alex muller".to_string(),
184                user_display_name: "alex muller".to_string(),
185                user_handle: URL_SAFE_NO_PAD
186                    .decode("YWxleCBtdWxsZXI")
187                    .unwrap()
188                    .as_slice()
189                    .into(),
190                key: URL_SAFE_NO_PAD
191                    .decode("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
192                    .unwrap()
193                    .as_slice()
194                    .into(),
195                fido2_extensions: None,
196            }))],
197            tags: None,
198            extensions: None,
199            scope: None,
200        };
201
202        let ciphers: Vec<ImportingCipher> = parse_item(item);
203        assert_eq!(ciphers.len(), 1);
204        let cipher = ciphers.first().unwrap();
205
206        assert_eq!(cipher.folder_id, None);
207        assert_eq!(cipher.name, "opotonniee.github.io");
208
209        let login = match &cipher.r#type {
210            CipherType::Login(login) => login,
211            _ => panic!("Expected login"),
212        };
213
214        assert_eq!(login.username, None);
215        assert_eq!(login.password, None);
216        assert_eq!(login.login_uris.len(), 0);
217        assert_eq!(login.totp, None);
218
219        let passkey = login.fido2_credentials.as_ref().unwrap().first().unwrap();
220        assert_eq!(passkey.credential_id, "b64.6NiHiekW4ZY8vYHa-ucbvA");
221        assert_eq!(passkey.key_type, "public-key");
222        assert_eq!(passkey.key_algorithm, "ECDSA");
223        assert_eq!(passkey.key_curve, "P-256");
224        assert_eq!(
225            passkey.key_value,
226            "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl"
227        );
228        assert_eq!(passkey.rp_id, "opotonniee.github.io");
229        assert_eq!(
230            passkey.user_handle.as_ref().map(|h| h.to_string()).unwrap(),
231            "YWxleCBtdWxsZXI"
232        );
233        assert_eq!(passkey.user_name, Some("alex muller".to_string()));
234        assert_eq!(passkey.counter, 0);
235        assert_eq!(passkey.rp_name, Some("opotonniee.github.io".to_string()));
236        assert_eq!(passkey.user_display_name, Some("alex muller".to_string()));
237        assert_eq!(passkey.discoverable, "true");
238        assert_eq!(
239            passkey.creation_date,
240            "2024-11-21T09:39:46Z".parse::<DateTime<Utc>>().unwrap()
241        );
242    }
243
244    #[test]
245    fn test_credit_card() {
246        let item = Item {
247            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
248            creation_at: Some(1706613834),
249            modified_at: Some(1706623773),
250            title: "My MasterCard".to_string(),
251            subtitle: None,
252            favorite: None,
253            credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
254                number: Some("1234 5678 9012 3456".to_string().into()),
255                full_name: Some("John Doe".to_string().into()),
256                card_type: Some("MasterCard".to_string().into()),
257                verification_number: Some("123".to_string().into()),
258                pin: None,
259                expiry_date: Some(
260                    EditableFieldYearMonth {
261                        year: 2026,
262                        month: Month::January,
263                    }
264                    .into(),
265                ),
266                valid_from: None,
267            }))],
268            tags: None,
269            extensions: None,
270            scope: None,
271        };
272
273        let ciphers: Vec<ImportingCipher> = parse_item(item);
274        assert_eq!(ciphers.len(), 1);
275        let cipher = ciphers.first().unwrap();
276
277        assert_eq!(cipher.folder_id, None);
278        assert_eq!(cipher.name, "My MasterCard");
279
280        let card = match &cipher.r#type {
281            CipherType::Card(card) => card,
282            _ => panic!("Expected card"),
283        };
284
285        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
286        assert_eq!(card.exp_month, Some("1".to_string()));
287        assert_eq!(card.exp_year, Some("2026".to_string()));
288        assert_eq!(card.code, Some("123".to_string()));
289        assert_eq!(card.brand, Some("Mastercard".to_string()));
290        assert_eq!(card.number, Some("1234 5678 9012 3456".to_string()));
291    }
292}