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