1package com.peachpasswords.vault23import com.peachpasswords.shared.extractDomain4import java.security.MessageDigest5import kotlin.math.roundToInt67object VaultHealth {8 private val SENSITIVE_PATTERN = Regex(9 "bank|pay|finance|invest|mail|gmail|outlook|yahoo|icloud|instagram|twitter|facebook|linkedin",10 RegexOption.IGNORE_CASE,11 )1213 private const val SENSITIVE_AGE_MS = 90L * 24 * 60 * 60 * 100014 private const val STANDARD_AGE_MS = 180L * 24 * 60 * 60 * 10001516 data class EntryHealthScore(17 val entryId: String,18 val score: Int,19 val isBreach: Boolean,20 val isReused: Boolean,21 val isAged: Boolean,22 val isWeak: Boolean,23 val missingTotp: Boolean,24 val isSensitive: Boolean,25 )2627 data class VaultHealthReport(28 val vaultScore: Int,29 val entries: List<EntryHealthScore>,30 val breachCount: Int,31 val reusedCount: Int,32 val agedCount: Int,33 val weakCount: Int,34 )3536 fun buildReport(entries: List<VaultEntry>): VaultHealthReport {37 val loginEntries = entries.filter { it.isLogin() && !it.isDeleted() }38 val passwordHashes = loginEntries.mapNotNull { entry ->39 entry.password?.let { pw -> sha256Hex(pw) to entry.id }40 }41 val hashCounts = passwordHashes.groupBy { it.first }.mapValues { it.value.size }42 val now = System.currentTimeMillis()43 val scores = loginEntries.map { entry ->44 val isSensitive = entry.url?.let { domain ->45 val extracted = extractDomain(domain)46 (extracted ?: domain).let { SENSITIVE_PATTERN.containsMatchIn(it) }47 } ?: false48 val ageThreshold = if (isSensitive) SENSITIVE_AGE_MS else STANDARD_AGE_MS49 val isBreach = (entry.breach ?: 0) > 050 val isReused = entry.password?.let { pw -> (hashCounts[sha256Hex(pw)] ?: 0) > 1 } ?: false51 val isAged = entry.passwordChangedAt?.let { (now - it) > ageThreshold } ?: false52 val isWeak = entry.password?.let { pw -> pw.length < 10 || countCharClasses(pw) < 3 } ?: false53 val missingTotp = isSensitive && entry.totp.isNullOrBlank()5455 var score = 056 if (isBreach) score += 6057 if (isReused) score += 2558 if (isAged) score += 1559 if (isWeak) score += 2060 if (missingTotp) score += 1061 score = minOf(score, 100)6263 EntryHealthScore(64 entryId = entry.id,65 score = score,66 isBreach = isBreach,67 isReused = isReused,68 isAged = isAged,69 isWeak = isWeak,70 missingTotp = missingTotp,71 isSensitive = isSensitive,72 )73 }7475 val totalWeight = scores.sumOf { if (it.isSensitive) 2L else 1L }.toFloat()76 val weightedSum = scores.sumOf { (it.score * (if (it.isSensitive) 2 else 1)).toLong() }.toFloat()77 val vaultScore = if (totalWeight > 0f) (weightedSum / totalWeight).toInt() else 07879 return VaultHealthReport(80 vaultScore = vaultScore,81 entries = scores,82 breachCount = scores.count { it.isBreach },83 reusedCount = scores.count { it.isReused },84 agedCount = scores.count { it.isAged },85 weakCount = scores.count { it.isWeak },86 )87 }8889 private fun countCharClasses(password: String): Int {90 var classes = 091 if (password.any(Char::isLowerCase)) classes++92 if (password.any(Char::isUpperCase)) classes++93 if (password.any(Char::isDigit)) classes++94 if (password.any { !it.isLetterOrDigit() }) classes++95 return classes96 }9798 private fun sha256Hex(input: String): String {99 val digest = MessageDigest.getInstance("SHA-256")100 val hash = digest.digest(input.toByteArray(Charsets.UTF_8))101 return hash.joinToString("") { "%02x".format(it) }102 }103104 105 106 107 108 109 110 111112 data class AdvancedFactors(113 val breached: Boolean,114 val breachCount: Int,115 val reused: Boolean,116 val aged: Boolean,117 val weak: Boolean,118 val missingTotpOnSensitive: Boolean,119 )120121 data class AdvancedEntryReport(122 val id: String,123 val title: String,124 val domain: String,125 val url: String?,126 val score: Int,127 val ageDays: Long,128 val reasons: List<String>,129 val factors: AdvancedFactors,130 )131132 data class AdvancedHealthReport(133 val vaultScore: Int,134 val generatedAt: Long,135 val entryCount: Int,136 val reusedGroups: Int,137 val agedCount: Int,138 val remediation: List<AdvancedEntryReport>,139 )140141 fun buildAdvancedReport(142 entries: List<VaultEntry>,143 now: Long = System.currentTimeMillis(),144 ): AdvancedHealthReport {145 val live = entries.filter { it.isLogin() && !it.isDeleted() }146147 val hashGroups = HashMap<String, MutableList<String>>()148 for (entry in live) {149 val pw = entry.password ?: continue150 hashGroups.getOrPut(sha256Hex(pw)) { mutableListOf() }.add(entry.id)151 }152 val reusedIds = HashSet<String>()153 var reusedGroups = 0154 for (ids in hashGroups.values) {155 if (ids.size > 1) {156 reusedGroups++157 reusedIds.addAll(ids)158 }159 }160161 val reports = mutableListOf<AdvancedEntryReport>()162 var agedCount = 0163 var weightedSum = 0L164 var weightSum = 0L165 for (entry in live) {166 val domain = entry.url?.let { extractDomain(it) }.orEmpty()167 val sensitive = domain.isNotEmpty() && SENSITIVE_PATTERN.containsMatchIn(domain)168 val ageMs = now - (entry.passwordChangedAt ?: entry.createdAt)169 val aged = entry.password != null &&170 ageMs > if (sensitive) SENSITIVE_AGE_MS else STANDARD_AGE_MS171 if (aged) agedCount++172173 val breachCount = entry.breach ?: 0174 val breached = breachCount > 0175 val reused = reusedIds.contains(entry.id)176 val weak = entry.password?.let { it.length < 10 || countCharClasses(it) < 3 } ?: false177 val missingTotp = sensitive && entry.totp.isNullOrBlank()178179 var score = 0180 if (breached) score += 60181 if (reused) score += 25182 if (aged) score += 15183 if (weak) score += 20184 if (missingTotp) score += 10185 if (score > 100) score = 100186187 val reasons = buildList {188 if (breached) add("Breached ($breachCount exposures)")189 if (reused) add("Reused")190 if (aged) add("${ageMs / DAY_MS} days old")191 if (weak) add("Weak")192 if (missingTotp) add("No 2FA on sensitive site")193 }194195 val weight = if (sensitive) 2L else 1L196 weightedSum += score.toLong() * weight197 weightSum += weight198199 if (score > 0) {200 reports += AdvancedEntryReport(201 id = entry.id,202 title = entry.title,203 domain = domain,204 url = entry.url,205 score = score,206 ageDays = ageMs / DAY_MS,207 reasons = reasons,208 factors = AdvancedFactors(breached, breachCount, reused, aged, weak, missingTotp),209 )210 }211 }212213 val vaultScore = if (weightSum > 0L) (weightedSum.toFloat() / weightSum).roundToInt() else 0214 return AdvancedHealthReport(215 vaultScore = vaultScore,216 generatedAt = now,217 entryCount = live.size,218 reusedGroups = reusedGroups,219 agedCount = agedCount,220 remediation = reports.sortedByDescending { it.score },221 )222 }223224 private const val DAY_MS = 86_400_000L225}