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
25fn 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 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, 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, 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 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, 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 if let Some(wifi) = grouped.wifi.first() {
106 let fields = wifi_to_fields(wifi);
107
108 output.push(ImportingCipher {
109 folder_id: None, 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
127fn 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}