use bitwarden_crypto::EFF_LONG_WORD_LIST;
use rand::{distributions::Distribution, seq::SliceRandom, Rng, RngCore};
use reqwest::StatusCode;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::util::capitalize_first_letter;
#[derive(Debug, Error)]
pub enum UsernameError {
#[error("Invalid API Key")]
InvalidApiKey,
#[error("Unknown error")]
Unknown,
#[error("Received error message from server: [{}] {}", .status, .message)]
ResponseContent { status: StatusCode, message: String },
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
}
#[derive(Serialize, Deserialize, Debug, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
pub enum AppendType {
Random,
WebsiteName { website: String },
}
#[derive(Serialize, Deserialize, Debug, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
pub enum ForwarderServiceType {
AddyIo {
api_token: String,
domain: String,
base_url: String,
},
DuckDuckGo {
token: String,
},
Firefox {
api_token: String,
},
Fastmail {
api_token: String,
},
ForwardEmail {
api_token: String,
domain: String,
},
SimpleLogin {
api_key: String,
},
}
#[derive(Serialize, Deserialize, Debug, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
pub enum UsernameGeneratorRequest {
Word {
capitalize: bool,
include_number: bool,
},
Subaddress {
r#type: AppendType,
email: String,
},
Catchall {
r#type: AppendType,
domain: String,
},
Forwarded {
service: ForwarderServiceType,
website: Option<String>,
},
}
impl ForwarderServiceType {
pub async fn generate(
self,
http: &reqwest::Client,
website: Option<String>,
) -> Result<String, UsernameError> {
use ForwarderServiceType::*;
use crate::username_forwarders::*;
match self {
AddyIo {
api_token,
domain,
base_url,
} => addyio::generate(http, api_token, domain, base_url, website).await,
DuckDuckGo { token } => duckduckgo::generate(http, token).await,
Firefox { api_token } => firefox::generate(http, api_token, website).await,
Fastmail { api_token } => fastmail::generate(http, api_token, website).await,
ForwardEmail { api_token, domain } => {
forwardemail::generate(http, api_token, domain, website).await
}
SimpleLogin { api_key } => simplelogin::generate(http, api_key, website).await,
}
}
}
pub(crate) async fn username(
input: UsernameGeneratorRequest,
http: &reqwest::Client,
) -> Result<String, UsernameError> {
use rand::thread_rng;
use UsernameGeneratorRequest::*;
match input {
Word {
capitalize,
include_number,
} => Ok(username_word(&mut thread_rng(), capitalize, include_number)),
Subaddress { r#type, email } => Ok(username_subaddress(&mut thread_rng(), r#type, email)),
Catchall { r#type, domain } => Ok(username_catchall(&mut thread_rng(), r#type, domain)),
Forwarded { service, website } => service.generate(http, website).await,
}
}
fn username_word(mut rng: impl RngCore, capitalize: bool, include_number: bool) -> String {
let word = EFF_LONG_WORD_LIST
.choose(&mut rng)
.expect("slice is not empty");
let mut word = if capitalize {
capitalize_first_letter(word)
} else {
word.to_string()
};
if include_number {
word.push_str(&random_number(&mut rng));
}
word
}
fn random_number(mut rng: impl RngCore) -> String {
let num = rng.gen_range(0..=9999);
format!("{num:0>4}")
}
fn username_subaddress(mut rng: impl RngCore, r#type: AppendType, email: String) -> String {
if email.len() < 3 {
return email;
}
let (email_begin, email_end) = match email.find('@') {
Some(pos) if pos > 0 && pos < email.len() - 1 => {
email.split_once('@').expect("The email contains @")
}
_ => return email,
};
let email_middle = match r#type {
AppendType::Random => random_lowercase_string(&mut rng, 8),
AppendType::WebsiteName { website } => website,
};
format!("{}+{}@{}", email_begin, email_middle, email_end)
}
fn username_catchall(mut rng: impl RngCore, r#type: AppendType, domain: String) -> String {
if domain.is_empty() {
return domain;
}
let email_start = match r#type {
AppendType::Random => random_lowercase_string(&mut rng, 8),
AppendType::WebsiteName { website } => website,
};
format!("{}@{}", email_start, domain)
}
fn random_lowercase_string(mut rng: impl RngCore, length: usize) -> String {
const LOWERCASE_ALPHANUMERICAL: &[u8] = b"abcdefghijklmnopqrstuvwxyz1234567890";
let dist = rand::distributions::Slice::new(LOWERCASE_ALPHANUMERICAL).expect("Non-empty slice");
dist.sample_iter(&mut rng)
.take(length)
.map(|&b| b as char)
.collect()
}
#[cfg(test)]
mod tests {
use rand::SeedableRng;
pub use super::*;
#[test]
fn test_username_word() {
let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
assert_eq!(username_word(&mut rng, true, true), "Subsystem6314");
assert_eq!(username_word(&mut rng, true, false), "Silenced");
assert_eq!(username_word(&mut rng, false, true), "dinginess4487");
}
#[test]
fn test_username_subaddress() {
let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
let user = username_subaddress(&mut rng, AppendType::Random, "[email protected]".into());
assert_eq!(user, "[email protected]");
let user = username_subaddress(
&mut rng,
AppendType::WebsiteName {
website: "bitwarden.com".into(),
},
"[email protected]".into(),
);
assert_eq!(user, "[email protected]");
}
#[test]
fn test_username_catchall() {
let mut rng = rand_chacha::ChaCha8Rng::from_seed([1u8; 32]);
let user = username_catchall(&mut rng, AppendType::Random, "test.com".into());
assert_eq!(user, "[email protected]");
let user = username_catchall(
&mut rng,
AppendType::WebsiteName {
website: "bitwarden.com".into(),
},
"test.com".into(),
);
assert_eq!(user, "[email protected]");
}
}