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 Random,
34 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#[allow(missing_docs)]
46pub enum ForwarderServiceType {
47 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 Word {
80 capitalize: bool,
82 include_number: bool,
84 },
85 Subaddress {
89 r#type: AppendType,
91 email: String,
93 },
94 Catchall {
95 r#type: AppendType,
97 domain: String,
99 },
100 Forwarded {
101 service: ForwarderServiceType,
104 website: Option<String>,
107 },
108}
109
110impl ForwarderServiceType {
111 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
141pub(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
180fn random_number(mut rng: impl Rng) -> String {
182 let num = rng.random_range(0..=9999);
183 format!("{num:0>4}")
184}
185
186fn 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
208fn 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}