diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedService.kt index 75fdf3f0b..fe13a4098 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedService.kt @@ -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 + /** * Check to see if the given password has been breached. Returns true if breached. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceImpl.kt index f6e2e9d6c..44d744541 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceImpl.kt @@ -6,7 +6,7 @@ import java.security.MessageDigest class HaveIBeenPwnedServiceImpl(private val api: HaveIBeenPwnedApi) : HaveIBeenPwnedService { @Suppress("MagicNumber") - override suspend fun hasPasswordBeenBreached(password: String): Result { + override suspend fun getPasswordBreachCount(password: String): Result { // 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: : + // 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 = getPasswordBreachCount(password) + .map { numberOfBreaches -> numberOfBreaches > 0 } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceTest.kt index 7f5a46bd2..1dc25d3b7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceTest.kt @@ -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")