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