1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
7use bitwarden_core::MissingFieldError;
8use bitwarden_fido::{string_to_guid_bytes, InvalidGuid};
9use bitwarden_vault::{FieldType, Totp, TotpAlgorithm};
10use chrono::{DateTime, Utc};
11use credential_exchange_format::{
12 AndroidAppIdCredential, BasicAuthCredential, CredentialScope, OTPHashAlgorithm,
13 PasskeyCredential, TotpCredential,
14};
15use thiserror::Error;
16
17use crate::{Fido2Credential, Field, Login, LoginUri};
18
19const ANDROID_APP_SCHEME: &str = "androidapp://";
21
22fn convert_otp_algorithm(algorithm: &OTPHashAlgorithm) -> TotpAlgorithm {
25 match algorithm {
26 OTPHashAlgorithm::Sha1 => TotpAlgorithm::Sha1,
27 OTPHashAlgorithm::Sha256 => TotpAlgorithm::Sha256,
28 OTPHashAlgorithm::Sha512 => TotpAlgorithm::Sha512,
29 OTPHashAlgorithm::Unknown(ref algo) if algo == "steam" => TotpAlgorithm::Steam,
30 OTPHashAlgorithm::Unknown(_) | _ => TotpAlgorithm::Sha1, }
33}
34
35fn totp_credential_to_totp(cxf_totp: &TotpCredential) -> Totp {
38 let algorithm = convert_otp_algorithm(&cxf_totp.algorithm);
39
40 let secret_bytes: Vec<u8> = cxf_totp.secret.clone().into();
41
42 Totp {
43 account: cxf_totp.username.clone(),
44 algorithm,
45 digits: cxf_totp.digits as u32,
46 issuer: cxf_totp.issuer.clone(),
47 period: cxf_totp.period as u32,
48 secret: secret_bytes,
49 }
50}
51
52pub(super) fn to_login(
53 creation_date: DateTime<Utc>,
54 basic_auth: Option<&BasicAuthCredential>,
55 passkey: Option<&PasskeyCredential>,
56 totp: Option<&TotpCredential>,
57 scope: Option<&CredentialScope>,
58) -> Login {
59 Login {
60 username: basic_auth.and_then(|v| v.username.clone().map(|v| v.into())),
61 password: basic_auth.and_then(|v| v.password.clone().map(|u| u.into())),
62 login_uris: scope.map(to_uris).unwrap_or_default(),
63 totp: totp.map(|t| totp_credential_to_totp(t).to_string()),
64 fido2_credentials: passkey.map(|p| {
65 vec![Fido2Credential {
66 credential_id: format!("b64.{}", p.credential_id),
67 key_type: "public-key".to_string(),
68 key_algorithm: "ECDSA".to_string(),
69 key_curve: "P-256".to_string(),
70 key_value: URL_SAFE_NO_PAD.encode(&p.key),
71 rp_id: p.rp_id.clone(),
72 user_handle: Some(p.user_handle.to_string()),
73 user_name: Some(p.username.clone()),
74 counter: 0,
75 rp_name: Some(p.rp_id.clone()),
76 user_display_name: Some(p.user_display_name.clone()),
77 discoverable: "true".to_string(),
78 creation_date,
79 }]
80 }),
81 }
82}
83
84fn to_uris(scope: &CredentialScope) -> Vec<LoginUri> {
88 let urls = scope.urls.iter().map(|u| LoginUri {
89 uri: Some(u.clone()),
90 r#match: None,
91 });
92
93 let android_apps = scope.android_apps.iter().map(|a| LoginUri {
94 uri: Some(format!("{ANDROID_APP_SCHEME}{}", a.bundle_id)),
95 r#match: None,
96 });
97
98 urls.chain(android_apps).collect()
99}
100
101#[allow(unused)]
105pub(super) fn to_fields(scope: &CredentialScope) -> Vec<Field> {
106 let urls = scope.urls.iter().enumerate().map(|(i, u)| Field {
107 name: Some(format!("Url {}", i + 1)),
108 value: Some(u.clone()),
109 r#type: FieldType::Text as u8,
110 linked_id: None,
111 });
112
113 let android_apps = scope.android_apps.iter().enumerate().map(|(i, a)| Field {
114 name: Some(format!("Android App {}", i + 1)),
115 value: Some(a.bundle_id.clone()),
116 r#type: FieldType::Text as u8,
117 linked_id: None,
118 });
119
120 urls.chain(android_apps).collect()
121}
122
123impl From<Login> for BasicAuthCredential {
124 fn from(login: Login) -> Self {
125 BasicAuthCredential {
126 username: login.username.map(|v| v.into()),
127 password: login.password.map(|v| v.into()),
128 }
129 }
130}
131
132impl From<Login> for CredentialScope {
133 fn from(login: Login) -> Self {
134 let (android_uris, urls): (Vec<_>, Vec<_>) = login
135 .login_uris
136 .into_iter()
137 .filter_map(|u| u.uri)
138 .partition(|uri| uri.starts_with(ANDROID_APP_SCHEME));
139
140 let android_apps = android_uris
141 .into_iter()
142 .map(|uri| {
143 let rest = uri.trim_start_matches(ANDROID_APP_SCHEME);
144 AndroidAppIdCredential {
145 bundle_id: rest.to_string(),
146 certificate: None,
147 name: None,
148 }
149 })
150 .collect();
151
152 CredentialScope { urls, android_apps }
153 }
154}
155
156#[derive(Error, Debug)]
157pub enum PasskeyError {
158 #[error("Counter is not zero")]
159 CounterNotZero,
160 #[error(transparent)]
161 InvalidGuid(InvalidGuid),
162 #[error(transparent)]
163 MissingField(MissingFieldError),
164 #[error(transparent)]
165 InvalidBase64(#[from] base64::DecodeError),
166}
167
168impl TryFrom<Fido2Credential> for PasskeyCredential {
169 type Error = PasskeyError;
170
171 fn try_from(value: Fido2Credential) -> Result<Self, Self::Error> {
172 if value.counter > 0 {
173 return Err(PasskeyError::CounterNotZero);
174 }
175
176 Ok(PasskeyCredential {
177 credential_id: string_to_guid_bytes(&value.credential_id)
178 .map_err(PasskeyError::InvalidGuid)?
179 .into(),
180 rp_id: value.rp_id,
181 username: value.user_name.unwrap_or_default(),
182 user_display_name: value.user_display_name.unwrap_or_default(),
183 user_handle: value
184 .user_handle
185 .map(|v| URL_SAFE_NO_PAD.decode(v))
186 .transpose()?
187 .map(|v| v.into())
188 .ok_or(PasskeyError::MissingField(MissingFieldError("user_handle")))?,
189 key: URL_SAFE_NO_PAD.decode(value.key_value)?.into(),
190 fido2_extensions: None,
191 })
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::LoginUri;
199
200 #[test]
201 fn test_basic_auth() {
202 let login = Login {
203 username: Some("[email protected]".to_string()),
204 password: Some("asdfasdfasdf".to_string()),
205 login_uris: vec![LoginUri {
206 uri: Some("https://vault.bitwarden.com".to_string()),
207 r#match: None,
208 }],
209 totp: None,
210 fido2_credentials: None,
211 };
212
213 let basic_auth: BasicAuthCredential = login.into();
214
215 let username = basic_auth.username.as_ref().unwrap();
216 assert_eq!(username.value.0, "[email protected]");
217 assert!(username.label.is_none());
218
219 let password = basic_auth.password.as_ref().unwrap();
220 assert_eq!(password.value.0, "asdfasdfasdf");
221 assert!(password.label.is_none());
222 }
223
224 #[test]
225 fn test_credential_scope() {
226 let login = Login {
227 username: None,
228 password: None,
229 login_uris: vec![LoginUri {
230 uri: Some("https://vault.bitwarden.com".to_string()),
231 r#match: None,
232 }],
233 totp: None,
234 fido2_credentials: None,
235 };
236
237 let scope: CredentialScope = login.into();
238
239 assert_eq!(scope.urls, vec!["https://vault.bitwarden.com".to_string()]);
240 }
241
242 #[test]
243 fn test_passkey() {
244 let credential = Fido2Credential {
245 credential_id: "e8d88789-e916-e196-3cbd-81dafae71bbc".to_string(),
246 key_type: "public-key".to_string(),
247 key_algorithm: "ECDSA".to_string(),
248 key_curve: "P-256".to_string(),
249 key_value: "AAECAwQFBg".to_string(),
250 rp_id: "123".to_string(),
251 user_handle: Some("AAECAwQFBg".to_string()),
252 user_name: None,
253 counter: 0,
254 rp_name: None,
255 user_display_name: None,
256 discoverable: "true".to_string(),
257 creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
258 };
259
260 let passkey: PasskeyCredential = credential.try_into().unwrap();
261
262 assert_eq!(passkey.credential_id.to_string(), "6NiHiekW4ZY8vYHa-ucbvA");
263 assert_eq!(passkey.rp_id, "123");
264 assert_eq!(passkey.username, "");
265 assert_eq!(passkey.user_display_name, "");
266 assert_eq!(String::from(passkey.user_handle.clone()), "AAECAwQFBg");
267 assert_eq!(String::from(passkey.key.clone()), "AAECAwQFBg");
268 assert!(passkey.fido2_extensions.is_none());
269 }
270
271 #[test]
272 fn test_to_uris_with_urls_only() {
273 let scope = CredentialScope {
274 urls: vec![
275 "https://vault.bitwarden.com".to_string(),
276 "https://bitwarden.com".to_string(),
277 ],
278 android_apps: vec![],
279 };
280
281 let uris = to_uris(&scope);
282
283 assert_eq!(
284 uris,
285 vec![
286 LoginUri {
287 uri: Some("https://vault.bitwarden.com".to_string()),
288 r#match: None
289 },
290 LoginUri {
291 uri: Some("https://bitwarden.com".to_string()),
292 r#match: None
293 },
294 ]
295 );
296 }
297
298 #[test]
299 fn test_to_uris_with_android_apps_only() {
300 let scope = CredentialScope {
301 urls: vec![],
302 android_apps: vec![
303 credential_exchange_format::AndroidAppIdCredential {
304 bundle_id: "com.bitwarden.app".to_string(),
305 certificate: None,
306 name: None,
307 },
308 credential_exchange_format::AndroidAppIdCredential {
309 bundle_id: "com.example.app".to_string(),
310 certificate: None,
311 name: None,
312 },
313 ],
314 };
315
316 let uris = to_uris(&scope);
317
318 assert_eq!(
319 uris,
320 vec![
321 LoginUri {
322 uri: Some("androidapp://com.bitwarden.app".to_string()),
323 r#match: None
324 },
325 LoginUri {
326 uri: Some("androidapp://com.example.app".to_string()),
327 r#match: None
328 },
329 ]
330 );
331 }
332
333 #[test]
334 fn test_to_uris_with_mixed_urls_and_android_apps() {
335 let scope = CredentialScope {
336 urls: vec![
337 "https://vault.bitwarden.com".to_string(),
338 "https://bitwarden.com".to_string(),
339 ],
340 android_apps: vec![
341 credential_exchange_format::AndroidAppIdCredential {
342 bundle_id: "com.bitwarden.app".to_string(),
343 certificate: None,
344 name: None,
345 },
346 credential_exchange_format::AndroidAppIdCredential {
347 bundle_id: "com.example.app".to_string(),
348 certificate: None,
349 name: None,
350 },
351 ],
352 };
353
354 let uris = to_uris(&scope);
355
356 assert_eq!(
357 uris,
358 vec![
359 LoginUri {
360 uri: Some("https://vault.bitwarden.com".to_string()),
361 r#match: None
362 },
363 LoginUri {
364 uri: Some("https://bitwarden.com".to_string()),
365 r#match: None
366 },
367 LoginUri {
368 uri: Some("androidapp://com.bitwarden.app".to_string()),
369 r#match: None
370 },
371 LoginUri {
372 uri: Some("androidapp://com.example.app".to_string()),
373 r#match: None
374 },
375 ]
376 );
377 }
378
379 #[test]
380 fn test_to_uris_with_empty_scope() {
381 let scope = CredentialScope {
382 urls: vec![],
383 android_apps: vec![],
384 };
385
386 let uris = to_uris(&scope);
387
388 assert!(uris.is_empty());
389 }
390
391 #[test]
392 fn test_credential_scope_with_android_apps_only() {
393 let login = Login {
394 username: None,
395 password: None,
396 login_uris: vec![
397 LoginUri {
398 uri: Some("androidapp://com.bitwarden.app".to_string()),
399 r#match: None,
400 },
401 LoginUri {
402 uri: Some("androidapp://com.example.app".to_string()),
403 r#match: None,
404 },
405 ],
406 totp: None,
407 fido2_credentials: None,
408 };
409
410 let scope: CredentialScope = login.into();
411 assert!(scope.urls.is_empty());
412 assert_eq!(scope.android_apps.len(), 2);
413 assert_eq!(scope.android_apps[0].bundle_id, "com.bitwarden.app");
414 assert_eq!(scope.android_apps[1].bundle_id, "com.example.app");
415 }
416
417 #[test]
418 fn test_credential_scope_with_mixed_urls_and_android_apps() {
419 let login = Login {
420 username: None,
421 password: None,
422 login_uris: vec![
423 LoginUri {
424 uri: Some("https://vault.bitwarden.com".to_string()),
425 r#match: None,
426 },
427 LoginUri {
428 uri: Some("androidapp://com.bitwarden.app".to_string()),
429 r#match: None,
430 },
431 LoginUri {
432 uri: Some("https://bitwarden.com".to_string()),
433 r#match: None,
434 },
435 LoginUri {
436 uri: Some("androidapp://com.example.app".to_string()),
437 r#match: None,
438 },
439 ],
440 totp: None,
441 fido2_credentials: None,
442 };
443
444 let scope: CredentialScope = login.into();
445 assert_eq!(
446 scope.urls,
447 vec![
448 "https://vault.bitwarden.com".to_string(),
449 "https://bitwarden.com".to_string(),
450 ]
451 );
452 assert_eq!(scope.android_apps.len(), 2);
453 assert_eq!(scope.android_apps[0].bundle_id, "com.bitwarden.app");
454 assert_eq!(scope.android_apps[1].bundle_id, "com.example.app");
455 }
456
457 #[test]
458 fn test_to_fields() {
459 let scope = CredentialScope {
460 urls: vec![
461 "https://vault.bitwarden.com".to_string(),
462 "https://bitwarden.com".to_string(),
463 ],
464 android_apps: vec![
465 credential_exchange_format::AndroidAppIdCredential {
466 bundle_id: "com.bitwarden.app".to_string(),
467 certificate: None,
468 name: None,
469 },
470 credential_exchange_format::AndroidAppIdCredential {
471 bundle_id: "com.example.app".to_string(),
472 certificate: None,
473 name: None,
474 },
475 ],
476 };
477
478 let fields = to_fields(&scope);
479 assert_eq!(
480 fields,
481 vec![
482 Field {
483 name: Some("Url 1".to_string()),
484 value: Some("https://vault.bitwarden.com".to_string()),
485 r#type: FieldType::Text as u8,
486 linked_id: None,
487 },
488 Field {
489 name: Some("Url 2".to_string()),
490 value: Some("https://bitwarden.com".to_string()),
491 r#type: FieldType::Text as u8,
492 linked_id: None,
493 },
494 Field {
495 name: Some("Android App 1".to_string()),
496 value: Some("com.bitwarden.app".to_string()),
497 r#type: FieldType::Text as u8,
498 linked_id: None,
499 },
500 Field {
501 name: Some("Android App 2".to_string()),
502 value: Some("com.example.app".to_string()),
503 r#type: FieldType::Text as u8,
504 linked_id: None,
505 },
506 ]
507 );
508 }
509
510 #[test]
512 fn test_totp_credential_to_totp_basic() {
513 let totp = TotpCredential {
514 secret: "Hello World!".as_bytes().to_vec().into(),
515 period: 30,
516 digits: 6,
517 username: Some("[email protected]".to_string()),
518 algorithm: OTPHashAlgorithm::Sha1,
519 issuer: Some("Example".to_string()),
520 };
521
522 let bitwarden_totp = totp_credential_to_totp(&totp);
523 let otpauth = bitwarden_totp.to_string();
524
525 assert!(otpauth.starts_with("otpauth://totp/Example:test%40example%2Ecom?secret="));
526 assert!(otpauth.contains("&issuer=Example"));
527 assert!(!otpauth.contains("&period=30"));
529 assert!(!otpauth.contains("&digits=6"));
530 assert!(!otpauth.contains("&algorithm=SHA1"));
531 }
532
533 #[test]
534 fn test_totp_credential_to_totp_custom_parameters() {
535 let totp = TotpCredential {
536 secret: "Hello World!".as_bytes().to_vec().into(),
537 period: 60,
538 digits: 8,
539 username: Some("user".to_string()),
540 algorithm: OTPHashAlgorithm::Sha256,
541 issuer: Some("Custom Issuer".to_string()),
542 };
543
544 let bitwarden_totp = totp_credential_to_totp(&totp);
545 let otpauth = bitwarden_totp.to_string();
546
547 assert!(otpauth.contains("Custom%20Issuer:user"));
548 assert!(otpauth.contains("&issuer=Custom%20Issuer"));
549 assert!(otpauth.contains("&period=60"));
550 assert!(otpauth.contains("&digits=8"));
551 assert!(otpauth.contains("&algorithm=SHA256"));
552 }
553
554 #[test]
556 fn test_convert_otp_algorithm_sha1() {
557 let result = convert_otp_algorithm(&OTPHashAlgorithm::Sha1);
558 assert_eq!(result, TotpAlgorithm::Sha1);
559 }
560
561 #[test]
562 fn test_convert_otp_algorithm_sha256() {
563 let result = convert_otp_algorithm(&OTPHashAlgorithm::Sha256);
564 assert_eq!(result, TotpAlgorithm::Sha256);
565 }
566
567 #[test]
568 fn test_convert_otp_algorithm_sha512() {
569 let result = convert_otp_algorithm(&OTPHashAlgorithm::Sha512);
570 assert_eq!(result, TotpAlgorithm::Sha512);
571 }
572
573 #[test]
574 fn test_convert_otp_algorithm_steam() {
575 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("steam".to_string()));
576 assert_eq!(result, TotpAlgorithm::Steam);
577 }
578
579 #[test]
580 fn test_convert_otp_algorithm_steam_case_sensitive() {
581 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("Steam".to_string()));
583 assert_eq!(result, TotpAlgorithm::Sha1); }
585
586 #[test]
587 fn test_convert_otp_algorithm_unknown_empty() {
588 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("".to_string()));
589 assert_eq!(result, TotpAlgorithm::Sha1); }
591
592 #[test]
593 fn test_convert_otp_algorithm_unknown_md5() {
594 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("md5".to_string()));
596 assert_eq!(result, TotpAlgorithm::Sha1); }
598
599 #[test]
600 fn test_convert_otp_algorithm_unknown_whitespace() {
601 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown(" steam ".to_string()));
603 assert_eq!(result, TotpAlgorithm::Sha1); }
605}