mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-08-02 02:07:06 -04:00
Make available diceword languages dynamic (#776)
This commit is contained in:
@@ -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(|_| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
168
core/rust/src/password_generator/tests.rs
Normal file
168
core/rust/src/password_generator/tests.rs
Normal 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());
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user