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..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 @@ -1,47 +1,242 @@ 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 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.JSONObject +import okhttp3.Response import org.koin.core.annotation.Single -import java.net.URLEncoder +import java.io.IOException +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 - ): Boolean = 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() + domain: String, + 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++ + + val statements = when (val answer = fetch(url)) { + is Result.Failure -> { + if (firstFailure == null) firstFailure = answer.error + continue + } + + is Result.Success -> answer.success + } + + 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() } - response.use { - if (!it.isSuccessful) return@use false + 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)) + } - JSONObject(it.body.string()).optBoolean("linked", false) + 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 } } -} \ No newline at end of file +} + +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/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..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 @@ -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, + 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 4bdc60102..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,9 +1,9 @@ package de.davis.keygo.feature.autofill.domain.usecase +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 @@ -12,20 +12,20 @@ class IsAppLinkedToWebsiteUseCase( private val signatureInfoProvider: SignatureInfoProvider, ) { - @OptIn(ExperimentalCoroutinesApi::class) suspend operator fun invoke( packageName: String, - domain: String - ): Boolean = coroutineScope { + domain: String, + ): WebsiteLinkStatus { val signatures = signatureInfoProvider.getSignatureInfo(packageName) - if (signatures.isEmpty()) return@coroutineScope false + if (signatures.isEmpty()) return WebsiteLinkStatus.NotLinked - signatures.any { sign -> - digitalAssetLinkCheck.isLinked( - packageName = packageName, - signature = sign, - domain = domain - ) - } + return digitalAssetLinkCheck.isLinked( + packageName = packageName, + domain = domain, + signatures = signatures, + ).fold( + onSuccess = { if (it) WebsiteLinkStatus.Linked else WebsiteLinkStatus.NotLinked }, + onFailure = { WebsiteLinkStatus.Unverified } + ) } -} \ No newline at end of file +} 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..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,12 +154,19 @@ 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) }, 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/AutofillViewModel.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt index a9dc335fc..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 @@ -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 @@ -28,7 +29,9 @@ 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 import de.davis.keygo.feature.autofill.presentation.model.AutofillValue import de.davis.keygo.feature.autofill.presentation.model.FieldType @@ -44,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( @@ -80,6 +85,7 @@ internal class AutofillViewModel( val uiState = _uiState.asStateFlow() private var smsOtpJob: Job? = null + private var requestJob: Job? = null fun start() { handleRequestData() @@ -105,34 +111,37 @@ 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 linked = when { - handleSuspicion -> requestData.form.url?.let { - isAppLinkedToWebsite( - packageName = requestData.form.appPackageName, - domain = it - ) - } == true + val linkStatus = when { + handleSuspicion -> requestData.form.url?.let { checkWebsiteLink(it) } + ?: 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) @@ -149,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) } @@ -236,9 +274,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) } } @@ -302,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) } @@ -485,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/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/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/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/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/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 f7163096c..bbd1e58a9 100644 --- a/feature/autofill/src/main/res/values/strings.xml +++ b/feature/autofill/src/main/res/values/strings.xml @@ -3,8 +3,14 @@ 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 (%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 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..f6251e273 --- /dev/null +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/data/repository/DigitalAssetLinkRepositoryImplTest.kt @@ -0,0 +1,408 @@ +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.assertContains +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +internal class DigitalAssetLinkRepositoryImplTest { + + @Test + fun `unresolvable host reports the site 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, DOMAIN, setOf(SIGNATURE)), + ) + } + + @Test + 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), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + } + + @Test + 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(true), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + "expected $fingerprint to match $SIGNATURE", + ) + } + } + + @Test + 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), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + "expected $code to answer that nothing is published", + ) + } + } + + @Test + 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), + site.repository.isLinked(PACKAGE_NAME, DOMAIN, setOf(SIGNATURE)), + ) + } + + @Test + 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), + 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) + } + + @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(answer.code) + .message("synthetic") + .body(answer.body.toResponseBody("application/json".toMediaType())) + .build() + }, + ) + .build(), + ) + } + + companion object { + + 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 952c43771..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,10 +2,12 @@ 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 import kotlin.test.Test -import kotlin.test.assertFalse +import kotlin.test.assertEquals import kotlin.test.assertTrue class IsAppLinkedToWebsiteUseCaseTest { @@ -23,55 +25,76 @@ class IsAppLinkedToWebsiteUseCaseTest { } @Test - fun `empty signatures returns false without querying digital asset links`() = runTest { - val packageName = "com.example.app" - val domain = "example.com" - signatureInfoProvider.signatures = mapOf(packageName to emptySet()) + fun `empty signatures returns unlinked without querying digital asset links`() = runTest { + signatureInfoProvider.signatures = mapOf(PACKAGE_NAME to emptySet()) - val result = useCase(packageName, domain) + val result = useCase(PACKAGE_NAME, DOMAIN) - assertFalse(result) - assertTrue(digitalAssetLinkRepository.linkedCalls.isEmpty()) + assertEquals(WebsiteLinkStatus.NotLinked, result) + assertTrue(digitalAssetLinkRepository.lookups.isEmpty()) } @Test - fun `one linked signature returns true`() = 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)) + 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, useCase(PACKAGE_NAME, DOMAIN)) + } - val result = useCase(packageName, domain) + @Test + fun `an unlinked app returns unlinked`() = runTest { + signatureInfoProvider.signatures = mapOf(PACKAGE_NAME to setOf(SIGNATURE, OTHER_SIGNATURE)) + digitalAssetLinkRepository.links = emptySet() - assertTrue(result) + assertEquals(WebsiteLinkStatus.NotLinked, useCase(PACKAGE_NAME, DOMAIN)) } @Test - fun `all unlinked signatures returns false`() = 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 `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), + ) - val result = useCase(packageName, domain) + assertEquals(WebsiteLinkStatus.Linked, useCase(PACKAGE_NAME, DOMAIN)) + assertEquals( + listOf(FakeDigitalAssetLinkRepository.Lookup(PACKAGE_NAME, DOMAIN, signatures)), + digitalAssetLinkRepository.lookups, + ) + } - assertFalse(result) + @Test + 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, useCase(PACKAGE_NAME, DOMAIN)) } @Test - fun `first linked signature returns true`() = 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)) + 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" - val result = useCase(packageName, domain) + private const val SIGNATURE = "A1:B2:C3:D4" - assertTrue(result) + 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 2b7f7eefc..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,7 +32,9 @@ 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 import de.davis.keygo.feature.autofill.presentation.model.FillRequestData import de.davis.keygo.feature.autofill.presentation.model.Form @@ -49,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 @@ -194,7 +198,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) } @@ -211,7 +217,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"), ) @@ -222,6 +230,124 @@ 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.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"), + ) + 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 `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() 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..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 @@ -1,22 +1,60 @@ 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. - * 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() - // Track calls for assertion (e.g., fake.linkedCalls.isEmpty() or fake.linkedCalls.contains(Triple(...))) - val linkedCalls: MutableList> = mutableListOf() + /** One statement a site publishes: [signature] proves [packageName] owns [domain]. */ + data class Link( + val packageName: String, + val domain: String, + val signature: String, + ) - override suspend fun isLinked(packageName: String, signature: String, domain: String): Boolean { - linkedCalls += Triple(packageName, signature, domain) - return Triple(packageName, signature, domain) in linkedTriples + /** 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 + + /** 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, + domain: String, + signatures: Set, + ): Result { + lookups += Lookup(packageName, domain, signatures) + gate?.await() + + if (domain in failingDomains) return Result.Failure(failure) + + return Result.Success( + signatures.any { Link(packageName, domain, it) in links }, + ) } }