mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-08-02 02:07:06 -04:00
Add Rust identity_generator scaffolding (#2279)
This commit is contained in:
307
core/rust/src/identity_generator/mod.rs
Normal file
307
core/rust/src/identity_generator/mod.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
//! Cross-platform random identity (alias persona) generation.
|
||||
//!
|
||||
//! This module is the single source of truth for identity generation across AliasVault.
|
||||
//! It accepts a JSON-serialized [`IdentityRequest`] and returns the generated
|
||||
//! [`Identity`] as JSON, matching the JSON-in/JSON-out convention used by the other
|
||||
//! core modules.
|
||||
//!
|
||||
//! An identity consists of a first name, last name, gender, birth date, email prefix
|
||||
//! and nickname (username). Names are drawn from embedded per-language dictionaries;
|
||||
//! for languages with decade-based dictionaries (de, it, ro) the first name is matched
|
||||
//! to names that were popular around the generated birth year.
|
||||
|
||||
pub mod dictionaries;
|
||||
mod username_email;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use chrono::{Datelike, Days, NaiveDate, Utc};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::VaultError;
|
||||
use crate::rng::{make_rng, unbiased_index};
|
||||
|
||||
/// Gender value used in generated identities.
|
||||
pub const GENDER_MALE: &str = "Male";
|
||||
/// Gender value used in generated identities.
|
||||
pub const GENDER_FEMALE: &str = "Female";
|
||||
|
||||
/// Default minimum age (in years) for generated birth dates.
|
||||
const DEFAULT_MIN_AGE: i32 = 21;
|
||||
/// Default maximum age (in years) for generated birth dates.
|
||||
const DEFAULT_MAX_AGE: i32 = 65;
|
||||
|
||||
/// The age range option values offered by the identity generator settings UIs.
|
||||
/// The first entry means "no preference" (default 21-65 age behavior).
|
||||
const AGE_RANGES: &[&str] = &[
|
||||
"random", "21-25", "26-30", "31-35", "36-40", "41-45", "46-50", "51-55", "56-60", "61-65",
|
||||
];
|
||||
|
||||
/// A generated identity. Serialized with camelCase field names to match the
|
||||
/// JSON contract shared by all clients.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Identity {
|
||||
/// First name, matching the requested language and the birth decade where available.
|
||||
pub first_name: String,
|
||||
/// Last name, matching the requested language.
|
||||
pub last_name: String,
|
||||
/// Gender the first name was drawn from: "Male" or "Female".
|
||||
pub gender: String,
|
||||
/// Birth date in `yyyy-MM-dd` format.
|
||||
pub birth_date: String,
|
||||
/// Email prefix derived from the name and birth year.
|
||||
pub email_prefix: String,
|
||||
/// Username derived from the name and birth year (alphanumeric only).
|
||||
pub nick_name: String,
|
||||
}
|
||||
|
||||
/// Explicit birth year window for generation: a target year plus/minus a deviation.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BirthdateOptions {
|
||||
/// The central birth year.
|
||||
pub target_year: i32,
|
||||
/// Allowed deviation in years around the target (0 = within the target year only).
|
||||
pub year_deviation: i32,
|
||||
}
|
||||
|
||||
/// Request controlling identity generation. Every field is optional so callers can
|
||||
/// pass only what they need; unknown languages fall back to English.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct IdentityRequest {
|
||||
/// Dictionary language code (e.g. "en", "nl"). Empty or unknown falls back to English.
|
||||
pub language: String,
|
||||
/// Gender preference: "male", "female" or "random" (default).
|
||||
pub gender: Option<String>,
|
||||
/// Age range preference as stored in settings (e.g. "21-25" or "random").
|
||||
/// Ignored when `birthdate_options` is set.
|
||||
pub age_range: Option<String>,
|
||||
/// Explicit birth year window. Takes precedence over `age_range`.
|
||||
pub birthdate_options: Option<BirthdateOptions>,
|
||||
/// Optional 32-byte RNG seed as a 64-character hex string for deterministic output
|
||||
/// (used by tests). Normal generation is non-deterministic.
|
||||
pub seed: Option<String>,
|
||||
}
|
||||
|
||||
/// Name and birth date input for username/email prefix generation, e.g. an identity
|
||||
/// previously generated by [`generate_identity`] or persona fields typed by the user.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct NameInput {
|
||||
/// First name.
|
||||
pub first_name: String,
|
||||
/// Last name.
|
||||
pub last_name: String,
|
||||
/// Birth date; only the leading `yyyy` year part is used.
|
||||
pub birth_date: String,
|
||||
/// Optional 32-byte RNG seed as a 64-character hex string (used by tests).
|
||||
pub seed: Option<String>,
|
||||
}
|
||||
|
||||
/// Generate a random identity from a JSON-serialized [`IdentityRequest`].
|
||||
///
|
||||
/// Returns the generated [`Identity`] as a JSON string, or a [`VaultError`] if the
|
||||
/// request JSON is invalid.
|
||||
pub fn generate_identity(request_json: &str) -> Result<String, VaultError> {
|
||||
let request: IdentityRequest = serde_json::from_str(request_json)?;
|
||||
let identity = generate_from_request(&request);
|
||||
Ok(serde_json::to_string(&identity)?)
|
||||
}
|
||||
|
||||
/// Generate a random identity from an already-parsed request.
|
||||
pub fn generate_from_request(request: &IdentityRequest) -> Identity {
|
||||
let mut rng = make_rng(request.seed.as_deref());
|
||||
let dictionary = dictionaries::resolve(&request.language);
|
||||
|
||||
// Determine gender: explicit "male"/"female" wins, anything else is a coin flip.
|
||||
let is_male = match request.gender.as_deref() {
|
||||
Some("male") => true,
|
||||
Some("female") => false,
|
||||
_ => unbiased_index(&mut rng, 2) == 0,
|
||||
};
|
||||
|
||||
// Generate the birth date first: decade-based languages select first names by birth year.
|
||||
let birthdate_options = request
|
||||
.birthdate_options
|
||||
.or_else(|| age_range_to_birthdate_options(request.age_range.as_deref().unwrap_or("")));
|
||||
let birth_date = random_birth_date(&mut rng, birthdate_options, Utc::now().date_naive());
|
||||
|
||||
let first_names = select_firstnames_for_birth_year(dictionary, birth_date.year(), is_male);
|
||||
let last_names = dictionary.lastnames();
|
||||
|
||||
let first_name = first_names[unbiased_index(&mut rng, first_names.len())].to_string();
|
||||
let last_name = last_names[unbiased_index(&mut rng, last_names.len())].to_string();
|
||||
let birth_year = Some(birth_date.year());
|
||||
|
||||
// The email prefix and nickname are generated independently (separate random rolls).
|
||||
let email_prefix =
|
||||
username_email::generate_email_prefix(&mut rng, &first_name, &last_name, birth_year);
|
||||
let nick_name = username_email::generate_username(&mut rng, &first_name, &last_name, birth_year);
|
||||
|
||||
Identity {
|
||||
first_name,
|
||||
last_name,
|
||||
gender: if is_male { GENDER_MALE } else { GENDER_FEMALE }.to_string(),
|
||||
birth_date: birth_date.format("%Y-%m-%d").to_string(),
|
||||
email_prefix,
|
||||
nick_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a username from a JSON-serialized [`NameInput`].
|
||||
pub fn generate_username(input_json: &str) -> Result<String, VaultError> {
|
||||
let input: NameInput = serde_json::from_str(input_json)?;
|
||||
let mut rng = make_rng(input.seed.as_deref());
|
||||
Ok(username_email::generate_username(
|
||||
&mut rng,
|
||||
&input.first_name,
|
||||
&input.last_name,
|
||||
parse_birth_year(&input.birth_date),
|
||||
))
|
||||
}
|
||||
|
||||
/// Generate an email prefix from a JSON-serialized [`NameInput`].
|
||||
pub fn generate_email_prefix(input_json: &str) -> Result<String, VaultError> {
|
||||
let input: NameInput = serde_json::from_str(input_json)?;
|
||||
let mut rng = make_rng(input.seed.as_deref());
|
||||
Ok(username_email::generate_email_prefix(
|
||||
&mut rng,
|
||||
&input.first_name,
|
||||
&input.last_name,
|
||||
parse_birth_year(&input.birth_date),
|
||||
))
|
||||
}
|
||||
|
||||
/// Generate a random alphanumeric email prefix that is not based on any identity,
|
||||
/// suitable for login-type credentials without persona fields.
|
||||
pub fn generate_random_email_prefix(length: u32) -> String {
|
||||
let mut rng = make_rng(None);
|
||||
username_email::generate_random_string(&mut rng, length.clamp(1, 64) as usize)
|
||||
}
|
||||
|
||||
/// List the language codes of all bundled identity dictionaries.
|
||||
pub fn available_languages() -> Vec<String> {
|
||||
dictionaries::available_codes()
|
||||
.into_iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List the age range option values offered by the settings UIs
|
||||
/// ("random" plus 5-year ranges from 21-25 to 61-65).
|
||||
pub fn available_age_ranges() -> Vec<String> {
|
||||
AGE_RANGES.iter().map(|r| r.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Convert an age range string (e.g. "21-25") to birth year options relative to the
|
||||
/// current year. Returns `None` for "random", empty or malformed input, which means
|
||||
/// the default age behavior (21-65) applies.
|
||||
pub fn age_range_to_birthdate_options(age_range: &str) -> Option<BirthdateOptions> {
|
||||
age_range_to_birthdate_options_at(age_range, Utc::now().year())
|
||||
}
|
||||
|
||||
/// [`age_range_to_birthdate_options`] with an explicit current year, for testability.
|
||||
fn age_range_to_birthdate_options_at(age_range: &str, current_year: i32) -> Option<BirthdateOptions> {
|
||||
let (min_part, max_part) = age_range.split_once('-')?;
|
||||
let min_age: i32 = min_part.parse().ok()?;
|
||||
let max_age: i32 = max_part.parse().ok()?;
|
||||
|
||||
let middle_age = (min_age + max_age).div_euclid(2);
|
||||
Some(BirthdateOptions {
|
||||
target_year: current_year - middle_age,
|
||||
year_deviation: (max_age - min_age).div_euclid(2),
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate a random birth date. With options, the date is uniform within the year
|
||||
/// window; otherwise it is uniform between `today - 65 years` and `today - 21 years`.
|
||||
fn random_birth_date<R: RngCore + ?Sized>(
|
||||
rng: &mut R,
|
||||
options: Option<BirthdateOptions>,
|
||||
today: NaiveDate,
|
||||
) -> NaiveDate {
|
||||
let (start, end) = match options {
|
||||
Some(BirthdateOptions {
|
||||
target_year,
|
||||
year_deviation,
|
||||
}) => (
|
||||
first_day_of_year(target_year - year_deviation.abs()),
|
||||
last_day_of_year(target_year + year_deviation.abs()),
|
||||
),
|
||||
None => (years_ago(today, DEFAULT_MAX_AGE), years_ago(today, DEFAULT_MIN_AGE)),
|
||||
};
|
||||
|
||||
let days = (end - start).num_days().max(0) as usize + 1;
|
||||
start
|
||||
.checked_add_days(Days::new(unbiased_index(rng, days) as u64))
|
||||
.unwrap_or(start)
|
||||
}
|
||||
|
||||
/// Select the first name pool for a birth year: the matching decade lists where
|
||||
/// available, all decade names when the year falls outside every range, otherwise
|
||||
/// the flat per-gender list.
|
||||
fn select_firstnames_for_birth_year(
|
||||
dictionary: &'static dictionaries::LanguageDictionary,
|
||||
birth_year: i32,
|
||||
is_male: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let decades = if is_male {
|
||||
dictionary.firstnames_male_by_decade
|
||||
} else {
|
||||
dictionary.firstnames_female_by_decade
|
||||
};
|
||||
|
||||
if !decades.is_empty() {
|
||||
let matching: Vec<&'static str> = decades
|
||||
.iter()
|
||||
.filter(|d| birth_year >= d.start_year && birth_year <= d.end_year)
|
||||
.flat_map(|d| d.names())
|
||||
.collect();
|
||||
if !matching.is_empty() {
|
||||
return matching;
|
||||
}
|
||||
|
||||
// No decade covers this birth year: fall back to all decade names combined.
|
||||
let all: Vec<&'static str> = decades.iter().flat_map(|d| d.names()).collect();
|
||||
if !all.is_empty() {
|
||||
return all;
|
||||
}
|
||||
}
|
||||
|
||||
if is_male {
|
||||
dictionary.firstnames_male()
|
||||
} else {
|
||||
dictionary.firstnames_female()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the leading `yyyy` year from a date string like `1990-05-15` or a full
|
||||
/// ISO 8601 timestamp. Returns `None` when no valid year prefix is present.
|
||||
fn parse_birth_year(birth_date: &str) -> Option<i32> {
|
||||
let digits: String = birth_date.trim().chars().take(4).collect();
|
||||
if digits.len() == 4 && digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
digits.parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// January 1 of the given year.
|
||||
fn first_day_of_year(year: i32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(year, 1, 1).unwrap_or(NaiveDate::MIN)
|
||||
}
|
||||
|
||||
/// December 31 of the given year.
|
||||
fn last_day_of_year(year: i32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(year, 12, 31).unwrap_or(NaiveDate::MAX)
|
||||
}
|
||||
|
||||
/// The same calendar day `years` years before `date` (Feb 29 maps to Feb 28).
|
||||
fn years_ago(date: NaiveDate, years: i32) -> NaiveDate {
|
||||
date.with_year(date.year() - years)
|
||||
.unwrap_or_else(|| NaiveDate::from_ymd_opt(date.year() - years, 2, 28).unwrap_or(NaiveDate::MIN))
|
||||
}
|
||||
303
core/rust/src/identity_generator/tests.rs
Normal file
303
core/rust/src/identity_generator/tests.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! Tests for the identity generator;
|
||||
|
||||
use super::*;
|
||||
|
||||
const SEED_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const SEED_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
fn request(language: &str) -> IdentityRequest {
|
||||
IdentityRequest {
|
||||
language: language.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn birth_year_of(identity: &Identity) -> i32 {
|
||||
identity.birth_date[..4].parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_valid_identity_for_each_language() {
|
||||
for language in dictionaries::available_codes() {
|
||||
let identity = generate_from_request(&request(language));
|
||||
|
||||
assert!(identity.first_name.chars().count() > 1, "{language}: first name too short");
|
||||
assert!(identity.last_name.chars().count() > 1, "{language}: last name too short");
|
||||
assert!(
|
||||
identity.gender == GENDER_MALE || identity.gender == GENDER_FEMALE,
|
||||
"{language}: unexpected gender {}",
|
||||
identity.gender
|
||||
);
|
||||
assert!(!identity.birth_date.is_empty(), "{language}: empty birth date");
|
||||
assert!(!identity.email_prefix.is_empty(), "{language}: empty email prefix");
|
||||
assert!(!identity.nick_name.is_empty(), "{language}: empty nickname");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_identities_differ() {
|
||||
let first = generate_from_request(&request("en"));
|
||||
let second = generate_from_request(&request("en"));
|
||||
assert!(
|
||||
first.first_name != second.first_name
|
||||
|| first.last_name != second.last_name
|
||||
|| first.birth_date != second.birth_date
|
||||
|| first.email_prefix != second.email_prefix,
|
||||
"two consecutive identities were identical"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeded_generation_is_deterministic() {
|
||||
let mut req = request("en");
|
||||
req.seed = Some(SEED_A.to_string());
|
||||
|
||||
let first = generate_from_request(&req);
|
||||
let second = generate_from_request(&req);
|
||||
assert_eq!(first.first_name, second.first_name);
|
||||
assert_eq!(first.last_name, second.last_name);
|
||||
assert_eq!(first.birth_date, second.birth_date);
|
||||
assert_eq!(first.email_prefix, second.email_prefix);
|
||||
assert_eq!(first.nick_name, second.nick_name);
|
||||
|
||||
req.seed = Some(SEED_B.to_string());
|
||||
let third = generate_from_request(&req);
|
||||
assert!(
|
||||
first.first_name != third.first_name || first.birth_date != third.birth_date,
|
||||
"different seeds should produce different identities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gender_preference_is_respected() {
|
||||
for _ in 0..10 {
|
||||
let mut req = request("en");
|
||||
req.gender = Some("male".to_string());
|
||||
assert_eq!(generate_from_request(&req).gender, GENDER_MALE);
|
||||
|
||||
req.gender = Some("female".to_string());
|
||||
assert_eq!(generate_from_request(&req).gender, GENDER_FEMALE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_birth_date_is_between_21_and_65_years_ago() {
|
||||
let today = Utc::now().date_naive();
|
||||
for _ in 0..50 {
|
||||
let identity = generate_from_request(&request("en"));
|
||||
let year = birth_year_of(&identity);
|
||||
assert!(year >= today.year() - 66, "birth year {year} too old");
|
||||
assert!(year <= today.year() - 20, "birth year {year} too young");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn birthdate_options_zero_deviation_pins_the_year() {
|
||||
let mut req = request("en");
|
||||
req.birthdate_options = Some(BirthdateOptions {
|
||||
target_year: 1990,
|
||||
year_deviation: 0,
|
||||
});
|
||||
|
||||
for _ in 0..25 {
|
||||
let identity = generate_from_request(&req);
|
||||
assert_eq!(birth_year_of(&identity), 1990);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn birthdate_options_deviation_bounds_the_year_range() {
|
||||
let mut req = request("en");
|
||||
req.birthdate_options = Some(BirthdateOptions {
|
||||
target_year: 1990,
|
||||
year_deviation: 5,
|
||||
});
|
||||
|
||||
let mut distinct_years = std::collections::HashSet::new();
|
||||
for _ in 0..50 {
|
||||
let identity = generate_from_request(&req);
|
||||
let year = birth_year_of(&identity);
|
||||
assert!((1985..=1995).contains(&year), "year {year} out of range");
|
||||
distinct_years.insert(year);
|
||||
}
|
||||
assert!(distinct_years.len() > 1, "expected varied years within the range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_decade_names_follow_the_birth_year() {
|
||||
for target_year in (1955..=2025).step_by(10) {
|
||||
let mut req = request("de");
|
||||
req.birthdate_options = Some(BirthdateOptions {
|
||||
target_year,
|
||||
year_deviation: 0,
|
||||
});
|
||||
|
||||
let identity = generate_from_request(&req);
|
||||
assert_eq!(birth_year_of(&identity), target_year);
|
||||
assert!(!identity.first_name.is_empty());
|
||||
assert!(!identity.last_name.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decade_language_falls_back_to_all_decades_for_uncovered_years() {
|
||||
// Italian decades stop at 2019, so a 2024 birth year has no direct match.
|
||||
let mut req = request("it");
|
||||
req.birthdate_options = Some(BirthdateOptions {
|
||||
target_year: 2024,
|
||||
year_deviation: 0,
|
||||
});
|
||||
|
||||
let identity = generate_from_request(&req);
|
||||
assert!(!identity.first_name.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_language_falls_back_to_english() {
|
||||
let identity = generate_from_request(&request("xx"));
|
||||
assert!(!identity.first_name.is_empty());
|
||||
assert!(!identity.last_name.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_range_is_used_when_no_birthdate_options_given() {
|
||||
let current_year = Utc::now().year();
|
||||
let mut req = request("en");
|
||||
req.age_range = Some("21-25".to_string());
|
||||
|
||||
for _ in 0..25 {
|
||||
let identity = generate_from_request(&req);
|
||||
let age = current_year - birth_year_of(&identity);
|
||||
assert!((20..=26).contains(&age), "age {age} outside expected 21-25 window");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_range_conversion_matches_reference_values() {
|
||||
let cases = [
|
||||
("21-25", 2002, 2),
|
||||
("26-30", 1997, 2),
|
||||
("61-65", 1962, 2),
|
||||
("25-25", 2000, 0),
|
||||
("20-24", 2003, 2),
|
||||
];
|
||||
for (range, target_year, year_deviation) in cases {
|
||||
let options = age_range_to_birthdate_options_at(range, 2025).unwrap();
|
||||
assert_eq!(options.target_year, target_year, "range {range}");
|
||||
assert_eq!(options.year_deviation, year_deviation, "range {range}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_range_conversion_rejects_invalid_input() {
|
||||
for input in ["random", "", "2125", "21-25-30", "abc-def", "21-abc"] {
|
||||
assert!(
|
||||
age_range_to_birthdate_options_at(input, 2025).is_none(),
|
||||
"expected None for {input:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_age_ranges_starts_with_random() {
|
||||
let ranges = available_age_ranges();
|
||||
assert_eq!(ranges[0], "random");
|
||||
assert!(ranges.contains(&"21-25".to_string()));
|
||||
assert!(ranges.contains(&"61-65".to_string()));
|
||||
assert_eq!(ranges.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_languages_contains_all_dictionaries() {
|
||||
let languages = available_languages();
|
||||
assert_eq!(languages.len(), 11);
|
||||
for code in ["da", "de", "en", "es", "fr", "it", "nl", "ro", "sv", "ur", "fa"] {
|
||||
assert!(languages.contains(&code.to_string()), "missing language {code}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_dictionaries_have_names_for_both_genders() {
|
||||
for dictionary in dictionaries::DICTIONARIES {
|
||||
assert!(!dictionary.lastnames().is_empty(), "{}: no last names", dictionary.code);
|
||||
|
||||
let male = select_firstnames_for_birth_year(dictionary, 1990, true);
|
||||
let female = select_firstnames_for_birth_year(dictionary, 1990, false);
|
||||
assert!(!male.is_empty(), "{}: no male first names", dictionary.code);
|
||||
assert!(!female.is_empty(), "{}: no female first names", dictionary.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_prefix_is_sanitized_and_length_clamped() {
|
||||
for i in 0..100 {
|
||||
let seed = format!("{:064x}", i + 1);
|
||||
let input = format!(
|
||||
r#"{{"firstName":"Jürgen","lastName":"O'Brien-Smith","birthDate":"1985-03-12","seed":"{seed}"}}"#
|
||||
);
|
||||
let prefix = generate_email_prefix(&input).unwrap();
|
||||
let count = prefix.chars().count();
|
||||
assert!((6..=20).contains(&count), "prefix {prefix:?} has bad length");
|
||||
assert!(
|
||||
prefix
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
|
||||
"prefix {prefix:?} contains invalid characters"
|
||||
);
|
||||
assert!(!prefix.starts_with(['.', '-', '_']), "prefix {prefix:?} starts with separator");
|
||||
assert!(!prefix.ends_with(['.', '-', '_']), "prefix {prefix:?} ends with separator");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_is_alphanumeric_and_length_clamped() {
|
||||
for i in 0..100 {
|
||||
let seed = format!("{:064x}", i + 1);
|
||||
let input = format!(
|
||||
r#"{{"firstName":"Anna-Marie","lastName":"de Vries","birthDate":"1992-11-30","seed":"{seed}"}}"#
|
||||
);
|
||||
let username = generate_username(&input).unwrap();
|
||||
let count = username.chars().count();
|
||||
assert!((6..=20).contains(&count), "username {username:?} has bad length");
|
||||
assert!(
|
||||
username.chars().all(|c| c.is_ascii_alphanumeric()),
|
||||
"username {username:?} contains non-alphanumeric characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_and_email_prefix_work_without_birth_year() {
|
||||
let input = r#"{"firstName":"John","lastName":"Doe","birthDate":""}"#;
|
||||
let username = generate_username(input).unwrap();
|
||||
let prefix = generate_email_prefix(input).unwrap();
|
||||
assert!((6..=20).contains(&username.chars().count()));
|
||||
assert!((6..=20).contains(&prefix.chars().count()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_email_prefix_has_requested_length_and_charset() {
|
||||
let prefix = generate_random_email_prefix(14);
|
||||
assert_eq!(prefix.chars().count(), 14);
|
||||
assert!(prefix.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_api_round_trips_with_camel_case_fields() {
|
||||
let output = generate_identity(r#"{"language":"nl","gender":"female","ageRange":"26-30"}"#).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
|
||||
for field in ["firstName", "lastName", "gender", "birthDate", "emailPrefix", "nickName"] {
|
||||
assert!(value.get(field).is_some(), "missing field {field}");
|
||||
}
|
||||
assert_eq!(value["gender"], "Female");
|
||||
|
||||
let birth_date = value["birthDate"].as_str().unwrap();
|
||||
assert_eq!(birth_date.len(), 10, "birthDate should be yyyy-MM-dd, got {birth_date}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_api_rejects_malformed_input() {
|
||||
assert!(generate_identity("not json").is_err());
|
||||
assert!(generate_username("{").is_err());
|
||||
}
|
||||
133
core/rust/src/identity_generator/username_email.rs
Normal file
133
core/rust/src/identity_generator/username_email.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! Username and email prefix generation based on an identity's name and birth year.
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::rng::unbiased_index;
|
||||
|
||||
const MIN_LENGTH: usize = 6;
|
||||
const MAX_LENGTH: usize = 20;
|
||||
const SYMBOLS: [char; 2] = ['.', '-'];
|
||||
const RANDOM_CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
/// Generate an email prefix from a first name, last name and optional birth year.
|
||||
pub fn generate_email_prefix<R: RngCore + ?Sized>(
|
||||
rng: &mut R,
|
||||
first_name: &str,
|
||||
last_name: &str,
|
||||
birth_year: Option<i32>,
|
||||
) -> String {
|
||||
let first = first_name.to_lowercase();
|
||||
let last = last_name.to_lowercase();
|
||||
|
||||
let name_part = match unbiased_index(rng, 4) {
|
||||
// First initial + last name
|
||||
0 => format!("{}{}", first_chars(&first, 1), last),
|
||||
// Full name
|
||||
1 => format!("{}{}", first, last),
|
||||
// First name + last initial
|
||||
2 => format!("{}{}", first, first_chars(&last, 1)),
|
||||
// First 3 chars of first name + last name
|
||||
_ => format!("{}{}", first_chars(&first, 3), last),
|
||||
};
|
||||
|
||||
let mut parts = vec![name_part];
|
||||
|
||||
// Add a birth year variation for uniqueness (full year or last two digits).
|
||||
if let Some(year) = birth_year {
|
||||
let year = year.to_string();
|
||||
parts.push(match unbiased_index(rng, 2) {
|
||||
0 => year,
|
||||
_ => year.chars().skip(year.len().saturating_sub(2)).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
// Join parts with a random symbol (2 in 3 chance of no symbol at all).
|
||||
let mut prefix = parts.join(&random_symbol(rng));
|
||||
|
||||
// 50% chance to insert one extra random symbol at a random position.
|
||||
if unbiased_index(rng, 2) == 0 && !prefix.is_empty() {
|
||||
let char_count = prefix.chars().count();
|
||||
let position = unbiased_index(rng, char_count);
|
||||
let byte_index = prefix
|
||||
.char_indices()
|
||||
.nth(position)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(prefix.len());
|
||||
prefix.insert_str(byte_index, &random_symbol(rng));
|
||||
}
|
||||
|
||||
prefix = sanitize_email_prefix(&prefix);
|
||||
adjust_length(rng, prefix)
|
||||
}
|
||||
|
||||
/// Generate a username from a first name, last name and optional birth year.
|
||||
///
|
||||
/// Uses the same construction as the email prefix but strips all non-alphanumeric
|
||||
/// characters. Note this rolls its own randomness, so a username generated alongside
|
||||
/// an email prefix is not simply the stripped version of that prefix.
|
||||
pub fn generate_username<R: RngCore + ?Sized>(
|
||||
rng: &mut R,
|
||||
first_name: &str,
|
||||
last_name: &str,
|
||||
birth_year: Option<i32>,
|
||||
) -> String {
|
||||
let prefix = generate_email_prefix(rng, first_name, last_name, birth_year);
|
||||
let username: String = prefix.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
|
||||
adjust_length(rng, username)
|
||||
}
|
||||
|
||||
/// Generate a random alphanumeric string, suitable for email prefixes that are not
|
||||
/// based on any identity (e.g. login-type credentials without persona fields).
|
||||
pub fn generate_random_string<R: RngCore + ?Sized>(rng: &mut R, length: usize) -> String {
|
||||
(0..length)
|
||||
.map(|_| RANDOM_CHARS[unbiased_index(rng, RANDOM_CHARS.len())] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Pad a too-short value with random characters or truncate a too-long one.
|
||||
fn adjust_length<R: RngCore + ?Sized>(rng: &mut R, mut value: String) -> String {
|
||||
let char_count = value.chars().count();
|
||||
if char_count < MIN_LENGTH {
|
||||
value.push_str(&generate_random_string(rng, MIN_LENGTH - char_count));
|
||||
} else if char_count > MAX_LENGTH {
|
||||
value = value.chars().take(MAX_LENGTH).collect();
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Return a random symbol 1 in 3 times, otherwise an empty string.
|
||||
fn random_symbol<R: RngCore + ?Sized>(rng: &mut R) -> String {
|
||||
if unbiased_index(rng, 3) == 0 {
|
||||
SYMBOLS[unbiased_index(rng, SYMBOLS.len())].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only ASCII letters, digits, dots, underscores and hyphens; collapse
|
||||
/// consecutive separator characters to one; trim leading and trailing separators.
|
||||
fn sanitize_email_prefix(input: &str) -> String {
|
||||
let filtered: String = input
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
||||
.collect();
|
||||
|
||||
let mut collapsed = String::with_capacity(filtered.len());
|
||||
let mut previous_was_separator = false;
|
||||
for c in filtered.chars() {
|
||||
let is_separator = matches!(c, '.' | '_' | '-');
|
||||
if is_separator && previous_was_separator {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(c);
|
||||
previous_was_separator = is_separator;
|
||||
}
|
||||
|
||||
collapsed
|
||||
.trim_matches(|c: char| matches!(c, '.' | '_' | '-'))
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// First `n` characters of a string (character based, not byte based).
|
||||
fn first_chars(value: &str, n: usize) -> String {
|
||||
value.chars().take(n).collect()
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
//! - **vault_merge**: Vault merge using Last-Write-Wins (LWW) strategy
|
||||
//! - **vault_pruner**: Prunes expired items from trash (30-day retention)
|
||||
//! - **credential_matcher**: Cross-platform credential filtering for autofill
|
||||
//! - **password_generator**: Password and passphrase (Diceware) generation
|
||||
//! - **identity_generator**: Random identity (alias persona) generation
|
||||
//! - **srp**: Secure Remote Password (SRP-6a) protocol for authentication
|
||||
//!
|
||||
//! This library accepts data as JSON and returns results as JSON.
|
||||
@@ -11,10 +13,12 @@
|
||||
//! and calls this library for the core logic.
|
||||
|
||||
pub mod error;
|
||||
mod rng;
|
||||
pub mod vault_merge;
|
||||
pub mod vault_pruner;
|
||||
pub mod credential_matcher;
|
||||
pub mod password_generator;
|
||||
pub mod identity_generator;
|
||||
pub mod srp;
|
||||
|
||||
pub use error::VaultError;
|
||||
@@ -30,6 +34,7 @@ pub use credential_matcher::{
|
||||
AutofillMatchingMode, CredentialMatcherInput, CredentialMatcherOutput,
|
||||
};
|
||||
pub use password_generator::{generate_password, PasswordSettings};
|
||||
pub use identity_generator::{generate_identity, Identity, IdentityRequest};
|
||||
pub use srp::{
|
||||
srp_generate_salt, srp_derive_private_key, srp_derive_verifier,
|
||||
srp_generate_ephemeral, srp_derive_session,
|
||||
|
||||
@@ -17,11 +17,10 @@ mod wordlists;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::VaultError;
|
||||
use crate::rng::{make_rng, unbiased_index};
|
||||
|
||||
/// Which generator to use.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -198,47 +197,3 @@ pub fn generate_from_settings(settings: &PasswordSettings) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the RNG used for generation.
|
||||
///
|
||||
/// If `seed` is a valid 64-character hex string (32 bytes), the RNG is deterministically
|
||||
/// seeded from it. Otherwise a fresh 32-byte seed is drawn from the OS CSPRNG.
|
||||
fn make_rng(seed: Option<&str>) -> StdRng {
|
||||
if let Some(bytes) = seed.and_then(parse_seed_hex) {
|
||||
return StdRng::from_seed(bytes);
|
||||
}
|
||||
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
StdRng::from_seed(bytes)
|
||||
}
|
||||
|
||||
/// Parse a 64-character hex string into a 32-byte seed, or `None` if it is malformed.
|
||||
fn parse_seed_hex(hex: &str) -> Option<[u8; 32]> {
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Get an unbiased random index in `0..max` using rejection sampling over the given CSPRNG.
|
||||
/// Handles modulo bias by rejecting values above the largest multiple of `max` that fits in a `u32`.
|
||||
fn unbiased_index<R: RngCore + ?Sized>(rng: &mut R, max: usize) -> usize {
|
||||
debug_assert!(max > 0, "unbiased_index requires max > 0");
|
||||
if max <= 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let max = max as u64;
|
||||
let limit = ((1u64 << 32) / max) * max;
|
||||
|
||||
loop {
|
||||
let value = rng.next_u32() as u64;
|
||||
if value < limit {
|
||||
return (value % max) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
core/rust/src/rng.rs
Normal file
49
core/rust/src/rng.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Shared RNG helpers used by the generator modules (password, identity).
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
|
||||
/// Build the RNG used for generation.
|
||||
///
|
||||
/// If `seed` is a valid 64-character hex string (32 bytes), the RNG is deterministically
|
||||
/// seeded from it. Otherwise a fresh 32-byte seed is drawn from the OS CSPRNG.
|
||||
pub(crate) fn make_rng(seed: Option<&str>) -> StdRng {
|
||||
if let Some(bytes) = seed.and_then(parse_seed_hex) {
|
||||
return StdRng::from_seed(bytes);
|
||||
}
|
||||
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
StdRng::from_seed(bytes)
|
||||
}
|
||||
|
||||
/// Parse a 64-character hex string into a 32-byte seed, or `None` if it is malformed.
|
||||
fn parse_seed_hex(hex: &str) -> Option<[u8; 32]> {
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Get an unbiased random index in `0..max` using rejection sampling over the given CSPRNG.
|
||||
/// Handles modulo bias by rejecting values above the largest multiple of `max` that fits in a `u32`.
|
||||
pub(crate) fn unbiased_index<R: RngCore + ?Sized>(rng: &mut R, max: usize) -> usize {
|
||||
debug_assert!(max > 0, "unbiased_index requires max > 0");
|
||||
if max <= 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let max = max as u64;
|
||||
let limit = ((1u64 << 32) / max) * max;
|
||||
|
||||
loop {
|
||||
let value = rng.next_u32() as u64;
|
||||
if value < limit {
|
||||
return (value % max) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,58 @@ pub fn get_diceware_languages() -> Vec<String> {
|
||||
crate::password_generator::available_languages()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Identity Generator Functions
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Generate a random identity from a JSON-serialized request.
|
||||
///
|
||||
/// The request accepts `language`, `gender` ("male"/"female"/"random"), `ageRange`
|
||||
/// (e.g. "21-25" or "random") and/or explicit `birthdateOptions`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_json` - JSON string, e.g. `{"language":"en","gender":"random","ageRange":"21-25"}`
|
||||
///
|
||||
/// # Returns
|
||||
/// The generated identity as a JSON string with camelCase fields:
|
||||
/// `{"firstName":"...","lastName":"...","gender":"Male","birthDate":"1990-05-15","emailPrefix":"...","nickName":"..."}`
|
||||
#[uniffi::export]
|
||||
pub fn generate_identity(request_json: String) -> Result<String, VaultError> {
|
||||
crate::identity_generator::generate_identity(&request_json)
|
||||
}
|
||||
|
||||
/// Generate a username from a JSON-serialized name input
|
||||
/// (`{"firstName":"...","lastName":"...","birthDate":"1990-05-15"}`).
|
||||
#[uniffi::export]
|
||||
pub fn generate_identity_username(input_json: String) -> Result<String, VaultError> {
|
||||
crate::identity_generator::generate_username(&input_json)
|
||||
}
|
||||
|
||||
/// Generate an email prefix from a JSON-serialized name input
|
||||
/// (`{"firstName":"...","lastName":"...","birthDate":"1990-05-15"}`).
|
||||
#[uniffi::export]
|
||||
pub fn generate_identity_email_prefix(input_json: String) -> Result<String, VaultError> {
|
||||
crate::identity_generator::generate_email_prefix(&input_json)
|
||||
}
|
||||
|
||||
/// Generate a random alphanumeric email prefix that is not based on any identity.
|
||||
#[uniffi::export]
|
||||
pub fn generate_random_email_prefix(length: u32) -> String {
|
||||
crate::identity_generator::generate_random_email_prefix(length)
|
||||
}
|
||||
|
||||
/// Get the list of bundled identity dictionary language codes.
|
||||
#[uniffi::export]
|
||||
pub fn get_identity_languages() -> Vec<String> {
|
||||
crate::identity_generator::available_languages()
|
||||
}
|
||||
|
||||
/// Get the list of age range option values ("random" plus 5-year ranges).
|
||||
#[uniffi::export]
|
||||
pub fn get_identity_age_ranges() -> Vec<String> {
|
||||
crate::identity_generator::available_age_ranges()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SRP (Secure Remote Password) Functions
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -174,6 +174,55 @@ pub fn get_diceware_languages_js() -> Vec<String> {
|
||||
available_languages()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Identity Generator WASM Bindings
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Generate a random identity from a JSON-serialized request.
|
||||
///
|
||||
/// The request accepts `language`, `gender` ("male"/"female"/"random"), `ageRange`
|
||||
/// (e.g. "21-25" or "random") and/or explicit `birthdateOptions`. Returns the
|
||||
/// generated identity as a JSON string with camelCase fields.
|
||||
#[wasm_bindgen(js_name = generateIdentity)]
|
||||
pub fn generate_identity_js(request_json: &str) -> Result<String, JsValue> {
|
||||
crate::identity_generator::generate_identity(request_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Identity generation failed: {}", e)))
|
||||
}
|
||||
|
||||
/// Generate a username from a JSON-serialized name input
|
||||
/// (`firstName`, `lastName`, `birthDate`).
|
||||
#[wasm_bindgen(js_name = generateIdentityUsername)]
|
||||
pub fn generate_identity_username_js(input_json: &str) -> Result<String, JsValue> {
|
||||
crate::identity_generator::generate_username(input_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Username generation failed: {}", e)))
|
||||
}
|
||||
|
||||
/// Generate an email prefix from a JSON-serialized name input
|
||||
/// (`firstName`, `lastName`, `birthDate`).
|
||||
#[wasm_bindgen(js_name = generateIdentityEmailPrefix)]
|
||||
pub fn generate_identity_email_prefix_js(input_json: &str) -> Result<String, JsValue> {
|
||||
crate::identity_generator::generate_email_prefix(input_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Email prefix generation failed: {}", e)))
|
||||
}
|
||||
|
||||
/// Generate a random alphanumeric email prefix that is not based on any identity.
|
||||
#[wasm_bindgen(js_name = generateRandomEmailPrefix)]
|
||||
pub fn generate_random_email_prefix_js(length: u32) -> String {
|
||||
crate::identity_generator::generate_random_email_prefix(length)
|
||||
}
|
||||
|
||||
/// Get the list of bundled identity dictionary language codes.
|
||||
#[wasm_bindgen(js_name = getIdentityLanguages)]
|
||||
pub fn get_identity_languages_js() -> Vec<String> {
|
||||
crate::identity_generator::available_languages()
|
||||
}
|
||||
|
||||
/// Get the list of age range option values ("random" plus 5-year ranges).
|
||||
#[wasm_bindgen(js_name = getIdentityAgeRanges)]
|
||||
pub fn get_identity_age_ranges_js() -> Vec<String> {
|
||||
crate::identity_generator::available_age_ranges()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SRP (Secure Remote Password) WASM Bindings
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user