Skip to content

Commit

Permalink
[PM-3436] Username generator (#285)
Browse files Browse the repository at this point in the history
## Type of change
```
- [ ] Bug fix
- [x] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [ ] Other
```

## Objective
Implement username generation.

Some of the methods are shared with password and passphrase generation
(like `capitalize_first_letter`, `random_lowercase_string` and the
username word generation). To avoid dependencies between the PRs, I
included them here again, once we're done with all the generators I'll
unify them.
  • Loading branch information
dani-garcia authored Dec 18, 2023
1 parent c8a1d77 commit 4c079e8
Show file tree
Hide file tree
Showing 15 changed files with 1,180 additions and 18 deletions.
17 changes: 16 additions & 1 deletion crates/bitwarden-uniffi/src/tool/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use std::sync::Arc;

use bitwarden::{
tool::{ExportFormat, PassphraseGeneratorRequest, PasswordGeneratorRequest},
tool::{
ExportFormat, PassphraseGeneratorRequest, PasswordGeneratorRequest,
UsernameGeneratorRequest,
},
vault::{Cipher, Collection, Folder},
};

Expand Down Expand Up @@ -35,6 +38,18 @@ impl ClientGenerators {
.passphrase(settings)
.await?)
}

/// **API Draft:** Generate Username
pub async fn username(&self, settings: UsernameGeneratorRequest) -> Result<String> {
Ok(self
.0
.0
.read()
.await
.generator()
.username(settings)
.await?)
}
}

#[derive(uniffi::Object)]
Expand Down
5 changes: 5 additions & 0 deletions crates/bitwarden/src/client/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ impl Client {
&self.__api_configurations
}

#[cfg(feature = "mobile")]
pub(crate) fn get_http_client(&self) -> &reqwest::Client {
&self.__api_configurations.api.client
}

#[cfg(feature = "secrets")]
#[deprecated(note = "Use auth().login_access_token() instead")]
pub async fn access_token_login(
Expand Down
33 changes: 29 additions & 4 deletions crates/bitwarden/src/tool/generators/client_generator.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use crate::{
error::Result,
tool::generators::passphrase::{passphrase, PassphraseGeneratorRequest},
tool::generators::password::{password, PasswordGeneratorRequest},
tool::generators::{
passphrase::{passphrase, PassphraseGeneratorRequest},
password::{password, PasswordGeneratorRequest},
username::{username, UsernameGeneratorRequest},
},
Client,
};

pub struct ClientGenerator<'a> {
pub(crate) _client: &'a crate::Client,
pub(crate) client: &'a crate::Client,
}

impl<'a> ClientGenerator<'a> {
Expand Down Expand Up @@ -59,10 +62,32 @@ impl<'a> ClientGenerator<'a> {
pub async fn passphrase(&self, input: PassphraseGeneratorRequest) -> Result<String> {
passphrase(input)
}

/// Generates a random username.
/// There are different username generation strategies, which can be customized using the `input` parameter.
///
/// Note that most generation strategies will be executed on the client side, but `Forwarded` will use third-party
/// services, which may require a specific setup or API key.
///
/// ```
/// use bitwarden::{Client, tool::{UsernameGeneratorRequest}, error::Result};
/// async fn test() -> Result<()> {
/// let input = UsernameGeneratorRequest::Word {
/// capitalize: true,
/// include_number: true,
/// };
/// let username = Client::new(None).generator().username(input).await.unwrap();
/// println!("{}", username);
/// Ok(())
/// }
/// ```
pub async fn username(&self, input: UsernameGeneratorRequest) -> Result<String> {
username(input, self.client.get_http_client()).await
}
}

impl<'a> Client {
pub fn generator(&'a self) -> ClientGenerator<'a> {
ClientGenerator { _client: self }
ClientGenerator { client: self }
}
}
3 changes: 3 additions & 0 deletions crates/bitwarden/src/tool/generators/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
mod client_generator;
mod passphrase;
mod password;
mod username;
mod username_forwarders;

pub use client_generator::ClientGenerator;
pub use passphrase::PassphraseGeneratorRequest;
pub use password::PasswordGeneratorRequest;
pub use username::{AppendType, ForwarderServiceType, UsernameGeneratorRequest};
13 changes: 1 addition & 12 deletions crates/bitwarden/src/tool/generators/passphrase.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::{error::Result, wordlist::EFF_LONG_WORD_LIST};
use crate::{error::Result, util::capitalize_first_letter, wordlist::EFF_LONG_WORD_LIST};
use rand::{seq::SliceRandom, Rng, RngCore};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -109,17 +109,6 @@ fn capitalize_words(words: &mut [String]) {
.for_each(|w| *w = capitalize_first_letter(w));
}

fn capitalize_first_letter(s: &str) -> String {
// Unicode case conversion can change the length of the string, so we can't capitalize in place.
// Instead we extract the first character and convert it to uppercase. This returns
// an iterator which we collect into a string, and then append the rest of the input.
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}

#[cfg(test)]
mod tests {
use rand::SeedableRng;
Expand Down
244 changes: 244 additions & 0 deletions crates/bitwarden/src/tool/generators/username.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
use crate::{error::Result, util::capitalize_first_letter, wordlist::EFF_LONG_WORD_LIST};
use rand::{distributions::Distribution, seq::SliceRandom, Rng, RngCore};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "mobile", derive(uniffi::Enum))]
pub enum AppendType {
/// Generates a random string of 8 lowercase characters as part of your username
Random,
/// Uses the websitename as part of your username
WebsiteName { website: String },
}

#[derive(Serialize, Deserialize, Debug, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "mobile", derive(uniffi::Enum))]
/// Configures the email forwarding service to use.
/// For instructions on how to configure each service, see the documentation:
/// <https://bitwarden.com/help/generator/#username-types>
pub enum ForwarderServiceType {
/// Previously known as "AnonAddy"
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 = "mobile", derive(uniffi::Enum))]
pub enum UsernameGeneratorRequest {
/// Generates a single word username
Word {
/// Capitalize the first letter of the word
capitalize: bool,
/// Include a 4 digit number at the end of the word
include_number: bool,
},
/// Generates an email using your provider's subaddressing capabilities.
/// Note that not all providers support this functionality.
/// This will generate an address of the format `[email protected]`
Subaddress {
/// The type of subaddress to add to the base email
r#type: AppendType,
/// The full email address to use as the base for the subaddress
email: String,
},
Catchall {
/// The type of username to use with the catchall email domain
r#type: AppendType,
/// The domain to use for the catchall email address
domain: String,
},
Forwarded {
/// The email forwarding service to use, see [ForwarderServiceType]
/// for instructions on how to configure each
service: ForwarderServiceType,
/// The website for which the email address is being generated
/// This is not used in all services, and is only used for display purposes
website: Option<String>,
},
}

impl ForwarderServiceType {
// Generate a username using the specified email forwarding service
// This requires an HTTP client to be passed in, as the service will need to make API calls
pub async fn generate(self, http: &reqwest::Client, website: Option<String>) -> Result<String> {
use crate::tool::generators::username_forwarders::*;
use ForwarderServiceType::*;

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,
}
}
}

/// Implementation of the username generator. This is not accessible to the public API.
/// See [`ClientGenerator::username`](crate::ClientGenerator::username) for the API function.
/// Note: The HTTP client is passed in as a required parameter for convenience,
/// as some username generators require making API calls.
pub(super) async fn username(
input: UsernameGeneratorRequest,
http: &reqwest::Client,
) -> Result<String> {
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
}

/// Generate a random 4 digit number, including leading zeros
fn random_number(mut rng: impl RngCore) -> String {
let num = rng.gen_range(0..=9999);
format!("{num:0>4}")
}

/// Generate a username using a plus addressed email address
/// The format is <username>+<random-or-website>@<domain>
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)
}

/// Generate a username using a catchall email address
/// The format is <random-or-website>@<domain>
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]");
}
}
Loading

0 comments on commit 4c079e8

Please sign in to comment.