Make available diceword languages dynamic (#776)

This commit is contained in:
Leendert de Borst
2026-06-25 13:04:10 +02:00
parent 24a833892f
commit 0821349969
5 changed files with 231 additions and 65 deletions

View File

@@ -13,7 +13,7 @@ const ALPHANUMERIC: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
/// Generate a Diceware passphrase based on the supplied settings.
pub fn generate<R: RngCore + ?Sized>(settings: &PasswordSettings, rng: &mut R) -> String {
let words = wordlists::list(settings.language);
let words = wordlists::list(&settings.language);
let chosen: Vec<String> = (0..settings.word_count)
.map(|_| {

View File

@@ -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<String> {
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.

View File

@@ -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());
}

View File

@@ -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 `<code>.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<Vec<&'static str>>; 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()
}

View File

@@ -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<String, JsValue> {
.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<String> {
available_languages()
}
// ═══════════════════════════════════════════════════════════════════════════════
// SRP (Secure Remote Password) WASM Bindings
// ═══════════════════════════════════════════════════════════════════════════════