5c05adf0b225

feat: Add protection breach check (v2.3.23)

- Add HIBP k-anonymity check-all-passwords flow in Protection settings - Parse padded range responses and update entry breach counts - Verify parser tests and full Gradle gate

time
2026-06-26 08:39 UTC · 4 months ago
machine
koh2
parent
c915805f8c62
4 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+12 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+67 −0app/src/test/java/com/peachpasswords/ui/settings/SettingsBreachCheckTest.kt+21 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23220020-        versionName "2.3.22"19+        versionCode 23230020+        versionName "2.3.23"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+12 −0
@@ -294,9 +294,21 @@294294         SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }295295     }296296     SubLabel("BREACH PROTECTION", fg)297+    val checkingBreaches by vm.checkingBreaches.collectAsState()298+    val breachResult by vm.breachResult.collectAsState()297299     RowLine(fg) {298300         Name("Breach monitoring", fg)299301         Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }302+    }303+    RowLine(fg) {304+        Name("Check all passwords", fg)305+        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }306+    }307+    breachResult?.let { result ->308+        RowLine(fg) {309+            Name("Last check", fg)310+            ValueText(result, fg)311+        }300312     }301313 302314     SubLabel("MASTER PASSWORD", fg)

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+67 −0
@@ -19,7 +19,12 @@1919 import kotlinx.coroutines.flow.MutableStateFlow2020 import kotlinx.coroutines.flow.StateFlow2121 import kotlinx.coroutines.flow.asStateFlow22+import kotlinx.coroutines.Dispatchers2223 import kotlinx.coroutines.launch24+import kotlinx.coroutines.withContext25+import okhttp3.OkHttpClient26+import okhttp3.Request27+import java.security.MessageDigest2328 import javax.inject.Inject2429 2530 @HiltViewModel@@ -69,6 +74,14 @@6974     private val _restoringSnapshotId = MutableStateFlow<String?>(null)7075     val restoringSnapshotId: StateFlow<String?> = _restoringSnapshotId.asStateFlow()7176 77+    private val _checkingBreaches = MutableStateFlow(false)78+    val checkingBreaches: StateFlow<Boolean> = _checkingBreaches.asStateFlow()79+80+    private val _breachResult = MutableStateFlow<String?>(null)81+    val breachResult: StateFlow<String?> = _breachResult.asStateFlow()82+83+    private val breachClient by lazy { OkHttpClient() }84+7285     fun refreshVaultHistory() {7386         _vaultHistory.value = vault.getVaultHistory()7487     }@@ -130,6 +143,44 @@130143         }131144     }132145 146+    fun checkAllBreaches() {147+        if (_checkingBreaches.value) return148+        _checkingBreaches.value = true149+        _breachResult.value = null150+        viewModelScope.launch {151+            val counts = withContext(Dispatchers.IO) {152+                vault.getEntries()153+                    .filter { (it.type == null || it.type == "login") && !it.password.isNullOrBlank() }154+                    .map { entry ->155+                        val count = runCatching { breachCountForPassword(entry.password.orEmpty()) }.getOrDefault(entry.breach ?: 0)156+                        if ((entry.breach ?: 0) != count) {157+                            runCatching { vault.updateEntry(entry.copy(breach = count.takeIf { it > 0 })) }158+                        }159+                        count160+                    }161+            }162+            val breached = counts.count { it > 0 }163+            _breachResult.value =164+                if (breached > 0) "$breached password${if (breached == 1) "" else "s"} found in breaches" else "All passwords clean"165+            _checkingBreaches.value = false166+        }167+    }168+169+    private fun breachCountForPassword(password: String): Int {170+        val hash = sha1Hex(password)171+        val prefix = hash.take(5)172+        val suffix = hash.drop(5)173+        val request = Request.Builder()174+            .url("https://api.pwnedpasswords.com/range/$prefix")175+            .header("Add-Padding", "true")176+            .header("User-Agent", "Peach-Android")177+            .build()178+        breachClient.newCall(request).execute().use { response ->179+            if (!response.isSuccessful) error("hibp_${response.code}")180+            return parsePwnedRangeCount(response.body?.string().orEmpty(), suffix)181+        }182+    }183+133184     val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries134185     val settings: StateFlow<VaultSettings> = vault.settings135186 @@ -246,4 +297,20 @@246297         CardId.Shortcuts -> "bindings · what your fingers know"247298         CardId.Feedback -> "tell us what would make it better"248299     }300+}301+302+internal fun sha1Hex(value: String): String =303+    MessageDigest.getInstance("SHA-1")304+        .digest(value.toByteArray(Charsets.UTF_8))305+        .joinToString("") { "%02X".format(it) }306+307+internal fun parsePwnedRangeCount(body: String, suffix: String): Int {308+    val wanted = suffix.uppercase()309+    return body.lineSequence()310+        .mapNotNull { line ->311+            val parts = line.trim().split(':', limit = 2)312+            if (parts.size == 2 && parts[0].uppercase() == wanted) parts[1].toIntOrNull() else null313+        }314+        .firstOrNull()315+        ?: 0249316 }

view file

app/src/test/java/com/peachpasswords/ui/settings/SettingsBreachCheckTest.kt+21 −0
@@ -0,0 +1,21 @@1+package com.peachpasswords.ui.settings2+3+import org.junit.Assert.assertEquals4+import org.junit.Test5+6+class SettingsBreachCheckTest {7+    @Test fun sha1_hex_is_uppercase_utf8() {8+        assertEquals("AAF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D", sha1Hex("hello"))9+    }10+11+    @Test fun pwned_range_parser_ignores_padding_rows() {12+        val body = """13+            00000000000000000000000000000000000:014+            AF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D:4215+            FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:016+        """.trimIndent()17+18+        assertEquals(42, parsePwnedRangeCount(body, "AF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D"))19+        assertEquals(0, parsePwnedRangeCount(body, "11111111111111111111111111111111111"))20+    }21+}

view file