diff options
Diffstat (limited to 'autofill-parser/src/main/java')
12 files changed, 2304 insertions, 0 deletions
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt new file mode 100644 index 00000000..30ff40d3 --- /dev/null +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt @@ -0,0 +1,217 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception + */ +package com.github.androidpasswordstore.autofillparser + +import android.app.assist.AssistStructure +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillId +import androidx.annotation.RequiresApi +import androidx.core.os.bundleOf +import com.github.ajalt.timberkt.d + +/** + * A unique identifier for either an Android app (package name) or a website (origin minus port). + */ +sealed class FormOrigin(open val identifier: String) { + + data class Web(override val identifier: String) : FormOrigin(identifier) + data class App(override val identifier: String) : FormOrigin(identifier) + + companion object { + + private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier" + private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier" + + fun fromBundle(bundle: Bundle): FormOrigin? { + val webIdentifier = bundle.getString(BUNDLE_KEY_WEB_IDENTIFIER) + if (webIdentifier != null) { + return Web(webIdentifier) + } else { + return App(bundle.getString(BUNDLE_KEY_APP_IDENTIFIER) ?: return null) + } + } + } + + fun getPrettyIdentifier(context: Context, untrusted: Boolean = true) = when (this) { + is Web -> identifier + is App -> { + val info = context.packageManager.getApplicationInfo( + identifier, PackageManager.GET_META_DATA + ) + val label = context.packageManager.getApplicationLabel(info) + if (untrusted) "“$label”" else "$label" + } + } + + fun toBundle() = when (this) { + is Web -> bundleOf(BUNDLE_KEY_WEB_IDENTIFIER to identifier) + is App -> bundleOf(BUNDLE_KEY_APP_IDENTIFIER to identifier) + } +} + +/** + * Manages the detection of fields to fill in an [AssistStructure] and determines the [FormOrigin]. + */ +@RequiresApi(Build.VERSION_CODES.O) +private class AutofillFormParser( + context: Context, + structure: AssistStructure, + isManualRequest: Boolean, + private val customSuffixes: Sequence<String> +) { + + companion object { + private val SUPPORTED_SCHEMES = listOf("http", "https") + } + + private val relevantFields = mutableListOf<FormField>() + val ignoredIds = mutableListOf<AutofillId>() + private var fieldIndex = 0 + + private var appPackage = structure.activityComponent.packageName + + private val trustedBrowserInfo = + getBrowserAutofillSupportInfoIfTrusted(context, appPackage) + val saveFlags = trustedBrowserInfo?.saveFlags + + private val webOrigins = mutableSetOf<String>() + + init { + d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" } + parseStructure(structure) + } + + val scenario = detectFieldsToFill(isManualRequest) + val formOrigin = determineFormOrigin(context) + + init { + d { "Origin: $formOrigin" } + } + + private fun parseStructure(structure: AssistStructure) { + for (i in 0 until structure.windowNodeCount) { + visitFormNode(structure.getWindowNodeAt(i).rootViewNode) + } + } + + private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) { + trackOrigin(node) + val field = + if (trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.WebView) { + FormField(node, fieldIndex, true, inheritedWebOrigin) + } else { + check(inheritedWebOrigin == null) + FormField(node, fieldIndex, false) + } + if (field.relevantField) { + d { "Relevant: $field" } + relevantFields.add(field) + fieldIndex++ + } else { + d { "Ignored : $field" } + ignoredIds.add(field.autofillId) + } + for (i in 0 until node.childCount) { + visitFormNode(node.getChildAt(i), field.webOriginToPassDown) + } + } + + private fun detectFieldsToFill(isManualRequest: Boolean) = autofillStrategy.match( + relevantFields, + singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None, + isManualRequest = isManualRequest + ) + + private fun trackOrigin(node: AssistStructure.ViewNode) { + if (trustedBrowserInfo == null) return + node.webOrigin?.let { + if (it !in webOrigins) { + d { "Origin encountered: $it" } + webOrigins.add(it) + } + } + } + + private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? { + val uri = Uri.parse(origin) ?: return null + val scheme = uri.scheme ?: return null + if (scheme !in SUPPORTED_SCHEMES) return null + val host = uri.host ?: return null + return FormOrigin.Web(getPublicSuffixPlusOne(context, host, customSuffixes)) + } + + private fun determineFormOrigin(context: Context): FormOrigin? { + if (scenario == null) return null + if (trustedBrowserInfo == null || webOrigins.isEmpty()) { + // Security assumption: If a trusted browser includes no web origin in the provided + // AssistStructure, then the form is a native browser form (e.g. for a sync password). + // TODO: Support WebViews in apps via Digital Asset Links + // See: https://developer.android.com/reference/android/service/autofill/AutofillService#web-security + return FormOrigin.App(appPackage) + } + return when (trustedBrowserInfo.multiOriginMethod) { + BrowserMultiOriginMethod.None -> { + // Security assumption: If a browser is trusted but does not support tracking + // multiple origins, it is expected to annotate a single field, in most cases its + // URL bar, with a webOrigin. We err on the side of caution and only trust the + // reported web origin if no other web origin appears on the page. + webOriginToFormOrigin(context, webOrigins.singleOrNull() ?: return null) + } + BrowserMultiOriginMethod.WebView, + BrowserMultiOriginMethod.Field -> { + // Security assumption: For browsers with full autofill support (the `Field` case), + // every form field is annotated with its origin. For browsers based on WebView, + // this is true after the web origins of WebViews are passed down to their children. + // + // For browsers with the WebView or Field method of multi origin support, we take + // the single origin among the detected fillable or saveable fields. If this origin + // is null, but we encountered web origins elsewhere in the AssistStructure, the + // situation is uncertain and Autofill should not be offered. + webOriginToFormOrigin( + context, + scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null + ) + } + } + } +} + +data class Credentials(val username: String?, val password: String?, val otp: String?) + +/** + * Represents a collection of fields in a specific app that can be filled or saved. This is the + * entry point to all fill and save features. + */ +@RequiresApi(Build.VERSION_CODES.O) +class FillableForm private constructor( + val formOrigin: FormOrigin, + val scenario: AutofillScenario<FormField>, + val ignoredIds: List<AutofillId>, + val saveFlags: Int? +) { + companion object { + /** + * Returns a [FillableForm] if a login form could be detected in [structure]. + */ + fun parseAssistStructure( + context: Context, + structure: AssistStructure, + isManualRequest: Boolean, + customSuffixes: Sequence<String> = emptySequence(), + ): FillableForm? { + val form = AutofillFormParser(context, structure, isManualRequest, customSuffixes) + if (form.formOrigin == null || form.scenario == null) return null + return FillableForm(form.formOrigin, form.scenario, form.ignoredIds, form.saveFlags) + } + } + + fun toClientState() = scenario.toBundle().apply { + putAll(formOrigin.toBundle()) + } +} diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt new file mode 100644 index 00000000..9273f432 --- /dev/null +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt @@ -0,0 +1,127 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception + */ +package com.github.androidpasswordstore.autofillparser + +import android.annotation.SuppressLint +import android.app.assist.AssistStructure +import android.content.Context +import android.content.IntentSender +import android.content.pm.PackageManager +import android.os.Build +import android.service.autofill.SaveCallback +import android.util.Base64 +import android.view.autofill.AutofillId +import android.widget.Toast +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.Timber.tag +import com.github.ajalt.timberkt.e +import java.security.MessageDigest + +private fun ByteArray.sha256(): ByteArray { + return MessageDigest.getInstance("SHA-256").run { + update(this@sha256) + digest() + } +} + +private fun ByteArray.base64(): String { + return Base64.encodeToString(this, Base64.NO_WRAP) +} + +private fun stableHash(array: Collection<ByteArray>): String { + val hashes = array.map { it.sha256().base64() } + return hashes.sorted().joinToString(separator = ";") +} + +/** + * Computes a stable hash of all certificates associated to the installed app with package name + * [appPackage]. + * + * In most cases apps will only have a single certificate. If there are multiple, this functions + * returns all of them in sorted order and separated with `;`. + */ +fun computeCertificatesHash(context: Context, appPackage: String): String { + // The warning does not apply since 1) we are specifically hashing **all** signatures and 2) it + // no longer applies to Android 4.4+. + // Even though there is a new way to get the certificates as of Android Pie, we need to keep + // hashes comparable between versions and hence default to using the deprecated API. + @SuppressLint("PackageManagerGetSignatures") + @Suppress("DEPRECATION") + val signaturesOld = + context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNATURES).signatures + val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() }) + if (Build.VERSION.SDK_INT >= 28) { + val info = context.packageManager.getPackageInfo( + appPackage, PackageManager.GET_SIGNING_CERTIFICATES + ) + val signaturesNew = + info.signingInfo.signingCertificateHistory ?: info.signingInfo.apkContentsSigners + val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() }) + if (stableHashNew != stableHashOld) tag("CertificatesHash").e { "Mismatch between old and new hash: $stableHashNew != $stableHashOld" } + } + return stableHashOld +} + +/** + * Returns the "origin" (without port information) of the [AssistStructure.ViewNode] derived from + * its `webDomain` and `webScheme`, if available. + */ +val AssistStructure.ViewNode.webOrigin: String? + @RequiresApi(Build.VERSION_CODES.O) get() = webDomain?.let { domain -> + val scheme = (if (Build.VERSION.SDK_INT >= 28) webScheme else null) ?: "https" + "$scheme://$domain" + } + +@RequiresApi(Build.VERSION_CODES.O) +class FixedSaveCallback(context: Context, private val callback: SaveCallback) { + + private val applicationContext = context.applicationContext + + fun onFailure(message: CharSequence) { + callback.onFailure(message) + // When targeting SDK 29, the message is no longer shown as a toast. + // See https://developer.android.com/reference/android/service/autofill/SaveCallback#onFailure(java.lang.CharSequence) + if (applicationContext.applicationInfo.targetSdkVersion >= 29) { + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() + } + } + + fun onSuccess(intentSender: IntentSender) { + if (Build.VERSION.SDK_INT >= 28) { + callback.onSuccess(intentSender) + } else { + callback.onSuccess() + // On SDKs < 28, we cannot advise the Autofill framework to launch the save intent in + // the context of the app that triggered the save request. Hence, we launch it here. + applicationContext.startIntentSender(intentSender, null, 0, 0, 0) + } + } +} + +private fun visitViewNodes(structure: AssistStructure, block: (AssistStructure.ViewNode) -> Unit) { + for (i in 0 until structure.windowNodeCount) { + visitViewNode(structure.getWindowNodeAt(i).rootViewNode, block) + } +} + +private fun visitViewNode( + node: AssistStructure.ViewNode, + block: (AssistStructure.ViewNode) -> Unit +) { + block(node) + for (i in 0 until node.childCount) { + visitViewNode(node.getChildAt(i), block) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +fun AssistStructure.findNodeByAutofillId(autofillId: AutofillId): AssistStructure.ViewNode? { + var node: AssistStructure.ViewNode? = null + visitViewNodes(this) { + if (it.autofillId == autofillId) + node = it + } + return node +} diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt new file mode 100644 index 00000000..a374bc37 --- /dev/null +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt @@ -0,0 +1,296 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception + */ +package com.github.androidpasswordstore.autofillparser + +import android.app.assist.AssistStructure +import android.os.Build +import android.os.Bundle +import android.service.autofill.Dataset +import android.view.autofill.AutofillId +import android.view.autofill.AutofillValue +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.e + +enum class AutofillAction { + Match, Search, Generate, FillOtpFromSms +} + +/** + * Represents a set of form fields with associated roles (e.g., username or new password) and + * contains the logic that decides which fields should be filled or saved. The type [T] is one of + * [FormField], [AssistStructure.ViewNode] or [AutofillId], depending on how much metadata about the + * field is needed and available in the particular situation. + */ +@RequiresApi(Build.VERSION_CODES.O) +sealed class AutofillScenario<out T : Any> { + + companion object { + + const val BUNDLE_KEY_USERNAME_ID = "usernameId" + const val BUNDLE_KEY_FILL_USERNAME = "fillUsername" + const val BUNDLE_KEY_OTP_ID = "otpId" + const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds" + const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds" + const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds" + + fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? { + return try { + Builder<AutofillId>().apply { + username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID) + fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME) + otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID) + currentPassword.addAll( + clientState.getParcelableArrayList( + BUNDLE_KEY_CURRENT_PASSWORD_IDS + ) ?: emptyList() + ) + newPassword.addAll( + clientState.getParcelableArrayList( + BUNDLE_KEY_NEW_PASSWORD_IDS + ) ?: emptyList() + ) + genericPassword.addAll( + clientState.getParcelableArrayList( + BUNDLE_KEY_GENERIC_PASSWORD_IDS + ) ?: emptyList() + ) + }.build() + } catch(e: Throwable) { + e(e) + null + } + } + } + + class Builder<T : Any> { + + var username: T? = null + var fillUsername = false + var otp: T? = null + val currentPassword = mutableListOf<T>() + val newPassword = mutableListOf<T>() + val genericPassword = mutableListOf<T>() + + fun build(): AutofillScenario<T> { + require(genericPassword.isEmpty() || (currentPassword.isEmpty() && newPassword.isEmpty())) + return if (currentPassword.isNotEmpty() || newPassword.isNotEmpty()) { + ClassifiedAutofillScenario( + username = username, + fillUsername = fillUsername, + otp = otp, + currentPassword = currentPassword, + newPassword = newPassword + ) + } else { + GenericAutofillScenario( + username = username, + fillUsername = fillUsername, + otp = otp, + genericPassword = genericPassword + ) + } + } + } + + abstract val username: T? + abstract val fillUsername: Boolean + abstract val otp: T? + abstract val allPasswordFields: List<T> + abstract val passwordFieldsToFillOnMatch: List<T> + abstract val passwordFieldsToFillOnSearch: List<T> + abstract val passwordFieldsToFillOnGenerate: List<T> + abstract val passwordFieldsToSave: List<T> + + val fieldsToSave + get() = listOfNotNull(username) + passwordFieldsToSave + + val allFields + get() = listOfNotNull(username, otp) + allPasswordFields + + fun fieldsToFillOn(action: AutofillAction): List<T> { + val credentialFieldsToFill = when (action) { + AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp) + AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp) + AutofillAction.Generate -> passwordFieldsToFillOnGenerate + AutofillAction.FillOtpFromSms -> listOfNotNull(otp) + } + return when { + action == AutofillAction.FillOtpFromSms -> { + // When filling from an SMS, we cannot get any data other than the OTP itself. + credentialFieldsToFill + } + credentialFieldsToFill.isNotEmpty() -> { + // If the current action would fill into any password field, we also fill into the + // username field if possible. + listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill + } + allPasswordFields.isEmpty() && action != AutofillAction.Generate -> { + // If there no password fields at all, we still offer to fill the username, e.g. in + // two-step login scenarios, but we do not offer to generate a password. + listOfNotNull(username.takeIf { fillUsername }) + } + else -> emptyList() + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +data class ClassifiedAutofillScenario<T : Any>( + override val username: T?, + override val fillUsername: Boolean, + override val otp: T?, + val currentPassword: List<T>, + val newPassword: List<T> +) : AutofillScenario<T>() { + + override val allPasswordFields + get() = currentPassword + newPassword + override val passwordFieldsToFillOnMatch + get() = currentPassword + override val passwordFieldsToFillOnSearch + get() = currentPassword + override val passwordFieldsToFillOnGenerate + get() = newPassword + override val passwordFieldsToSave + get() = if (newPassword.isNotEmpty()) newPassword else currentPassword +} + +@RequiresApi(Build.VERSION_CODES.O) +data class GenericAutofillScenario<T : Any>( + override val username: T?, + override val fillUsername: Boolean, + override val otp: T?, + val genericPassword: List<T> +) : AutofillScenario<T>() { + + override val allPasswordFields + get() = genericPassword + override val passwordFieldsToFillOnMatch + get() = if (genericPassword.size == 1) genericPassword else emptyList() + override val passwordFieldsToFillOnSearch + get() = if (genericPassword.size == 1) genericPassword else emptyList() + override val passwordFieldsToFillOnGenerate + get() = genericPassword + override val passwordFieldsToSave + get() = genericPassword +} + +fun AutofillScenario<FormField>.passesOriginCheck(singleOriginMode: Boolean): Boolean { + return if (singleOriginMode) { + // In single origin mode, only the browsers URL bar (which is never filled) should have + // a webOrigin. + allFields.all { it.webOrigin == null } + } else { + // In apps or browsers in multi origin mode, every field in a dataset has to belong to + // the same (possibly null) origin. + allFields.map { it.webOrigin }.toSet().size == 1 + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@JvmName("fillWithAutofillId") +fun Dataset.Builder.fillWith( + scenario: AutofillScenario<AutofillId>, + action: AutofillAction, + credentials: Credentials? +) { + val credentialsToFill = credentials ?: Credentials( + "USERNAME", + "PASSWORD", + "OTP" + ) + for (field in scenario.fieldsToFillOn(action)) { + val value = when (field) { + scenario.username -> credentialsToFill.username + scenario.otp -> credentialsToFill.otp + else -> credentialsToFill.password + } + setValue(field, AutofillValue.forText(value)) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@JvmName("fillWithFormField") +fun Dataset.Builder.fillWith( + scenario: AutofillScenario<FormField>, + action: AutofillAction, + credentials: Credentials? +) { + fillWith(scenario.map { it.autofillId }, action, credentials) +} + +inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): AutofillScenario<S> { + val builder = AutofillScenario.Builder<S>() + builder.username = username?.let(transform) + builder.fillUsername = fillUsername + builder.otp = otp?.let(transform) + when (this) { + is ClassifiedAutofillScenario -> { + builder.currentPassword.addAll(currentPassword.map(transform)) + builder.newPassword.addAll(newPassword.map(transform)) + } + is GenericAutofillScenario -> { + builder.genericPassword.addAll(genericPassword.map(transform)) + } + } + return builder.build() +} + +@RequiresApi(Build.VERSION_CODES.O) +@JvmName("toBundleAutofillId") +private fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) { + is ClassifiedAutofillScenario<AutofillId> -> { + Bundle(5).apply { + putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) + putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) + putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) + putParcelableArrayList( + AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword) + ) + putParcelableArrayList( + AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword) + ) + } + } + is GenericAutofillScenario<AutofillId> -> { + Bundle(4).apply { + putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) + putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) + putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) + putParcelableArrayList( + AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword) + ) + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@JvmName("toBundleFormField") +fun AutofillScenario<FormField>.toBundle(): Bundle = map { it.autofillId }.toBundle() + +@RequiresApi(Build.VERSION_CODES.O) +fun AutofillScenario<AutofillId>.recoverNodes(structure: AssistStructure): AutofillScenario<AssistStructure.ViewNode>? { + return map { autofillId -> + structure.findNodeByAutofillId(autofillId) ?: return null + } +} + +val AutofillScenario<AssistStructure.ViewNode>.usernameValue: String? + @RequiresApi(Build.VERSION_CODES.O) get() { + val value = username?.autofillValue ?: return null + return if (value.isText) value.textValue.toString() else null + } +val AutofillScenario<AssistStructure.ViewNode>.passwordValue: String? + @RequiresApi(Build.VERSION_CODES.O) get() { + val distinctValues = passwordFieldsToSave.map { + if (it.autofillValue?.isText == true) { + it.autofillValue?.textValue?.toString() + } else { + null + } + }.toSet() + // Only return a non-null password value when all password fields agree + return distinctValues.singleOrNull() + } diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt new file mode 100644 index 00000000..b8356783 --- /dev/null +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt @@ -0,0 +1,208 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception + */ +package com.github.androidpasswordstore.autofillparser + +import android.os.Build +import androidx.annotation.RequiresApi +import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Certain +import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Likely + +private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) = + predicate(first) && predicate(second) + +private inline fun <T> Pair<T, T>.any(predicate: T.() -> Boolean) = + predicate(first) || predicate(second) + +private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) = + !predicate(first) && !predicate(second) + +/** + * The strategy used to detect [AutofillScenario]s; expressed using the DSL implemented in + * [AutofillDsl]. + */ +@RequiresApi(Build.VERSION_CODES.O) +val autofillStrategy = strategy { + + // Match two new password fields, an optional current password field right below or above, and + // an optional username field with autocomplete hint. + // TODO: Introduce a custom fill/generate/update flow for this scenario + rule { + newPassword { + takePair { all { hasHintNewPassword } } + breakTieOnPair { any { isFocused } } + } + currentPassword(optional = true) { + takeSingle { alreadyMatched -> + val adjacentToNewPasswords = + directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched) + // The Autofill framework has not hint that applies to current passwords only. + // In this scenario, we have already matched fields a pair of fields with a specific + // new password hint, so we take a generic Autofill password hint to mean a current + // password. + (hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) && + adjacentToNewPasswords + } + } + username(optional = true) { + takeSingle { hasHintUsername } + breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } + breakTieOnSingle { isFocused } + } + } + + // Match a single focused current password field and hidden username field with autocomplete + // hint. This configuration is commonly used in two-step login flows to allow password managers + // to save the username. + // See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands + // Note: The username is never filled in this scenario since usernames are generally only filled + // in visible fields. + rule { + username(matchHidden = true) { + takeSingle { + couldBeTwoStepHiddenUsername + } + } + currentPassword { + takeSingle { _ -> + hasAutocompleteHintCurrentPassword && isFocused + } + } + } + + // Match a single current password field and optional username field with autocomplete hint. + rule { + currentPassword { + takeSingle { hasAutocompleteHintCurrentPassword } + breakTieOnSingle { isFocused } + } + username(optional = true) { + takeSingle { hasHintUsername } + breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } + breakTieOnSingle { isFocused } + } + } + + // Match two adjacent password fields, implicitly understood as new passwords, and optional + // username field. + rule { + newPassword { + takePair { all { passwordCertainty >= Likely } } + breakTieOnPair { all { passwordCertainty >= Certain } } + breakTieOnPair { any { isFocused } } + } + username(optional = true) { + takeSingle { usernameCertainty >= Likely } + breakTieOnSingle { usernameCertainty >= Certain } + breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } + breakTieOnSingle { isFocused } + } + } + + // Match a single password field and optional username field. + rule { + genericPassword { + takeSingle { passwordCertainty >= Likely } + breakTieOnSingle { passwordCertainty >= Certain } + breakTieOnSingle { isFocused } + } + username(optional = true) { + takeSingle { usernameCertainty >= Likely } + breakTieOnSingle { usernameCertainty >= Certain } + breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } + breakTieOnSingle { isFocused } + } + } + + // Match a single focused new password field and optional preceding username field. + // This rule can apply in single origin mode since it only fills into a single focused password + // field. + rule(applyInSingleOriginMode = true) { + newPassword { + takeSingle { hasHintNewPassword && isFocused } + } + username(optional = true) { + takeSingle { alreadyMatched -> + usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) + } + } + } + + // Match a single focused current password field and optional preceding username field. + // This rule can apply in single origin mode since it only fills into a single focused password + // field. + rule(applyInSingleOriginMode = true) { + currentPassword { + takeSingle { hasAutocompleteHintCurrentPassword && isFocused } + } + username(optional = true) { + takeSingle { alreadyMatched -> + usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) + } + } + } + + // Match a single focused password field and optional preceding username field. + // This rule can apply in single origin mode since it only fills into a single focused password + // field. + rule(applyInSingleOriginMode = true) { + genericPassword { + takeSingle { passwordCertainty >= Likely && isFocused } + } + username(optional = true) { + takeSingle { alreadyMatched -> + usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) + } + } + } + + // Match a focused username field with autocomplete hint directly followed by a hidden password + // field, which is a common scenario in two-step login flows. No tie breakers are used to limit + // filling of hidden password fields to scenarios where this is clearly warranted. + rule { + username { + takeSingle { hasHintUsername && isFocused } + } + currentPassword(matchHidden = true) { + takeSingle { alreadyMatched -> + directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword + } + } + } + + // Match a single focused OTP field. + rule(applyInSingleOriginMode = true) { + otp { + takeSingle { otpCertainty >= Likely && isFocused } + } + } + + // Match a single focused username field without a password field. + rule(applyInSingleOriginMode = true) { + username { + takeSingle { usernameCertainty >= Likely && isFocused } + breakTieOnSingle { usernameCertainty >= Certain } + breakTieOnSingle { hasHintUsername } + } + } + + // Match any focused password field with optional username field on manual request. + rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { + genericPassword { + takeSingle { isFocused } + } + username(optional = true) { + takeSingle { alreadyMatched -> + usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) + } + } + } + + // Match any focused username field on manual request. + rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { + username { + takeSingle { isFocused } + } + } +} diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt new file mode 100644 index 00000000..86201be8 --- /dev/null +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt @@ -0,0 +1,381 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception + */ +package com.github.androidpasswordstore.autofillparser + +import android.os.Build +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w + +@DslMarker +annotation class AutofillDsl + +@RequiresApi(Build.VERSION_CODES.O) +interface FieldMatcher { + + fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? + + @AutofillDsl + class Builder { + + private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null + private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = + mutableListOf() + + private var takePair: (Pair<FormField, FormField>.(List<FormField>) -> Boolean)? = null + private var tieBreakersPair: MutableList<Pair<FormField, FormField>.(List<FormField>) -> Boolean> = + mutableListOf() + + fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) { + check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" } + takeSingle = block + } + + fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) { + check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" } + check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" } + tieBreakersSingle.add(block) + } + + fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) { + check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" } + takePair = block + } + + fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) { + check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" } + check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" } + tieBreakersPair.add(block) + } + + fun build(): FieldMatcher { + val takeSingle = takeSingle + val takePair = takePair + return when { + takeSingle != null -> SingleFieldMatcher(takeSingle, tieBreakersSingle) + takePair != null -> PairOfFieldsMatcher(takePair, tieBreakersPair) + else -> throw IllegalArgumentException("Every block needs a take{Single,Pair} block") + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +class SingleFieldMatcher( + private val take: (FormField, List<FormField>) -> Boolean, + private val tieBreakers: List<(FormField, List<FormField>) -> Boolean> +) : FieldMatcher { + + @AutofillDsl + class Builder { + + private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null + private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = + mutableListOf() + + fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) { + check(takeSingle == null) { "Every block can only have at most one takeSingle block" } + takeSingle = block + } + + fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) { + check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" } + tieBreakersSingle.add(block) + } + + fun build() = SingleFieldMatcher( + takeSingle + ?: throw IllegalArgumentException("Every block needs a take{Single,Pair} block"), + tieBreakersSingle + ) + } + + override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? { + return fields.minus(alreadyMatched).filter { take(it, alreadyMatched) }.let { contestants -> + var current = contestants + for ((i, tieBreaker) in tieBreakers.withIndex()) { + // Successively filter matched fields via tie breakers... + val new = current.filter { tieBreaker(it, alreadyMatched) } + // skipping those tie breakers that are not satisfied for any remaining field... + if (new.isEmpty()) { + d { "Tie breaker #${i + 1}: Didn't match any field; skipping" } + continue + } + // and return if the available options have been narrowed to a single field. + if (new.size == 1) { + d { "Tie breaker #${i + 1}: Success" } + current = new + break + } + d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" } + current = new + } + listOf(current.singleOrNull() ?: return null) + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private class PairOfFieldsMatcher( + private val take: (Pair<FormField, FormField>, List<FormField>) -> Boolean, + private val tieBreakers: List<(Pair<FormField, FormField>, List<FormField>) -> Boolean> +) : FieldMatcher { + + override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? { + return fields.minus(alreadyMatched).zipWithNext() + .filter { it.first directlyPrecedes it.second }.filter { take(it, alreadyMatched) } + .let { contestants -> + var current = contestants + for ((i, tieBreaker) in tieBreakers.withIndex()) { + val new = current.filter { tieBreaker(it, alreadyMatched) } + if (new.isEmpty()) { + d { "Tie breaker #${i + 1}: Didn't match any field; skipping" } + continue + } + // and return if the available options have been narrowed to a single field. + if (new.size == 1) { + d { "Tie breaker #${i + 1}: Success" } + current = new + break + } + d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" } + current = new + } + current.singleOrNull()?.toList() + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillRule private constructor( + private val matchers: List<AutofillRuleMatcher>, + private val applyInSingleOriginMode: Boolean, + private val applyOnManualRequestOnly: Boolean, + private val name: String +) { + + data class AutofillRuleMatcher( + val type: FillableFieldType, + val matcher: FieldMatcher, + val optional: Boolean, + val matchHidden: Boolean + ) + + enum class FillableFieldType { + Username, Otp, CurrentPassword, NewPassword, GenericPassword, + } + + @AutofillDsl + class Builder( + private val applyInSingleOriginMode: Boolean, + private val applyOnManualRequestOnly: Boolean + ) { + + companion object { + + private var ruleId = 1 + } + + private val matchers = mutableListOf<AutofillRuleMatcher>() + var name: String? = null + + fun username(optional: Boolean = false, matchHidden: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) { + require(matchers.none { it.type == FillableFieldType.Username }) { "Every rule block can only have at most one username block" } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.Username, + matcher = SingleFieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = matchHidden + ) + ) + } + + fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) { + require(matchers.none { it.type == FillableFieldType.Otp }) { "Every rule block can only have at most one otp block" } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.Otp, + matcher = SingleFieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = false + ) + ) + } + + fun currentPassword(optional: Boolean = false, matchHidden: Boolean = false, block: FieldMatcher.Builder.() -> Unit) { + require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.CurrentPassword, + matcher = FieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = matchHidden + ) + ) + } + + fun newPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) { + require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.NewPassword, + matcher = FieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = false + ) + ) + } + + fun genericPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) { + require(matchers.none { + it.type in listOf( + FillableFieldType.CurrentPassword, + FillableFieldType.NewPassword, + ) + }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.GenericPassword, + matcher = FieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = false + ) + ) + } + + fun build(): AutofillRule { + if (applyInSingleOriginMode) { + require(matchers.none { it.matcher is PairOfFieldsMatcher }) { "Rules with applyInSingleOriginMode set to true must only match single fields" } + require(matchers.filter { it.type != FillableFieldType.Username }.size <= 1) { "Rules with applyInSingleOriginMode set to true must only match at most one password field" } + require(matchers.none { it.matchHidden }) { "Rules with applyInSingleOriginMode set to true must not fill into hidden fields" } + } + return AutofillRule( + matchers, applyInSingleOriginMode, applyOnManualRequestOnly, name ?: "Rule #$ruleId" + ).also { ruleId++ } + } + } + + fun match( + allPassword: List<FormField>, + allUsername: List<FormField>, + allOtp: List<FormField>, + singleOriginMode: Boolean, + isManualRequest: Boolean + ): AutofillScenario<FormField>? { + if (singleOriginMode && !applyInSingleOriginMode) { + d { "$name: Skipped in single origin mode" } + return null + } + if (!isManualRequest && applyOnManualRequestOnly) { + d { "$name: Skipped since not a manual request" } + return null + } + d { "$name: Applying..." } + val scenarioBuilder = AutofillScenario.Builder<FormField>() + val alreadyMatched = mutableListOf<FormField>() + for ((type, matcher, optional, matchHidden) in matchers) { + val fieldsToMatchOn = when (type) { + FillableFieldType.Username -> allUsername + FillableFieldType.Otp -> allOtp + else -> allPassword + }.filter { matchHidden || it.isVisible } + val matchResult = matcher.match(fieldsToMatchOn, alreadyMatched) ?: if (optional) { + d { "$name: Skipping optional $type matcher" } + continue + } else { + d { "$name: Required $type matcher didn't match; passing to next rule" } + return null + } + d { "$name: Matched $type" } + when (type) { + FillableFieldType.Username -> { + check(matchResult.size == 1 && scenarioBuilder.username == null) + scenarioBuilder.username = matchResult.single() + // Hidden username fields should be saved but not filled. + scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true + } + FillableFieldType.Otp -> { + check(matchResult.size == 1 && scenarioBuilder.otp == null) + scenarioBuilder.otp = matchResult.single() + } + FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll( + matchResult + ) + FillableFieldType.NewPassword -> scenarioBuilder.newPassword.addAll(matchResult) + FillableFieldType.GenericPassword -> scenarioBuilder.genericPassword.addAll( + matchResult + ) + } + alreadyMatched.addAll(matchResult) + } + return scenarioBuilder.build().takeIf { scenario -> + scenario.passesOriginCheck(singleOriginMode = singleOriginMode).also { passed -> + if (passed) { + d { "$name: Detected scenario:\n$scenario" } + } else { + w { "$name: Scenario failed origin check:\n$scenario" } + } + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillStrategy private constructor(private val rules: List<AutofillRule>) { + + @AutofillDsl + class Builder { + + private val rules: MutableList<AutofillRule> = mutableListOf() + + fun rule( + applyInSingleOriginMode: Boolean = false, + applyOnManualRequestOnly: Boolean = false, + block: AutofillRule.Builder.() -> Unit + ) { + rules.add( + AutofillRule.Builder( + applyInSingleOriginMode = applyInSingleOriginMode, + applyOnManualRequestOnly = applyOnManualRequestOnly + ).apply(block).build() + ) + } + + fun build() = AutofillStrategy(rules) + } + + fun match( + fields: List<FormField>, + singleOriginMode: Boolean, + isManualRequest: Boolean + ): AutofillScenario<FormField>? { + val possiblePasswordFields = + fields.filter { it.passwordCertainty >= CertaintyLevel.Possible } + d { "Possible password fields: ${possiblePasswordFields.size}" } + val possibleUsernameFields = + fields.filter { it.usernameCertainty >= CertaintyLevel.Possible } + d { "Possible username fields: ${possibleUsernameFields.size}" } + val possibleOtpFields = + fields.filter { it.otpCertainty >= CertaintyLevel.Possible } + d { "Possible otp fields: ${possibleOtpFields.size}" } + // Return the result of the first rule that matches + d { "Rules: ${rules.size}" } + for (rule in rules) { + return rule.match( + possiblePasswordFields, + possibleUsernameFields, + possibleOtpFields, + singleOriginMode = singleOriginMode, + isManualRequest = isManualRequest + ) + ?: continue + } + return null + } +} + +fun strategy(block: AutofillStrategy.Builder.() -> Unit) = + AutofillStrategy.Builder().apply(block).build() diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt new file mode 100644 index 00000000..b243a4c0 --- /dev/null +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt @@ -0,0 +1,213 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception + */ +package com.github.androidpasswordstore.autofillparser + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.annotation.RequiresApi + +/* + In order to add a new browser, do the following: + + 1. Obtain the .apk from a trusted source. For example, download it from the Play Store on your + phone and use adb pull to get it onto your computer. We will assume that it is called + browser.apk. + + 2. Run + + aapt dump badging browser.apk | grep package: | grep -Eo " name='[a-zA-Z0-9_\.]*" | cut -c8- + + to obtain the package name (actually, the application ID) of the app in the .apk. + + 3. Run + + apksigner verify --print-certs browser.apk | grep "#1 certificate SHA-256" | grep -Eo "[a-f0-9]{64}" | tr -d '\n' | xxd -r -p | base64 + + to calculate the hash of browser.apk's first signing certificate. + Note: This will only work if the apk has a single signing certificate. Apps with multiple + signers are very rare, so there is probably no need to add them. + Refer to computeCertificatesHash to learn how the hash would be computed in this case. + + 4. Verify the package name and the hash, for example by asking other people to repeat the steps + above. + + 5. Add an entry with the browser apps's package name and the hash to + TRUSTED_BROWSER_CERTIFICATE_HASH. + + 6. Optionally, try adding the browser's package name to BROWSERS_WITH_SAVE_SUPPORT and check + whether a save request to Password Store is triggered when you submit a registration form. + + 7. Optionally, try adding the browser's package name to BROWSERS_WITH_MULTI_ORIGIN_SUPPORT and + check whether it correctly distinguishes web origins even if iframes are present on the page. + You can use https://fabianhenneke.github.io/Android-Password-Store/ as a test form. + */ + +/* + * **Security assumption**: Browsers on this list correctly report the web origin of the top-level + * window as part of their AssistStructure. + * + * Note: Browsers can be on this list even if they don't report the correct web origins of all + * fields on the page, e.g. of those in iframes. + */ +private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf( + "com.android.chrome" to "8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M=", + "com.brave.browser" to "nC23BRNRX9v7vFhbPt89cSPU3GfJT/0wY2HB15u/GKw=", + "com.chrome.beta" to "2mM9NLaeY64hA7SdU84FL8X388U6q5T9wqIIvf0UJJw=", + "com.chrome.canary" to "IBnfofsj779wxbzRRDxb6rBPPy/0Nm6aweNFdjmiTPw=", + "com.chrome.dev" to "kETuX+5LvF4h3URmVDHE6x8fcaMnFqC8knvLs5Izyr8=", + "com.duckduckgo.mobile.android" to "u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=", + "com.microsoft.emmx" to "AeGZlxCoLCdJtNUMRF3IXWcLYTYInQp2anOCfIKh6sk=", + "com.opera.mini.native" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=", + "com.opera.mini.native.beta" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=", + "com.opera.touch" to "qtjiBNJNF3k0yc0MY8xqo4779CxKaVcJfiIQ9X+qZ6o=", + "org.bromite.bromite" to "4e5c0HbXsNyEyytF+3i4bfLrOaO2xWuj3CkqXgw7lQQ=", + "org.gnu.icecat" to "wi2iuVvK/WYZUzd2g0Qzn9ef3kAisQURZ8U1WSMTkcM=", + "org.mozilla.fenix" to "UAR3kIjn+YjVvFzF+HmP6/T4zQhKGypG79TI7krq8hE=", + "org.mozilla.fenix.nightly" to "d+rEzu02r++6dheZMd1MwZWrDNVLrzVdIV57vdKOQCo=", + "org.mozilla.fennec_aurora" to "vASIg40G9Mpr8yOG2qsN2OvPPncweHRZ9i+zzRShuqo=", + "org.mozilla.fennec_fdroid" to "BmZTWO/YugW+I2pHoSywlY19dd2TnXfCsx9TmFN+vcU=", + "org.mozilla.firefox" to "p4tipRZbRJSy/q2edqKA0i2Tf+5iUa7OWZRGsuoxmwQ=", + "org.mozilla.firefox_beta" to "p4tipRZbRJSy/q2edqKA0i2Tf+5iUa7OWZRGsuoxmwQ=", + "org.mozilla.focus" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=", + "org.mozilla.klar" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=", + "org.torproject.torbrowser" to "IAYfBF5zfGc3XBd5TP7bQ2oDzsa6y3y5+WZCIFyizsg=", + "org.ungoogled.chromium.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=", + "org.ungoogled.chromium.extensions.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=", + "com.kiwibrowser.browser" to "wGnqlmMy6R4KDDzFd+b1Cf49ndr3AVrQxcXvj9o/hig=", +) + +private fun isTrustedBrowser(context: Context, appPackage: String): Boolean { + val expectedCertificateHash = TRUSTED_BROWSER_CERTIFICATE_HASH[appPackage] ?: return false + val certificateHash = computeCertificatesHash(context, appPackage) + return certificateHash == expectedCertificateHash +} + +enum class BrowserMultiOriginMethod { + None, WebView, Field +} + +/** + * **Security assumption**: Browsers on this list correctly distinguish the web origins of form + * fields, e.g. on a page which contains both a first-party login form and an iframe with a + * (potentially malicious) third-party login form. + * + * There are two methods used by browsers: + * - Browsers based on Android's WebView report web domains on each WebView view node, which then + * needs to be propagated to the child nodes ([BrowserMultiOriginMethod.WebView]). + * - Browsers with custom Autofill implementations report web domains on each input field ( + * [BrowserMultiOriginMethod.Field]). + */ +private val BROWSER_MULTI_ORIGIN_METHOD = mapOf( + "com.duckduckgo.mobile.android" to BrowserMultiOriginMethod.WebView, + "com.opera.mini.native" to BrowserMultiOriginMethod.WebView, + "com.opera.mini.native.beta" to BrowserMultiOriginMethod.WebView, + "com.opera.touch" to BrowserMultiOriginMethod.WebView, + "org.gnu.icecat" to BrowserMultiOriginMethod.WebView, + "org.mozilla.fenix" to BrowserMultiOriginMethod.Field, + "org.mozilla.fenix.nightly" to BrowserMultiOriginMethod.Field, + "org.mozilla.fennec_aurora" to BrowserMultiOriginMethod.Field, + "org.mozilla.fennec_fdroid" to BrowserMultiOriginMethod.Field, + "org.mozilla.firefox" to BrowserMultiOriginMethod.WebView, + "org.mozilla.firefox_beta" to BrowserMultiOriginMethod.WebView, + "org.mozilla.focus" to BrowserMultiOriginMethod.Field, + "org.mozilla.klar" to BrowserMultiOriginMethod.Field, + "org.torproject.torbrowser" to BrowserMultiOriginMethod.WebView, +) + +private fun getBrowserMultiOriginMethod(appPackage: String): BrowserMultiOriginMethod = + BROWSER_MULTI_ORIGIN_METHOD[appPackage] ?: BrowserMultiOriginMethod.None + +/** + * Browsers on this list issue Autofill save requests and provide unmasked passwords as + * `autofillValue`. + * + * Some browsers may not issue save requests automatically and thus need + * `FLAG_SAVE_ON_ALL_VIEW_INVISIBLE` to be set. + */ +@RequiresApi(Build.VERSION_CODES.O) +private val BROWSER_SAVE_FLAG = mapOf( + "com.duckduckgo.mobile.android" to 0, + "org.mozilla.klar" to 0, + "org.mozilla.focus" to 0, + "org.mozilla.fenix" to 0, + "org.mozilla.fenix.nightly" to 0, + "org.mozilla.fennec_aurora" to 0, + "com.opera.mini.native" to 0, + "com.opera.mini.native.beta" to 0, + "com.opera.touch" to 0, +) + +@RequiresApi(Build.VERSION_CODES.O) +private fun getBrowserSaveFlag(appPackage: String): Int? = BROWSER_SAVE_FLAG[appPackage] + +data class BrowserAutofillSupportInfo( + val multiOriginMethod: BrowserMultiOriginMethod, + val saveFlags: Int? +) + +@RequiresApi(Build.VERSION_CODES.O) +fun getBrowserAutofillSupportInfoIfTrusted( + context: Context, + appPackage: String +): BrowserAutofillSupportInfo? { + if (!isTrustedBrowser(context, appPackage)) return null + return BrowserAutofillSupportInfo( + multiOriginMethod = getBrowserMultiOriginMethod(appPackage), + saveFlags = getBrowserSaveFlag(appPackage) + ) +} + +private val FLAKY_BROWSERS = listOf( + "com.android.chrome", + "com.chrome.beta", + "com.chrome.canary", + "com.chrome.dev", + "org.bromite.bromite", + "org.ungoogled.chromium.stable", + "com.kiwibrowser.browser", +) + +enum class BrowserAutofillSupportLevel { + None, + FlakyFill, + PasswordFill, + GeneralFill, + GeneralFillAndSave, +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun getBrowserAutofillSupportLevel( + context: Context, + appPackage: String +): BrowserAutofillSupportLevel { + val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage) + return when { + browserInfo == null -> BrowserAutofillSupportLevel.None + appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill + browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None -> BrowserAutofillSupportLevel.PasswordFill + browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill + else -> BrowserAutofillSupportLevel.GeneralFillAndSave + } +} + +@RequiresApi(Build.VERSION_CODES.O) +fun getInstalledBrowsersWithAutofillSupportLevel(context: Context): List<Pair<String, BrowserAutofillSupportLevel>> { + val testWebIntent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("http://example.org") + } + val installedBrowsers = context.packageManager.queryIntentActivities( + testWebIntent, + PackageManager.MATCH_ALL + ) + return installedBrowsers.map { + it to getBrowserAutofillSupportLevel(context, it.activityInfo.packageName) + }.filter { it.first.isDefault || it.second != BrowserAutofillSupportLevel.None }.map { + context.packageManager.getApplicationLabel(it.first.activityInfo.applicationInfo) + .toString() to it.second + } +} diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt new file mode 100644 index 00000000..ae16a995 --- /dev/null +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt @@ -0,0 +1,303 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception + */ +package com.github.androidpasswordstore.autofillparser + +import android.app.assist.AssistStructure +import android.os.Build +import android.text.InputType +import android.view.View +import android.view.autofill.AutofillId +import androidx.annotation.RequiresApi +import androidx.autofill.HintConstants +import java.util.Locale + +enum class CertaintyLevel { + Impossible, Possible, Likely, Certain +} + +/** + * Represents a single potentially fillable or saveable field together with all meta data + * extracted from its [AssistStructure.ViewNode]. + */ +@RequiresApi(Build.VERSION_CODES.O) +class FormField( + node: AssistStructure.ViewNode, + private val index: Int, + passDownWebViewOrigins: Boolean, + passedDownWebOrigin: String? = null +) { + + companion object { + + private val HINTS_USERNAME = listOf( + HintConstants.AUTOFILL_HINT_USERNAME, + HintConstants.AUTOFILL_HINT_NEW_USERNAME, + ) + + private val HINTS_NEW_PASSWORD = listOf( + HintConstants.AUTOFILL_HINT_NEW_PASSWORD, + ) + + private val HINTS_PASSWORD = HINTS_NEW_PASSWORD + listOf( + HintConstants.AUTOFILL_HINT_PASSWORD, + ) + + private val HINTS_OTP = listOf( + HintConstants.AUTOFILL_HINT_SMS_OTP, + ) + + @Suppress("DEPRECATION") + private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf( + HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS, + HintConstants.AUTOFILL_HINT_NAME, + HintConstants.AUTOFILL_HINT_PERSON_NAME, + HintConstants.AUTOFILL_HINT_PHONE, + HintConstants.AUTOFILL_HINT_PHONE_NUMBER, + ) + + private val ANDROID_TEXT_FIELD_CLASS_NAMES = listOf( + "android.widget.EditText", + "android.widget.AutoCompleteTextView", + "androidx.appcompat.widget.AppCompatEditText", + "android.support.v7.widget.AppCompatEditText", + "com.google.android.material.textfield.TextInputEditText", + ) + + private const val ANDROID_WEB_VIEW_CLASS_NAME = "android.webkit.WebView" + + private fun isPasswordInputType(inputType: Int): Boolean { + val typeClass = inputType and InputType.TYPE_MASK_CLASS + val typeVariation = inputType and InputType.TYPE_MASK_VARIATION + return when (typeClass) { + InputType.TYPE_CLASS_NUMBER -> typeVariation == InputType.TYPE_NUMBER_VARIATION_PASSWORD + InputType.TYPE_CLASS_TEXT -> typeVariation in listOf( + InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, + ) + else -> false + } + } + + private val HTML_INPUT_FIELD_TYPES_USERNAME = listOf( + "email", + "tel", + "text", + ) + private val HTML_INPUT_FIELD_TYPES_PASSWORD = listOf( + "password", + ) + private val HTML_INPUT_FIELD_TYPES_OTP = listOf( + "tel", + "text", + ) + private val HTML_INPUT_FIELD_TYPES_FILLABLE = + (HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList() + + @RequiresApi(Build.VERSION_CODES.O) + private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE + + private val EXCLUDED_TERMS = listOf( + "url_bar", // Chrome/Edge/Firefox address bar + "url_field", // Opera address bar + "location_bar_edit_text", // Samsung address bar + "search", "find", "captcha", + "postal", // Prevent postal code fields from being mistaken for OTP fields + + ) + private val PASSWORD_HEURISTIC_TERMS = listOf( + "pass", + "pswd", + "pwd", + ) + private val USERNAME_HEURISTIC_TERMS = listOf( + "alias", + "e-mail", + "email", + "login", + "user", + ) + private val OTP_HEURISTIC_TERMS = listOf( + "einmal", + "otp", + "challenge", + "verification", + ) + private val OTP_WEAK_HEURISTIC_TERMS = listOf( + "code", + ) + } + + private val List<String>.anyMatchesFieldInfo + get() = any { + fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) + } + + val autofillId: AutofillId = node.autofillId!! + + // Information for heuristics and exclusion rules based only on the current field + private val htmlId = node.htmlInfo?.attributes?.firstOrNull { it.first == "id" }?.second + private val resourceId = node.idEntry + private val fieldId = (htmlId ?: resourceId ?: "").toLowerCase(Locale.US) + private val hint = node.hint?.toLowerCase(Locale.US) ?: "" + private val className: String? = node.className + private val inputType = node.inputType + + // Information for advanced heuristics taking multiple fields and page context into account + val isFocused = node.isFocused + + // The webOrigin of a WebView should be passed down to its children in certain browsers + private val isWebView = node.className == ANDROID_WEB_VIEW_CLASS_NAME + val webOrigin = node.webOrigin ?: if (passDownWebViewOrigins) passedDownWebOrigin else null + val webOriginToPassDown = if (passDownWebViewOrigins) { + if (isWebView) webOrigin else passedDownWebOrigin + } else { + null + } + + // Basic type detection for HTML fields + private val htmlTag = node.htmlInfo?.tag + private val htmlAttributes: Map<String, String> = + node.htmlInfo?.attributes?.filter { it.first != null && it.second != null } + ?.associate { Pair(it.first.toLowerCase(Locale.US), it.second.toLowerCase(Locale.US)) } + ?: emptyMap() + + private val htmlAttributesDebug = + htmlAttributes.entries.joinToString { "${it.key}=${it.value}" } + private val htmlInputType = htmlAttributes["type"] + private val htmlName = htmlAttributes["name"] ?: "" + private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull() + private val isHtmlField = htmlTag == "input" + private val isHtmlPasswordField = + isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD + private val isHtmlTextField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_FILLABLE + + // Basic type detection for native fields + private val hasPasswordInputType = isPasswordInputType(inputType) + + // HTML fields with non-fillable types (such as submit buttons) should be excluded here + private val isAndroidTextField = !isHtmlField && className in ANDROID_TEXT_FIELD_CLASS_NAMES + private val isAndroidPasswordField = isAndroidTextField && hasPasswordInputType + + private val isTextField = isAndroidTextField || isHtmlTextField + + // Autofill hint detection for native fields + private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList() + private val excludedByAutofillHints = + if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty() + val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty() + private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty() + private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty() + private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty() + + // W3C autocomplete hint detection for HTML fields + private val htmlAutocomplete = htmlAttributes["autocomplete"] + + // Ignored for now, see excludedByHints + private val excludedByAutocompleteHint = htmlAutocomplete == "off" + private val hasAutocompleteHintUsername = htmlAutocomplete == "username" + val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password" + private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password" + private val hasAutocompleteHintPassword = + hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword + private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code" + + // Results of hint-based field type detection + val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername + val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword + val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword + val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp + + // Basic autofill exclusion checks + private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT + val isVisible = node.visibility == View.VISIBLE && htmlAttributes["aria-hidden"] != "true" + + // Hidden username fields are used to help password managers save credentials in two-step login + // flows. + // See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands + val couldBeTwoStepHiddenUsername = !isVisible && isHtmlTextField && hasAutocompleteHintUsername + + // Some websites with two-step login flows offer hidden password fields to fill the password + // already in the first step. Thus, we delegate the decision about filling invisible password + // fields to the fill rules and only exclude those fields that have incompatible autocomplete + // hint. + val couldBeTwoStepHiddenPassword = + !isVisible && isHtmlPasswordField && (hasAutocompleteHintCurrentPassword || htmlAutocomplete == null) + + // Since many site put autocomplete=off on login forms for compliance reasons or since they are + // worried of the user's browser automatically (i.e., without any user interaction) filling + // them, which we never do, we choose to ignore the value of excludedByAutocompleteHint. + // TODO: Revisit this decision in the future + private val excludedByHints = excludedByAutofillHints + + // Only offer to fill into custom views if they explicitly opted into Autofill. + val relevantField = hasAutofillTypeText && (isTextField || autofillHints.isNotEmpty()) && !excludedByHints + + // Exclude fields based on hint, resource ID or HTML name. + // Note: We still report excluded fields as relevant since they count for adjacency heuristics, + // but ensure that they are never detected as password or username fields. + private val hasExcludedTerm = EXCLUDED_TERMS.anyMatchesFieldInfo + private val notExcluded = relevantField && !hasExcludedTerm + + // Password field heuristics (based only on the current field) + private val isPossiblePasswordField = + notExcluded && (isAndroidPasswordField || isHtmlPasswordField) + private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword + private val isLikelyPasswordField = isPossiblePasswordField && + (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo) + val passwordCertainty = + if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible + + // OTP field heuristics (based only on the current field) + private val isPossibleOtpField = notExcluded && !isPossiblePasswordField + private val isCertainOtpField = isPossibleOtpField && hasHintOtp + private val isLikelyOtpField = isPossibleOtpField && ( + isCertainOtpField || OTP_HEURISTIC_TERMS.anyMatchesFieldInfo || + ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo)) + val otpCertainty = + if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible + + // Username field heuristics (based only on the current field) + private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField + private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername + private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo)) + val usernameCertainty = + if (isCertainUsernameField) CertaintyLevel.Certain else if (isLikelyUsernameField) CertaintyLevel.Likely else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible + + infix fun directlyPrecedes(that: FormField?): Boolean { + return index == (that ?: return false).index - 1 + } + + infix fun directlyPrecedes(that: Iterable<FormField>): Boolean { + val firstIndex = that.map { it.index }.minOrNull() ?: return false + return index == firstIndex - 1 + } + + infix fun directlyFollows(that: FormField?): Boolean { + return index == (that ?: return false).index + 1 + } + + infix fun directlyFollows(that: Iterable<FormField>): Boolean { + val lastIndex = that.map { it.index }.maxOrNull() ?: return false + return index == lastIndex + 1 + } + + override fun toString(): String { + val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className + val description = + "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug, $autofillHints" + return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty" + } + + override fun equals(other: Any?): Boolean { + if (other == null) return false + if (this.javaClass != other.javaClass) return false + return autofillId == (other as FormField).autofillId + } + + override fun hashCode(): Int { + return autofillId.hashCode() + } +} diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt new file mode 100644 index 00000000..316d102b --- /dev/null +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt @@ -0,0 +1,80 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception + */ +package com.github.androidpasswordstore.autofillparser + +import android.content.Context +import android.util.Patterns +import kotlinx.coroutines.runBlocking +import mozilla.components.lib.publicsuffixlist.PublicSuffixList + +private object PublicSuffixListCache { + + private lateinit var publicSuffixList: PublicSuffixList + + fun getOrCachePublicSuffixList(context: Context): PublicSuffixList { + if (!::publicSuffixList.isInitialized) { + publicSuffixList = PublicSuffixList(context) + // Trigger loading the actual public suffix list, but don't block. + @Suppress("DeferredResultUnused") + publicSuffixList.prefetch() + } + return publicSuffixList + } +} + +fun cachePublicSuffixList(context: Context) { + PublicSuffixListCache.getOrCachePublicSuffixList(context) +} + +/** + * Returns the eTLD+1 (also called registrable domain), i.e. the direct subdomain of the public + * suffix of [domain]. + * + * Note: Invalid domains, such as IP addresses, are returned unchanged and thus never collide with + * the return value for valid domains. + */ +fun getPublicSuffixPlusOne(context: Context, domain: String, customSuffixes: Sequence<String>) = runBlocking { + // We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne. + // We do not check whether the domain actually exists (actually, not even whether its TLD + // exists). As long as we restrict ourselves to syntactically valid domain names, + // getPublicSuffixPlusOne will return non-colliding results. + if (!Patterns.DOMAIN_NAME.matcher(domain).matches() || Patterns.IP_ADDRESS.matcher(domain) + .matches() + ) { + domain + } else { + getCanonicalSuffix(context, domain, customSuffixes) + } +} + +/** + * Returns: + * - [domain], if [domain] equals [suffix]; + * - null, if [domain] does not have [suffix] as a domain suffix or only with an empty prefix; + * - the direct subdomain of [suffix] of which [domain] is a subdomain. + */ +fun getSuffixPlusUpToOne(domain: String, suffix: String): String? { + if (domain == suffix) + return domain + val prefix = domain.removeSuffix(".$suffix") + if (prefix == domain || prefix.isEmpty()) + return null + val lastPrefixPart = prefix.takeLastWhile { it != '.' } + return "$lastPrefixPart.$suffix" +} + +suspend fun getCanonicalSuffix(context: Context, domain: String, customSuffixes: Sequence<String>): String { + val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context) + val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await() + ?: return domain + var longestSuffix = publicSuffixPlusOne + for (customSuffix in customSuffixes) { + val suffixPlusUpToOne = getSuffixPlusUpToOne(domain, customSuffix) ?: continue + // A shorter suffix is automatically a substring. + if (suffixPlusUpToOne.length > longestSuffix.length) + longestSuffix = suffixPlusUpToOne + } + return longestSuffix +} diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt new file mode 100644 index 00000000..d4d1c1af --- /dev/null +++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt @@ -0,0 +1,139 @@ +/* + * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0 + */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.publicsuffixlist + +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async + +/** + * API for reading and accessing the public suffix list. + * + * > A "public suffix" is one under which Internet users can (or historically could) directly register names. Some + * > examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. The Public Suffix List is a list of all known + * > public suffixes. + * + * Note that this implementation applies the rules of the public suffix list only and does not validate domains. + * + * https://publicsuffix.org/ + * https://github.com/publicsuffix/list + */ +class PublicSuffixList( + context: Context, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val scope: CoroutineScope = CoroutineScope(dispatcher) +) { + + private val data: PublicSuffixListData by lazy { PublicSuffixListLoader.load(context) } + + /** + * Prefetch the public suffix list from disk so that it is available in memory. + */ + fun prefetch(): Deferred<Unit> = scope.async { + data.run { Unit } + } + + /** + * Returns true if the given [domain] is a public suffix; false otherwise. + * + * E.g.: + * ``` + * co.uk -> true + * com -> true + * mozilla.org -> false + * org -> true + * ``` + * + * Note that this method ignores the default "prevailing rule" described in the formal public suffix list algorithm: + * If no rule matches then the passed [domain] is assumed to *not* be a public suffix. + * + * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values + * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. + */ + fun isPublicSuffix(domain: String): Deferred<Boolean> = scope.async { + when (data.getPublicSuffixOffset(domain)) { + is PublicSuffixOffset.PublicSuffix -> true + else -> false + } + } + + /** + * Returns the public suffix and one more level; known as the registrable domain. Returns `null` if + * [domain] is a public suffix itself. + * + * E.g.: + * ``` + * wwww.mozilla.org -> mozilla.org + * www.bcc.co.uk -> bbc.co.uk + * a.b.ide.kyoto.jp -> b.ide.kyoto.jp + * ``` + * + * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values + * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. + */ + fun getPublicSuffixPlusOne(domain: String): Deferred<String?> = scope.async { + when (val offset = data.getPublicSuffixOffset(domain)) { + is PublicSuffixOffset.Offset -> domain + .split('.') + .drop(offset.value) + .joinToString(separator = ".") + else -> null + } + } + + /** + * Returns the public suffix of the given [domain]; known as the effective top-level domain (eTLD). Returns `null` + * if the [domain] is a public suffix itself. + * + * E.g.: + * ``` + * wwww.mozilla.org -> org + * www.bcc.co.uk -> co.uk + * a.b.ide.kyoto.jp -> ide.kyoto.jp + * ``` + * + * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values + * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. + */ + fun getPublicSuffix(domain: String) = scope.async { + when (val offset = data.getPublicSuffixOffset(domain)) { + is PublicSuffixOffset.Offset -> domain + .split('.') + .drop(offset.value + 1) + .joinToString(separator = ".") + else -> null + } + } + + /** + * Strips the public suffix from the given [domain]. Returns the original domain if no public suffix could be + * stripped. + * + * E.g.: + * ``` + * wwww.mozilla.org -> www.mozilla + * www.bcc.co.uk -> www.bbc + * a.b.ide.kyoto.jp -> a.b + * ``` + * + * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values + * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. + */ + fun stripPublicSuffix(domain: String) = scope.async { + when (val offset = data.getPublicSuffixOffset(domain)) { + is PublicSuffixOffset.Offset -> domain + .split('.') + .joinToString(separator = ".", limit = offset.value + 1, truncated = "") + .dropLast(1) + else -> domain + } + } +} diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt new file mode 100644 index 00000000..595d46b1 --- /dev/null +++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt @@ -0,0 +1,163 @@ +/* + * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0 + */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.publicsuffixlist + +import mozilla.components.lib.publicsuffixlist.ext.binarySearch +import java.net.IDN + +/** + * Class wrapping the public suffix list data and offering methods for accessing rules in it. + */ +internal class PublicSuffixListData( + private val rules: ByteArray, + private val exceptions: ByteArray +) { + + private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? { + return rules.binarySearch(labels, labelIndex) + } + + private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? { + return exceptions.binarySearch(labels, labelIndex) + } + + @Suppress("ReturnCount") + fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? { + if (domain.isEmpty()) { + return null + } + + val domainLabels = IDN.toUnicode(domain).split('.') + if (domainLabels.find { it.isEmpty() } != null) { + // At least one of the labels is empty: Bail out. + return null + } + + val rule = findMatchingRule(domainLabels) + + if (domainLabels.size == rule.size && rule[0][0] != PublicSuffixListData.EXCEPTION_MARKER) { + // The domain is a public suffix. + return if (rule == PublicSuffixListData.PREVAILING_RULE) { + PublicSuffixOffset.PrevailingRule + } else { + PublicSuffixOffset.PublicSuffix + } + } + + return if (rule[0][0] == PublicSuffixListData.EXCEPTION_MARKER) { + // Exception rules hold the effective TLD plus one. + PublicSuffixOffset.Offset(domainLabels.size - rule.size) + } else { + // Otherwise the rule is for a public suffix, so we must take one more label. + PublicSuffixOffset.Offset(domainLabels.size - (rule.size + 1)) + } + } + + /** + * Find a matching rule for the given domain labels. + * + * This algorithm is based on OkHttp's PublicSuffixDatabase class: + * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/publicsuffix/PublicSuffixDatabase.java + */ + private fun findMatchingRule(domainLabels: List<String>): List<String> { + // Break apart the domain into UTF-8 labels, i.e. foo.bar.com turns into [foo, bar, com]. + val domainLabelsBytes = domainLabels.map { it.toByteArray(Charsets.UTF_8) } + + val exactMatch = findExactMatch(domainLabelsBytes) + val wildcardMatch = findWildcardMatch(domainLabelsBytes) + val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch) + + if (exceptionMatch != null) { + return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.') + } + + if (exactMatch == null && wildcardMatch == null) { + return PublicSuffixListData.PREVAILING_RULE + } + + val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE + val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE + + return if (exactRuleLabels.size > wildcardRuleLabels.size) { + exactRuleLabels + } else { + wildcardRuleLabels + } + } + + /** + * Returns an exact match or null. + */ + private fun findExactMatch(labels: List<ByteArray>): String? { + // Start by looking for exact matches. We start at the leftmost label. For example, foo.bar.com + // will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins. + + for (i in 0 until labels.size) { + val rule = binarySearchRules(labels, i) + + if (rule != null) { + return rule + } + } + + return null + } + + /** + * Returns a wildcard match or null. + */ + private fun findWildcardMatch(labels: List<ByteArray>): String? { + // In theory, wildcard rules are not restricted to having the wildcard in the leftmost position. + // In practice, wildcards are always in the leftmost position. For now, this implementation + // cheats and does not attempt every possible permutation. Instead, it only considers wildcards + // in the leftmost position. We assert this fact when we generate the public suffix file. If + // this assertion ever fails we'll need to refactor this implementation. + if (labels.size > 1) { + val labelsWithWildcard = labels.toMutableList() + for (labelIndex in 0 until labelsWithWildcard.size) { + labelsWithWildcard[labelIndex] = PublicSuffixListData.WILDCARD_LABEL + val rule = binarySearchRules(labelsWithWildcard, labelIndex) + if (rule != null) { + return rule + } + } + } + + return null + } + + private fun findExceptionMatch(labels: List<ByteArray>, wildcardMatch: String?): String? { + // Exception rules only apply to wildcard rules, so only try it if we matched a wildcard. + if (wildcardMatch == null) { + return null + } + + for (labelIndex in 0 until labels.size) { + val rule = binarySearchExceptions(labels, labelIndex) + if (rule != null) { + return rule + } + } + + return null + } + + companion object { + + val WILDCARD_LABEL = byteArrayOf('*'.toByte()) + val PREVAILING_RULE = listOf("*") + val EMPTY_RULE = listOf<String>() + const val EXCEPTION_MARKER = '!' + } +} + +internal sealed class PublicSuffixOffset { + data class Offset(val value: Int) : PublicSuffixOffset() + object PublicSuffix : PublicSuffixOffset() + object PrevailingRule : PublicSuffixOffset() +} diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt new file mode 100644 index 00000000..d8542f94 --- /dev/null +++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0 + */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.publicsuffixlist + +import android.content.Context +import java.io.BufferedInputStream +import java.io.IOException + +private const val PUBLIC_SUFFIX_LIST_FILE = "publicsuffixes" + +internal object PublicSuffixListLoader { + + fun load(context: Context): PublicSuffixListData = context.assets.open( + PUBLIC_SUFFIX_LIST_FILE + ).buffered().use { stream -> + val publicSuffixSize = stream.readInt() + val publicSuffixBytes = stream.readFully(publicSuffixSize) + + val exceptionSize = stream.readInt() + val exceptionBytes = stream.readFully(exceptionSize) + + PublicSuffixListData(publicSuffixBytes, exceptionBytes) + } +} + +@Suppress("MagicNumber") +private fun BufferedInputStream.readInt(): Int { + return (read() and 0xff shl 24 + or (read() and 0xff shl 16) + or (read() and 0xff shl 8) + or (read() and 0xff)) +} + +private fun BufferedInputStream.readFully(size: Int): ByteArray { + val bytes = ByteArray(size) + + var offset = 0 + while (offset < size) { + val read = read(bytes, offset, size - offset) + if (read == -1) { + throw IOException("Unexpected end of stream") + } + offset += read + } + + return bytes +} diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt new file mode 100644 index 00000000..fbe8ec5c --- /dev/null +++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt @@ -0,0 +1,125 @@ +/* + * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0 + */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.publicsuffixlist.ext + +import kotlin.experimental.and + +private const val BITMASK = 0xff.toByte() + +/** + * Performs a binary search for the provided [labels] on the [ByteArray]'s data. + * + * This algorithm is based on OkHttp's PublicSuffixDatabase class: + * https://github.com/square/okhttp/blob/1977136/okhttp/src/main/kotlin/okhttp3/internal/publicsuffix/PublicSuffixDatabase.kt + */ +@Suppress("ComplexMethod", "NestedBlockDepth") +internal fun ByteArray.binarySearch(labels: List<ByteArray>, labelIndex: Int): String? { + var low = 0 + var high = size + var match: String? = null + + while (low < high) { + val mid = (low + high) / 2 + val start = findStartOfLineFromIndex(mid) + val end = findEndOfLineFromIndex(start) + + val publicSuffixLength = start + end - start + + var compareResult: Int + var currentLabelIndex = labelIndex + var currentLabelByteIndex = 0 + var publicSuffixByteIndex = 0 + + var expectDot = false + while (true) { + val byte0 = if (expectDot) { + expectDot = false + '.'.toByte() + } else { + labels[currentLabelIndex][currentLabelByteIndex] and BITMASK + } + + val byte1 = this[start + publicSuffixByteIndex] and BITMASK + + // Compare the bytes. Note that the file stores UTF-8 encoded bytes, so we must compare the + // unsigned bytes. + @Suppress("EXPERIMENTAL_API_USAGE") + compareResult = (byte0.toUByte() - byte1.toUByte()).toInt() + if (compareResult != 0) { + break + } + + publicSuffixByteIndex++ + currentLabelByteIndex++ + + if (publicSuffixByteIndex == publicSuffixLength) { + break + } + + if (labels[currentLabelIndex].size == currentLabelByteIndex) { + // We've exhausted our current label. Either there are more labels to compare, in which + // case we expect a dot as the next character. Otherwise, we've checked all our labels. + if (currentLabelIndex == labels.size - 1) { + break + } else { + currentLabelIndex++ + currentLabelByteIndex = -1 + expectDot = true + } + } + } + + if (compareResult < 0) { + high = start - 1 + } else if (compareResult > 0) { + low = start + end + 1 + } else { + // We found a match, but are the lengths equal? + val publicSuffixBytesLeft = publicSuffixLength - publicSuffixByteIndex + var labelBytesLeft = labels[currentLabelIndex].size - currentLabelByteIndex + for (i in currentLabelIndex + 1 until labels.size) { + labelBytesLeft += labels[i].size + } + + if (labelBytesLeft < publicSuffixBytesLeft) { + high = start - 1 + } else if (labelBytesLeft > publicSuffixBytesLeft) { + low = start + end + 1 + } else { + // Found a match. + match = String(this, start, publicSuffixLength, Charsets.UTF_8) + break + } + } + } + + return match +} + +/** + * Search for a '\n' that marks the start of a value. Don't go back past the start of the array. + */ +private fun ByteArray.findStartOfLineFromIndex(start: Int): Int { + var index = start + while (index > -1 && this[index] != '\n'.toByte()) { + index-- + } + index++ + return index +} + +/** + * Search for a '\n' that marks the end of a value. + */ +private fun ByteArray.findEndOfLineFromIndex(start: Int): Int { + var end = 1 + while (this[start + end] != '\n'.toByte()) { + end++ + } + return end +} |