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 chrono::{DateTime, Utc};
10use credential_exchange_format::{BasicAuthCredential, CredentialScope, PasskeyCredential};
11use thiserror::Error;
12
13use crate::{Fido2Credential, Login, LoginUri};
14
15pub(super) fn to_login(
16    creation_date: DateTime<Utc>,
17    basic_auth: Option<&BasicAuthCredential>,
18    passkey: Option<&PasskeyCredential>,
19    scope: Option<CredentialScope>,
20) -> Login {
21    let login = Login {
22        username: basic_auth.and_then(|v| v.username.clone().map(|v| v.into())),
23        password: basic_auth.and_then(|v| v.password.clone().map(|u| u.into())),
24        login_uris: scope
25            .map(|v| {
26                v.urls
27                    .iter()
28                    .map(|u| LoginUri {
29                        uri: Some(u.clone()),
30                        r#match: None,
31                    })
32                    .collect()
33            })
34            .unwrap_or_default(),
35        totp: None,
36        fido2_credentials: passkey.map(|p| {
37            vec![Fido2Credential {
38                credential_id: format!("b64.{}", p.credential_id),
39                key_type: "public-key".to_string(),
40                key_algorithm: "ECDSA".to_string(),
41                key_curve: "P-256".to_string(),
42                key_value: URL_SAFE_NO_PAD.encode(&p.key),
43                rp_id: p.rp_id.clone(),
44                user_handle: Some(p.user_handle.to_string()),
45                user_name: Some(p.username.clone()),
46                counter: 0,
47                rp_name: Some(p.rp_id.clone()),
48                user_display_name: Some(p.user_display_name.clone()),
49                discoverable: "true".to_string(),
50                creation_date,
51            }]
52        }),
53    };
54    login
55}
56
57impl From<Login> for BasicAuthCredential {
58    fn from(login: Login) -> Self {
59        BasicAuthCredential {
60            username: login.username.map(|v| v.into()),
61            password: login.password.map(|v| v.into()),
62        }
63    }
64}
65
66impl From<Login> for CredentialScope {
67    fn from(login: Login) -> Self {
68        CredentialScope {
69            urls: login.login_uris.into_iter().filter_map(|u| u.uri).collect(),
70            android_apps: vec![],
71        }
72    }
73}
74
75#[derive(Error, Debug)]
76pub enum PasskeyError {
77    #[error("Counter is not zero")]
78    CounterNotZero,
79    #[error(transparent)]
80    InvalidGuid(InvalidGuid),
81    #[error(transparent)]
82    MissingField(MissingFieldError),
83    #[error(transparent)]
84    InvalidBase64(#[from] base64::DecodeError),
85}
86
87impl TryFrom<Fido2Credential> for PasskeyCredential {
88    type Error = PasskeyError;
89
90    fn try_from(value: Fido2Credential) -> Result<Self, Self::Error> {
91        if value.counter > 0 {
92            return Err(PasskeyError::CounterNotZero);
93        }
94
95        Ok(PasskeyCredential {
96            credential_id: string_to_guid_bytes(&value.credential_id)
97                .map_err(PasskeyError::InvalidGuid)?
98                .into(),
99            rp_id: value.rp_id,
100            username: value.user_name.unwrap_or_default(),
101            user_display_name: value.user_display_name.unwrap_or_default(),
102            user_handle: value
103                .user_handle
104                .map(|v| URL_SAFE_NO_PAD.decode(v))
105                .transpose()?
106                .map(|v| v.into())
107                .ok_or(PasskeyError::MissingField(MissingFieldError("user_handle")))?,
108            key: URL_SAFE_NO_PAD.decode(value.key_value)?.into(),
109            fido2_extensions: None,
110        })
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::LoginUri;
118
119    #[test]
120    fn test_basic_auth() {
121        let login = Login {
122            username: Some("[email protected]".to_string()),
123            password: Some("asdfasdfasdf".to_string()),
124            login_uris: vec![LoginUri {
125                uri: Some("https://vault.bitwarden.com".to_string()),
126                r#match: None,
127            }],
128            totp: None,
129            fido2_credentials: None,
130        };
131
132        let basic_auth: BasicAuthCredential = login.into();
133
134        let username = basic_auth.username.as_ref().unwrap();
135        assert_eq!(username.value.0, "[email protected]");
136        assert!(username.label.is_none());
137
138        let password = basic_auth.password.as_ref().unwrap();
139        assert_eq!(password.value.0, "asdfasdfasdf");
140        assert!(password.label.is_none());
141    }
142
143    #[test]
144    fn test_credential_scope() {
145        let login = Login {
146            username: None,
147            password: None,
148            login_uris: vec![LoginUri {
149                uri: Some("https://vault.bitwarden.com".to_string()),
150                r#match: None,
151            }],
152            totp: None,
153            fido2_credentials: None,
154        };
155
156        let scope: CredentialScope = login.into();
157
158        assert_eq!(scope.urls, vec!["https://vault.bitwarden.com".to_string()]);
159    }
160
161    #[test]
162    fn test_passkey() {
163        let credential = Fido2Credential {
164            credential_id: "e8d88789-e916-e196-3cbd-81dafae71bbc".to_string(),
165            key_type: "public-key".to_string(),
166            key_algorithm: "ECDSA".to_string(),
167            key_curve: "P-256".to_string(),
168            key_value: "AAECAwQFBg".to_string(),
169            rp_id: "123".to_string(),
170            user_handle: Some("AAECAwQFBg".to_string()),
171            user_name: None,
172            counter: 0,
173            rp_name: None,
174            user_display_name: None,
175            discoverable: "true".to_string(),
176            creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
177        };
178
179        let passkey: PasskeyCredential = credential.try_into().unwrap();
180
181        assert_eq!(passkey.credential_id.to_string(), "6NiHiekW4ZY8vYHa-ucbvA");
182        assert_eq!(passkey.rp_id, "123");
183        assert_eq!(passkey.username, "");
184        assert_eq!(passkey.user_display_name, "");
185        assert_eq!(String::from(passkey.user_handle.clone()), "AAECAwQFBg");
186        assert_eq!(String::from(passkey.key.clone()), "AAECAwQFBg");
187        assert!(passkey.fido2_extensions.is_none());
188    }
189}