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