1package com.peachpasswords.vault23import com.peachpasswords.shared.extractDomain4import java.security.MessageDigest56object VaultHealth {7 private val SENSITIVE_PATTERN = Regex(8 "bank|pay|finance|invest|mail|gmail|outlook|yahoo|icloud|instagram|twitter|facebook|linkedin",9 RegexOption.IGNORE_CASE,10 )1112 private const val SENSITIVE_AGE_MS = 90L * 24 * 60 * 60 * 100013 private const val STANDARD_AGE_MS = 180L * 24 * 60 * 60 * 10001415 data class EntryHealthScore(16 val entryId: String,17 val score: Int,18 val isBreach: Boolean,19 val isReused: Boolean,20 val isAged: Boolean,21 val isWeak: Boolean,22 val missingTotp: Boolean,23 val isSensitive: Boolean,24 )2526 data class VaultHealthReport(27 val vaultScore: Int,28 val entries: List<EntryHealthScore>,29 val breachCount: Int,30 val reusedCount: Int,31 val agedCount: Int,32 val weakCount: Int,33 )3435 fun buildReport(entries: List<VaultEntry>): VaultHealthReport {36 val loginEntries = entries.filter { it.isLogin() && !it.isDeleted() }37 val passwordHashes = loginEntries.mapNotNull { entry ->38 entry.password?.let { pw -> sha256Hex(pw) to entry.id }39 }40 val hashCounts = passwordHashes.groupBy { it.first }.mapValues { it.value.size }41 val now = System.currentTimeMillis()42 val scores = loginEntries.map { entry ->43 val isSensitive = entry.url?.let { domain ->44 val extracted = extractDomain(domain)45 (extracted ?: domain).let { SENSITIVE_PATTERN.containsMatchIn(it) }46 } ?: false47 val ageThreshold = if (isSensitive) SENSITIVE_AGE_MS else STANDARD_AGE_MS48 val isBreach = (entry.breach ?: 0) > 049 val isReused = entry.password?.let { pw -> (hashCounts[sha256Hex(pw)] ?: 0) > 1 } ?: false50 val isAged = entry.passwordChangedAt?.let { (now - it) > ageThreshold } ?: false51 val isWeak = entry.password?.let { pw -> pw.length < 10 || countCharClasses(pw) < 3 } ?: false52 val missingTotp = isSensitive && entry.totp.isNullOrBlank()5354 var score = 055 if (isBreach) score += 6056 if (isReused) score += 2557 if (isAged) score += 1558 if (isWeak) score += 2059 if (missingTotp) score += 1060 score = minOf(score, 100)6162 EntryHealthScore(63 entryId = entry.id,64 score = score,65 isBreach = isBreach,66 isReused = isReused,67 isAged = isAged,68 isWeak = isWeak,69 missingTotp = missingTotp,70 isSensitive = isSensitive,71 )72 }7374 val totalWeight = scores.sumOf { if (it.isSensitive) 2L else 1L }.toFloat()75 val weightedSum = scores.sumOf { (it.score * (if (it.isSensitive) 2 else 1)).toLong() }.toFloat()76 val vaultScore = if (totalWeight > 0f) (weightedSum / totalWeight).toInt() else 07778 return VaultHealthReport(79 vaultScore = vaultScore,80 entries = scores,81 breachCount = scores.count { it.isBreach },82 reusedCount = scores.count { it.isReused },83 agedCount = scores.count { it.isAged },84 weakCount = scores.count { it.isWeak },85 )86 }8788 private fun countCharClasses(password: String): Int {89 var classes = 090 if (password.any(Char::isLowerCase)) classes++91 if (password.any(Char::isUpperCase)) classes++92 if (password.any(Char::isDigit)) classes++93 if (password.any { !it.isLetterOrDigit() }) classes++94 return classes95 }9697 private fun sha256Hex(input: String): String {98 val digest = MessageDigest.getInstance("SHA-256")99 val hash = digest.digest(input.toByteArray(Charsets.UTF_8))100 return hash.joinToString("") { "%02x".format(it) }101 }102}