PasswordStrength.kt

4.4 KB · raw

1package com.peachpasswords.ui.security23/**4 * Dependency-free password strength scoring (0..4), modelled on zxcvbn's5 * *intent* (entropy from length + character variety, minus penalties for6 * predictable structure) without bundling the dictionary-heavy library.7 *8 * This is a UI affordance and a creation gate — NOT a cryptographic strength9 * guarantee. The real defence is Argon2id KDF at the vault layer. The score10 * exists to steer users away from trivially guessable master passwords.11 *12 * Scoring model13 * -------------14 * We estimate a rough bit-entropy as `log2(poolSize) * length`, where `poolSize`15 * is the count of character classes present (lower/upper/digit/symbol) mapped to16 * a conventional alphabet size, then bucket entropy into 0..4. Penalties knock17 * the score down for the patterns zxcvbn weights most heavily:18 *   - exact match against a small common-password list  → forced to 019 *   - the password is a single repeated character        → forced to 020 *   - a long straight run (sequence or repeat) dominates → −1 bucket21 * The buckets are calibrated so the prior heuristic's behaviour is preserved:22 * mixed-class passwords of length >= 12 still reach "good" (3), and adding a23 * 4th class / more length reaches "strong" (4). This keeps the existing24 * ExportScreen gate (score >= 3) behaving as before.25 */2627private val COMMON_PASSWORDS: Set<String> = setOf(28    "password", "passw0rd", "password1", "password123", "123456", "1234567",29    "12345678", "123456789", "1234567890", "qwerty", "qwertyuiop", "abc123",30    "111111", "000000", "iloveyou", "admin", "letmein", "welcome", "monkey",31    "dragon", "sunshine", "princess", "football", "baseball", "master",32    "superman", "trustno1", "whatever", "starwars", "hello", "freedom",33    "qazwsx", "michael", "shadow", "ashley", "qwerty123", "1q2w3e4r",34    "zaq12wsx", "changeme", "peach", "peachpasswords",35)3637// Conventional alphabet sizes per character class, used for the entropy estimate.38private const val POOL_LOWER = 2639private const val POOL_UPPER = 2640private const val POOL_DIGIT = 1041private const val POOL_SYMBOL = 33 // printable ASCII punctuation, approx.4243/**44 * Score [pw] from 0 (very weak) to 4 (strong). Empty input scores 0.45 *46 * The returned value pairs with [strengthLabel] for display. Callers gate on47 * `>= 2` (creation, "fair") or `>= 3` (export backups, "good").48 */49fun scorePassword(pw: String): Int {50    if (pw.isEmpty()) return 05152    val normalized = pw.lowercase()53    if (normalized in COMMON_PASSWORDS) return 054    // A single repeated character ("aaaaaaaa") carries almost no entropy.55    if (pw.toSet().size == 1) return 05657    var pool = 058    if (pw.any { it in 'a'..'z' }) pool += POOL_LOWER59    if (pw.any { it in 'A'..'Z' }) pool += POOL_UPPER60    if (pw.any { it.isDigit() }) pool += POOL_DIGIT61    if (pw.any { !it.isLetterOrDigit() }) pool += POOL_SYMBOL62    if (pool == 0) pool = POOL_LOWER // pathological (e.g. only whitespace)6364    val bitsPerChar = log2(pool.toDouble())65    var entropy = bitsPerChar * pw.length6667    // Predictable runs (straight sequences or repeats) cost a bucket. A run is a68    // maximal stretch where each char is equal to, or one step from, the prior.69    if (longestRun(pw) >= 4) entropy -= bitsPerChar * 47071    return bucket(entropy)72}7374/** Display label for a 0..4 [score]; values outside the range clamp to the ends. */75fun strengthLabel(score: Int): String = when (score.coerceIn(0, 4)) {76    0 -> "very weak"77    1 -> "weak"78    2 -> "fair"79    3 -> "good"80    else -> "strong"81}8283// Entropy thresholds (bits) for each bucket boundary. Tuned so:84//   "Tr0ub4dor&3"-class (mixed, ~11+ chars) → 3, longer/4-class → 4,85//   short single-class → 0/1.86private fun bucket(entropy: Double): Int = when {87    entropy < 28.0 -> 088    entropy < 40.0 -> 189    entropy < 56.0 -> 290    entropy < 72.0 -> 391    else -> 492}9394/** Length of the longest ascending/descending/repeating run of adjacent chars. */95private fun longestRun(pw: String): Int {96    if (pw.length < 2) return pw.length97    var longest = 198    var current = 199    for (i in 1 until pw.length) {100        val delta = pw[i].code - pw[i - 1].code101        if (delta == 0 || delta == 1 || delta == -1) {102            current++103            if (current > longest) longest = current104        } else {105            current = 1106        }107    }108    return longest109}110111private fun log2(x: Double): Double = Math.log(x) / Math.log(2.0)