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;
10use chrono::{DateTime, Utc};
11use credential_exchange_format::{
12    AndroidAppIdCredential, BasicAuthCredential, CredentialScope, PasskeyCredential,
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
21pub(super) fn to_login(
22    creation_date: DateTime<Utc>,
23    basic_auth: Option<&BasicAuthCredential>,
24    passkey: Option<&PasskeyCredential>,
25    scope: Option<&CredentialScope>,
26) -> Login {
27    Login {
28        username: basic_auth.and_then(|v| v.username.clone().map(|v| v.into())),
29        password: basic_auth.and_then(|v| v.password.clone().map(|u| u.into())),
30        login_uris: scope.map(to_uris).unwrap_or_default(),
31        totp: None,
32        fido2_credentials: passkey.map(|p| {
33            vec![Fido2Credential {
34                credential_id: format!("b64.{}", p.credential_id),
35                key_type: "public-key".to_string(),
36                key_algorithm: "ECDSA".to_string(),
37                key_curve: "P-256".to_string(),
38                key_value: URL_SAFE_NO_PAD.encode(&p.key),
39                rp_id: p.rp_id.clone(),
40                user_handle: Some(p.user_handle.to_string()),
41                user_name: Some(p.username.clone()),
42                counter: 0,
43                rp_name: Some(p.rp_id.clone()),
44                user_display_name: Some(p.user_display_name.clone()),
45                discoverable: "true".to_string(),
46                creation_date,
47            }]
48        }),
49    }
50}
51
52/// Converts a `CredentialScope` to a vector of `LoginUri` objects.
53///
54/// This is used for login credentials.
55fn to_uris(scope: &CredentialScope) -> Vec<LoginUri> {
56    let urls = scope.urls.iter().map(|u| LoginUri {
57        uri: Some(u.clone()),
58        r#match: None,
59    });
60
61    let android_apps = scope.android_apps.iter().map(|a| LoginUri {
62        uri: Some(format!("{ANDROID_APP_SCHEME}{}", a.bundle_id)),
63        r#match: None,
64    });
65
66    urls.chain(android_apps).collect()
67}
68
69/// Converts a `CredentialScope` to a vector of `Field` objects.
70///
71/// This is used for non-login credentials.
72pub(crate) fn to_fields(scope: &CredentialScope) -> Vec<Field> {
73    let urls = scope.urls.iter().enumerate().map(|(i, u)| Field {
74        name: Some(format!("Url {}", i + 1)),
75        value: Some(u.clone()),
76        r#type: FieldType::Text as u8,
77        linked_id: None,
78    });
79
80    let android_apps = scope.android_apps.iter().enumerate().map(|(i, a)| Field {
81        name: Some(format!("Android App {}", i + 1)),
82        value: Some(a.bundle_id.clone()),
83        r#type: FieldType::Text as u8,
84        linked_id: None,
85    });
86
87    urls.chain(android_apps).collect()
88}
89
90impl From<Login> for BasicAuthCredential {
91    fn from(login: Login) -> Self {
92        BasicAuthCredential {
93            username: login.username.map(|v| v.into()),
94            password: login.password.map(|v| v.into()),
95        }
96    }
97}
98
99impl From<Login> for CredentialScope {
100    fn from(login: Login) -> Self {
101        let (android_uris, urls): (Vec<_>, Vec<_>) = login
102            .login_uris
103            .into_iter()
104            .filter_map(|u| u.uri)
105            .partition(|uri| uri.starts_with(ANDROID_APP_SCHEME));
106
107        let android_apps = android_uris
108            .into_iter()
109            .map(|uri| {
110                let rest = uri.trim_start_matches(ANDROID_APP_SCHEME);
111                AndroidAppIdCredential {
112                    bundle_id: rest.to_string(),
113                    certificate: None,
114                    name: None,
115                }
116            })
117            .collect();
118
119        CredentialScope { urls, android_apps }
120    }
121}
122
123#[derive(Error, Debug)]
124pub enum PasskeyError {
125    #[error("Counter is not zero")]
126    CounterNotZero,
127    #[error(transparent)]
128    InvalidGuid(InvalidGuid),
129    #[error(transparent)]
130    MissingField(MissingFieldError),
131    #[error(transparent)]
132    InvalidBase64(#[from] base64::DecodeError),
133}
134
135impl TryFrom<Fido2Credential> for PasskeyCredential {
136    type Error = PasskeyError;
137
138    fn try_from(value: Fido2Credential) -> Result<Self, Self::Error> {
139        if value.counter > 0 {
140            return Err(PasskeyError::CounterNotZero);
141        }
142
143        Ok(PasskeyCredential {
144            credential_id: string_to_guid_bytes(&value.credential_id)
145                .map_err(PasskeyError::InvalidGuid)?
146                .into(),
147            rp_id: value.rp_id,
148            username: value.user_name.unwrap_or_default(),
149            user_display_name: value.user_display_name.unwrap_or_default(),
150            user_handle: value
151                .user_handle
152                .map(|v| URL_SAFE_NO_PAD.decode(v))
153                .transpose()?
154                .map(|v| v.into())
155                .ok_or(PasskeyError::MissingField(MissingFieldError("user_handle")))?,
156            key: URL_SAFE_NO_PAD.decode(value.key_value)?.into(),
157            fido2_extensions: None,
158        })
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::LoginUri;
166
167    #[test]
168    fn test_basic_auth() {
169        let login = Login {
170            username: Some("[email protected]".to_string()),
171            password: Some("asdfasdfasdf".to_string()),
172            login_uris: vec![LoginUri {
173                uri: Some("https://vault.bitwarden.com".to_string()),
174                r#match: None,
175            }],
176            totp: None,
177            fido2_credentials: None,
178        };
179
180        let basic_auth: BasicAuthCredential = login.into();
181
182        let username = basic_auth.username.as_ref().unwrap();
183        assert_eq!(username.value.0, "[email protected]");
184        assert!(username.label.is_none());
185
186        let password = basic_auth.password.as_ref().unwrap();
187        assert_eq!(password.value.0, "asdfasdfasdf");
188        assert!(password.label.is_none());
189    }
190
191    #[test]
192    fn test_credential_scope() {
193        let login = Login {
194            username: None,
195            password: None,
196            login_uris: vec![LoginUri {
197                uri: Some("https://vault.bitwarden.com".to_string()),
198                r#match: None,
199            }],
200            totp: None,
201            fido2_credentials: None,
202        };
203
204        let scope: CredentialScope = login.into();
205
206        assert_eq!(scope.urls, vec!["https://vault.bitwarden.com".to_string()]);
207    }
208
209    #[test]
210    fn test_passkey() {
211        let credential = Fido2Credential {
212            credential_id: "e8d88789-e916-e196-3cbd-81dafae71bbc".to_string(),
213            key_type: "public-key".to_string(),
214            key_algorithm: "ECDSA".to_string(),
215            key_curve: "P-256".to_string(),
216            key_value: "AAECAwQFBg".to_string(),
217            rp_id: "123".to_string(),
218            user_handle: Some("AAECAwQFBg".to_string()),
219            user_name: None,
220            counter: 0,
221            rp_name: None,
222            user_display_name: None,
223            discoverable: "true".to_string(),
224            creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
225        };
226
227        let passkey: PasskeyCredential = credential.try_into().unwrap();
228
229        assert_eq!(passkey.credential_id.to_string(), "6NiHiekW4ZY8vYHa-ucbvA");
230        assert_eq!(passkey.rp_id, "123");
231        assert_eq!(passkey.username, "");
232        assert_eq!(passkey.user_display_name, "");
233        assert_eq!(String::from(passkey.user_handle.clone()), "AAECAwQFBg");
234        assert_eq!(String::from(passkey.key.clone()), "AAECAwQFBg");
235        assert!(passkey.fido2_extensions.is_none());
236    }
237
238    #[test]
239    fn test_to_uris_with_urls_only() {
240        let scope = CredentialScope {
241            urls: vec![
242                "https://vault.bitwarden.com".to_string(),
243                "https://bitwarden.com".to_string(),
244            ],
245            android_apps: vec![],
246        };
247
248        let uris = to_uris(&scope);
249
250        assert_eq!(
251            uris,
252            vec![
253                LoginUri {
254                    uri: Some("https://vault.bitwarden.com".to_string()),
255                    r#match: None
256                },
257                LoginUri {
258                    uri: Some("https://bitwarden.com".to_string()),
259                    r#match: None
260                },
261            ]
262        );
263    }
264
265    #[test]
266    fn test_to_uris_with_android_apps_only() {
267        let scope = CredentialScope {
268            urls: vec![],
269            android_apps: vec![
270                credential_exchange_format::AndroidAppIdCredential {
271                    bundle_id: "com.bitwarden.app".to_string(),
272                    certificate: None,
273                    name: None,
274                },
275                credential_exchange_format::AndroidAppIdCredential {
276                    bundle_id: "com.example.app".to_string(),
277                    certificate: None,
278                    name: None,
279                },
280            ],
281        };
282
283        let uris = to_uris(&scope);
284
285        assert_eq!(
286            uris,
287            vec![
288                LoginUri {
289                    uri: Some("androidapp://com.bitwarden.app".to_string()),
290                    r#match: None
291                },
292                LoginUri {
293                    uri: Some("androidapp://com.example.app".to_string()),
294                    r#match: None
295                },
296            ]
297        );
298    }
299
300    #[test]
301    fn test_to_uris_with_mixed_urls_and_android_apps() {
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                credential_exchange_format::AndroidAppIdCredential {
309                    bundle_id: "com.bitwarden.app".to_string(),
310                    certificate: None,
311                    name: None,
312                },
313                credential_exchange_format::AndroidAppIdCredential {
314                    bundle_id: "com.example.app".to_string(),
315                    certificate: None,
316                    name: None,
317                },
318            ],
319        };
320
321        let uris = to_uris(&scope);
322
323        assert_eq!(
324            uris,
325            vec![
326                LoginUri {
327                    uri: Some("https://vault.bitwarden.com".to_string()),
328                    r#match: None
329                },
330                LoginUri {
331                    uri: Some("https://bitwarden.com".to_string()),
332                    r#match: None
333                },
334                LoginUri {
335                    uri: Some("androidapp://com.bitwarden.app".to_string()),
336                    r#match: None
337                },
338                LoginUri {
339                    uri: Some("androidapp://com.example.app".to_string()),
340                    r#match: None
341                },
342            ]
343        );
344    }
345
346    #[test]
347    fn test_to_uris_with_empty_scope() {
348        let scope = CredentialScope {
349            urls: vec![],
350            android_apps: vec![],
351        };
352
353        let uris = to_uris(&scope);
354
355        assert!(uris.is_empty());
356    }
357
358    #[test]
359    fn test_credential_scope_with_android_apps_only() {
360        let login = Login {
361            username: None,
362            password: None,
363            login_uris: vec![
364                LoginUri {
365                    uri: Some("androidapp://com.bitwarden.app".to_string()),
366                    r#match: None,
367                },
368                LoginUri {
369                    uri: Some("androidapp://com.example.app".to_string()),
370                    r#match: None,
371                },
372            ],
373            totp: None,
374            fido2_credentials: None,
375        };
376
377        let scope: CredentialScope = login.into();
378        assert!(scope.urls.is_empty());
379        assert_eq!(scope.android_apps.len(), 2);
380        assert_eq!(scope.android_apps[0].bundle_id, "com.bitwarden.app");
381        assert_eq!(scope.android_apps[1].bundle_id, "com.example.app");
382    }
383
384    #[test]
385    fn test_credential_scope_with_mixed_urls_and_android_apps() {
386        let login = Login {
387            username: None,
388            password: None,
389            login_uris: vec![
390                LoginUri {
391                    uri: Some("https://vault.bitwarden.com".to_string()),
392                    r#match: None,
393                },
394                LoginUri {
395                    uri: Some("androidapp://com.bitwarden.app".to_string()),
396                    r#match: None,
397                },
398                LoginUri {
399                    uri: Some("https://bitwarden.com".to_string()),
400                    r#match: None,
401                },
402                LoginUri {
403                    uri: Some("androidapp://com.example.app".to_string()),
404                    r#match: None,
405                },
406            ],
407            totp: None,
408            fido2_credentials: None,
409        };
410
411        let scope: CredentialScope = login.into();
412        assert_eq!(
413            scope.urls,
414            vec![
415                "https://vault.bitwarden.com".to_string(),
416                "https://bitwarden.com".to_string(),
417            ]
418        );
419        assert_eq!(scope.android_apps.len(), 2);
420        assert_eq!(scope.android_apps[0].bundle_id, "com.bitwarden.app");
421        assert_eq!(scope.android_apps[1].bundle_id, "com.example.app");
422    }
423
424    #[test]
425    fn test_to_fields() {
426        let scope = CredentialScope {
427            urls: vec![
428                "https://vault.bitwarden.com".to_string(),
429                "https://bitwarden.com".to_string(),
430            ],
431            android_apps: vec![
432                credential_exchange_format::AndroidAppIdCredential {
433                    bundle_id: "com.bitwarden.app".to_string(),
434                    certificate: None,
435                    name: None,
436                },
437                credential_exchange_format::AndroidAppIdCredential {
438                    bundle_id: "com.example.app".to_string(),
439                    certificate: None,
440                    name: None,
441                },
442            ],
443        };
444
445        let fields = to_fields(&scope);
446        assert_eq!(
447            fields,
448            vec![
449                Field {
450                    name: Some("Url 1".to_string()),
451                    value: Some("https://vault.bitwarden.com".to_string()),
452                    r#type: FieldType::Text as u8,
453                    linked_id: None,
454                },
455                Field {
456                    name: Some("Url 2".to_string()),
457                    value: Some("https://bitwarden.com".to_string()),
458                    r#type: FieldType::Text as u8,
459                    linked_id: None,
460                },
461                Field {
462                    name: Some("Android App 1".to_string()),
463                    value: Some("com.bitwarden.app".to_string()),
464                    r#type: FieldType::Text as u8,
465                    linked_id: None,
466                },
467                Field {
468                    name: Some("Android App 2".to_string()),
469                    value: Some("com.example.app".to_string()),
470                    r#type: FieldType::Text as u8,
471                    linked_id: None,
472                },
473            ]
474        );
475    }
476}