From 0821349969de72d12d21f3393546b4e2ba79b0e6 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 25 Jun 2026 13:04:10 +0200 Subject: [PATCH] Make available diceword languages dynamic (#776) --- core/rust/src/password_generator/diceware.rs | 2 +- core/rust/src/password_generator/mod.rs | 38 ++-- core/rust/src/password_generator/tests.rs | 168 ++++++++++++++++++ .../src/password_generator/wordlists/mod.rs | 80 ++++----- core/rust/src/wasm.rs | 8 +- 5 files changed, 231 insertions(+), 65 deletions(-) create mode 100644 core/rust/src/password_generator/tests.rs diff --git a/core/rust/src/password_generator/diceware.rs b/core/rust/src/password_generator/diceware.rs index a7f9ca106..cf56308c6 100644 --- a/core/rust/src/password_generator/diceware.rs +++ b/core/rust/src/password_generator/diceware.rs @@ -13,7 +13,7 @@ const ALPHANUMERIC: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ /// Generate a Diceware passphrase based on the supplied settings. pub fn generate(settings: &PasswordSettings, rng: &mut R) -> String { - let words = wordlists::list(settings.language); + let words = wordlists::list(&settings.language); let chosen: Vec = (0..settings.word_count) .map(|_| { diff --git a/core/rust/src/password_generator/mod.rs b/core/rust/src/password_generator/mod.rs index 2674b98ed..61d2bdce1 100644 --- a/core/rust/src/password_generator/mod.rs +++ b/core/rust/src/password_generator/mod.rs @@ -33,25 +33,6 @@ pub enum GeneratorType { Diceware, } -/// Wordlist language for Diceware generation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "PascalCase")] -pub enum Language { - /// English wordlist. - #[default] - English, - /// Dutch wordlist. - Dutch, - /// German wordlist. - German, - /// French wordlist. - French, - /// Spanish wordlist. - Spanish, - /// Italian wordlist. - Italian, -} - /// Capitalization applied to each Diceware word. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "PascalCase")] @@ -137,9 +118,10 @@ pub struct PasswordSettings { /// Number of words in the passphrase. #[serde(default = "default_word_count")] pub word_count: u32, - /// Wordlist language. - #[serde(default)] - pub language: Language, + /// Wordlist language code (free text, case-insensitive). Unknown codes fall back to + /// English, so the TypeScript model and apps never need updating to add a language. + #[serde(default = "default_language")] + pub language: String, /// Capitalization applied to each word. #[serde(default)] pub capitalization: Capitalization, @@ -172,6 +154,18 @@ fn default_true() -> bool { true } +fn default_language() -> String { + "English".to_string() +} + +/// List the language codes of all bundled Diceware wordlists (first is the default, English). +pub fn available_languages() -> Vec { + wordlists::available_codes() + .into_iter() + .map(|c| c.to_string()) + .collect() +} + /// Generate a password or passphrase from a JSON-serialized [`PasswordSettings`]. /// /// Returns the generated string, or a [`VaultError`] if the settings JSON is invalid. diff --git a/core/rust/src/password_generator/tests.rs b/core/rust/src/password_generator/tests.rs new file mode 100644 index 000000000..3d9a96f6d --- /dev/null +++ b/core/rust/src/password_generator/tests.rs @@ -0,0 +1,168 @@ +//! Tests for password and passphrase generation. + +use super::*; + +/// Build a default settings object (matches the app defaults). +fn default_settings() -> PasswordSettings { + serde_json::from_str("{}").expect("empty object should deserialize via serde defaults") +} + +#[test] +fn empty_json_uses_defaults() { + let s = default_settings(); + assert_eq!(s.generator_type, GeneratorType::Basic); + assert_eq!(s.length, 18); + assert!(s.use_lowercase && s.use_uppercase && s.use_numbers && s.use_special_chars); + assert!(!s.use_non_ambiguous_chars); + assert_eq!(s.word_count, 4); + assert_eq!(s.language, "English"); + assert_eq!(s.capitalization, Capitalization::Lowercase); + assert_eq!(s.separator, Separator::Dash); + assert_eq!(s.salt, Salt::None); +} + +#[test] +fn type_field_round_trips_as_pascal_key() { + // The persisted JSON key must be exactly "Type" with lowercase values. + let json = r#"{"Type":"diceware"}"#; + let s: PasswordSettings = serde_json::from_str(json).unwrap(); + assert_eq!(s.generator_type, GeneratorType::Diceware); + + let out = serde_json::to_string(&s).unwrap(); + assert!(out.contains("\"Type\":\"diceware\""), "serialized: {out}"); +} + +#[test] +fn basic_respects_length() { + let json = r#"{"Type":"basic","Length":24}"#; + let pw = generate_password(json).unwrap(); + assert_eq!(pw.chars().count(), 24); +} + +#[test] +fn basic_includes_each_enabled_class() { + let json = r#"{"Type":"basic","Length":40,"UseLowercase":true,"UseUppercase":true,"UseNumbers":true,"UseSpecialChars":true}"#; + let pw = generate_password(json).unwrap(); + assert!(pw.chars().any(|c| c.is_ascii_lowercase())); + assert!(pw.chars().any(|c| c.is_ascii_uppercase())); + assert!(pw.chars().any(|c| c.is_ascii_digit())); + assert!(pw.chars().any(|c| "!@#$%^&*()_+-=[]{}|;:,.<>?".contains(c))); +} + +#[test] +fn basic_only_numbers_yields_only_digits() { + let json = r#"{"Type":"basic","Length":30,"UseLowercase":false,"UseUppercase":false,"UseNumbers":true,"UseSpecialChars":false}"#; + let pw = generate_password(json).unwrap(); + assert!(pw.chars().all(|c| c.is_ascii_digit())); +} + +#[test] +fn basic_no_classes_falls_back_to_lowercase() { + let json = r#"{"Type":"basic","Length":20,"UseLowercase":false,"UseUppercase":false,"UseNumbers":false,"UseSpecialChars":false}"#; + let pw = generate_password(json).unwrap(); + assert_eq!(pw.chars().count(), 20); + assert!(pw.chars().all(|c| c.is_ascii_lowercase())); +} + +#[test] +fn basic_non_ambiguous_excludes_ambiguous_chars() { + let ambiguous = "Il1O0oZzSsBbGg2568|[]{}()<>;:,.`'\"_-"; + let json = r#"{"Type":"basic","Length":80,"UseNonAmbiguousChars":true}"#; + // Run several times since generation is random. + for _ in 0..20 { + let pw = generate_password(json).unwrap(); + assert!( + pw.chars().all(|c| !ambiguous.contains(c)), + "found ambiguous char in {pw}" + ); + } +} + +#[test] +fn diceware_word_count_and_separator() { + let json = r#"{"Type":"diceware","WordCount":5,"Separator":"Dash","Capitalization":"Lowercase","Salt":"None"}"#; + let pw = generate_password(json).unwrap(); + let parts: Vec<&str> = pw.split('-').collect(); + assert_eq!(parts.len(), 5); + assert!(parts.iter().all(|p| !p.is_empty())); +} + +#[test] +fn diceware_capitalization_title_case() { + let json = r#"{"Type":"diceware","WordCount":3,"Separator":"Space","Capitalization":"TitleCase","Salt":"None"}"#; + let pw = generate_password(json).unwrap(); + for word in pw.split(' ') { + let mut chars = word.chars(); + if let Some(first) = chars.next() { + assert!(first.is_uppercase() || !first.is_alphabetic()); + } + } +} + +#[test] +fn diceware_separator_none_concatenates() { + let json = r#"{"Type":"diceware","WordCount":3,"Separator":"None","Salt":"None"}"#; + let pw = generate_password(json).unwrap(); + assert!(!pw.contains('-') && !pw.contains(' ') && !pw.contains('_')); + assert!(!pw.is_empty()); +} + +#[test] +fn diceware_salt_suffix_appends_alphanumeric() { + let json = r#"{"Type":"diceware","WordCount":2,"Separator":"Dash","Salt":"Suffix"}"#; + let pw = generate_password(json).unwrap(); + let last = pw.chars().last().unwrap(); + assert!(last.is_ascii_alphanumeric()); +} + +const TEST_SEED: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + +#[test] +fn same_seed_yields_same_output() { + let json = format!(r#"{{"Type":"diceware","WordCount":5,"Seed":"{TEST_SEED}"}}"#); + let a = generate_password(&json).unwrap(); + let b = generate_password(&json).unwrap(); + assert_eq!(a, b); +} + +#[test] +fn same_seed_different_separator_keeps_words() { + // With the same seed, only the separator should change — the words stay identical. + let dash = generate_password(&format!( + r#"{{"Type":"diceware","WordCount":4,"Separator":"Dash","Seed":"{TEST_SEED}"}}"# + )) + .unwrap(); + let dot = generate_password(&format!( + r#"{{"Type":"diceware","WordCount":4,"Separator":"Dot","Seed":"{TEST_SEED}"}}"# + )) + .unwrap(); + + let dash_words: Vec<&str> = dash.split('-').collect(); + let dot_words: Vec<&str> = dot.split('.').collect(); + assert_eq!(dash_words, dot_words); + assert_ne!(dash, dot); +} + +#[test] +fn invalid_seed_falls_back_to_random() { + // A malformed seed must not error — it falls back to a random seed. + let pw = generate_password(r#"{"Type":"diceware","Seed":"not-hex"}"#).unwrap(); + assert!(!pw.is_empty()); +} + +#[test] +fn all_wordlists_have_7776_words() { + for code in wordlists::available_codes() { + let words = wordlists::list(code); + assert_eq!(words.len(), 7776, "{code} should have 7776 words"); + } +} + +#[test] +fn unknown_language_falls_back_to_english() { + // Case-insensitive match, and an unknown code must not error. + assert_eq!(wordlists::list("english"), wordlists::list("English")); + assert_eq!(wordlists::list("Klingon"), wordlists::list("English")); + let pw = generate_password(r#"{"Type":"diceware","Language":"Klingon"}"#).unwrap(); + assert!(!pw.is_empty()); +} diff --git a/core/rust/src/password_generator/wordlists/mod.rs b/core/rust/src/password_generator/wordlists/mod.rs index b7ba6815d..cb8620e59 100644 --- a/core/rust/src/password_generator/wordlists/mod.rs +++ b/core/rust/src/password_generator/wordlists/mod.rs @@ -1,51 +1,49 @@ //! Embedded Diceware wordlists. //! //! Each list contains exactly 7776 words (6^5), one word per line, in dice order. -//! The lists are embedded into the binary via `include_str!` and parsed lazily -//! (once per language) into a `Vec<&'static str>`. +//! The lists are embedded into the binary via `include_str!`. +//! +//! Languages are keyed by a free-text code (case-insensitive). Adding a new language is a +//! Rust-only change: drop the `.diceware` file in this directory and add one entry to +//! the `WORDLISTS` registry below — no changes are needed in the TypeScript model or the +//! apps. Unknown codes fall back to English. -use std::sync::OnceLock; - -use super::Language; - -static EN_RAW: &str = include_str!("en.diceware"); -static NL_RAW: &str = include_str!("nl.diceware"); -static DE_RAW: &str = include_str!("de.diceware"); -static FR_RAW: &str = include_str!("fr.diceware"); -static ES_RAW: &str = include_str!("es.diceware"); -static IT_RAW: &str = include_str!("it.diceware"); - -/// Parsed wordlists, memoized per language so we only split each list once. -static PARSED: [OnceLock>; 6] = [ - OnceLock::new(), - OnceLock::new(), - OnceLock::new(), - OnceLock::new(), - OnceLock::new(), - OnceLock::new(), +/// The registry of bundled wordlists, as `(code, raw text)`. The first entry is the +/// default/fallback (English). To add a language, add one line here plus the `.diceware` +/// file — nothing else needs to change. +static WORDLISTS: &[(&str, &str)] = &[ + ("English", include_str!("en.diceware")), + ("Dutch", include_str!("nl.diceware")), + ("German", include_str!("de.diceware")), + ("French", include_str!("fr.diceware")), + ("Spanish", include_str!("es.diceware")), + ("Italian", include_str!("it.diceware")), ]; -/// Map a language to its embedded raw text and stable index. -fn raw_for(language: Language) -> (usize, &'static str) { - match language { - Language::English => (0, EN_RAW), - Language::Dutch => (1, NL_RAW), - Language::German => (2, DE_RAW), - Language::French => (3, FR_RAW), - Language::Spanish => (4, ES_RAW), - Language::Italian => (5, IT_RAW), - } +/// Resolve a language code (case-insensitive) to its raw wordlist text, falling back to +/// English (the first registry entry) for any unknown or empty code. +fn resolve_raw(code: &str) -> &'static str { + WORDLISTS + .iter() + .find(|(c, _)| c.eq_ignore_ascii_case(code)) + .map(|(_, raw)| *raw) + .unwrap_or(WORDLISTS[0].1) } -/// Get the wordlist for the given language as a slice of words. +/// Get the wordlist for the given language code as a vector of words. /// -/// The returned slice is memoized, so repeated calls for the same language are cheap. -pub fn list(language: Language) -> &'static [&'static str] { - let (index, raw) = raw_for(language); - PARSED[index].get_or_init(|| { - raw.lines() - .map(|line| line.trim_end_matches('\r')) - .filter(|line| !line.is_empty()) - .collect() - }) +/// Unknown codes fall back to the English list. Parsing is cheap (~7776 line splits) and +/// happens once per generation call. +pub fn list(code: &str) -> Vec<&'static str> { + resolve_raw(code) + .lines() + .map(|line| line.trim_end_matches('\r')) + .filter(|line| !line.is_empty()) + .collect() +} + +/// List the codes of all bundled languages (first is the default, English). +/// Used to build a language picker in the UI without hard-coding the set there. +pub fn available_codes() -> Vec<&'static str> { + WORDLISTS.iter().map(|(code, _)| *code).collect() } diff --git a/core/rust/src/wasm.rs b/core/rust/src/wasm.rs index e8ea4e6f0..9f81d2687 100644 --- a/core/rust/src/wasm.rs +++ b/core/rust/src/wasm.rs @@ -5,7 +5,7 @@ use wasm_bindgen::prelude::*; use crate::credential_matcher::{ filter_credentials, CredentialMatcherInput, CredentialMatcherOutput, }; -use crate::password_generator::generate_password; +use crate::password_generator::{available_languages, generate_password}; use crate::vault_merge::{merge_vaults, MergeInput, MergeOutput}; use crate::vault_pruner::{prune_vault, PruneInput, PruneOutput}; @@ -158,6 +158,12 @@ pub fn generate_password_js(settings_json: &str) -> Result { .map_err(|e| JsValue::from_str(&format!("Password generation failed: {}", e))) } +/// Get the list of bundled Diceware language codes (first is the default, English). +#[wasm_bindgen(js_name = getDicewareLanguages)] +pub fn get_diceware_languages_js() -> Vec { + available_languages() +} + // ═══════════════════════════════════════════════════════════════════════════════ // SRP (Secure Remote Password) WASM Bindings // ═══════════════════════════════════════════════════════════════════════════════