VaultHealth.kt

3.9 KB · raw

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}