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
20fn 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 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, 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, 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
80fn 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}