bitwarden_generators/
username.rs

1use bitwarden_crypto::EFF_LONG_WORD_LIST;
2use bitwarden_error::bitwarden_error;
3use rand::{distributions::Distribution, seq::SliceRandom, Rng, RngCore};
4use reqwest::StatusCode;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8#[cfg(feature = "wasm")]
9use tsify_next::Tsify;
10
11use crate::util::capitalize_first_letter;
12
13#[bitwarden_error(flat)]
14#[derive(Debug, Error)]
15pub enum UsernameError {
16    #[error("Invalid API Key")]
17    InvalidApiKey,
18    #[error("Unknown error")]
19    Unknown,
20
21    #[error("Received error message from server: [{}] {}", .status, .message)]
22    ResponseContent { status: StatusCode, message: String },
23
24    #[error(transparent)]
25    Reqwest(#[from] reqwest::Error),
26}
27
28#[derive(Serialize, Deserialize, Debug, JsonSchema)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
31#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
32pub enum AppendType {
33    /// Generates a random string of 8 lowercase characters as part of your username
34    Random,
35    /// Uses the websitename as part of your username
36    WebsiteName { website: String },
37}
38
39#[derive(Serialize, Deserialize, Debug, JsonSchema)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
42#[cfg_attr(
43    feature = "wasm",
44    derive(tsify_next::Tsify),
45    tsify(into_wasm_abi, from_wasm_abi)
46)]
47/// Configures the email forwarding service to use.
48/// For instructions on how to configure each service, see the documentation:
49/// <https://bitwarden.com/help/generator/#username-types>
50pub enum ForwarderServiceType {
51    /// Previously known as "AnonAddy"
52    AddyIo {
53        api_token: String,
54        domain: String,
55        base_url: String,
56    },
57    DuckDuckGo {
58        token: String,
59    },
60    Firefox {
61        api_token: String,
62    },
63    Fastmail {
64        api_token: String,
65    },
66    ForwardEmail {
67        api_token: String,
68        domain: String,
69    },
70    SimpleLogin {
71        api_key: String,
72        base_url: String,
73    },
74}
75
76#[derive(Serialize, Deserialize, Debug, JsonSchema)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
79#[cfg_attr(
80    feature = "wasm",
81    derive(tsify_next::Tsify),
82    tsify(into_wasm_abi, from_wasm_abi)
83)]
84pub enum UsernameGeneratorRequest {
85    /// Generates a single word username
86    Word {
87        /// Capitalize the first letter of the word
88        capitalize: bool,
89        /// Include a 4 digit number at the end of the word
90        include_number: bool,
91    },
92    /// Generates an email using your provider's subaddressing capabilities.
93    /// Note that not all providers support this functionality.
94    /// This will generate an address of the format `[email protected]`
95    Subaddress {
96        /// The type of subaddress to add to the base email
97        r#type: AppendType,
98        /// The full email address to use as the base for the subaddress
99        email: String,
100    },
101    Catchall {
102        /// The type of username to use with the catchall email domain
103        r#type: AppendType,
104        /// The domain to use for the catchall email address
105        domain: String,
106    },
107    Forwarded {
108        /// The email forwarding service to use, see [ForwarderServiceType]
109        /// for instructions on how to configure each
110        service: ForwarderServiceType,
111        /// The website for which the email address is being generated
112        /// This is not used in all services, and is only used for display purposes
113        website: Option<String>,
114    },
115}
116
117impl ForwarderServiceType {
118    // Generate a username using the specified email forwarding service
119    // This requires an HTTP client to be passed in, as the service will need to make API calls
120    pub async fn generate(
121        self,
122        http: &reqwest::Client,
123        website: Option<String>,
124    ) -> Result<String, UsernameError> {
125        use ForwarderServiceType::*;
126
127        use crate::username_forwarders::*;
128
129        match self {
130            AddyIo {
131                api_token,
132                domain,
133                base_url,
134            } => addyio::generate(http, api_token, domain, base_url, website).await,
135            DuckDuckGo { token } => duckduckgo::generate(http, token).await,
136            Firefox { api_token } => firefox::generate(http, api_token, website).await,
137            Fastmail { api_token } => fastmail::generate(http, api_token, website).await,
138            ForwardEmail { api_token, domain } => {
139                forwardemail::generate(http, api_token, domain, website).await
140            }
141            SimpleLogin { api_key, base_url } => {
142                simplelogin::generate(http, api_key, base_url, website).await
143            }
144        }
145    }
146}
147
148/// Implementation of the username generator.
149///
150/// Note: The HTTP client is passed in as a required parameter for convenience,
151/// as some username generators require making API calls.
152pub(crate) async fn username(
153    input: UsernameGeneratorRequest,
154    http: &reqwest::Client,
155) -> Result<String, UsernameError> {
156    use rand::thread_rng;
157    use UsernameGeneratorRequest::*;
158    match input {
159        Word {
160            capitalize,
161            include_number,
162        } => Ok(username_word(&mut thread_rng(), capitalize, include_number)),
163        Subaddress { r#type, email } => Ok(username_subaddress(&mut thread_rng(), r#type, email)),
164        Catchall { r#type, domain } => Ok(username_catchall(&mut thread_rng(), r#type, domain)),
165        Forwarded { service, website } => service.generate(http, website).await,
166    }
167}
168
169fn username_word(mut rng: impl RngCore, capitalize: bool, include_number: bool) -> String {
170    let word = EFF_LONG_WORD_LIST
171        .choose(&mut rng)
172        .expect("slice is not empty");
173
174    let mut word = if capitalize {
175        capitalize_first_letter(word)
176    } else {
177        word.to_string()
178    };
179
180    if include_number {
181        word.push_str(&random_number(&mut rng));
182    }
183
184    word
185}
186
187/// Generate a random 4 digit number, including leading zeros
188fn random_number(mut rng: impl RngCore) -> String {
189    let num = rng.gen_range(0..=9999);
190    format!("{num:0>4}")
191}
192
193/// Generate a username using a plus addressed email address
194/// The format is `<username>+<random-or-website>@<domain>`
195fn username_subaddress(mut rng: impl RngCore, r#type: AppendType, email: String) -> String {
196    if email.len() < 3 {
197        return email;
198    }
199
200    let (email_begin, email_end) = match email.find('@') {
201        Some(pos) if pos > 0 && pos < email.len() - 1 => {
202            email.split_once('@').expect("The email contains @")
203        }
204        _ => return email,
205    };
206
207    let email_middle = match r#type {
208        AppendType::Random => random_lowercase_string(&mut rng, 8),
209        AppendType::WebsiteName { website } => website,
210    };
211
212    format!("{}+{}@{}", email_begin, email_middle, email_end)
213}
214
215/// Generate a username using a catchall email address
216/// The format is `<random-or-website>@<domain>`
217fn username_catchall(mut rng: impl RngCore, r#type: AppendType, domain: String) -> String {
218    if domain.is_empty() {
219        return domain;
220    }
221
222    let email_start = match r#type {
223        AppendType::Random => random_lowercase_string(&mut rng, 8),
224        AppendType::WebsiteName { website } => website,
225    };
226
227    format!("{}@{}", email_start, domain)
228}
229
230fn random_lowercase_string(mut rng: impl RngCore, length: usize) -> String {
231    const LOWERCASE_ALPHANUMERICAL: &[u8] = b"abcdefghijklmnopqrstuvwxyz1234567890";
232    let dist = rand::distributions::Slice::new(LOWERCASE_ALPHANUMERICAL).expect("Non-empty slice");
233
234    dist.sample_iter(&mut rng)
235        .take(length)
236        .map(|&b| b as char)
237        .collect()
238}
239
240#[cfg(test)]
241mod tests {
242    use rand::SeedableRng;
243
244    pub use super::*;
245
246    #[test]
247    fn test_username_word() {
248        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
249        assert_eq!(username_word(&mut rng, true, true), "Subsystem6314");
250        assert_eq!(username_word(&mut rng, true, false), "Silenced");
251        assert_eq!(username_word(&mut rng, false, true), "dinginess4487");
252    }
253
254    #[test]
255    fn test_username_subaddress() {
256        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
257        let user = username_subaddress(&mut rng, AppendType::Random, "[email protected]".into());
258        assert_eq!(user, "[email protected]");
259
260        let user = username_subaddress(
261            &mut rng,
262            AppendType::WebsiteName {
263                website: "bitwarden.com".into(),
264            },
265            "[email protected]".into(),
266        );
267        assert_eq!(user, "[email protected]");
268    }
269
270    #[test]
271    fn test_username_catchall() {
272        let mut rng = rand_chacha::ChaCha8Rng::from_seed([1u8; 32]);
273        let user = username_catchall(&mut rng, AppendType::Random, "test.com".into());
274        assert_eq!(user, "[email protected]");
275
276        let user = username_catchall(
277            &mut rng,
278            AppendType::WebsiteName {
279                website: "bitwarden.com".into(),
280            },
281            "test.com".into(),
282        );
283        assert_eq!(user, "[email protected]");
284    }
285}