package com.peachpasswords.vault import com.peachpasswords.shared.extractDomain import java.security.MessageDigest import kotlin.math.roundToInt object VaultHealth { private val SENSITIVE_PATTERN = Regex( "bank|pay|finance|invest|mail|gmail|outlook|yahoo|icloud|instagram|twitter|facebook|linkedin", RegexOption.IGNORE_CASE, ) private const val SENSITIVE_AGE_MS = 90L * 24 * 60 * 60 * 1000 private const val STANDARD_AGE_MS = 180L * 24 * 60 * 60 * 1000 data class EntryHealthScore( val entryId: String, val score: Int, val isBreach: Boolean, val isReused: Boolean, val isAged: Boolean, val isWeak: Boolean, val missingTotp: Boolean, val isSensitive: Boolean, ) data class VaultHealthReport( val vaultScore: Int, val entries: List, val breachCount: Int, val reusedCount: Int, val agedCount: Int, val weakCount: Int, ) fun buildReport(entries: List): VaultHealthReport { val loginEntries = entries.filter { it.isLogin() && !it.isDeleted() } val passwordHashes = loginEntries.mapNotNull { entry -> entry.password?.let { pw -> sha256Hex(pw) to entry.id } } val hashCounts = passwordHashes.groupBy { it.first }.mapValues { it.value.size } val now = System.currentTimeMillis() val scores = loginEntries.map { entry -> val isSensitive = entry.url?.let { domain -> val extracted = extractDomain(domain) (extracted ?: domain).let { SENSITIVE_PATTERN.containsMatchIn(it) } } ?: false val ageThreshold = if (isSensitive) SENSITIVE_AGE_MS else STANDARD_AGE_MS val isBreach = (entry.breach ?: 0) > 0 val isReused = entry.password?.let { pw -> (hashCounts[sha256Hex(pw)] ?: 0) > 1 } ?: false val isAged = entry.passwordChangedAt?.let { (now - it) > ageThreshold } ?: false val isWeak = entry.password?.let { pw -> pw.length < 10 || countCharClasses(pw) < 3 } ?: false val missingTotp = isSensitive && entry.totp.isNullOrBlank() var score = 0 if (isBreach) score += 60 if (isReused) score += 25 if (isAged) score += 15 if (isWeak) score += 20 if (missingTotp) score += 10 score = minOf(score, 100) EntryHealthScore( entryId = entry.id, score = score, isBreach = isBreach, isReused = isReused, isAged = isAged, isWeak = isWeak, missingTotp = missingTotp, isSensitive = isSensitive, ) } val totalWeight = scores.sumOf { if (it.isSensitive) 2L else 1L }.toFloat() val weightedSum = scores.sumOf { (it.score * (if (it.isSensitive) 2 else 1)).toLong() }.toFloat() val vaultScore = if (totalWeight > 0f) (weightedSum / totalWeight).toInt() else 0 return VaultHealthReport( vaultScore = vaultScore, entries = scores, breachCount = scores.count { it.isBreach }, reusedCount = scores.count { it.isReused }, agedCount = scores.count { it.isAged }, weakCount = scores.count { it.isWeak }, ) } private fun countCharClasses(password: String): Int { var classes = 0 if (password.any(Char::isLowerCase)) classes++ if (password.any(Char::isUpperCase)) classes++ if (password.any(Char::isDigit)) classes++ if (password.any { !it.isLetterOrDigit() }) classes++ return classes } private fun sha256Hex(input: String): String { val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(input.toByteArray(Charsets.UTF_8)) return hash.joinToString("") { "%02x".format(it) } } // ---- Advanced report (Pro, parity V1) --------------------------------- // Ports peach-browser background/health.ts buildAdvancedHealthReport. Shares // the scoring engine above (same pattern/thresholds/weights via the private // helpers); adds per-entry reasons + factors, age in days, a ranked // remediation queue, and a reused-group count for the Pro health surface. // ponytail: buildReport and buildAdvancedReport duplicate the weight loop; // fold into one shared per-entry scorer if a third consumer appears. data class AdvancedFactors( val breached: Boolean, val breachCount: Int, val reused: Boolean, val aged: Boolean, val weak: Boolean, val missingTotpOnSensitive: Boolean, ) data class AdvancedEntryReport( val id: String, val title: String, val domain: String, val url: String?, val score: Int, val ageDays: Long, val reasons: List, val factors: AdvancedFactors, ) data class AdvancedHealthReport( val vaultScore: Int, val generatedAt: Long, val entryCount: Int, val reusedGroups: Int, val agedCount: Int, val remediation: List, ) fun buildAdvancedReport( entries: List, now: Long = System.currentTimeMillis(), ): AdvancedHealthReport { val live = entries.filter { it.isLogin() && !it.isDeleted() } val hashGroups = HashMap>() for (entry in live) { val pw = entry.password ?: continue hashGroups.getOrPut(sha256Hex(pw)) { mutableListOf() }.add(entry.id) } val reusedIds = HashSet() var reusedGroups = 0 for (ids in hashGroups.values) { if (ids.size > 1) { reusedGroups++ reusedIds.addAll(ids) } } val reports = mutableListOf() var agedCount = 0 var weightedSum = 0L var weightSum = 0L for (entry in live) { val domain = entry.url?.let { extractDomain(it) }.orEmpty() val sensitive = domain.isNotEmpty() && SENSITIVE_PATTERN.containsMatchIn(domain) val ageMs = now - (entry.passwordChangedAt ?: entry.createdAt) val aged = entry.password != null && ageMs > if (sensitive) SENSITIVE_AGE_MS else STANDARD_AGE_MS if (aged) agedCount++ val breachCount = entry.breach ?: 0 val breached = breachCount > 0 val reused = reusedIds.contains(entry.id) val weak = entry.password?.let { it.length < 10 || countCharClasses(it) < 3 } ?: false val missingTotp = sensitive && entry.totp.isNullOrBlank() var score = 0 if (breached) score += 60 if (reused) score += 25 if (aged) score += 15 if (weak) score += 20 if (missingTotp) score += 10 if (score > 100) score = 100 val reasons = buildList { if (breached) add("Breached ($breachCount exposures)") if (reused) add("Reused") if (aged) add("${ageMs / DAY_MS} days old") if (weak) add("Weak") if (missingTotp) add("No 2FA on sensitive site") } val weight = if (sensitive) 2L else 1L weightedSum += score.toLong() * weight weightSum += weight if (score > 0) { reports += AdvancedEntryReport( id = entry.id, title = entry.title, domain = domain, url = entry.url, score = score, ageDays = ageMs / DAY_MS, reasons = reasons, factors = AdvancedFactors(breached, breachCount, reused, aged, weak, missingTotp), ) } } val vaultScore = if (weightSum > 0L) (weightedSum.toFloat() / weightSum).roundToInt() else 0 return AdvancedHealthReport( vaultScore = vaultScore, generatedAt = now, entryCount = live.size, reusedGroups = reusedGroups, agedCount = agedCount, remediation = reports.sortedByDescending { it.score }, ) } private const val DAY_MS = 86_400_000L }