5c05adf0b225
feat: Add protection breach check (v2.3.23)
- time
- 2026-06-26 08:39 UTC · 4 months ago
- machine
- koh2
- parent
- c915805f8c62
↓ peach-android-5c05adf0.tar.gzatlas at this save
files changed4 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 −0app/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 {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)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 }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+}