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 Random,
35 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)]
47pub enum ForwarderServiceType {
51 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 Word {
87 capitalize: bool,
89 include_number: bool,
91 },
92 Subaddress {
96 r#type: AppendType,
98 email: String,
100 },
101 Catchall {
102 r#type: AppendType,
104 domain: String,
106 },
107 Forwarded {
108 service: ForwarderServiceType,
111 website: Option<String>,
114 },
115}
116
117impl ForwarderServiceType {
118 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
148pub(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
187fn random_number(mut rng: impl RngCore) -> String {
189 let num = rng.gen_range(0..=9999);
190 format!("{num:0>4}")
191}
192
193fn 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
215fn 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}