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