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