Skip to main content

bitwarden_generators/
username.rs

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