bitwarden_exporters/cxf/
import.rs

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