Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-3436] Username generator #285

Merged
merged 21 commits into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Hinton marked this conversation as resolved.
Show resolved Hide resolved
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
Hinton marked this conversation as resolved.
Show resolved Hide resolved
}

#[cfg(feature = "secrets")]
#[deprecated(note = "Use auth().login_access_token() instead")]
pub async fn access_token_login(
Expand Down
35 changes: 31 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,34 @@ 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, UsernameGeneratorType}, error::Result};
/// async fn test() -> Result<()> {
/// let input = UsernameGeneratorRequest {
/// r#type: UsernameGeneratorType::Word {
/// capitalize: Some(true),
/// include_number: Some(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 }
}
}
5 changes: 5 additions & 0 deletions crates/bitwarden/src/tool/generators/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
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::{
AddressType, ForwarderServiceType, UsernameGeneratorRequest, UsernameGeneratorType,
};
12 changes: 1 addition & 11 deletions crates/bitwarden/src/tool/generators/passphrase.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::{
error::{Error, Result},
util::capitalize_first_letter,
wordlist::EFF_LONG_WORD_LIST,
};
use rand::{seq::SliceRandom, Rng, RngCore};
Expand Down Expand Up @@ -112,17 +113,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
237 changes: 237 additions & 0 deletions crates/bitwarden/src/tool/generators/username.rs
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
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};

#[derive(Serialize, Deserialize, Debug, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "mobile", derive(uniffi::Enum))]
pub enum AddressType {
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
/// 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 UsernameGeneratorType {
/// Generates a single word username
Word {
/// When set to true, capitalizes the first letter of the word. Defaults to false
capitalize: Option<bool>,
/// When set to true, includes a 4 digit number at the end of the word. Defaults to false
include_number: Option<bool>,
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
},
/// 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: AddressType,
/// 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: AddressType,
/// 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>,
},
}

#[derive(Serialize, Deserialize, Debug, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "mobile", derive(uniffi::Record))]
pub struct UsernameGeneratorRequest {
pub r#type: UsernameGeneratorType,
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
}

pub(super) async fn username(
input: UsernameGeneratorRequest,
http: &reqwest::Client,
) -> Result<String> {
use rand::thread_rng;
use UsernameGeneratorType::*;

match input.r#type {
Word {
capitalize,
include_number,
} => {
let capitalize = capitalize.unwrap_or(true);
let include_number = include_number.unwrap_or(true);
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 } => {
use crate::tool::generators::username_forwarders::*;
use ForwarderServiceType::*;
match service {
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,
}
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

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 username_subaddress(mut rng: impl RngCore, r#type: AddressType, 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 {
AddressType::Random => random_lowercase_string(&mut rng, 8),
AddressType::WebsiteName { website } => website,
};

format!("{}+{}@{}", email_begin, email_middle, email_end)
}

fn username_catchall(mut rng: impl RngCore, r#type: AddressType, domain: String) -> String {
if domain.is_empty() {
return domain;
}

let email_start = match r#type {
AddressType::Random => random_lowercase_string(&mut rng, 8),
AddressType::WebsiteName { website } => website,
};

format!("{}@{}", email_start, domain)
}

fn random_number(mut rng: impl RngCore) -> String {
let num = rng.gen_range(0..=9999);
format!("{num:0>4}")
}

fn random_lowercase_string(mut rng: impl RngCore, length: usize) -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz1234567890";
(0..length)
.map(|_| (*CHARSET.choose(&mut rng).expect("slice is not empty")) as char)
.collect()
}
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved

#[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, AddressType::Random, "[email protected]".into());
assert_eq!(user, "[email protected]");

let user = username_subaddress(
&mut rng,
AddressType::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([0u8; 32]);
let user = username_catchall(&mut rng, AddressType::Random, "test.com".into());
assert_eq!(user, "[email protected]");

let user = username_catchall(
&mut rng,
AddressType::WebsiteName {
website: "bitwarden.com".into(),
},
"test.com".into(),
);
assert_eq!(user, "[email protected]");
}
}
Loading