From 6d5e4259f5fbc21555af2b255e079f0efe0ed48e Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 17 Jul 2026 18:54:28 +0200 Subject: [PATCH] Add Rust identity_generator scaffolding (#2279) --- core/rust/src/identity_generator/mod.rs | 307 ++++++++++++++++++ core/rust/src/identity_generator/tests.rs | 303 +++++++++++++++++ .../src/identity_generator/username_email.rs | 133 ++++++++ core/rust/src/lib.rs | 5 + core/rust/src/password_generator/mod.rs | 47 +-- core/rust/src/rng.rs | 49 +++ core/rust/src/uniffi_api.rs | 52 +++ core/rust/src/wasm.rs | 49 +++ 8 files changed, 899 insertions(+), 46 deletions(-) create mode 100644 core/rust/src/identity_generator/mod.rs create mode 100644 core/rust/src/identity_generator/tests.rs create mode 100644 core/rust/src/identity_generator/username_email.rs create mode 100644 core/rust/src/rng.rs diff --git a/core/rust/src/identity_generator/mod.rs b/core/rust/src/identity_generator/mod.rs new file mode 100644 index 000000000..08854e0ea --- /dev/null +++ b/core/rust/src/identity_generator/mod.rs @@ -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, + /// Age range preference as stored in settings (e.g. "21-25" or "random"). + /// Ignored when `birthdate_options` is set. + pub age_range: Option, + /// Explicit birth year window. Takes precedence over `age_range`. + pub birthdate_options: Option, + /// 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, +} + +/// 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, +} + +/// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( + rng: &mut R, + options: Option, + 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 { + 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)) +} diff --git a/core/rust/src/identity_generator/tests.rs b/core/rust/src/identity_generator/tests.rs new file mode 100644 index 000000000..76c392b53 --- /dev/null +++ b/core/rust/src/identity_generator/tests.rs @@ -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()); +} diff --git a/core/rust/src/identity_generator/username_email.rs b/core/rust/src/identity_generator/username_email.rs new file mode 100644 index 000000000..c70ab85b6 --- /dev/null +++ b/core/rust/src/identity_generator/username_email.rs @@ -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( + rng: &mut R, + first_name: &str, + last_name: &str, + birth_year: Option, +) -> 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( + rng: &mut R, + first_name: &str, + last_name: &str, + birth_year: Option, +) -> 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(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(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(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() +} diff --git a/core/rust/src/lib.rs b/core/rust/src/lib.rs index a0a9676b8..97a24604a 100644 --- a/core/rust/src/lib.rs +++ b/core/rust/src/lib.rs @@ -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, diff --git a/core/rust/src/password_generator/mod.rs b/core/rust/src/password_generator/mod.rs index 8626701de..6fa031aee 100644 --- a/core/rust/src/password_generator/mod.rs +++ b/core/rust/src/password_generator/mod.rs @@ -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(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; - } - } -} diff --git a/core/rust/src/rng.rs b/core/rust/src/rng.rs new file mode 100644 index 000000000..aa8e613ff --- /dev/null +++ b/core/rust/src/rng.rs @@ -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(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; + } + } +} diff --git a/core/rust/src/uniffi_api.rs b/core/rust/src/uniffi_api.rs index c52e2fc67..65226c8ce 100644 --- a/core/rust/src/uniffi_api.rs +++ b/core/rust/src/uniffi_api.rs @@ -148,6 +148,58 @@ pub fn get_diceware_languages() -> Vec { 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 { + 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 { + 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 { + 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 { + 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 { + crate::identity_generator::available_age_ranges() +} + // ═══════════════════════════════════════════════════════════════════════════════ // SRP (Secure Remote Password) Functions // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/core/rust/src/wasm.rs b/core/rust/src/wasm.rs index a1f572aec..b762d3e51 100644 --- a/core/rust/src/wasm.rs +++ b/core/rust/src/wasm.rs @@ -174,6 +174,55 @@ pub fn get_diceware_languages_js() -> Vec { 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 { + 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 { + 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 { + 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 { + 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 { + crate::identity_generator::available_age_ranges() +} + // ═══════════════════════════════════════════════════════════════════════════════ // SRP (Secure Remote Password) WASM Bindings // ═══════════════════════════════════════════════════════════════════════════════