diff --git a/.deny.toml b/.deny.toml index ed42dc4b27e..48f3f6c4a2a 100644 --- a/.deny.toml +++ b/.deny.toml @@ -47,5 +47,8 @@ allow-git = [ "https://github.com/jplatte/const_panic", # A patch override for the bindings: https://github.com/smol-rs/async-compat/pull/22 "https://github.com/element-hq/async-compat", - "https://github.com/mozilla/uniffi-rs" + "https://github.com/mozilla/uniffi-rs", + # Pinned one commit past 3.1.1 for the unreleased guess-magnitude + # overflow fix, needed by the bindings' password strength estimation. + "https://github.com/shssoichiro/zxcvbn-rs", ] diff --git a/Cargo.lock b/Cargo.lock index ecfc473e165..42d7d2d9ea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,7 +485,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -494,6 +503,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -1927,10 +1942,21 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fancy_constructor" version = "2.1.0" @@ -3599,6 +3625,7 @@ dependencies = [ "uuid", "vergen-gitcl", "zeroize", + "zxcvbn", ] [[package]] @@ -4624,7 +4651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.101", @@ -5003,9 +5030,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -6237,7 +6264,7 @@ dependencies = [ "anyhow", "base64", "bitflags 2.10.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset", @@ -8242,3 +8269,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zxcvbn" +version = "3.1.1" +source = "git+https://github.com/shssoichiro/zxcvbn-rs?rev=4e8e784b23541d118800df84feedf8160879d1af#4e8e784b23541d118800df84feedf8160879d1af" +dependencies = [ + "chrono", + "fancy-regex 0.18.0", + "itertools 0.14.0", + "lazy_static", + "regex", + "time", + "wasm-bindgen", + "web-sys", +] diff --git a/Cargo.toml b/Cargo.toml index 59fc0eaa9c7..046f3496a0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,6 +125,7 @@ wasm-bindgen-test = { version = "0.3.55", default-features = false, features = [ web-sys = { version = "0.3.82", default-features = false } wiremock = { version = "0.6.5", default-features = false } zeroize = { version = "1.8.2", default-features = false } +zxcvbn = { git = "https://github.com/shssoichiro/zxcvbn-rs", rev = "4e8e784b23541d118800df84feedf8160879d1af", default-features = false } matrix-sdk = { path = "crates/matrix-sdk", version = "0.18.0", default-features = false } matrix-sdk-base = { path = "crates/matrix-sdk-base", version = "0.18.0" } diff --git a/bindings/matrix-sdk-ffi/Cargo.toml b/bindings/matrix-sdk-ffi/Cargo.toml index f414ad03074..8ff0e238987 100644 --- a/bindings/matrix-sdk-ffi/Cargo.toml +++ b/bindings/matrix-sdk-ffi/Cargo.toml @@ -110,6 +110,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } url.workspace = true uuid = { version = "1.4.1", default-features = false, features = ["std", "v4"] } zeroize.workspace = true +zxcvbn.workspace = true oauth2.workspace = true [target.'cfg(target_family = "wasm")'.dependencies] diff --git a/bindings/matrix-sdk-ffi/changelog.d/6708.feature.md b/bindings/matrix-sdk-ffi/changelog.d/6708.feature.md new file mode 100644 index 00000000000..ed29d124496 --- /dev/null +++ b/bindings/matrix-sdk-ffi/changelog.d/6708.feature.md @@ -0,0 +1 @@ +Add `PasswordStrengthEstimator` to the FFI layer, exposing password strength estimation via the zxcvbn algorithm with caller-configurable ranking thresholds. \ No newline at end of file diff --git a/bindings/matrix-sdk-ffi/src/lib.rs b/bindings/matrix-sdk-ffi/src/lib.rs index 22bf101a61a..146f13f5cf8 100644 --- a/bindings/matrix-sdk-ffi/src/lib.rs +++ b/bindings/matrix-sdk-ffi/src/lib.rs @@ -15,6 +15,7 @@ mod identity_status_change; mod live_locations_observer; mod notification; mod notification_settings; +mod password_strength; mod platform; mod qr_code; mod room; diff --git a/bindings/matrix-sdk-ffi/src/password_strength.rs b/bindings/matrix-sdk-ffi/src/password_strength.rs new file mode 100644 index 00000000000..8638e136aba --- /dev/null +++ b/bindings/matrix-sdk-ffi/src/password_strength.rs @@ -0,0 +1,416 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Password strength estimation is powered by zxcvbn, which evaluates passwords +// via pattern matching: dictionary words, keyboard walks, repeats, dates, l33t +// speak, etc. +// +// zxcvbn produces a numeric score (log₁₀ of estimated guesses needed to crack +// the password), accounting for both brute force and pattern-based attacks — +// whichever requires fewer guesses. We do not use zxcvbn's own ranking +// (Score::Zero–Four), because the library was written over a decade ago and its +// thresholds have not been updated to reflect modern hardware attack rates. +// Instead, the caller supplies PasswordStrengthThresholds, which define the +// minimum score required to achieve each ranking level. The final ranking is +// derived solely from that score against the thresholds, giving callers full +// control over what constitutes an acceptable password. + +use zxcvbn::feedback::{Suggestion as ZxcvbnSuggestion, Warning as ZxcvbnWarning}; + +/// A ranking representing the estimated strength of a password, ranging from +/// `VeryWeak` (easily guessable) to `VeryStrong` (highly resistant to attack). +#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PasswordStrengthRanking { + VeryWeak, + Weak, + Fair, + Strong, + VeryStrong, +} + +/// A warning explaining what is wrong with the password. +#[derive(uniffi::Enum)] +pub enum PasswordStrengthWarning { + StraightRowsOfKeysAreEasyToGuess, + ShortKeyboardPatternsAreEasyToGuess, + RepeatsLikeAaaAreEasyToGuess, + RepeatsLikeAbcAbcAreOnlySlightlyHarderToGuess, + ThisIsATop10Password, + ThisIsATop100Password, + ThisIsACommonPassword, + ThisIsSimilarToACommonlyUsedPassword, + SequencesLikeAbcAreEasyToGuess, + RecentYearsAreEasyToGuess, + AWordByItselfIsEasyToGuess, + DatesAreOftenEasyToGuess, + NamesAndSurnamesByThemselvesAreEasyToGuess, + CommonNamesAndSurnamesAreEasyToGuess, +} + +impl From for PasswordStrengthWarning { + fn from(warning: ZxcvbnWarning) -> Self { + match warning { + ZxcvbnWarning::StraightRowsOfKeysAreEasyToGuess => { + Self::StraightRowsOfKeysAreEasyToGuess + } + ZxcvbnWarning::ShortKeyboardPatternsAreEasyToGuess => { + Self::ShortKeyboardPatternsAreEasyToGuess + } + ZxcvbnWarning::RepeatsLikeAaaAreEasyToGuess => Self::RepeatsLikeAaaAreEasyToGuess, + ZxcvbnWarning::RepeatsLikeAbcAbcAreOnlySlightlyHarderToGuess => { + Self::RepeatsLikeAbcAbcAreOnlySlightlyHarderToGuess + } + ZxcvbnWarning::ThisIsATop10Password => Self::ThisIsATop10Password, + ZxcvbnWarning::ThisIsATop100Password => Self::ThisIsATop100Password, + ZxcvbnWarning::ThisIsACommonPassword => Self::ThisIsACommonPassword, + ZxcvbnWarning::ThisIsSimilarToACommonlyUsedPassword => { + Self::ThisIsSimilarToACommonlyUsedPassword + } + ZxcvbnWarning::SequencesLikeAbcAreEasyToGuess => Self::SequencesLikeAbcAreEasyToGuess, + ZxcvbnWarning::RecentYearsAreEasyToGuess => Self::RecentYearsAreEasyToGuess, + ZxcvbnWarning::AWordByItselfIsEasyToGuess => Self::AWordByItselfIsEasyToGuess, + ZxcvbnWarning::DatesAreOftenEasyToGuess => Self::DatesAreOftenEasyToGuess, + ZxcvbnWarning::NamesAndSurnamesByThemselvesAreEasyToGuess => { + Self::NamesAndSurnamesByThemselvesAreEasyToGuess + } + ZxcvbnWarning::CommonNamesAndSurnamesAreEasyToGuess => { + Self::CommonNamesAndSurnamesAreEasyToGuess + } + } + } +} + +/// A suggestion to help the user choose a stronger password. +#[derive(uniffi::Enum)] +pub enum PasswordStrengthSuggestion { + UseAFewWordsAvoidCommonPhrases, + NoNeedForSymbolsDigitsOrUppercaseLetters, + AddAnotherWordOrTwo, + CapitalizationDoesntHelpVeryMuch, + AllUppercaseIsAlmostAsEasyToGuessAsAllLowercase, + ReversedWordsArentMuchHarderToGuess, + PredictableSubstitutionsDontHelpVeryMuch, + UseALongerKeyboardPatternWithMoreTurns, + AvoidRepeatedWordsAndCharacters, + AvoidSequences, + AvoidRecentYears, + AvoidYearsThatAreAssociatedWithYou, + AvoidDatesAndYearsThatAreAssociatedWithYou, +} + +impl From for PasswordStrengthSuggestion { + fn from(suggestion: ZxcvbnSuggestion) -> Self { + match suggestion { + ZxcvbnSuggestion::UseAFewWordsAvoidCommonPhrases => { + Self::UseAFewWordsAvoidCommonPhrases + } + ZxcvbnSuggestion::NoNeedForSymbolsDigitsOrUppercaseLetters => { + Self::NoNeedForSymbolsDigitsOrUppercaseLetters + } + ZxcvbnSuggestion::AddAnotherWordOrTwo => Self::AddAnotherWordOrTwo, + ZxcvbnSuggestion::CapitalizationDoesntHelpVeryMuch => { + Self::CapitalizationDoesntHelpVeryMuch + } + ZxcvbnSuggestion::AllUppercaseIsAlmostAsEasyToGuessAsAllLowercase => { + Self::AllUppercaseIsAlmostAsEasyToGuessAsAllLowercase + } + ZxcvbnSuggestion::ReversedWordsArentMuchHarderToGuess => { + Self::ReversedWordsArentMuchHarderToGuess + } + ZxcvbnSuggestion::PredictableSubstitutionsDontHelpVeryMuch => { + Self::PredictableSubstitutionsDontHelpVeryMuch + } + ZxcvbnSuggestion::UseALongerKeyboardPatternWithMoreTurns => { + Self::UseALongerKeyboardPatternWithMoreTurns + } + ZxcvbnSuggestion::AvoidRepeatedWordsAndCharacters => { + Self::AvoidRepeatedWordsAndCharacters + } + ZxcvbnSuggestion::AvoidSequences => Self::AvoidSequences, + ZxcvbnSuggestion::AvoidRecentYears => Self::AvoidRecentYears, + ZxcvbnSuggestion::AvoidYearsThatAreAssociatedWithYou => { + Self::AvoidYearsThatAreAssociatedWithYou + } + ZxcvbnSuggestion::AvoidDatesAndYearsThatAreAssociatedWithYou => { + Self::AvoidDatesAndYearsThatAreAssociatedWithYou + } + } + } +} + +/// Verbal feedback to help the user choose a stronger password. +#[derive(uniffi::Record)] +pub struct PasswordStrengthFeedback { + /// An optional warning explaining what is wrong with the password. + pub warning: Option, + /// A possibly-empty list of actionable suggestions. + pub suggestions: Vec, +} + +/// The full result of a password strength estimation. +#[derive(uniffi::Record)] +pub struct PasswordStrengthEstimate { + /// Overall strength ranking from VeryWeak to VeryStrong. + pub ranking: PasswordStrengthRanking, + /// Estimated number of guesses needed to crack the password. + pub guesses: u64, + /// A numeric score derived from the order of magnitude of `guesses` + /// (i.e. log base 10). + pub score: f64, + /// Verbal feedback to help choose a better password. Only set when the + /// ranking is Fair or below. + pub feedback: Option, +} + +/// Minimum `score` (log₁₀ of estimated guesses) required to achieve each +/// ranking level. Any score below `weak` is ranked `VeryWeak`. +#[derive(uniffi::Record)] +pub struct PasswordStrengthThresholds { + /// Minimum score to achieve `Weak`. + pub weak: f64, + /// Minimum score to achieve `Fair`. + pub fair: f64, + /// Minimum score to achieve `Strong`. + pub strong: f64, + /// Minimum score to achieve `VeryStrong`. + pub very_strong: f64, +} + +impl PasswordStrengthThresholds { + fn ranking_for_score(&self, score: f64) -> PasswordStrengthRanking { + if score >= self.very_strong { + PasswordStrengthRanking::VeryStrong + } else if score >= self.strong { + PasswordStrengthRanking::Strong + } else if score >= self.fair { + PasswordStrengthRanking::Fair + } else if score >= self.weak { + PasswordStrengthRanking::Weak + } else { + PasswordStrengthRanking::VeryWeak + } + } +} + +/// Estimates password strength using caller-supplied thresholds. +/// +/// Construct once with your desired thresholds, then call `estimate` for each +/// password without having to re-supply the thresholds every time. +#[derive(uniffi::Object)] +pub struct PasswordStrengthEstimator { + thresholds: PasswordStrengthThresholds, +} + +#[matrix_sdk_ffi_macros::export] +impl PasswordStrengthEstimator { + #[uniffi::constructor] + pub fn new(thresholds: PasswordStrengthThresholds) -> Self { + Self { thresholds } + } + + /// Creates an estimator using zxcvbn's original thresholds. + #[uniffi::constructor] + pub fn with_zxcvbn_defaults() -> Self { + Self { + thresholds: PasswordStrengthThresholds { + weak: 3.0, // 10^3 + fair: 6.0, // 10^6 + strong: 8.0, // 10^8 + very_strong: 10.0, // 10^10 + }, + } + } + + /// Creates an estimator using thresholds tuned for modern hardware (2025). + /// Values derived from determining entropy from the chart at https://www.hivesystems.com/blog/are-your-passwords-in-the-green + #[uniffi::constructor] + pub fn with_modern_defaults2025() -> Self { + Self { + thresholds: PasswordStrengthThresholds { + weak: 11.0, + fair: 16.5, + strong: 22.0, + very_strong: 25.5, + }, + } + } + + /// Estimates the strength of `password`. + /// + /// Optionally, pass a list of `user_inputs` (e.g. username, email address) + /// so that the estimator can penalize passwords that contain personal + /// information. + /// + /// The returned ranking is derived from the configured thresholds applied + /// to the estimated guess count, which already accounts for pattern-based + /// attacks. + pub fn estimate(&self, password: String, user_inputs: Vec) -> PasswordStrengthEstimate { + let inputs: Vec<&str> = user_inputs.iter().map(String::as_str).collect(); + let entropy = zxcvbn::zxcvbn(&password, &inputs); + + let feedback = entropy.feedback().map(|f| PasswordStrengthFeedback { + warning: f.warning().map(PasswordStrengthWarning::from), + suggestions: f + .suggestions() + .iter() + .copied() + .map(PasswordStrengthSuggestion::from) + .collect(), + }); + + let ranking = self.thresholds.ranking_for_score(entropy.guesses_log10()); + + PasswordStrengthEstimate { + ranking, + guesses: entropy.guesses(), + score: entropy.guesses_log10(), + feedback, + } + } +} + +#[cfg(test)] +// These tests are to cover our barrier — threshold logic and data passthrough — +// not zxcvbn's internals. We verify that our ranking derivation is correct, +// that zxcvbn output (score, feedback, user input penalties) is correctly +// forwarded, and that threshold configuration produces the expected ranking +// behavior. We do not test zxcvbn's pattern detection or scoring logic. +mod tests { + use super::*; + + // Known-output tests: confirm specific passwords produce expected rankings + // using zxcvbn default thresholds. + #[test] + fn test_same_leniency_as_zxcvbn() { + let estimator = PasswordStrengthEstimator::with_zxcvbn_defaults(); + + let cases: &[(&str, PasswordStrengthRanking)] = &[ + ("password", PasswordStrengthRanking::VeryWeak), + ("123456", PasswordStrengthRanking::VeryWeak), + ("15", PasswordStrengthRanking::VeryWeak), + ("154", PasswordStrengthRanking::VeryWeak), + ("hunter2", PasswordStrengthRanking::Weak), + ("qwerty2025", PasswordStrengthRanking::Weak), + ("foo bar", PasswordStrengthRanking::Fair), + ("March212024!", PasswordStrengthRanking::Strong), + ("Tr0ub4dor&3", PasswordStrengthRanking::VeryStrong), + ("correct horse battery staple", PasswordStrengthRanking::VeryStrong), + ("correcthorsebatterystaple!extra", PasswordStrengthRanking::VeryStrong), + ("xK#9mP$2nL@7qR!4vZ^6wT&5yU*8sA", PasswordStrengthRanking::VeryStrong), + ]; + + for (pw, expected_ranking) in cases { + let result = estimator.estimate((*pw).to_owned(), vec![]); + assert_eq!(result.ranking, *expected_ranking, "unexpected ranking for {:?}", pw); + } + } + + // More lenient thresholds — passwords rank higher than zxcvbn would. + #[test] + fn test_more_lenient_thresholds() { + let lenient_estimator = PasswordStrengthEstimator::new(PasswordStrengthThresholds { + weak: 1.0, + fair: 2.0, + strong: 3.0, + very_strong: 4.0, + }); + + let cases: &[(&str, PasswordStrengthRanking)] = &[ + ("password", PasswordStrengthRanking::VeryWeak), + ("123456", PasswordStrengthRanking::VeryWeak), + ("15", PasswordStrengthRanking::Weak), + ("154", PasswordStrengthRanking::Fair), + ("hunter2", PasswordStrengthRanking::Strong), + ("qwerty2025", PasswordStrengthRanking::VeryStrong), + ("foo bar", PasswordStrengthRanking::VeryStrong), + ("March212024!", PasswordStrengthRanking::VeryStrong), + ("Tr0ub4dor&3", PasswordStrengthRanking::VeryStrong), + ("correct horse battery staple", PasswordStrengthRanking::VeryStrong), + ("correcthorsebatterystaple!extra", PasswordStrengthRanking::VeryStrong), + ("xK#9mP$2nL@7qR!4vZ^6wT&5yU*8sA", PasswordStrengthRanking::VeryStrong), + ]; + + for (pw, expected_ranking) in cases { + let result = lenient_estimator.estimate((*pw).to_owned(), vec![]); + assert_eq!(result.ranking, *expected_ranking, "unexpected ranking for {:?}", pw); + } + } + + // Stricter thresholds — passwords rank lower than zxcvbn would. + #[test] + fn test_stricter_thresholds() { + let strict_estimator = PasswordStrengthEstimator::new(PasswordStrengthThresholds { + weak: 6.0, + fair: 10.0, + strong: 17.0, + very_strong: 20.0, + }); + + let cases: &[(&str, PasswordStrengthRanking)] = &[ + ("password", PasswordStrengthRanking::VeryWeak), + ("123456", PasswordStrengthRanking::VeryWeak), + ("15", PasswordStrengthRanking::VeryWeak), + ("154", PasswordStrengthRanking::VeryWeak), + ("hunter2", PasswordStrengthRanking::VeryWeak), + ("qwerty2025", PasswordStrengthRanking::VeryWeak), + ("foo bar", PasswordStrengthRanking::Weak), + ("March212024!", PasswordStrengthRanking::Weak), + ("Tr0ub4dor&3", PasswordStrengthRanking::Fair), + ("correct horse battery staple", PasswordStrengthRanking::VeryStrong), + ("correcthorsebatterystaple!extra", PasswordStrengthRanking::VeryStrong), + ("xK#9mP$2nL@7qR!4vZ^6wT&5yU*8sA", PasswordStrengthRanking::VeryStrong), + ]; + + for (pw, expected_ranking) in cases { + let result = strict_estimator.estimate((*pw).to_owned(), vec![]); + assert_eq!(result.ranking, *expected_ranking, "unexpected ranking for {:?}", pw); + println!("{pw}, {0}", result.score); + } + } + + #[test] + fn test_user_inputs_lower_score() { + let estimator = PasswordStrengthEstimator::with_zxcvbn_defaults(); + let password = "michael1985".to_owned(); + + let without_inputs = estimator.estimate(password.clone(), vec![]); + let with_inputs = + estimator.estimate(password.clone(), vec!["michael".to_owned(), "1985".to_owned()]); + let with_nonmatching_inputs = + estimator.estimate(password, vec!["foo".to_owned(), "blar".to_owned()]); + + assert!( + with_inputs.score <= without_inputs.score, + "score should be lower or equal when matching user inputs are provided" + ); + + assert!( + with_inputs.score == with_nonmatching_inputs.score, + "score should be equal when no matching user inputs are provided" + ); + } + + #[test] + fn test_feedback_present_for_weak_passwords() { + let estimator = PasswordStrengthEstimator::with_zxcvbn_defaults(); + + let weak = estimator.estimate("password".to_owned(), vec![]); + let weak_feedback = weak.feedback.as_ref().expect("expected feedback for a weak password"); + assert!(weak_feedback.warning.is_some(), "expected a warning for a weak password"); + assert!(!weak_feedback.suggestions.is_empty(), "expected suggestions for a weak password"); + + let strong = estimator.estimate("correct horse battery staple".to_owned(), vec![]); + assert!(strong.feedback.is_none(), "expected no feedback for a strong password"); + } +}