Skip to main content

bitwarden_exporters/cxf/
login.rs

1//! Login credential conversion
2//!
3//! Handles conversion between internal [Login] and credential exchange [BasicAuthCredential] and
4//! [PasskeyCredential].
5
6use 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
18/// Prefix that indicates the URL is an Android app scheme.
19const ANDROID_APP_SCHEME: &str = "androidapp://";
20
21/// Convert CXF OTPHashAlgorithm to Bitwarden's TotpAlgorithm
22/// Handles standard algorithms and special cases like Steam
23fn 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, /* Default to SHA1 for unknown
30                                                                  * algorithms */
31    }
32}
33
34/// Convert CXF TotpCredential to Bitwarden's Totp struct
35/// This ensures we use the exact same encoding and formatting as Bitwarden's core implementation
36fn 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    // Use basic_auth username first, fallback to non-empty passkey username
59    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    // Use scope URIs first, fallback to passkey rp_id
68    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
98/// Creates a LoginUri from a URL string
99fn create_login_uri(uri: String) -> LoginUri {
100    LoginUri {
101        uri: Some(uri),
102        r#match: None,
103    }
104}
105
106/// Creates URIs from a passkey's rp_id, adding https:// prefix for domain-like strings
107fn 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
116/// Converts a `CredentialScope` to a vector of `LoginUri` objects.
117///
118/// This is used for login credentials.
119fn 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/// Converts a `CredentialScope` to a vector of `Field` objects.
131///
132/// This is used for non-login credentials.
133#[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    // TOTP tests
543    #[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        // Default period (30) and digits (6) and algorithm (SHA1) should not be included
560        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    // Algorithm conversion tests
587    #[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        // Test that "steam" is case-sensitive
614        let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("Steam".to_string()));
615        assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1
616    }
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); // will default to SHA1
622    }
623
624    #[test]
625    fn test_convert_otp_algorithm_unknown_md5() {
626        // Test an algorithm that might exist in other systems but isn't supported
627        let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("md5".to_string()));
628        assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1
629    }
630
631    #[test]
632    fn test_convert_otp_algorithm_unknown_whitespace() {
633        // Test steam with whitespace (will not match)
634        let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown(" steam ".to_string()));
635        assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1
636    }
637
638    // Tests for the new helper functions
639    #[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}