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!(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    // TOTP tests
540    #[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        // Default period (30) and digits (6) and algorithm (SHA1) should not be included
557        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    // Algorithm conversion tests
584    #[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        // Test that "steam" is case-sensitive
611        let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("Steam".to_string()));
612        assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1
613    }
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); // will default to SHA1
619    }
620
621    #[test]
622    fn test_convert_otp_algorithm_unknown_md5() {
623        // Test an algorithm that might exist in other systems but isn't supported
624        let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown("md5".to_string()));
625        assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1
626    }
627
628    #[test]
629    fn test_convert_otp_algorithm_unknown_whitespace() {
630        // Test steam with whitespace (will not match)
631        let result = convert_otp_algorithm(&OTPHashAlgorithm::Unknown(" steam ".to_string()));
632        assert_eq!(result, TotpAlgorithm::Sha1); // will default to SHA1
633    }
634
635    // Tests for the new helper functions
636    #[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}