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 Random,
36 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#[allow(missing_docs)]
52pub enum ForwarderServiceType {
53 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 Word {
90 capitalize: bool,
92 include_number: bool,
94 },
95 Subaddress {
99 r#type: AppendType,
101 email: String,
103 },
104 Catchall {
105 r#type: AppendType,
107 domain: String,
109 },
110 Forwarded {
111 service: ForwarderServiceType,
114 website: Option<String>,
117 },
118}
119
120impl ForwarderServiceType {
121 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
151pub(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
190fn random_number(mut rng: impl RngCore) -> String {
192 let num = rng.gen_range(0..=9999);
193 format!("{num:0>4}")
194}
195
196fn 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
218fn 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}