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!(username.value.0, "[email protected]");
246 assert!(username.label.is_none());
247
248 let password = basic_auth.password.as_ref().unwrap();
249 assert_eq!(password.value.0, "asdfasdfasdf");
250 assert!(password.label.is_none());
251 }
252
253 #[test]
254 fn test_credential_scope() {
255 let login = Login {
256 username: None,
257 password: None,
258 login_uris: vec![LoginUri {
259 uri: Some("https://vault.bitwarden.com".to_string()),
260 r#match: None,
261 }],
262 totp: None,
263 fido2_credentials: None,
264 };
265
266 let scope: CredentialScope = login.into();
267
268 assert_eq!(scope.urls, vec!["https://vault.bitwarden.com".to_string()]);
269 }
270
271 #[test]
272 fn test_passkey() {
273 let credential = Fido2Credential {
274 credential_id: "e8d88789-e916-e196-3cbd-81dafae71bbc".to_string(),
275 key_type: "public-key".to_string(),
276 key_algorithm: "ECDSA".to_string(),
277 key_curve: "P-256".to_string(),
278 key_value: "AAECAwQFBg".to_string(),
279 rp_id: "123".to_string(),
280 user_handle: Some("AAECAwQFBg".to_string()),
281 user_name: None,
282 counter: 0,
283 rp_name: None,
284 user_display_name: None,
285 discoverable: "true".to_string(),
286 creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
287 };
288
289 let passkey: PasskeyCredential = credential.try_into().unwrap();
290
291 assert_eq!(passkey.credential_id.to_string(), "6NiHiekW4ZY8vYHa-ucbvA");
292 assert_eq!(passkey.rp_id, "123");
293 assert_eq!(passkey.username, "");
294 assert_eq!(passkey.user_display_name, "");
295 assert_eq!(String::from(passkey.user_handle.clone()), "AAECAwQFBg");
296 assert_eq!(String::from(passkey.key.clone()), "AAECAwQFBg");
297 assert!(passkey.fido2_extensions.is_none());
298 }
299
300 #[test]
301 fn test_to_uris_with_urls_only() {
302 let scope = CredentialScope {
303 urls: vec![
304 "https://vault.bitwarden.com".to_string(),
305 "https://bitwarden.com".to_string(),
306 ],
307 android_apps: vec![],
308 };
309
310 let uris = to_uris(&scope);
311
312 assert_eq!(
313 uris,
314 vec![
315 LoginUri {
316 uri: Some("https://vault.bitwarden.com".to_string()),
317 r#match: None
318 },
319 LoginUri {
320 uri: Some("https://bitwarden.com".to_string()),
321 r#match: None
322 },
323 ]
324 );
325 }
326
327 #[test]
328 fn test_to_uris_with_android_apps_only() {
329 let scope = CredentialScope {
330 urls: vec![],
331 android_apps: vec![
332 credential_exchange_format::AndroidAppIdCredential {
333 bundle_id: "com.bitwarden.app".to_string(),
334 certificate: None,
335 name: None,
336 },
337 credential_exchange_format::AndroidAppIdCredential {
338 bundle_id: "com.example.app".to_string(),
339 certificate: None,
340 name: None,
341 },
342 ],
343 };
344
345 let uris = to_uris(&scope);
346
347 assert_eq!(
348 uris,
349 vec![
350 LoginUri {
351 uri: Some("androidapp://com.bitwarden.app".to_string()),
352 r#match: None
353 },
354 LoginUri {
355 uri: Some("androidapp://com.example.app".to_string()),
356 r#match: None
357 },
358 ]
359 );
360 }
361
362 #[test]
363 fn test_to_uris_with_mixed_urls_and_android_apps() {
364 let scope = CredentialScope {
365 urls: vec![
366 "https://vault.bitwarden.com".to_string(),
367 "https://bitwarden.com".to_string(),
368 ],
369 android_apps: vec![
370 credential_exchange_format::AndroidAppIdCredential {
371 bundle_id: "com.bitwarden.app".to_string(),
372 certificate: None,
373 name: None,
374 },
375 credential_exchange_format::AndroidAppIdCredential {
376 bundle_id: "com.example.app".to_string(),
377 certificate: None,
378 name: None,
379 },
380 ],
381 };
382
383 let uris = to_uris(&scope);
384
385 assert_eq!(
386 uris,
387 vec![
388 LoginUri {
389 uri: Some("https://vault.bitwarden.com".to_string()),
390 r#match: None
391 },
392 LoginUri {
393 uri: Some("https://bitwarden.com".to_string()),
394 r#match: None
395 },
396 LoginUri {
397 uri: Some("androidapp://com.bitwarden.app".to_string()),
398 r#match: None
399 },
400 LoginUri {
401 uri: Some("androidapp://com.example.app".to_string()),
402 r#match: None
403 },
404 ]
405 );
406 }
407
408 #[test]
409 fn test_to_uris_with_empty_scope() {
410 let scope = CredentialScope {
411 urls: vec![],
412 android_apps: vec![],
413 };
414
415 let uris = to_uris(&scope);
416
417 assert!(uris.is_empty());
418 }
419
420 #[test]
421 fn test_credential_scope_with_android_apps_only() {
422 let login = Login {
423 username: None,
424 password: None,
425 login_uris: vec![
426 LoginUri {
427 uri: Some("androidapp://com.bitwarden.app".to_string()),
428 r#match: None,
429 },
430 LoginUri {
431 uri: Some("androidapp://com.example.app".to_string()),
432 r#match: None,
433 },
434 ],
435 totp: None,
436 fido2_credentials: None,
437 };
438
439 let scope: CredentialScope = login.into();
440 assert!(scope.urls.is_empty());
441 assert_eq!(scope.android_apps.len(), 2);
442 assert_eq!(scope.android_apps[0].bundle_id, "com.bitwarden.app");
443 assert_eq!(scope.android_apps[1].bundle_id, "com.example.app");
444 }
445
446 #[test]
447 fn test_credential_scope_with_mixed_urls_and_android_apps() {
448 let login = Login {
449 username: None,
450 password: None,
451 login_uris: vec![
452 LoginUri {
453 uri: Some("https://vault.bitwarden.com".to_string()),
454 r#match: None,
455 },
456 LoginUri {
457 uri: Some("androidapp://com.bitwarden.app".to_string()),
458 r#match: None,
459 },
460 LoginUri {
461 uri: Some("https://bitwarden.com".to_string()),
462 r#match: None,
463 },
464 LoginUri {
465 uri: Some("androidapp://com.example.app".to_string()),
466 r#match: None,
467 },
468 ],
469 totp: None,
470 fido2_credentials: None,
471 };
472
473 let scope: CredentialScope = login.into();
474 assert_eq!(
475 scope.urls,
476 vec![
477 "https://vault.bitwarden.com".to_string(),
478 "https://bitwarden.com".to_string(),
479 ]
480 );
481 assert_eq!(scope.android_apps.len(), 2);
482 assert_eq!(scope.android_apps[0].bundle_id, "com.bitwarden.app");
483 assert_eq!(scope.android_apps[1].bundle_id, "com.example.app");
484 }
485
486 #[test]
487 fn test_to_fields() {
488 let scope = CredentialScope {
489 urls: vec![
490 "https://vault.bitwarden.com".to_string(),
491 "https://bitwarden.com".to_string(),
492 ],
493 android_apps: vec![
494 credential_exchange_format::AndroidAppIdCredential {
495 bundle_id: "com.bitwarden.app".to_string(),
496 certificate: None,
497 name: None,
498 },
499 credential_exchange_format::AndroidAppIdCredential {
500 bundle_id: "com.example.app".to_string(),
501 certificate: None,
502 name: None,
503 },
504 ],
505 };
506
507 let fields = to_fields(&scope);
508 assert_eq!(
509 fields,
510 vec![
511 Field {
512 name: Some("Url 1".to_string()),
513 value: Some("https://vault.bitwarden.com".to_string()),
514 r#type: FieldType::Text as u8,
515 linked_id: None,
516 },
517 Field {
518 name: Some("Url 2".to_string()),
519 value: Some("https://bitwarden.com".to_string()),
520 r#type: FieldType::Text as u8,
521 linked_id: None,
522 },
523 Field {
524 name: Some("Android App 1".to_string()),
525 value: Some("com.bitwarden.app".to_string()),
526 r#type: FieldType::Text as u8,
527 linked_id: None,
528 },
529 Field {
530 name: Some("Android App 2".to_string()),
531 value: Some("com.example.app".to_string()),
532 r#type: FieldType::Text as u8,
533 linked_id: None,
534 },
535 ]
536 );
537 }
538
539 #[test]
541 fn test_totp_credential_to_totp_basic() {
542 let totp = TotpCredential {
543 secret: "Hello World!".as_bytes().to_vec().into(),
544 period: 30,
545 digits: 6,
546 username: Some("[email protected]".to_string()),
547 algorithm: OTPHashAlgorithm::Sha1,
548 issuer: Some("Example".to_string()),
549 };
550
551 let bitwarden_totp = totp_credential_to_totp(&totp);
552 let otpauth = bitwarden_totp.to_string();
553
554 assert!(otpauth.starts_with("otpauth://totp/Example:test%40example%2Ecom?secret="));
555 assert!(otpauth.contains("&issuer=Example"));
556 assert!(!otpauth.contains("&period=30"));
558 assert!(!otpauth.contains("&digits=6"));
559 assert!(!otpauth.contains("&algorithm=SHA1"));
560 }
561
562 #[test]
563 fn test_totp_credential_to_totp_custom_parameters() {
564 let totp = TotpCredential {
565 secret: "Hello World!".as_bytes().to_vec().into(),
566 period: 60,
567 digits: 8,
568 username: Some("user".to_string()),
569 algorithm: OTPHashAlgorithm::Sha256,
570 issuer: Some("Custom Issuer".to_string()),
571 };
572
573 let bitwarden_totp = totp_credential_to_totp(&totp);
574 let otpauth = bitwarden_totp.to_string();
575
576 assert!(otpauth.contains("Custom%20Issuer:user"));
577 assert!(otpauth.contains("&issuer=Custom%20Issuer"));
578 assert!(otpauth.contains("&period=60"));
579 assert!(otpauth.contains("&digits=8"));
580 assert!(otpauth.contains("&algorithm=SHA256"));
581 }
582
583 #[test]
585 fn test_convert_otp_algorithm_sha1() {
586 let result = convert_otp_algorithm(&OTPHashAlgorithm::Sha1);
587 assert_eq!(result, TotpAlgorithm::Sha1);
588 }
589
590 #[test]
591 fn test_convert_otp_algorithm_sha256() {
592 let result = convert_otp_algorithm(&OTPHashAlgorithm::Sha256);
593 assert_eq!(result, TotpAlgorithm::Sha256);
594 }
595
596 #[test]
597 fn test_convert_otp_algorithm_sha512() {
598 let result = convert_otp_algorithm(&OTPHashAlgorithm::Sha512);
599 assert_eq!(result, TotpAlgorithm::Sha512);
600 }
601
602 #[test]
603 fn test_convert_otp_algorithm_steam() {
604 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("steam".to_string()));
605 assert_eq!(result, TotpAlgorithm::Steam);
606 }
607
608 #[test]
609 fn test_convert_otp_algorithm_steam_case_sensitive() {
610 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("Steam".to_string()));
612 assert_eq!(result, TotpAlgorithm::Sha1); }
614
615 #[test]
616 fn test_convert_otp_algorithm_unknown_empty() {
617 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("".to_string()));
618 assert_eq!(result, TotpAlgorithm::Sha1); }
620
621 #[test]
622 fn test_convert_otp_algorithm_unknown_md5() {
623 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("md5".to_string()));
625 assert_eq!(result, TotpAlgorithm::Sha1); }
627
628 #[test]
629 fn test_convert_otp_algorithm_unknown_whitespace() {
630 let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown(" steam ".to_string()));
632 assert_eq!(result, TotpAlgorithm::Sha1); }
634
635 #[test]
637 fn test_passkey_rp_id_to_uri_with_domain() {
638 let uri = passkey_rp_id_to_uri("example.com");
639 assert_eq!(uri.uri, Some("https://example.com".to_string()));
640 assert_eq!(uri.r#match, None);
641 }
642
643 #[test]
644 fn test_passkey_rp_id_to_uri_with_https() {
645 let uri = passkey_rp_id_to_uri("https://example.com");
646 assert_eq!(uri.uri, Some("https://example.com".to_string()));
647 assert_eq!(uri.r#match, None);
648 }
649
650 #[test]
651 fn test_passkey_rp_id_to_uri_without_domain() {
652 let uri = passkey_rp_id_to_uri("localhost");
653 assert_eq!(uri.uri, Some("localhost".to_string()));
654 assert_eq!(uri.r#match, None);
655 }
656
657 #[test]
658 fn test_create_login_uri() {
659 let uri = create_login_uri("https://test.example".to_string());
660 assert_eq!(uri.uri, Some("https://test.example".to_string()));
661 assert_eq!(uri.r#match, None);
662 }
663}