VaultHealth.kt

8.3 KB · raw

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    // ---- Advanced report (Pro, parity V1) ---------------------------------105    // Ports peach-browser background/health.ts buildAdvancedHealthReport. Shares106    // the scoring engine above (same pattern/thresholds/weights via the private107    // helpers); adds per-entry reasons + factors, age in days, a ranked108    // remediation queue, and a reused-group count for the Pro health surface.109    // ponytail: buildReport and buildAdvancedReport duplicate the weight loop;110    //   fold into one shared per-entry scorer if a third consumer appears.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}