package com.peachpasswords.ui.security /** * Dependency-free password strength scoring (0..4), modelled on zxcvbn's * *intent* (entropy from length + character variety, minus penalties for * predictable structure) without bundling the dictionary-heavy library. * * This is a UI affordance and a creation gate — NOT a cryptographic strength * guarantee. The real defence is Argon2id KDF at the vault layer. The score * exists to steer users away from trivially guessable master passwords. * * Scoring model * ------------- * We estimate a rough bit-entropy as `log2(poolSize) * length`, where `poolSize` * is the count of character classes present (lower/upper/digit/symbol) mapped to * a conventional alphabet size, then bucket entropy into 0..4. Penalties knock * the score down for the patterns zxcvbn weights most heavily: * - exact match against a small common-password list → forced to 0 * - the password is a single repeated character → forced to 0 * - a long straight run (sequence or repeat) dominates → −1 bucket * The buckets are calibrated so the prior heuristic's behaviour is preserved: * mixed-class passwords of length >= 12 still reach "good" (3), and adding a * 4th class / more length reaches "strong" (4). This keeps the existing * ExportScreen gate (score >= 3) behaving as before. */ private val COMMON_PASSWORDS: Set = setOf( "password", "passw0rd", "password1", "password123", "123456", "1234567", "12345678", "123456789", "1234567890", "qwerty", "qwertyuiop", "abc123", "111111", "000000", "iloveyou", "admin", "letmein", "welcome", "monkey", "dragon", "sunshine", "princess", "football", "baseball", "master", "superman", "trustno1", "whatever", "starwars", "hello", "freedom", "qazwsx", "michael", "shadow", "ashley", "qwerty123", "1q2w3e4r", "zaq12wsx", "changeme", "peach", "peachpasswords", ) // Conventional alphabet sizes per character class, used for the entropy estimate. private const val POOL_LOWER = 26 private const val POOL_UPPER = 26 private const val POOL_DIGIT = 10 private const val POOL_SYMBOL = 33 // printable ASCII punctuation, approx. /** * Score [pw] from 0 (very weak) to 4 (strong). Empty input scores 0. * * The returned value pairs with [strengthLabel] for display. Callers gate on * `>= 2` (creation, "fair") or `>= 3` (export backups, "good"). */ fun scorePassword(pw: String): Int { if (pw.isEmpty()) return 0 val normalized = pw.lowercase() if (normalized in COMMON_PASSWORDS) return 0 // A single repeated character ("aaaaaaaa") carries almost no entropy. if (pw.toSet().size == 1) return 0 var pool = 0 if (pw.any { it in 'a'..'z' }) pool += POOL_LOWER if (pw.any { it in 'A'..'Z' }) pool += POOL_UPPER if (pw.any { it.isDigit() }) pool += POOL_DIGIT if (pw.any { !it.isLetterOrDigit() }) pool += POOL_SYMBOL if (pool == 0) pool = POOL_LOWER // pathological (e.g. only whitespace) val bitsPerChar = log2(pool.toDouble()) var entropy = bitsPerChar * pw.length // Predictable runs (straight sequences or repeats) cost a bucket. A run is a // maximal stretch where each char is equal to, or one step from, the prior. if (longestRun(pw) >= 4) entropy -= bitsPerChar * 4 return bucket(entropy) } /** Display label for a 0..4 [score]; values outside the range clamp to the ends. */ fun strengthLabel(score: Int): String = when (score.coerceIn(0, 4)) { 0 -> "very weak" 1 -> "weak" 2 -> "fair" 3 -> "good" else -> "strong" } // Entropy thresholds (bits) for each bucket boundary. Tuned so: // "Tr0ub4dor&3"-class (mixed, ~11+ chars) → 3, longer/4-class → 4, // short single-class → 0/1. private fun bucket(entropy: Double): Int = when { entropy < 28.0 -> 0 entropy < 40.0 -> 1 entropy < 56.0 -> 2 entropy < 72.0 -> 3 else -> 4 } /** Length of the longest ascending/descending/repeating run of adjacent chars. */ private fun longestRun(pw: String): Int { if (pw.length < 2) return pw.length var longest = 1 var current = 1 for (i in 1 until pw.length) { val delta = pw[i].code - pw[i - 1].code if (delta == 0 || delta == 1 || delta == -1) { current++ if (current > longest) longest = current } else { current = 1 } } return longest } private fun log2(x: Double): Double = Math.log(x) / Math.log(2.0)