PasswordStrength.kt
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)