Skip to content

Password Strength Analyser — Rust source

Estimate password strength with zxcvbn - realistic dictionary and pattern cracking with crack-time estimates and improvement suggestions. Runs entirely in your browser.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! password-strength-analyser — password strength scoring & feedback.
//!
//! Language: Rust (edition 2021, standard library only)
//! Source:   CosmoDev polyglot showcase port of the Password Strength
//!           Analyser tool, ported from cli/password-strength-analyser/
//!           password-strength-analyser.go (the live Go twin, which wraps
//!           zxcvbn) and src/lib/password-strength.ts (the canonical TS lib).
//! License:  display source — part of CosmoDev's polyglot tool pages.
//!
//! Design goals:
//!   - Pure + deterministic; never panics (public API returns values, no Result).
//!   - Self-contained: std only (no crates.io dependencies — no zxcvbn port).
//!   - Mirrors the Go twin's public API and result shape: `analyse` returns a
//!     `score` (0–4), a human `label`, the entropy in bits, and a single
//!     crack-time display string.
//!
//! Parity note: the live Go and TS twins both delegate scoring to `zxcvbn`, a
//! dictionary + pattern estimator whose ranking data is far too large to vendor
//! in a stdlib display port. This snippet reproduces the SAME 0–4 scale and the
//! SAME public contract with a self-contained heuristic estimator:
//!   (a) a curated common-password blocklist — forces the obvious weak
//!       passwords (password, 123456, qwerty, ...) to score 0, exactly as
//!       zxcvbn's dictionary does;
//!   (b) a character-pool entropy estimate (length × log2(pool));
//!   (c) penalties for low symbol variety, keyboard / numeric sequences, and
//!       user-supplied related tokens (the `userInputs` seed zxcvbn accepts).
//! It agrees with zxcvbn on the clear cases — common passwords score 0, long
//! diverse passphrases score 3–4 — and is a reasonable approximation in between.
//! This is a deliberate, documented trade-off to keep the port dependency-free;
//! for exact scores use the live Go/TS twin.

use std::collections::HashSet;
use std::f64::consts::LOG10_2;

/// Strength breakdown. Mirrors the Go twin's `pwstrength.Result` and the TS
/// `PasswordAnalysis` (the four universally-available fields).
pub struct Analysis {
    /// 0 (worst) … 4 (best), identical scale to zxcvbn.
    pub score: u8,
    /// Human-readable strength label.
    pub label: &'static str,
    /// Estimated guess entropy, in bits.
    pub entropy: f64,
    /// Single human-readable crack-time string (offline-fast model).
    pub crack_time_display: String,
}

/// Strength labels indexed by score. Identical to the Go/TS twins.
const LABELS: [&str; 5] = ["Very weak", "Weak", "Fair", "Good", "Strong"];

/// Curated common passwords (lowercased). An exact match forces score 0 — the
/// observable effect of zxcvbn's dictionary for the most leaked passwords. A
/// small, hand-picked set rather than zxcvbn's ~30k-entry list: enough to make
/// the showcase behaviour meaningful without vendoring a data file.
const COMMON: &[&str] = &[
    "1234", "12345", "123123", "123456", "12345678", "123456789", "1234567890",
    "000000", "111111", "654321", "666666", "abc123", "admin", "baseball",
    "batman", "dragon", "football", "iloveyou", "letmein", "login", "master",
    "monkey", "passw0rd", "password", "password1", "princess", "qwerty", "root",
    "shadow", "superman", "sunshine", "trustno1", "welcome",
];

/// Keyboard rows and numeric / alphabetic runs. Any length-4 window of these
/// appearing inside the password signals an easy-to-guess sequence and incurs
/// an entropy penalty.
const SEQUENCES: &[&str] = &[
    "qwertyuiop", "asdfghjkl", "zxcvbnm", "1234567890", "0987654321", "abcdefg",
    "gfedcba",
];

/// Min length for a sequence window to count as a match (avoids trivial
/// 2–3 char coincidences inside long passphrases).
const SEQ_MIN_LEN: usize = 4;

/// Entropy thresholds mapping a bit count to the 0–4 score. Shared across the
/// whole polyglot showcase so every port lands on the same bucket.
const THRESHOLDS: [f64; 4] = [28.0, 36.0, 60.0, 128.0];

/// Tally the distinct character-class buckets present and return the combined
/// guess-pool size. Buckets mirror what a scorer can observe cheaply: lower,
/// upper, digit, ASCII punctuation/space (33 symbols), and a generous "other"
/// bucket for non-ASCII code points.
fn pool_size(password: &str) -> usize {
    let (mut lower, mut upper, mut digit, mut sym, mut other) = (false, false, false, false, false);
    for c in password.chars() {
        if c.is_ascii_lowercase() {
            lower = true;
        } else if c.is_ascii_uppercase() {
            upper = true;
        } else if c.is_ascii_digit() {
            digit = true;
        } else if c.is_ascii() {
            sym = true; // ASCII punctuation or whitespace (not alphanumeric)
        } else {
            other = true; // any non-ASCII code point
        }
    }
    let mut pool = 0;
    if lower { pool += 26; }
    if upper { pool += 26; }
    if digit { pool += 10; }
    if sym { pool += 33; } // printable ASCII non-alnum: 32 punctuation + space
    if other { pool += 128; } // broad bucket for the rest of Unicode
    pool
}

/// Reports whether `lower` contains any length-`min` window drawn from `seq` —
/// i.e. a keyboard/numeric run of at least `min` consecutive symbols.
fn contains_sequence(lower: &str, seq: &str, min: usize) -> bool {
    let chars: Vec<char> = seq.chars().collect();
    if chars.len() < min {
        return false;
    }
    chars.windows(min).any(|w| {
        let win: String = w.iter().collect();
        lower.contains(win.as_str())
    })
}

/// Human-readable crack-time for an offline fast attack (1e10 guesses/sec),
/// mirroring zxcvbn's `offline_fast_hashing_1e10_per_second` display. Works in
/// log-space so large entropies (2^200 ≈ 1e60 guesses) stay inside f64 range.
fn crack_time_display(entropy: f64) -> String {
    if entropy <= 0.0 {
        return String::from("instant");
    }
    let log10_seconds = entropy * LOG10_2 - 10.0; // log10(guesses) − log10(1e10)
    if log10_seconds < 0.0 {
        return String::from("instant");
    }
    let seconds = 10f64.powf(log10_seconds);
    if seconds < 1.0 {
        return String::from("instant");
    }
    const MINUTE: f64 = 60.0;
    const HOUR: f64 = 3_600.0;
    const DAY: f64 = 86_400.0;
    const MONTH: f64 = 2_592_000.0; // 30 days
    const YEAR: f64 = 31_536_000.0; // 365 days
    if seconds < MINUTE {
        return format!("{} seconds", seconds.round() as u64);
    }
    if seconds < HOUR {
        return format!("{} minutes", (seconds / MINUTE).round() as u64);
    }
    if seconds < DAY {
        return format!("{} hours", (seconds / HOUR).round() as u64);
    }
    if seconds < MONTH {
        return format!("{} days", (seconds / DAY).round() as u64);
    }
    if seconds < YEAR {
        return format!("{} months", (seconds / MONTH).round() as u64);
    }
    let years = seconds / YEAR;
    if years < 1_000.0 {
        return format!("{} years", years.round() as u64);
    }
    if years < 1_000_000.0 {
        return format!("{} thousand years", (years / 1_000.0).round() as u64);
    }
    if years < 1_000_000_000.0 {
        return format!("{} million years", (years / 1_000_000.0).round() as u64);
    }
    String::from("centuries")
}

/// Score a password's strength. `user_inputs` seeds the estimator with related
/// tokens (username, site name, ...) — a substring match weakens the score,
/// mirroring the `userInputs` parameter of the Go/TS twins.
pub fn analyse(password: &str, user_inputs: &[&str]) -> Analysis {
    // Empty / whitespace-only → trivially weak.
    if password.is_empty() {
        return Analysis { score: 0, label: LABELS[0], entropy: 0.0, crack_time_display: String::from("instant") };
    }

    let len = password.chars().count();
    let pool = pool_size(password);
    let log_pool = if pool > 1 { (pool as f64).log2() } else { 0.0 };

    // Base entropy: length × log2(pool), capped by symbol variety so heavy
    // repetition ("aaaaaaaa") collapses instead of accruing length credit.
    let raw = len as f64 * log_pool;
    let unique = password.chars().collect::<HashSet<char>>().len();
    let variety = if unique < len && pool > 1 {
        // Repeats earn only a quarter credit: U full picks + 0.25× the repeats.
        (unique as f64 + (len - unique) as f64 * 0.25) * log_pool
    } else {
        raw
    };
    let mut entropy = if pool > 1 { raw.min(variety) } else { 0.0 };

    // Lowercased view for dictionary / sequence / user-input matching.
    let lower = password.to_lowercase();

    // (a) common-password blocklist forces score 0 and zero entropy, the way
    //     zxcvbn's dictionary collapses a known leak.
    let common = COMMON.iter().any(|c| *c == lower);
    if common {
        entropy = 0.0;
    } else {
        // (b) keyboard / numeric sequence penalty.
        for seq in SEQUENCES {
            if contains_sequence(&lower, seq, SEQ_MIN_LEN) {
                entropy -= 12.0;
            }
        }
        // (c) user-input relatedness penalty.
        for ui in user_inputs {
            let l = ui.to_lowercase();
            if l.chars().count() >= 3 && lower.contains(l.as_str()) {
                entropy -= 10.0;
            }
        }
        if entropy < 0.0 {
            entropy = 0.0;
        }
    }

    // Map entropy (and a minimum-length floor) to the 0–4 bucket.
    let score: u8 = if len < 4 || entropy < THRESHOLDS[0] {
        0
    } else if entropy < THRESHOLDS[1] {
        1
    } else if entropy < THRESHOLDS[2] {
        2
    } else if entropy < THRESHOLDS[3] {
        3
    } else {
        4
    };

    Analysis {
        score,
        label: LABELS[score as usize],
        entropy,
        crack_time_display: crack_time_display(entropy),
    }
}

/// Convenience wrapper with no user inputs — the common "just score this" case.
pub fn analyse_default(password: &str) -> Analysis {
    analyse(password, &[])
}

// ---------- tests (showcase-only; the canonical suite lives in src/lib) ----------
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn common_password_is_very_weak() {
        let r = analyse_default("password");
        assert_eq!(r.score, 0);
        assert_eq!(r.label, "Very weak");
    }

    #[test]
    fn strong_passphrase_scores_high() {
        let r = analyse_default("correct horse battery staple");
        assert!(r.score >= 3, "expected score >= 3, got {}", r.score);
    }

    #[test]
    fn weak_scores_no_higher_than_strong() {
        let weak = analyse_default("123456");
        let strong = analyse_default("correct horse battery staple");
        assert!(weak.score <= strong.score);
    }

    #[test]
    fn entropy_grows_with_diversity() {
        let repeated = analyse_default("aaaaaaaa");
        let diverse = analyse_default("aB3$xK9p");
        assert!(diverse.entropy > repeated.entropy);
    }

    #[test]
    fn score_and_label_always_in_range() {
        for pw in ["password", "12345678", "monkey", "Tr0ub4dour&3", "correct horse battery staple", "u2#9Xq!Lp$7wZ"] {
            let r = analyse_default(pw);
            assert!(r.score <= 4, "{pw} scored {}", r.score);
            assert!(!r.label.is_empty());
            assert!(!r.crack_time_display.is_empty());
        }
    }

    #[test]
    fn user_inputs_can_only_weaken() {
        let without = analyse("cosmolabs2024", &[]);
        let with_input = analyse("cosmolabs2024", &["cosmolabs"]);
        assert!(with_input.score <= without.score);
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →