Add 'Have I Been Pwnd' method that returns a breach count (#281)

This commit is contained in:
David Perez 2023-11-27 15:10:41 -06:00 committed by Álison Fernandes
parent dfc653b72e
commit 912d6b62fe
3 changed files with 59 additions and 15 deletions

View file

@ -5,6 +5,13 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service
*/
interface HaveIBeenPwnedService {
/**
* Check to see if the given password has been breached. Returns the number of breaches.
*/
suspend fun getPasswordBreachCount(
password: String,
): Result<Int>
/**
* Check to see if the given password has been breached. Returns true if breached.
*/

View file

@ -6,7 +6,7 @@ import java.security.MessageDigest
class HaveIBeenPwnedServiceImpl(private val api: HaveIBeenPwnedApi) : HaveIBeenPwnedService {
@Suppress("MagicNumber")
override suspend fun hasPasswordBeenBreached(password: String): Result<Boolean> {
override suspend fun getPasswordBreachCount(password: String): Result<Int> {
// Hash the password:
val hashedPassword = MessageDigest
.getInstance("SHA-1")
@ -18,19 +18,31 @@ class HaveIBeenPwnedServiceImpl(private val api: HaveIBeenPwnedApi) : HaveIBeenP
return api
.fetchBreachedPasswords(hashPrefix = hashPrefix)
.mapCatching { responseBody ->
val allPwnedPasswords = responseBody.string()
responseBody.string()
// First split the response by newline: each hashed password is on a new line.
.split("\r\n")
.map { pwnedSuffix ->
// Then remove everything after the ":", since we only want the pwned hash:
// Before: 20d61603aba324bf08799896110561f05e1ad3be:12
// After: 20d61603aba324bf08799896110561f05e1ad3be
pwnedSuffix.substring(0, endIndex = pwnedSuffix.indexOf(":"))
.split("\r\n", "\r", "\n")
.associate { pwnedSuffix ->
// Then split everything on the ":", since we want to compare the pwned
// hash but we also want to return the count:
// Pattern: <pwnd_hash_suffix>:<breach_count>
// Example: 20d61603aba324bf08799896110561f05e1ad3be:12
val split = pwnedSuffix.split(":")
split[0] to split[1]
}
// Then see if any of those passwords match our full password hash:
allPwnedPasswords.any { pwnedSuffix ->
(hashPrefix + pwnedSuffix).equals(hashedPassword, ignoreCase = true)
}
.entries
.find { (pwnedSuffix, _) ->
// Then see if any of those passwords match our full password hash:
(hashPrefix + pwnedSuffix).equals(hashedPassword, ignoreCase = true)
}
// If we found a match, return the value as this is the number of breaches.
?.value
?.toIntOrNull()
?: 0
}
}
override suspend fun hasPasswordBeenBreached(
password: String,
): Result<Boolean> = getPasswordBreachCount(password)
.map { numberOfBreaches -> numberOfBreaches > 0 }
}

View file

@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.api.HaveIBeenPwnedApi
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@ -15,14 +16,38 @@ class HaveIBeenPwnedServiceTest : BaseServiceTest() {
private val service = HaveIBeenPwnedServiceImpl(haveIBeenPwnedApi)
@Test
fun `when service returns failure should return failure`() = runTest {
fun `getPasswordBreachCount should return failure when service returns failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
assertTrue(service.getPasswordBreachCount(PWNED_PASSWORD).isFailure)
}
@Test
fun `getPasswordBreachCount should return breach count when password is in response`() =
runTest {
val response = MockResponse().setBody(HIBP_RESPONSE)
server.enqueue(response)
val result = service.getPasswordBreachCount(PWNED_PASSWORD)
assertEquals(36865, result.getOrThrow())
}
@Test
fun `getPasswordBreachCount should returns 0 when password is not in response`() = runTest {
val response = MockResponse().setBody(HIBP_RESPONSE)
server.enqueue(response)
val result = service.getPasswordBreachCount("testpassword")
assertEquals(0, result.getOrThrow())
}
@Test
fun `hasPasswordBeenBreached should return failure when service returns failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
assertTrue(service.hasPasswordBeenBreached(PWNED_PASSWORD).isFailure)
}
@Test
fun `when given password is in response returns true`() = runTest {
fun `hasPasswordBeenBreached should return true when password is in response`() = runTest {
val response = MockResponse().setBody(HIBP_RESPONSE)
server.enqueue(response)
val result = service.hasPasswordBeenBreached(PWNED_PASSWORD)
@ -30,7 +55,7 @@ class HaveIBeenPwnedServiceTest : BaseServiceTest() {
}
@Test
fun `when given password is not in response returns false`() = runTest {
fun `hasPasswordBeenBreached should return false when password is not in response`() = runTest {
val response = MockResponse().setBody(HIBP_RESPONSE)
server.enqueue(response)
val result = service.hasPasswordBeenBreached("testpassword")