From 49fd789116055a199589866d066deb386c9594f5 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 5 Sep 2026 01:37:44 +0200 Subject: [PATCH 1/4] feat: enhance suspicion handling with detailed reasons and digital asset link status --- .../DigitalAssetLinkRepositoryImpl.kt | 42 ++++--- .../domain/model/DigitalAssetLinkFailure.kt | 7 ++ .../domain/model/WebsiteLinkStatus.kt | 7 ++ .../repository/DigitalAssetLinkRepository.kt | 11 +- .../usecase/IsAppLinkedToWebsiteUseCase.kt | 22 +++- .../activity/AutofillViewModel.kt | 26 +++-- .../model/SuspicionDialogVisibility.kt | 9 +- .../DigitalAssetLinkRepositoryImplTest.kt | 106 ++++++++++++++++++ .../IsAppLinkedToWebsiteUseCaseTest.kt | 48 ++++++-- .../activity/AutofillViewModelTest.kt | 22 +++- .../FakeDigitalAssetLinkRepository.kt | 27 ++++- 11 files changed, 281 insertions(+), 46 deletions(-) create mode 100644 feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/DigitalAssetLinkFailure.kt create mode 100644 feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/WebsiteLinkStatus.kt create mode 100644 feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImplTest.kt diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt index a87ea1e6a..eb2aa7f0f 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt @@ -1,12 +1,17 @@ package de.davis.keygo.feature.autofill.data.repository +import android.util.Log +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.autofill.domain.model.DigitalAssetLinkFailure import de.davis.keygo.feature.autofill.domain.repository.DigitalAssetLinkRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import org.json.JSONException import org.json.JSONObject import org.koin.core.annotation.Single +import java.io.IOException import java.net.URLEncoder @Single @@ -17,24 +22,35 @@ internal class DigitalAssetLinkRepositoryImpl( override suspend fun isLinked( packageName: String, signature: String, - domain: String - ): Boolean = withContext(Dispatchers.IO) { + domain: String, + ): Result = withContext(Dispatchers.IO) { fun enc(s: String) = URLEncoder.encode(s, "UTF-8") - val response = http.newCall( - Request.Builder() - .url(API_ENDPOINT.format(enc(domain), enc(packageName), enc(signature))) - .build() - ).execute() - - response.use { - if (!it.isSuccessful) return@use false - - JSONObject(it.body.string()).optBoolean("linked", false) + val request = Request.Builder() + .url(API_ENDPOINT.format(enc(domain), enc(packageName), enc(signature))) + .build() + + try { + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) + return@use Result.Failure(DigitalAssetLinkFailure.NoVerdict) + + Result.Success(JSONObject(response.body.string()).optBoolean("linked", false)) + } + } catch (e: IOException) { + // The autofill dialog runs wherever the user happens to be, so being offline, behind a + // captive portal or on a broken DNS is normal. None of that is a verdict. + Log.w(TAG, "Could not reach the digital asset link API for $domain", e) + Result.Failure(DigitalAssetLinkFailure.Unreachable) + } catch (e: JSONException) { + Log.w(TAG, "Digital asset link API returned an unreadable body for $domain", e) + Result.Failure(DigitalAssetLinkFailure.NoVerdict) } } companion object { + private const val TAG = "DigitalAssetLink" + private const val RELATION = "delegate_permission/common.get_login_creds" private const val API_ENDPOINT = @@ -44,4 +60,4 @@ internal class DigitalAssetLinkRepositoryImpl( "&target.androidApp.packageName=%s" + "&target.androidApp.certificate.sha256Fingerprint=%s" } -} \ No newline at end of file +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/DigitalAssetLinkFailure.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/DigitalAssetLinkFailure.kt new file mode 100644 index 000000000..91632da04 --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/DigitalAssetLinkFailure.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.autofill.domain.model + +sealed interface DigitalAssetLinkFailure { + + data object Unreachable : DigitalAssetLinkFailure + data object NoVerdict : DigitalAssetLinkFailure +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/WebsiteLinkStatus.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/WebsiteLinkStatus.kt new file mode 100644 index 000000000..5ba026f1a --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/WebsiteLinkStatus.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.autofill.domain.model + +enum class WebsiteLinkStatus { + Linked, + NotLinked, + Unverified, +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/DigitalAssetLinkRepository.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/DigitalAssetLinkRepository.kt index 3288b2c57..793e17a54 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/DigitalAssetLinkRepository.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/DigitalAssetLinkRepository.kt @@ -1,6 +1,13 @@ package de.davis.keygo.feature.autofill.domain.repository +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.autofill.domain.model.DigitalAssetLinkFailure + interface DigitalAssetLinkRepository { - suspend fun isLinked(packageName: String, signature: String, domain: String): Boolean -} \ No newline at end of file + suspend fun isLinked( + packageName: String, + signature: String, + domain: String, + ): Result +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt index 4bdc60102..651abc37a 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt @@ -1,6 +1,8 @@ package de.davis.keygo.feature.autofill.domain.usecase +import de.davis.keygo.core.util.Result import de.davis.keygo.feature.autofill.domain.SignatureInfoProvider +import de.davis.keygo.feature.autofill.domain.model.WebsiteLinkStatus import de.davis.keygo.feature.autofill.domain.repository.DigitalAssetLinkRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope @@ -16,16 +18,26 @@ class IsAppLinkedToWebsiteUseCase( suspend operator fun invoke( packageName: String, domain: String - ): Boolean = coroutineScope { + ): WebsiteLinkStatus = coroutineScope { val signatures = signatureInfoProvider.getSignatureInfo(packageName) - if (signatures.isEmpty()) return@coroutineScope false + if (signatures.isEmpty()) return@coroutineScope WebsiteLinkStatus.NotLinked - signatures.any { sign -> - digitalAssetLinkCheck.isLinked( + var anyLookupFailed = false + signatures.forEach { sign -> + val verdict = digitalAssetLinkCheck.isLinked( packageName = packageName, signature = sign, domain = domain ) + + when (verdict) { + is Result.Success -> + if (verdict.success) return@coroutineScope WebsiteLinkStatus.Linked + + is Result.Failure -> anyLookupFailed = true + } } + + if (anyLookupFailed) WebsiteLinkStatus.Unverified else WebsiteLinkStatus.NotLinked } -} \ No newline at end of file +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt index a9dc335fc..a5b824181 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt @@ -20,6 +20,7 @@ import de.davis.keygo.core.security.domain.model.BiometricAuthError import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.feature.autofill.domain.model.WebsiteLinkStatus import de.davis.keygo.feature.autofill.domain.usecase.AddRegistrableDomainsToLoginUseCase import de.davis.keygo.feature.autofill.domain.usecase.DoesItemHaveDomainReferencesUseCase import de.davis.keygo.feature.autofill.domain.usecase.IsAppLinkedToWebsiteUseCase @@ -29,6 +30,7 @@ import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillBiome import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillEvent import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillUiEvent import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionDialogVisibility +import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionReason import de.davis.keygo.feature.autofill.presentation.model.AutofillUiState import de.davis.keygo.feature.autofill.presentation.model.AutofillValue import de.davis.keygo.feature.autofill.presentation.model.FieldType @@ -108,31 +110,37 @@ internal class AutofillViewModel( private fun handleRequestData(ignoreSuspicion: Boolean = false) = viewModelScope.launch { val handleSuspicion = !ignoreSuspicion && requestData.form.isSuspicious - val linked = when { + val linkStatus = when { handleSuspicion -> requestData.form.url?.let { isAppLinkedToWebsite( packageName = requestData.form.appPackageName, domain = it ) - } == true + } ?: WebsiteLinkStatus.NotLinked - else -> false + else -> null } - val showSuspicionDialog = !linked && handleSuspicion + val suspicionReason = when (linkStatus) { + WebsiteLinkStatus.NotLinked -> SuspicionReason.NotLinked + WebsiteLinkStatus.Unverified -> SuspicionReason.Unverified + WebsiteLinkStatus.Linked, + null -> null + } _uiState.update { it.copy( - suspicionDialogVisibility = if (showSuspicionDialog) + suspicionDialogVisibility = if (suspicionReason != null) SuspicionDialogVisibility.Visible( appPackageName = requestData.form.appPackageName, - website = requestData.form.url.orEmpty() + website = requestData.form.url.orEmpty(), + reason = suspicionReason, ) else SuspicionDialogVisibility.Hidden ) } - if (showSuspicionDialog) return@launch + if (suspicionReason != null) return@launch when (requestData) { is SaveRequestData -> handleSaveRequest(requestData) @@ -236,9 +244,9 @@ internal class AutofillViewModel( fun onBiometricLoginFailed(error: UnlockError) { viewModelScope.launch { - when(error) { + when (error) { is UnlockError.BiometricFailed -> { - when(error.error) { + when (error.error) { BiometricAuthError.Canceled -> eventChannel.send(AutofillEvent.Abort) else -> _uiState.update { it.copy(request = Request.JustAuthenticateWithPwd) } } diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/SuspicionDialogVisibility.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/SuspicionDialogVisibility.kt index 0b4ee7599..baf85c3dd 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/SuspicionDialogVisibility.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/SuspicionDialogVisibility.kt @@ -1,6 +1,11 @@ package de.davis.keygo.feature.autofill.presentation.activity.model internal sealed interface SuspicionDialogVisibility { - data class Visible(val appPackageName: String, val website: String) : SuspicionDialogVisibility + data class Visible( + val appPackageName: String, + val website: String, + val reason: SuspicionReason, + ) : SuspicionDialogVisibility + data object Hidden : SuspicionDialogVisibility -} \ No newline at end of file +} diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImplTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImplTest.kt new file mode 100644 index 000000000..87a2cfa98 --- /dev/null +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImplTest.kt @@ -0,0 +1,106 @@ +package de.davis.keygo.feature.autofill.data.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.autofill.domain.model.DigitalAssetLinkFailure +import kotlinx.coroutines.test.runTest +import okhttp3.Dns +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.net.InetAddress +import java.net.UnknownHostException +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +internal class DigitalAssetLinkRepositoryImplTest { + + @Test + fun `unresolvable host reports the api as unreachable`() = runTest { + val repository = DigitalAssetLinkRepositoryImpl( + OkHttpClient.Builder() + .dns(object : Dns { + override fun lookup(hostname: String): List = + throw UnknownHostException(hostname) + }) + .build(), + ) + + assertEquals( + Result.Failure(DigitalAssetLinkFailure.Unreachable), + repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN), + ) + } + + @Test + fun `linked verdict is reported`() = runTest { + val repository = repositoryAnswering(body = """{"linked": true}""") + + assertEquals(Result.Success(true), repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN)) + } + + @Test + fun `unlinked verdict is reported`() = runTest { + val repository = repositoryAnswering(body = """{"linked": false}""") + + assertEquals(Result.Success(false), repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN)) + } + + @Test + fun `missing linked flag is treated as unlinked`() = runTest { + val repository = repositoryAnswering(body = "{}") + + assertEquals(Result.Success(false), repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN)) + } + + @Test + fun `error status reports no verdict`() = runTest { + val repository = repositoryAnswering(code = 503, body = "unavailable") + + assertEquals( + Result.Failure(DigitalAssetLinkFailure.NoVerdict), + repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN), + ) + } + + @Test + fun `unreadable body reports no verdict`() = runTest { + val repository = repositoryAnswering(body = "not json") + + assertEquals( + Result.Failure(DigitalAssetLinkFailure.NoVerdict), + repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN), + ) + } + + private fun repositoryAnswering(code: Int = 200, body: String) = + DigitalAssetLinkRepositoryImpl( + OkHttpClient.Builder() + .addInterceptor( + Interceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("synthetic") + .body(body.toResponseBody("application/json".toMediaType())) + .build() + }, + ) + .build(), + ) + + companion object { + + private const val PACKAGE_NAME = "com.example" + private const val SIGNATURE = "AA:BB:CC" + private const val DOMAIN = "https://example.com" + } +} diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCaseTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCaseTest.kt index 952c43771..b574ea9a9 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCaseTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCaseTest.kt @@ -2,10 +2,11 @@ package de.davis.keygo.feature.autofill.domain.usecase import de.davis.keygo.core.feature.autofill.FakeDigitalAssetLinkRepository import de.davis.keygo.core.feature.autofill.FakeSignatureInfoProvider +import de.davis.keygo.feature.autofill.domain.model.WebsiteLinkStatus import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertFalse +import kotlin.test.assertEquals import kotlin.test.assertTrue class IsAppLinkedToWebsiteUseCaseTest { @@ -23,19 +24,19 @@ class IsAppLinkedToWebsiteUseCaseTest { } @Test - fun `empty signatures returns false without querying digital asset links`() = runTest { + fun `empty signatures returns unlinked without querying digital asset links`() = runTest { val packageName = "com.example.app" val domain = "example.com" signatureInfoProvider.signatures = mapOf(packageName to emptySet()) val result = useCase(packageName, domain) - assertFalse(result) + assertEquals(WebsiteLinkStatus.NotLinked, result) assertTrue(digitalAssetLinkRepository.linkedCalls.isEmpty()) } @Test - fun `one linked signature returns true`() = runTest { + fun `one linked signature returns linked`() = runTest { val packageName = "com.example.app" val signature = "ABCD1234" val domain = "example.com" @@ -44,11 +45,11 @@ class IsAppLinkedToWebsiteUseCaseTest { val result = useCase(packageName, domain) - assertTrue(result) + assertEquals(WebsiteLinkStatus.Linked, result) } @Test - fun `all unlinked signatures returns false`() = runTest { + fun `all unlinked signatures returns unlinked`() = runTest { val packageName = "com.example.app" val sig1 = "SIGNATURE_1" val sig2 = "SIGNATURE_2" @@ -58,11 +59,11 @@ class IsAppLinkedToWebsiteUseCaseTest { val result = useCase(packageName, domain) - assertFalse(result) + assertEquals(WebsiteLinkStatus.NotLinked, result) } @Test - fun `first linked signature returns true`() = runTest { + fun `first linked signature returns linked`() = runTest { val packageName = "com.example.app" val sig1 = "SIGNATURE_1" val sig2 = "SIGNATURE_2" @@ -72,6 +73,35 @@ class IsAppLinkedToWebsiteUseCaseTest { val result = useCase(packageName, domain) - assertTrue(result) + assertEquals(WebsiteLinkStatus.Linked, result) + } + + @Test + fun `failed lookup is not treated as linked`() = runTest { + val packageName = "com.example.app" + val signature = "ABCD1234" + val domain = "example.com" + signatureInfoProvider.signatures = mapOf(packageName to setOf(signature)) + digitalAssetLinkRepository.linkedTriples = setOf(Triple(packageName, signature, domain)) + digitalAssetLinkRepository.failingSignatures = setOf(signature) + + val result = useCase(packageName, domain) + + assertEquals(WebsiteLinkStatus.Unverified, result) + } + + @Test + fun `a proven signature wins over a failed lookup`() = runTest { + val packageName = "com.example.app" + val failing = "SIGNATURE_1" + val linked = "SIGNATURE_2" + val domain = "example.com" + signatureInfoProvider.signatures = mapOf(packageName to setOf(failing, linked)) + digitalAssetLinkRepository.linkedTriples = setOf(Triple(packageName, linked, domain)) + digitalAssetLinkRepository.failingSignatures = setOf(failing) + + val result = useCase(packageName, domain) + + assertEquals(WebsiteLinkStatus.Linked, result) } } diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt index 2b7f7eefc..4365ca8dd 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt @@ -33,6 +33,7 @@ import de.davis.keygo.feature.autofill.presentation.activity.model.AssociationDi import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillEvent import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillUiEvent import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionDialogVisibility +import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionReason import de.davis.keygo.feature.autofill.presentation.model.FieldType import de.davis.keygo.feature.autofill.presentation.model.FillRequestData import de.davis.keygo.feature.autofill.presentation.model.Form @@ -194,7 +195,9 @@ internal class AutofillViewModelTest { val vm = buildVm(requestData) vm.start() - assertIs(vm.uiState.value.suspicionDialogVisibility) + val visibility = vm.uiState.value.suspicionDialogVisibility + assertIs(visibility) + assertEquals(SuspicionReason.NotLinked, visibility.reason) assertEquals(Request.None, vm.uiState.value.request) } @@ -222,6 +225,23 @@ internal class AutofillViewModelTest { assertEquals(Request.SelectItem, vm.uiState.value.request) } + @Test + fun `suspicious form whose lookup fails shows the unverified dialog`() = runTest { + signatureProvider.signatures = mapOf("com.example" to setOf("sig1")) + dalRepo.linkedTriples = setOf(Triple("com.example", "sig1", "https://example.com")) + dalRepo.failingSignatures = setOf("sig1") + val requestData = FillRequestData.App( + form(isSuspicious = true, url = "https://example.com", appPackageName = "com.example"), + ) + val vm = buildVm(requestData) + vm.start() + + val visibility = vm.uiState.value.suspicionDialogVisibility + assertIs(visibility) + assertEquals(SuspicionReason.Unverified, visibility.reason) + assertEquals(Request.None, vm.uiState.value.request) + } + @Test fun `continuing past suspicion hides dialog and proceeds`() = runTest { signatureProvider.signatures = emptyMap() diff --git a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeDigitalAssetLinkRepository.kt b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeDigitalAssetLinkRepository.kt index 153d64987..2f03701da 100644 --- a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeDigitalAssetLinkRepository.kt +++ b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeDigitalAssetLinkRepository.kt @@ -1,22 +1,39 @@ package de.davis.keygo.core.feature.autofill +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.autofill.domain.model.DigitalAssetLinkFailure import de.davis.keygo.feature.autofill.domain.repository.DigitalAssetLinkRepository /** * In-memory [DigitalAssetLinkRepository] for tests. * - * Set [linkedTriples] to configure which (packageName, signature, domain) combinations are linked. - * Inspect [linkedCalls] to verify which combinations were queried. + * Set [linkedTriples] to configure which (packageName, signature, domain) combinations are linked, + * and [failingSignatures] to make a lookup come back without a verdict. Inspect [linkedCalls] to + * verify which combinations were queried. */ class FakeDigitalAssetLinkRepository : DigitalAssetLinkRepository { // Configurable: set of (packageName, signature, domain) triples that are "linked" var linkedTriples: Set> = emptySet() + // Configurable: signatures whose lookup fails with [failure] instead of returning a verdict + var failingSignatures: Set = emptySet() + + // Configurable: the failure reported for [failingSignatures] + var failure: DigitalAssetLinkFailure = DigitalAssetLinkFailure.Unreachable + // Track calls for assertion (e.g., fake.linkedCalls.isEmpty() or fake.linkedCalls.contains(Triple(...))) val linkedCalls: MutableList> = mutableListOf() - override suspend fun isLinked(packageName: String, signature: String, domain: String): Boolean { - linkedCalls += Triple(packageName, signature, domain) - return Triple(packageName, signature, domain) in linkedTriples + override suspend fun isLinked( + packageName: String, + signature: String, + domain: String, + ): Result { + val call = Triple(packageName, signature, domain) + linkedCalls += call + + if (signature in failingSignatures) return Result.Failure(failure) + + return Result.Success(call in linkedTriples) } } From 6fdf0e820294e9b0b1b2f26f6917601a26f94f02 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 5 Sep 2026 01:37:44 +0200 Subject: [PATCH 2/4] feat: add suspicion reasons and enhance autofill activity descriptions --- .../presentation/activity/AutofillActivity.kt | 3 +- .../activity/component/SuspicionDialog.kt | 37 +++++++++++++++++-- .../activity/model/SuspicionReason.kt | 6 +++ .../autofill/src/main/res/values/strings.xml | 5 ++- 4 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/SuspicionReason.kt diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt index 2c273329d..a1594b5db 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt @@ -156,7 +156,8 @@ internal class AutofillActivity : FragmentActivity() { onContinue = { viewModel.onEvent(AutofillUiEvent.OnContinueInSuspicion) }, onAbort = { viewModel.onEvent(AutofillUiEvent.OnAbortInSuspicion) }, appPackageName = suspicionDialogVisibility.appPackageName, - website = suspicionDialogVisibility.website + website = suspicionDialogVisibility.website, + reason = suspicionDialogVisibility.reason ) diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/component/SuspicionDialog.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/component/SuspicionDialog.kt index 009466e95..fcd66c834 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/component/SuspicionDialog.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/component/SuspicionDialog.kt @@ -13,7 +13,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import de.davis.keygo.core.ui.text.htmlStringResource import de.davis.keygo.feature.autofill.R +import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionReason @Composable internal fun SuspicionDialog( @@ -21,6 +23,7 @@ internal fun SuspicionDialog( onAbort: () -> Unit, appPackageName: String, website: String, + reason: SuspicionReason, modifier: Modifier = Modifier ) { AlertDialog( @@ -43,12 +46,22 @@ internal fun SuspicionDialog( Icon(imageVector = Icons.Default.WarningAmber, contentDescription = null) }, title = { - Text(text = stringResource(R.string.suspicious_activity)) + Text( + text = htmlStringResource( + when (reason) { + SuspicionReason.NotLinked -> R.string.suspicious_activity + SuspicionReason.Unverified -> R.string.unverified_activity + }, + ) + ) }, text = { Text( - text = stringResource( - R.string.suspicious_activity_description, + text = htmlStringResource( + when (reason) { + SuspicionReason.NotLinked -> R.string.suspicious_activity_description + SuspicionReason.Unverified -> R.string.unverified_activity_description + }, appPackageName, website ) @@ -67,7 +80,23 @@ private fun SuspicionDialogPreview() { onAbort = {}, appPackageName = "com.example.app", website = "example.com", + reason = SuspicionReason.NotLinked, modifier = Modifier.fillMaxWidth() ) } -} \ No newline at end of file +} + +@Preview +@Composable +private fun SuspicionDialogUnverifiedPreview() { + MaterialTheme { + SuspicionDialog( + onContinue = {}, + onAbort = {}, + appPackageName = "com.example.app", + website = "example.com", + reason = SuspicionReason.Unverified, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/SuspicionReason.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/SuspicionReason.kt new file mode 100644 index 000000000..151194111 --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/SuspicionReason.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.autofill.presentation.activity.model + +internal enum class SuspicionReason { + NotLinked, + Unverified, +} diff --git a/feature/autofill/src/main/res/values/strings.xml b/feature/autofill/src/main/res/values/strings.xml index f7163096c..b2bdc128a 100644 --- a/feature/autofill/src/main/res/values/strings.xml +++ b/feature/autofill/src/main/res/values/strings.xml @@ -4,7 +4,10 @@ Fill anyway Suspicious Activity - This app (%1$s) isn\'t a recognized browser. It is showing a website (%2$s). Autofilling here may give your credentials to this app. Only continue if you trust this app and the site. + This app (<b>%1$s</b>) isn\'t a recognized browser. It is showing a website (<b>%2$s</b>). Autofilling here may give your credentials to this app. Only continue if you trust this app and the site. + + Couldn\'t verify this app + This app (<b>%1$s</b>) isn\'t a recognized browser, and it wasn\'t possible to check whether it is allowed to use <b>%2$s</b>. The verification service couldn\'t be reached, which usually means this device is offline or this app has no network access. Only continue if you trust this app and the site. Suggest Suggest this item From 703a5041722e20ce6b505d0ba741b62eb1e3c622 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 5 Sep 2026 14:16:14 +0200 Subject: [PATCH 3/4] feat: enhance digital asset link handling with support for multiple signatures and improved timeout settings --- .../DigitalAssetLinkRepositoryImpl.kt | 241 ++++++++++-- .../feature/autofill/di/AutofillModule.kt | 2 + .../repository/DigitalAssetLinkRepository.kt | 2 +- .../usecase/IsAppLinkedToWebsiteUseCase.kt | 36 +- .../DigitalAssetLinkRepositoryImplTest.kt | 348 ++++++++++++++++-- .../IsAppLinkedToWebsiteUseCaseTest.kt | 105 +++--- .../activity/AutofillViewModelTest.kt | 10 +- .../FakeDigitalAssetLinkRepository.kt | 51 ++- 8 files changed, 642 insertions(+), 153 deletions(-) diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt index eb2aa7f0f..028970970 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt @@ -4,60 +4,239 @@ import android.util.Log import de.davis.keygo.core.util.Result import de.davis.keygo.feature.autofill.domain.model.DigitalAssetLinkFailure import de.davis.keygo.feature.autofill.domain.repository.DigitalAssetLinkRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request -import org.json.JSONException -import org.json.JSONObject +import okhttp3.Response import org.koin.core.annotation.Single import java.io.IOException -import java.net.URLEncoder +import kotlin.coroutines.resume +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource @Single internal class DigitalAssetLinkRepositoryImpl( - private val http: OkHttpClient + http: OkHttpClient, ) : DigitalAssetLinkRepository { + /** + * A statement list only counts at its own well-known location, so an open redirect on the + * site must not be able to source it from somewhere else, and the user is waiting on a dialog + * while this runs. + */ + private val http = http.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + override suspend fun isLinked( packageName: String, - signature: String, domain: String, - ): Result = withContext(Dispatchers.IO) { - fun enc(s: String) = URLEncoder.encode(s, "UTF-8") - val request = Request.Builder() - .url(API_ENDPOINT.format(enc(domain), enc(packageName), enc(signature))) - .build() + signatures: Set, + ): Result { + val url = domain.statementListUrl() + if (url == null) { + Log.w(TAG, "Could not build a statement list URL for $domain") + return Result.Failure(DigitalAssetLinkFailure.NoVerdict) + } + + val fingerprints = signatures.mapTo(mutableSetOf()) { it.canonicalFingerprint() } + + return search(packageName = packageName, root = url, fingerprints = fingerprints) + } + + private suspend fun search( + packageName: String, + root: HttpUrl, + fingerprints: Set, + ): Result { + val deadline = TimeSource.Monotonic.markNow() + LOOKUP_BUDGET + + val queue = ArrayDeque(listOf(Pending(url = root, depth = 0))) + val visited = mutableSetOf(root) + + var fetches = 0 + var firstFailure: DigitalAssetLinkFailure? = null + var truncated = false + + while (queue.isNotEmpty()) { + if (deadline.hasPassedNow()) { + Log.w(TAG, "Gave up on $root after $LOOKUP_BUDGET, ${queue.size} list(s) unread") + truncated = true + break + } + + val (url, depth) = queue.removeFirst() + fetches++ - try { - http.newCall(request).execute().use { response -> - if (!response.isSuccessful) - return@use Result.Failure(DigitalAssetLinkFailure.NoVerdict) + val statements = when (val answer = fetch(url)) { + is Result.Failure -> { + if (firstFailure == null) firstFailure = answer.error + continue + } - Result.Success(JSONObject(response.body.string()).optBoolean("linked", false)) + is Result.Success -> answer.success } - } catch (e: IOException) { - // The autofill dialog runs wherever the user happens to be, so being offline, behind a - // captive portal or on a broken DNS is normal. None of that is a verdict. - Log.w(TAG, "Could not reach the digital asset link API for $domain", e) - Result.Failure(DigitalAssetLinkFailure.Unreachable) - } catch (e: JSONException) { - Log.w(TAG, "Digital asset link API returned an unreadable body for $domain", e) - Result.Failure(DigitalAssetLinkFailure.NoVerdict) + + if (statements.any { it.links(packageName, fingerprints) }) + return Result.Success(true) + + val includes = statements.mapNotNull { it.include?.includeUrl() } + if (includes.isEmpty()) continue + + if (depth == MAX_INCLUDE_DEPTH) { + Log.w(TAG, "Not following the include(s) at $url, depth $depth is the limit") + truncated = true + continue + } + + includes.forEach { include -> + if (include in visited) return@forEach + + // Counting what is already queued keeps a single very wide list from claiming the + // whole budget before anything below it has been read. + if (fetches + queue.size >= MAX_STATEMENT_FETCHES) { + Log.w(TAG, "Not queueing $include, $MAX_STATEMENT_FETCHES lists is the limit") + truncated = true + return@forEach + } + + visited += include + queue += Pending(url = include, depth = depth + 1) + } + } + + return when { + firstFailure != null -> Result.Failure(firstFailure) + truncated -> Result.Failure(DigitalAssetLinkFailure.NoVerdict) + else -> Result.Success(false) + } + } + + private suspend fun fetch( + url: HttpUrl, + ): Result, DigitalAssetLinkFailure> = + suspendCancellableCoroutine { cont -> + val call = http.newCall(Request.Builder().url(url).build()) + cont.invokeOnCancellation { call.cancel() } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.w(TAG, "Could not reach the statement list at $url", e) + cont.resume(Result.Failure(DigitalAssetLinkFailure.Unreachable)) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response.use { it.readStatements(url) }) + } + }) + } + + private fun Response.readStatements( + url: HttpUrl, + ): Result, DigitalAssetLinkFailure> { + if (!isSuccessful) { + // No statement list means the site delegated nothing to anyone, which is a real answer. + // Only a server that failed to answer at all leaves the question open. + if (code in ABSENT_STATEMENT_LIST) return Result.Success(emptyList()) + + Log.w(TAG, "Statement list at $url answered with $code") + return Result.Failure(DigitalAssetLinkFailure.NoVerdict) + } + + // peekBody caps what a hostile or misconfigured host can make us hold in memory. A list + // larger than the cap arrives truncated and fails to parse, which is the outcome we want. + val statements = runCatching { + json.decodeFromString>(peekBody(MAX_BODY_BYTES).string()) + }.getOrElse { + Log.w(TAG, "Statement list at $url is unreadable", it) + return Result.Failure(DigitalAssetLinkFailure.NoVerdict) + } + + return Result.Success(statements) + } + + private fun AssetLinkStatement.links(packageName: String, fingerprints: Set): Boolean { + if (RELATION !in relation) return false + if (target.namespace != ANDROID_APP_NAMESPACE) return false + if (target.packageName != packageName) return false + + return target.fingerprints.any { it.canonicalFingerprint() in fingerprints } + } + + private fun String.statementListUrl(): HttpUrl? { + val absolute = if ("://" in this) this else "https://$this" + + return absolute.toHttpUrlOrNull() + ?.newBuilder() + ?.scheme("https") + ?.username("") + ?.password("") + ?.encodedPath(WELL_KNOWN_PATH) + ?.query(null) + ?.fragment(null) + ?.build() + } + + private fun String.includeUrl(): HttpUrl? { + val url = toHttpUrlOrNull() + if (url == null) { + Log.w(TAG, "Ignoring an include that is not an absolute URL") + return null } + + if (!url.isHttps) { + Log.w(TAG, "Ignoring the plaintext include at $url") + return null + } + + return url.newBuilder() + .username("") + .password("") + .fragment(null) + .build() } + private fun String.canonicalFingerprint() = + filterNot { it == ':' || it.isWhitespace() }.uppercase() + companion object { private const val TAG = "DigitalAssetLink" private const val RELATION = "delegate_permission/common.get_login_creds" + private const val ANDROID_APP_NAMESPACE = "android_app" + private const val WELL_KNOWN_PATH = "/.well-known/assetlinks.json" + private const val MAX_BODY_BYTES = 512L * 1024 + private val ABSENT_STATEMENT_LIST = setOf(404, 410) + + private const val MAX_INCLUDE_DEPTH = 3 + private const val MAX_STATEMENT_FETCHES = 8 + private val LOOKUP_BUDGET = 8.seconds - private const val API_ENDPOINT = - "https://digitalassetlinks.googleapis.com/v1/assetlinks:check" + - "?source.web.site=%s" + - "&relation=$RELATION" + - "&target.androidApp.packageName=%s" + - "&target.androidApp.certificate.sha256Fingerprint=%s" + private val json = Json { ignoreUnknownKeys = true } } } + +private data class Pending(val url: HttpUrl, val depth: Int) + +@Serializable +private data class AssetLinkStatement( + val relation: List = emptyList(), + val target: AssetLinkTarget = AssetLinkTarget(), + val include: String? = null, +) + +@Serializable +private data class AssetLinkTarget( + val namespace: String? = null, + @SerialName("package_name") val packageName: String? = null, + @SerialName("sha256_cert_fingerprints") val fingerprints: List = emptyList(), +) diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt index 2ff2923f6..9ebc43c12 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt @@ -14,6 +14,7 @@ import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module import org.koin.core.annotation.Single +import kotlin.time.Duration.Companion.seconds @Module(includes = [CoreIdentityModule::class, CoreUtilModule::class]) @Configuration @@ -35,6 +36,7 @@ object AutofillModule { maxSize = 3 * 1024 * 1024 // 3 MB ) ) + .callTimeout(5.seconds) .build() @Single diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/DigitalAssetLinkRepository.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/DigitalAssetLinkRepository.kt index 793e17a54..fa4df0929 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/DigitalAssetLinkRepository.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/DigitalAssetLinkRepository.kt @@ -7,7 +7,7 @@ interface DigitalAssetLinkRepository { suspend fun isLinked( packageName: String, - signature: String, domain: String, + signatures: Set, ): Result } diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt index 651abc37a..7635016da 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt @@ -1,11 +1,9 @@ package de.davis.keygo.feature.autofill.domain.usecase -import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.fold import de.davis.keygo.feature.autofill.domain.SignatureInfoProvider import de.davis.keygo.feature.autofill.domain.model.WebsiteLinkStatus import de.davis.keygo.feature.autofill.domain.repository.DigitalAssetLinkRepository -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.coroutineScope import org.koin.core.annotation.Single @Single @@ -14,30 +12,20 @@ class IsAppLinkedToWebsiteUseCase( private val signatureInfoProvider: SignatureInfoProvider, ) { - @OptIn(ExperimentalCoroutinesApi::class) suspend operator fun invoke( packageName: String, - domain: String - ): WebsiteLinkStatus = coroutineScope { + domain: String, + ): WebsiteLinkStatus { val signatures = signatureInfoProvider.getSignatureInfo(packageName) - if (signatures.isEmpty()) return@coroutineScope WebsiteLinkStatus.NotLinked + if (signatures.isEmpty()) return WebsiteLinkStatus.NotLinked - var anyLookupFailed = false - signatures.forEach { sign -> - val verdict = digitalAssetLinkCheck.isLinked( - packageName = packageName, - signature = sign, - domain = domain - ) - - when (verdict) { - is Result.Success -> - if (verdict.success) return@coroutineScope WebsiteLinkStatus.Linked - - is Result.Failure -> anyLookupFailed = true - } - } - - if (anyLookupFailed) WebsiteLinkStatus.Unverified else WebsiteLinkStatus.NotLinked + return digitalAssetLinkCheck.isLinked( + packageName = packageName, + domain = domain, + signatures = signatures, + ).fold( + onSuccess = { if (it) WebsiteLinkStatus.Linked else WebsiteLinkStatus.NotLinked }, + onFailure = { WebsiteLinkStatus.Unverified } + ) } } diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImplTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImplTest.kt index 87a2cfa98..f6251e273 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImplTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImplTest.kt @@ -16,6 +16,7 @@ import org.robolectric.annotation.Config import java.net.InetAddress import java.net.UnknownHostException import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals @RunWith(RobolectricTestRunner::class) @@ -23,7 +24,7 @@ import kotlin.test.assertEquals internal class DigitalAssetLinkRepositoryImplTest { @Test - fun `unresolvable host reports the api as unreachable`() = runTest { + fun `unresolvable host reports the site as unreachable`() = runTest { val repository = DigitalAssetLinkRepositoryImpl( OkHttpClient.Builder() .dns(object : Dns { @@ -35,72 +36,373 @@ internal class DigitalAssetLinkRepositoryImplTest { assertEquals( Result.Failure(DigitalAssetLinkFailure.Unreachable), - repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN), + repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), ) } @Test - fun `linked verdict is reported`() = runTest { - val repository = repositoryAnswering(body = """{"linked": true}""") + fun `a statement naming the app and signature is linked`() = runTest { + val site = FakeSite(WELL_KNOWN_URL to Answer(body = statementList())) - assertEquals(Result.Success(true), repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN)) + assertEquals( + Result.Success(true), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) } @Test - fun `unlinked verdict is reported`() = runTest { - val repository = repositoryAnswering(body = """{"linked": false}""") + fun `a fingerprint written in another case or without colons still matches`() = runTest { + val published = listOf( + "a1:b2:c3:d4", + "A1B2C3D4", + "a1b2c3d4", + " A1:B2:C3:D4 ", + ) + + published.forEach { fingerprint -> + val site = FakeSite(WELL_KNOWN_URL to Answer(body = statementList(fingerprint))) - assertEquals(Result.Success(false), repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN)) + assertEquals( + Result.Success(true), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + "expected $fingerprint to match $SIGNATURE", + ) + } } @Test - fun `missing linked flag is treated as unlinked`() = runTest { - val repository = repositoryAnswering(body = "{}") + fun `a site without a statement list is a verdict, not a gap`() = runTest { + listOf(404, 410).forEach { code -> + val site = FakeSite(WELL_KNOWN_URL to Answer(code = code, body = "not found")) - assertEquals(Result.Success(false), repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN)) + assertEquals( + Result.Success(false), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + "expected $code to answer that nothing is published", + ) + } } @Test - fun `error status reports no verdict`() = runTest { - val repository = repositoryAnswering(code = 503, body = "unavailable") + fun `a server that fails to answer leaves the question open`() = runTest { + listOf(500, 503, 429).forEach { code -> + val site = FakeSite(WELL_KNOWN_URL to Answer(code = code, body = "nope")) + + assertEquals( + Result.Failure(DigitalAssetLinkFailure.NoVerdict), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + "expected $code to leave the question open", + ) + } + } + + @Test + fun `a hostile or malformed statement list never escapes as an exception`() = runTest { + val bodies = listOf( + """[{"relation": {}}]""", + """[{"relation": [{}]}]""", + """[{"target": {"sha256_cert_fingerprints": "not-a-list"}}]""", + """[{"target": []}]""", + """{"relation": []}""", + """[[]]""", + "null", + "not json", + "", + ) + + bodies.forEach { body -> + val site = FakeSite(WELL_KNOWN_URL to Answer(body = body)) + + assertEquals( + Result.Failure(DigitalAssetLinkFailure.NoVerdict), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + "expected $body to be reported as unreadable", + ) + } + } + + @Test + fun `a statement list larger than the cap is not parsed`() = runTest { + val padding = "A".repeat(600 * 1024) + val oversized = + statementList().dropLast(2) + ""","pad":"$padding"}]""" + val site = FakeSite(WELL_KNOWN_URL to Answer(body = oversized)) assertEquals( Result.Failure(DigitalAssetLinkFailure.NoVerdict), - repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), ) } @Test - fun `unreadable body reports no verdict`() = runTest { - val repository = repositoryAnswering(body = "not json") + fun `statements for another app, relation or namespace do not link`() = runTest { + val bodies = listOf( + statementList(relation = "delegate_permission/common.handle_all_urls"), + statementList(namespace = "web"), + statementList(packageName = "com.other.app"), + statementList(fingerprint = "FF:FF:FF:FF"), + "[]", + ) + + bodies.forEach { body -> + val site = FakeSite(WELL_KNOWN_URL to Answer(body = body)) + + assertEquals( + Result.Success(false), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + "expected $body not to link", + ) + } + } + + @Test + fun `a plaintext page is still verified over https`() = runTest { + val site = FakeSite(WELL_KNOWN_URL to Answer(body = statementList())) + + val result = site.repository.isLinked( + packageName = PACKAGE_NAME, + domain = "http://example.com/login?redirect=%2Fhome#form", + signatures = setOf(SIGNATURE), + ) + + assertEquals(Result.Success(true), result) + assertEquals(listOf(WELL_KNOWN_URL), site.requested) + } + + @Test + fun `only the origin of the page reaches the site`() = runTest { + val origins = mapOf( + "https://example.com" to WELL_KNOWN_URL, + "https://example.com/a/b?q=1#f" to WELL_KNOWN_URL, + "https://EXAMPLE.com" to WELL_KNOWN_URL, + "http://example.com:80" to WELL_KNOWN_URL, + "example.com" to WELL_KNOWN_URL, + // Whatever the page URL carried, no credentials travel to the site. + "https://user:pw@example.com/a/b" to WELL_KNOWN_URL, + // A statement list belongs to an origin, so a real port is part of the address. + "http://example.com:8080/x" to "https://example.com:8080/.well-known/assetlinks.json", + ) + + origins.forEach { (domain, expected) -> + val site = FakeSite(WELL_KNOWN_URL to Answer(body = statementList())) + + site.repository.isLinked(PACKAGE_NAME, domain, setOf(SIGNATURE)) + + assertEquals(listOf(expected), site.requested, "unexpected URL for $domain") + } + } + + @Test + fun `a domain that is not an address is never requested`() = runTest { + val site = FakeSite(WELL_KNOWN_URL to Answer(body = statementList())) + + assertEquals( + Result.Failure(DigitalAssetLinkFailure.NoVerdict), + site.repository.isLinked(PACKAGE_NAME, "https://not a host", setOf(SIGNATURE)), + ) + assertEquals(emptyList(), site.requested) + } + + @Test + fun `a delegated statement list is followed`() = runTest { + val site = FakeSite( + WELL_KNOWN_URL to Answer(body = """[{"include": "$INCLUDE_URL"}]"""), + INCLUDE_URL to Answer(body = statementList()), + ) + + assertEquals( + Result.Success(true), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + assertContains(site.requested, INCLUDE_URL) + } + + @Test + fun `a delegation we cannot read is not a clean no`() = runTest { + val site = FakeSite( + WELL_KNOWN_URL to Answer(body = """[{"include": "$INCLUDE_URL"}]"""), + INCLUDE_URL to Answer(code = 503, body = "nope"), + ) assertEquals( Result.Failure(DigitalAssetLinkFailure.NoVerdict), - repository.isLinked(PACKAGE_NAME, SIGNATURE, DOMAIN), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + } + + @Test + fun `a delegation over plaintext is ignored`() = runTest { + val site = FakeSite( + WELL_KNOWN_URL to + Answer(body = """[{"include": "http://cdn.example.com/statements.json"}]"""), ) + + assertEquals( + Result.Success(false), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + assertEquals(listOf(WELL_KNOWN_URL), site.requested) } - private fun repositoryAnswering(code: Int = 200, body: String) = - DigitalAssetLinkRepositoryImpl( + @Test + fun `an include that is not an absolute https URL is ignored`() = runTest { + val ignored = listOf( + "/statements.json", + "cdn.example.com/statements.json", + "file:///etc/passwd", + "not a url", + "", + ) + + ignored.forEach { include -> + val site = FakeSite(WELL_KNOWN_URL to Answer(body = includeList(include))) + + assertEquals( + Result.Success(false), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + "expected $include not to be followed", + ) + assertEquals(listOf(WELL_KNOWN_URL), site.requested, "unexpected request for $include") + } + } + + @Test + fun `a cycle between statement lists still ends`() = runTest { + val site = FakeSite( + WELL_KNOWN_URL to Answer(body = includeList(INCLUDE_URL)), + INCLUDE_URL to Answer(body = includeList(WELL_KNOWN_URL)), + ) + + assertEquals( + Result.Success(false), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + assertEquals(listOf(WELL_KNOWN_URL, INCLUDE_URL), site.requested) + } + + @Test + fun `a list reachable by two paths is read once`() = runTest { + val left = "https://cdn.example.com/left.json" + val right = "https://cdn.example.com/right.json" + val shared = "https://cdn.example.com/shared.json" + + val site = FakeSite( + WELL_KNOWN_URL to Answer(body = includeList(left, right)), + left to Answer(body = includeList(shared)), + right to Answer(body = includeList(shared)), + shared to Answer(body = "[]"), + ) + + assertEquals( + Result.Success(false), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + assertEquals(1, site.requested.count { it == shared }) + } + + @Test + fun `a delegation chain deeper than the limit is not a clean no`() = runTest { + val chain = (1..5).map { "https://cdn.example.com/$it.json" } + val site = FakeSite( + WELL_KNOWN_URL to Answer(body = includeList(chain.first())), + *chain.zipWithNext() + .map { (from, to) -> from to Answer(body = includeList(to)) } + .toTypedArray(), + chain.last() to Answer(body = statementList()), + ) + + assertEquals( + Result.Failure(DigitalAssetLinkFailure.NoVerdict), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + assertEquals(listOf(WELL_KNOWN_URL) + chain.take(3), site.requested) + } + + @Test + fun `a list wider than the fetch budget is not a clean no`() = runTest { + val fanOut = (1..20).map { "https://cdn.example.com/$it.json" } + val site = FakeSite(WELL_KNOWN_URL to Answer(body = includeList(*fanOut.toTypedArray()))) + + assertEquals( + Result.Failure(DigitalAssetLinkFailure.NoVerdict), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + assertEquals(8, site.requested.size) + } + + @Test + fun `an unreadable delegation does not hide a valid one`() = runTest { + val broken = "https://cdn.example.com/broken.json" + + val site = FakeSite( + WELL_KNOWN_URL to Answer(body = includeList(broken, INCLUDE_URL)), + broken to Answer(code = 503, body = "nope"), + INCLUDE_URL to Answer(body = statementList()), + ) + + assertEquals( + Result.Success(true), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + } + + private data class Answer(val code: Int = 200, val body: String) + + /** + * Serves canned bodies per URL and records what was asked for. Anything not configured answers + * 404, which is what a site that publishes no statement list does. + */ + private class FakeSite(vararg answers: Pair) { + + private val answers = answers.toMap() + + val requested: MutableList = mutableListOf() + + val repository = DigitalAssetLinkRepositoryImpl( OkHttpClient.Builder() .addInterceptor( Interceptor { chain -> + val url = chain.request().url.toString() + requested += url + + val answer = this.answers[url] ?: Answer(code = 404, body = "") Response.Builder() .request(chain.request()) .protocol(Protocol.HTTP_1_1) - .code(code) + .code(answer.code) .message("synthetic") - .body(body.toResponseBody("application/json".toMediaType())) + .body(answer.body.toResponseBody("application/json".toMediaType())) .build() }, ) .build(), ) + } companion object { - private const val PACKAGE_NAME = "com.example" - private const val SIGNATURE = "AA:BB:CC" + private const val PACKAGE_NAME = "com.example.app" + + private const val SIGNATURE = "A1:B2:C3:D4" + private const val DOMAIN = "https://example.com" + + private const val WELL_KNOWN_URL = "https://example.com/.well-known/assetlinks.json" + + private const val INCLUDE_URL = "https://cdn.example.com/statements.json" + + private const val RELATION = "delegate_permission/common.get_login_creds" + + private fun includeList(vararg urls: String) = + urls.joinToString(prefix = "[", postfix = "]") { """{"include":"$it"}""" } + + private fun statementList( + fingerprint: String = SIGNATURE, + relation: String = RELATION, + namespace: String = "android_app", + packageName: String = PACKAGE_NAME, + ) = """ + [{"relation":["$relation"],"target":{"namespace":"$namespace","package_name":"$packageName","sha256_cert_fingerprints":["$fingerprint"]}}] + """.trimIndent() } } diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCaseTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCaseTest.kt index b574ea9a9..83801a002 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCaseTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/IsAppLinkedToWebsiteUseCaseTest.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.autofill.domain.usecase import de.davis.keygo.core.feature.autofill.FakeDigitalAssetLinkRepository import de.davis.keygo.core.feature.autofill.FakeSignatureInfoProvider +import de.davis.keygo.feature.autofill.domain.model.DigitalAssetLinkFailure import de.davis.keygo.feature.autofill.domain.model.WebsiteLinkStatus import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest @@ -25,83 +26,75 @@ class IsAppLinkedToWebsiteUseCaseTest { @Test fun `empty signatures returns unlinked without querying digital asset links`() = runTest { - val packageName = "com.example.app" - val domain = "example.com" - signatureInfoProvider.signatures = mapOf(packageName to emptySet()) + signatureInfoProvider.signatures = mapOf(PACKAGE_NAME to emptySet()) - val result = useCase(packageName, domain) + val result = useCase(PACKAGE_NAME, DOMAIN) assertEquals(WebsiteLinkStatus.NotLinked, result) - assertTrue(digitalAssetLinkRepository.linkedCalls.isEmpty()) + assertTrue(digitalAssetLinkRepository.lookups.isEmpty()) } @Test - fun `one linked signature returns linked`() = runTest { - val packageName = "com.example.app" - val signature = "ABCD1234" - val domain = "example.com" - signatureInfoProvider.signatures = mapOf(packageName to setOf(signature)) - digitalAssetLinkRepository.linkedTriples = setOf(Triple(packageName, signature, domain)) - - val result = useCase(packageName, domain) + fun `a linked signature returns linked`() = runTest { + signatureInfoProvider.signatures = mapOf(PACKAGE_NAME to setOf(SIGNATURE)) + digitalAssetLinkRepository.links = setOf( + FakeDigitalAssetLinkRepository.Link(PACKAGE_NAME, DOMAIN, SIGNATURE), + ) - assertEquals(WebsiteLinkStatus.Linked, result) + assertEquals(WebsiteLinkStatus.Linked, useCase(PACKAGE_NAME, DOMAIN)) } @Test - fun `all unlinked signatures returns unlinked`() = runTest { - val packageName = "com.example.app" - val sig1 = "SIGNATURE_1" - val sig2 = "SIGNATURE_2" - val domain = "example.com" - signatureInfoProvider.signatures = mapOf(packageName to setOf(sig1, sig2)) - digitalAssetLinkRepository.linkedTriples = emptySet() + fun `an unlinked app returns unlinked`() = runTest { + signatureInfoProvider.signatures = mapOf(PACKAGE_NAME to setOf(SIGNATURE, OTHER_SIGNATURE)) + digitalAssetLinkRepository.links = emptySet() - val result = useCase(packageName, domain) - - assertEquals(WebsiteLinkStatus.NotLinked, result) + assertEquals(WebsiteLinkStatus.NotLinked, useCase(PACKAGE_NAME, DOMAIN)) } @Test - fun `first linked signature returns linked`() = runTest { - val packageName = "com.example.app" - val sig1 = "SIGNATURE_1" - val sig2 = "SIGNATURE_2" - val domain = "example.com" - signatureInfoProvider.signatures = mapOf(packageName to setOf(sig1, sig2)) - digitalAssetLinkRepository.linkedTriples = setOf(Triple(packageName, sig1, domain)) - - val result = useCase(packageName, domain) + fun `every signature is offered to the site in one lookup`() = runTest { + val signatures = setOf(SIGNATURE, OTHER_SIGNATURE) + signatureInfoProvider.signatures = mapOf(PACKAGE_NAME to signatures) + digitalAssetLinkRepository.links = setOf( + FakeDigitalAssetLinkRepository.Link(PACKAGE_NAME, DOMAIN, OTHER_SIGNATURE), + ) - assertEquals(WebsiteLinkStatus.Linked, result) + assertEquals(WebsiteLinkStatus.Linked, useCase(PACKAGE_NAME, DOMAIN)) + assertEquals( + listOf(FakeDigitalAssetLinkRepository.Lookup(PACKAGE_NAME, DOMAIN, signatures)), + digitalAssetLinkRepository.lookups, + ) } @Test - fun `failed lookup is not treated as linked`() = runTest { - val packageName = "com.example.app" - val signature = "ABCD1234" - val domain = "example.com" - signatureInfoProvider.signatures = mapOf(packageName to setOf(signature)) - digitalAssetLinkRepository.linkedTriples = setOf(Triple(packageName, signature, domain)) - digitalAssetLinkRepository.failingSignatures = setOf(signature) - - val result = useCase(packageName, domain) + fun `a failed lookup is unverified rather than unlinked`() = runTest { + signatureInfoProvider.signatures = mapOf(PACKAGE_NAME to setOf(SIGNATURE)) + digitalAssetLinkRepository.failingDomains = setOf(DOMAIN) - assertEquals(WebsiteLinkStatus.Unverified, result) + assertEquals(WebsiteLinkStatus.Unverified, useCase(PACKAGE_NAME, DOMAIN)) } @Test - fun `a proven signature wins over a failed lookup`() = runTest { - val packageName = "com.example.app" - val failing = "SIGNATURE_1" - val linked = "SIGNATURE_2" - val domain = "example.com" - signatureInfoProvider.signatures = mapOf(packageName to setOf(failing, linked)) - digitalAssetLinkRepository.linkedTriples = setOf(Triple(packageName, linked, domain)) - digitalAssetLinkRepository.failingSignatures = setOf(failing) - - val result = useCase(packageName, domain) - - assertEquals(WebsiteLinkStatus.Linked, result) + fun `a failed lookup is never treated as linked`() = runTest { + signatureInfoProvider.signatures = mapOf(PACKAGE_NAME to setOf(SIGNATURE)) + digitalAssetLinkRepository.links = setOf( + FakeDigitalAssetLinkRepository.Link(PACKAGE_NAME, DOMAIN, SIGNATURE), + ) + digitalAssetLinkRepository.failingDomains = setOf(DOMAIN) + digitalAssetLinkRepository.failure = DigitalAssetLinkFailure.NoVerdict + + assertEquals(WebsiteLinkStatus.Unverified, useCase(PACKAGE_NAME, DOMAIN)) + } + + companion object { + + private const val PACKAGE_NAME = "com.example.app" + + private const val DOMAIN = "https://example.com" + + private const val SIGNATURE = "A1:B2:C3:D4" + + private const val OTHER_SIGNATURE = "E5:F6:07:18" } } diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt index 4365ca8dd..e124e3adb 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt @@ -214,7 +214,9 @@ internal class AutofillViewModelTest { @Test fun `suspicious form linked to website shows no suspicion dialog`() = runTest { signatureProvider.signatures = mapOf("com.example" to setOf("sig1")) - dalRepo.linkedTriples = setOf(Triple("com.example", "sig1", "https://example.com")) + dalRepo.links = setOf( + FakeDigitalAssetLinkRepository.Link("com.example", "https://example.com", "sig1"), + ) val requestData = FillRequestData.App( form(isSuspicious = true, url = "https://example.com", appPackageName = "com.example"), ) @@ -228,8 +230,10 @@ internal class AutofillViewModelTest { @Test fun `suspicious form whose lookup fails shows the unverified dialog`() = runTest { signatureProvider.signatures = mapOf("com.example" to setOf("sig1")) - dalRepo.linkedTriples = setOf(Triple("com.example", "sig1", "https://example.com")) - dalRepo.failingSignatures = setOf("sig1") + dalRepo.links = setOf( + FakeDigitalAssetLinkRepository.Link("com.example", "https://example.com", "sig1"), + ) + dalRepo.failingDomains = setOf("https://example.com") val requestData = FillRequestData.App( form(isSuspicious = true, url = "https://example.com", appPackageName = "com.example"), ) diff --git a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeDigitalAssetLinkRepository.kt b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeDigitalAssetLinkRepository.kt index 2f03701da..3785924cc 100644 --- a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeDigitalAssetLinkRepository.kt +++ b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeDigitalAssetLinkRepository.kt @@ -3,37 +3,58 @@ package de.davis.keygo.core.feature.autofill import de.davis.keygo.core.util.Result import de.davis.keygo.feature.autofill.domain.model.DigitalAssetLinkFailure import de.davis.keygo.feature.autofill.domain.repository.DigitalAssetLinkRepository +import kotlinx.coroutines.CompletableDeferred /** * In-memory [DigitalAssetLinkRepository] for tests. * - * Set [linkedTriples] to configure which (packageName, signature, domain) combinations are linked, - * and [failingSignatures] to make a lookup come back without a verdict. Inspect [linkedCalls] to - * verify which combinations were queried. + * Set [links] to configure which app proves which domain with which signature, and [failingDomains] + * to make a domain answer with [failure] instead of a verdict. Setting [gate] makes each lookup + * suspend on it first, which lets a test hold one open and check what is on screen meanwhile. + * Inspect [lookups] to see what was asked. */ class FakeDigitalAssetLinkRepository : DigitalAssetLinkRepository { - // Configurable: set of (packageName, signature, domain) triples that are "linked" - var linkedTriples: Set> = emptySet() - // Configurable: signatures whose lookup fails with [failure] instead of returning a verdict - var failingSignatures: Set = emptySet() + /** One statement a site publishes: [signature] proves [packageName] owns [domain]. */ + data class Link( + val packageName: String, + val domain: String, + val signature: String, + ) - // Configurable: the failure reported for [failingSignatures] + /** A lookup answers true when any queried signature matches one of these. */ + var links: Set = emptySet() + + /** Domains whose statement list cannot be read; they answer with [failure]. */ + var failingDomains: Set = emptySet() + + /** The failure reported for [failingDomains]. */ var failure: DigitalAssetLinkFailure = DigitalAssetLinkFailure.Unreachable - // Track calls for assertion (e.g., fake.linkedCalls.isEmpty() or fake.linkedCalls.contains(Triple(...))) - val linkedCalls: MutableList> = mutableListOf() + /** When set, every lookup suspends on it before answering. */ + var gate: CompletableDeferred? = null + + /** Every lookup, in order, for assertions such as `lookups.isEmpty()`. */ + val lookups: MutableList = mutableListOf() + + data class Lookup( + val packageName: String, + val domain: String, + val signatures: Set, + ) override suspend fun isLinked( packageName: String, - signature: String, domain: String, + signatures: Set, ): Result { - val call = Triple(packageName, signature, domain) - linkedCalls += call + lookups += Lookup(packageName, domain, signatures) + gate?.await() - if (signature in failingSignatures) return Result.Failure(failure) + if (domain in failingDomains) return Result.Failure(failure) - return Result.Success(call in linkedTriples) + return Result.Success( + signatures.any { Link(packageName, domain, it) in links }, + ) } } From 36584ca76406c6513b1f763c0307ef8db75b1b57 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 5 Sep 2026 17:18:03 +0200 Subject: [PATCH 4/4] feat: add link check dialog handling with visibility state and cancellation support --- .../presentation/activity/AutofillActivity.kt | 9 ++ .../activity/AutofillViewModel.kt | 49 +++++++-- .../component/LinkCheckPendingDialog.kt | 78 ++++++++++++++ .../activity/model/AutofillUiEvent.kt | 2 + .../model/LinkCheckDialogVisibility.kt | 7 ++ .../presentation/model/AutofillUiState.kt | 2 + .../autofill/src/main/res/values/strings.xml | 3 + .../activity/AutofillViewModelTest.kt | 102 ++++++++++++++++++ 8 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/component/LinkCheckPendingDialog.kt create mode 100644 feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/LinkCheckDialogVisibility.kt diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt index a1594b5db..d9f81d865 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt @@ -27,11 +27,13 @@ import de.davis.keygo.core.util.onSuccess import de.davis.keygo.core.util.presentation.ObserveAsEvents import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.autofill.presentation.activity.component.AssociationDialog +import de.davis.keygo.feature.autofill.presentation.activity.component.LinkCheckPendingDialog import de.davis.keygo.feature.autofill.presentation.activity.component.SmsCodePendingDialog import de.davis.keygo.feature.autofill.presentation.activity.component.SuspicionDialog import de.davis.keygo.feature.autofill.presentation.activity.model.AssociationDialogVisibility import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillEvent import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillUiEvent +import de.davis.keygo.feature.autofill.presentation.activity.model.LinkCheckDialogVisibility import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionDialogVisibility import de.davis.keygo.feature.autofill.presentation.model.Request import de.davis.keygo.feature.autofill.presentation.model.RequestData @@ -62,6 +64,7 @@ internal class AutofillActivity : FragmentActivity() { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val dialogVisibility = uiState.associationDialogVisibility val suspicionDialogVisibility = uiState.suspicionDialogVisibility + val linkCheckDialogVisibility = uiState.linkCheckDialogVisibility val biometricCryptoController = rememberBiometricCryptoController() val biometricUnlockAdapter = rememberBiometricUnlockAdapter() @@ -151,6 +154,12 @@ internal class AutofillActivity : FragmentActivity() { onDismiss = { viewModel.onEvent(AutofillUiEvent.OnCancelAssociation) } ) + if (linkCheckDialogVisibility is LinkCheckDialogVisibility.Visible) + LinkCheckPendingDialog( + website = linkCheckDialogVisibility.website, + onCancel = { viewModel.onEvent(AutofillUiEvent.OnCancelLinkCheck) } + ) + if (suspicionDialogVisibility is SuspicionDialogVisibility.Visible) SuspicionDialog( onContinue = { viewModel.onEvent(AutofillUiEvent.OnContinueInSuspicion) }, diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt index a5b824181..400dbca2f 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt @@ -29,6 +29,7 @@ import de.davis.keygo.feature.autofill.presentation.activity.model.AssociationDi import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillBiometricRequest import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillEvent import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillUiEvent +import de.davis.keygo.feature.autofill.presentation.activity.model.LinkCheckDialogVisibility import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionDialogVisibility import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionReason import de.davis.keygo.feature.autofill.presentation.model.AutofillUiState @@ -46,12 +47,14 @@ import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import de.davis.keygo.feature.totp.domain.repository.TotpGenerator import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class AutofillViewModel( @@ -82,6 +85,7 @@ internal class AutofillViewModel( val uiState = _uiState.asStateFlow() private var smsOtpJob: Job? = null + private var requestJob: Job? = null fun start() { handleRequestData() @@ -107,16 +111,13 @@ internal class AutofillViewModel( _uiState.update { it.copy(request = Request.SaveItem(requestData.form.toRawItem())) } } - private fun handleRequestData(ignoreSuspicion: Boolean = false) = - viewModelScope.launch { + private fun handleRequestData(ignoreSuspicion: Boolean = false) { + requestJob?.cancel() + requestJob = viewModelScope.launch { val handleSuspicion = !ignoreSuspicion && requestData.form.isSuspicious val linkStatus = when { - handleSuspicion -> requestData.form.url?.let { - isAppLinkedToWebsite( - packageName = requestData.form.appPackageName, - domain = it - ) - } ?: WebsiteLinkStatus.NotLinked + handleSuspicion -> requestData.form.url?.let { checkWebsiteLink(it) } + ?: WebsiteLinkStatus.NotLinked else -> null } @@ -157,6 +158,35 @@ internal class AutofillViewModel( is FillRequestData.Suggestion -> handleSuggestionRequest(requestData) } } + } + + private suspend fun checkWebsiteLink(domain: String): WebsiteLinkStatus { + val indicator = viewModelScope.launch { + delay(LINK_CHECK_INDICATOR_DELAY) + _uiState.update { + it.copy(linkCheckDialogVisibility = LinkCheckDialogVisibility.Visible(domain)) + } + } + + return try { + isAppLinkedToWebsite( + packageName = requestData.form.appPackageName, + domain = domain, + ) + } finally { + indicator.cancel() + _uiState.update { + it.copy(linkCheckDialogVisibility = LinkCheckDialogVisibility.Hidden) + } + } + } + + private fun cancelLinkCheck() { + requestJob?.cancel() + requestJob = null + _uiState.update { it.copy(linkCheckDialogVisibility = LinkCheckDialogVisibility.Hidden) } + viewModelScope.launch { eventChannel.send(AutofillEvent.Abort) } + } private suspend fun handleSuggestionRequest(suggestionInfo: FillRequestData.Suggestion) { _uiState.update { it.copy(itemId = suggestionInfo.vaultId) } @@ -310,6 +340,7 @@ internal class AutofillViewModel( AutofillUiEvent.OnAuthenticated -> onAuthenticated() AutofillUiEvent.OnCancelAssociation -> hideAssociationDialog() is AutofillUiEvent.OnItemSelected -> onItemSelected(event.itemId) + AutofillUiEvent.OnCancelLinkCheck -> cancelLinkCheck() AutofillUiEvent.OnContinueInSuspicion -> { _uiState.update { it.copy(suspicionDialogVisibility = SuspicionDialogVisibility.Hidden) } @@ -493,5 +524,7 @@ internal class AutofillViewModel( companion object { const val KEY_AUTOFILL_INFORMATION = "extraction" + + private val LINK_CHECK_INDICATOR_DELAY = 150.milliseconds } } \ No newline at end of file diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/component/LinkCheckPendingDialog.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/component/LinkCheckPendingDialog.kt new file mode 100644 index 000000000..010aa6d9e --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/component/LinkCheckPendingDialog.kt @@ -0,0 +1,78 @@ +package de.davis.keygo.feature.autofill.presentation.activity.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.ui.text.htmlStringResource +import de.davis.keygo.feature.autofill.R + +/** + * Shown while the digital asset link lookup for [website] is still in flight. The lookup is a + * network call, so without this the transparent autofill activity would sit there showing nothing. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun LinkCheckPendingDialog( + website: String, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + AlertDialog( + onDismissRequest = onCancel, + confirmButton = { + TextButton( + onClick = onCancel + ) { + Text(text = stringResource(R.string.cancel)) + } + }, + icon = { + Icon(imageVector = Icons.Default.Link, contentDescription = null) + }, + title = { + Text(text = stringResource(R.string.checking_website_link)) + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = htmlStringResource( + R.string.checking_website_link_description, + website, + ) + ) + LoadingIndicator() + } + }, + modifier = modifier + ) +} + +@Preview +@Composable +private fun LinkCheckPendingDialogPreview() { + MaterialTheme { + LinkCheckPendingDialog( + website = "example.com", + onCancel = {}, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/AutofillUiEvent.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/AutofillUiEvent.kt index 584781468..abda514a5 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/AutofillUiEvent.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/AutofillUiEvent.kt @@ -8,6 +8,8 @@ internal sealed interface AutofillUiEvent { data object OnAssociate : AutofillUiEvent data object OnCancelAssociation : AutofillUiEvent + data object OnCancelLinkCheck : AutofillUiEvent + data object OnContinueInSuspicion : AutofillUiEvent data object OnAbortInSuspicion : AutofillUiEvent diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/LinkCheckDialogVisibility.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/LinkCheckDialogVisibility.kt new file mode 100644 index 000000000..3d53bb4e7 --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/LinkCheckDialogVisibility.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.autofill.presentation.activity.model + +internal sealed interface LinkCheckDialogVisibility { + data class Visible(val website: String) : LinkCheckDialogVisibility + + data object Hidden : LinkCheckDialogVisibility +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/AutofillUiState.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/AutofillUiState.kt index 659b3d807..8f095e9f5 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/AutofillUiState.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/AutofillUiState.kt @@ -2,12 +2,14 @@ package de.davis.keygo.feature.autofill.presentation.model import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.feature.autofill.presentation.activity.model.AssociationDialogVisibility +import de.davis.keygo.feature.autofill.presentation.activity.model.LinkCheckDialogVisibility import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionDialogVisibility internal data class AutofillUiState( val request: Request<*> = Request.None, val associationDialogVisibility: AssociationDialogVisibility = AssociationDialogVisibility.Hidden, val suspicionDialogVisibility: SuspicionDialogVisibility = SuspicionDialogVisibility.Hidden, + val linkCheckDialogVisibility: LinkCheckDialogVisibility = LinkCheckDialogVisibility.Hidden, val showGeneratePassword: Boolean = false, val showSmsPending: Boolean = false, val itemId: ItemId? = null diff --git a/feature/autofill/src/main/res/values/strings.xml b/feature/autofill/src/main/res/values/strings.xml index b2bdc128a..bbd1e58a9 100644 --- a/feature/autofill/src/main/res/values/strings.xml +++ b/feature/autofill/src/main/res/values/strings.xml @@ -3,6 +3,9 @@ Autofill Service Fill anyway + Checking this app + KeyGo is asking <b>%1$s</b> whether this app is allowed to use it. + Suspicious Activity This app (<b>%1$s</b>) isn\'t a recognized browser. It is showing a website (<b>%2$s</b>). Autofilling here may give your credentials to this app. Only continue if you trust this app and the site. diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt index e124e3adb..813e3fcc0 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt @@ -32,6 +32,7 @@ import de.davis.keygo.feature.autofill.domain.usecase.IsAppLinkedToWebsiteUseCas import de.davis.keygo.feature.autofill.presentation.activity.model.AssociationDialogVisibility import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillEvent import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillUiEvent +import de.davis.keygo.feature.autofill.presentation.activity.model.LinkCheckDialogVisibility import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionDialogVisibility import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionReason import de.davis.keygo.feature.autofill.presentation.model.FieldType @@ -50,7 +51,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -246,6 +249,105 @@ internal class AutofillViewModelTest { assertEquals(Request.None, vm.uiState.value.request) } + @Test + fun `a slow website link check shows the loading dialog until it answers`() = runTest { + signatureProvider.signatures = mapOf("com.example" to setOf("sig1")) + dalRepo.links = setOf( + FakeDigitalAssetLinkRepository.Link("com.example", "https://example.com", "sig1"), + ) + val gate = CompletableDeferred() + dalRepo.gate = gate + + val requestData = FillRequestData.App( + form(isSuspicious = true, url = "https://example.com", appPackageName = "com.example"), + ) + val vm = buildVm(requestData) + vm.start() + advanceUntilIdle() + + val visibility = vm.uiState.value.linkCheckDialogVisibility + assertIs(visibility) + assertEquals("https://example.com", visibility.website) + + gate.complete(Unit) + advanceUntilIdle() + + assertEquals(LinkCheckDialogVisibility.Hidden, vm.uiState.value.linkCheckDialogVisibility) + assertEquals(Request.SelectItem, vm.uiState.value.request) + } + + @Test + fun `a website link check that answers at once never shows the loading dialog`() = runTest { + signatureProvider.signatures = mapOf("com.example" to setOf("sig1")) + dalRepo.links = setOf( + FakeDigitalAssetLinkRepository.Link("com.example", "https://example.com", "sig1"), + ) + + val requestData = FillRequestData.App( + form(isSuspicious = true, url = "https://example.com", appPackageName = "com.example"), + ) + val vm = buildVm(requestData) + + val seen = mutableListOf() + val collector = launch(UnconfinedTestDispatcher(testScheduler)) { + vm.uiState.collect { seen += it.linkCheckDialogVisibility } + } + + vm.start() + advanceUntilIdle() + collector.cancel() + + assertTrue(seen.isNotEmpty()) + assertTrue(seen.all { it is LinkCheckDialogVisibility.Hidden }) + assertEquals(Request.SelectItem, vm.uiState.value.request) + } + + @Test + fun `cancelling the website link check aborts and clears the loading dialog`() = runTest { + signatureProvider.signatures = mapOf("com.example" to setOf("sig1")) + dalRepo.gate = CompletableDeferred() + + val requestData = FillRequestData.App( + form(isSuspicious = true, url = "https://example.com", appPackageName = "com.example"), + ) + val vm = buildVm(requestData) + vm.start() + advanceUntilIdle() + + assertIs(vm.uiState.value.linkCheckDialogVisibility) + + val abortDeferred = async { vm.events.first() } + vm.onEvent(AutofillUiEvent.OnCancelLinkCheck) + + assertEquals(AutofillEvent.Abort, abortDeferred.await()) + assertEquals(LinkCheckDialogVisibility.Hidden, vm.uiState.value.linkCheckDialogVisibility) + assertEquals(Request.None, vm.uiState.value.request) + } + + @Test + fun `a verdict arriving after cancellation shows no suspicion dialog`() = runTest { + signatureProvider.signatures = mapOf("com.example" to setOf("sig1")) + val gate = CompletableDeferred() + dalRepo.gate = gate + + val requestData = FillRequestData.App( + form(isSuspicious = true, url = "https://example.com", appPackageName = "com.example"), + ) + val vm = buildVm(requestData) + vm.start() + advanceUntilIdle() + + val abortDeferred = async { vm.events.first() } + vm.onEvent(AutofillUiEvent.OnCancelLinkCheck) + assertEquals(AutofillEvent.Abort, abortDeferred.await()) + + gate.complete(Unit) + advanceUntilIdle() + + assertEquals(SuspicionDialogVisibility.Hidden, vm.uiState.value.suspicionDialogVisibility) + assertEquals(Request.None, vm.uiState.value.request) + } + @Test fun `continuing past suspicion hides dialog and proceeds`() = runTest { signatureProvider.signatures = emptyMap()