From 08102734445257f9bbd4a7477c49e9e55fd88eb2 Mon Sep 17 00:00:00 2001 From: Vincent Breitmoser Date: Wed, 16 Sep 2020 20:17:55 +0200 Subject: Autofill: Extract AutofillParser into separate subproject (#1101) Co-authored-by: Harsh Shandilya Co-authored-by: Fabian Henneke --- .github/workflows/update_publicsuffix_data.yml | 2 +- .idea/gradle.xml | 2 +- app/build.gradle.kts | 2 +- app/src/main/assets/publicsuffixes | Bin 105817 -> 0 bytes .../main/java/com/zeapo/pwdstore/PasswordStore.kt | 4 +- .../main/java/com/zeapo/pwdstore/UserPreference.kt | 4 +- .../zeapo/pwdstore/autofill/oreo/AutofillHelper.kt | 210 ----------- .../pwdstore/autofill/oreo/AutofillMatcher.kt | 2 + .../pwdstore/autofill/oreo/AutofillPreferences.kt | 15 + .../autofill/oreo/AutofillResponseBuilder.kt | 192 +++++++++++ .../pwdstore/autofill/oreo/AutofillScenario.kt | 298 ---------------- .../pwdstore/autofill/oreo/AutofillStrategy.kt | 208 ----------- .../pwdstore/autofill/oreo/AutofillStrategyDsl.kt | 381 -------------------- .../pwdstore/autofill/oreo/AutofillViewUtils.kt | 67 ++++ .../autofill/oreo/FeatureAndTrustDetection.kt | 213 ------------ .../java/com/zeapo/pwdstore/autofill/oreo/Form.kt | 383 --------------------- .../com/zeapo/pwdstore/autofill/oreo/FormField.kt | 303 ---------------- .../pwdstore/autofill/oreo/OreoAutofillService.kt | 27 +- .../autofill/oreo/PublicSuffixListCache.kt | 90 ----- .../autofill/oreo/ui/AutofillDecryptActivity.kt | 10 +- .../autofill/oreo/ui/AutofillFilterActivity.kt | 2 +- .../oreo/ui/AutofillPublisherChangedActivity.kt | 4 +- .../autofill/oreo/ui/AutofillSaveActivity.kt | 10 +- .../lib/publicsuffixlist/PublicSuffixList.kt | 139 -------- .../lib/publicsuffixlist/PublicSuffixListData.kt | 163 --------- .../lib/publicsuffixlist/PublicSuffixListLoader.kt | 52 --- .../lib/publicsuffixlist/ext/ByteArray.kt | 125 ------- .../autofill/oreo/ui/AutofillSmsActivity.kt | 19 +- autofill-parser/.gitignore | 1 + autofill-parser/build.gradle.kts | 19 + autofill-parser/consumer-rules.pro | 0 autofill-parser/proguard-rules.pro | 21 ++ autofill-parser/src/main/AndroidManifest.xml | 7 + autofill-parser/src/main/assets/publicsuffixes | Bin 0 -> 105817 bytes .../autofillparser/AutofillFormParser.kt | 217 ++++++++++++ .../autofillparser/AutofillHelper.kt | 127 +++++++ .../autofillparser/AutofillScenario.kt | 296 ++++++++++++++++ .../autofillparser/AutofillStrategy.kt | 208 +++++++++++ .../autofillparser/AutofillStrategyDsl.kt | 381 ++++++++++++++++++++ .../autofillparser/FeatureAndTrustDetection.kt | 213 ++++++++++++ .../autofillparser/FormField.kt | 303 ++++++++++++++++ .../autofillparser/PublicSuffixListCache.kt | 80 +++++ .../lib/publicsuffixlist/PublicSuffixList.kt | 139 ++++++++ .../lib/publicsuffixlist/PublicSuffixListData.kt | 163 +++++++++ .../lib/publicsuffixlist/PublicSuffixListLoader.kt | 52 +++ .../lib/publicsuffixlist/ext/ByteArray.kt | 125 +++++++ buildSrc/src/main/java/Dependencies.kt | 1 + settings.gradle.kts | 1 + 48 files changed, 2684 insertions(+), 2597 deletions(-) delete mode 100644 app/src/main/assets/publicsuffixes delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt delete mode 100644 app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt delete mode 100644 app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt delete mode 100644 app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt delete mode 100644 app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt create mode 100644 autofill-parser/.gitignore create mode 100644 autofill-parser/build.gradle.kts create mode 100644 autofill-parser/consumer-rules.pro create mode 100644 autofill-parser/proguard-rules.pro create mode 100644 autofill-parser/src/main/AndroidManifest.xml create mode 100644 autofill-parser/src/main/assets/publicsuffixes create mode 100644 autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt create mode 100644 autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt create mode 100644 autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt create mode 100644 autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt create mode 100644 autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt create mode 100644 autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt create mode 100644 autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt create mode 100644 autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt create mode 100644 autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt create mode 100644 autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt create mode 100644 autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt create mode 100644 autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt diff --git a/.github/workflows/update_publicsuffix_data.yml b/.github/workflows/update_publicsuffix_data.yml index 34722279..29f1777f 100644 --- a/.github/workflows/update_publicsuffix_data.yml +++ b/.github/workflows/update_publicsuffix_data.yml @@ -16,7 +16,7 @@ jobs: git config user.email noreply@github.com - name: Download new publicsuffix data - run: curl -L https://github.com/mozilla-mobile/android-components/raw/master/components/lib/publicsuffixlist/src/main/assets/publicsuffixes -o app/src/main/assets/publicsuffixes + run: curl -L https://github.com/mozilla-mobile/android-components/raw/master/components/lib/publicsuffixlist/src/main/assets/publicsuffixes -o autofill-parser/src/main/assets/publicsuffixes - name: Compare list changes run: if [[ $(git diff --binary --stat) != '' ]]; then echo "::set-env name=UPDATED::true"; fi diff --git a/.idea/gradle.xml b/.idea/gradle.xml index f7e941c7..be83284a 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -12,11 +12,11 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2d5c7262..293af246 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,6 @@ android { dependencies { compileOnly(Dependencies.AndroidX.annotation) implementation(Dependencies.AndroidX.activity_ktx) - implementation(Dependencies.AndroidX.autofill) implementation(Dependencies.AndroidX.appcompat) implementation(Dependencies.AndroidX.biometric) implementation(Dependencies.AndroidX.constraint_layout) @@ -109,6 +108,7 @@ dependencies { implementation(Dependencies.Kotlin.Coroutines.android) implementation(Dependencies.Kotlin.Coroutines.core) + implementation(project(Dependencies.FirstParty.autofill_parser)) implementation(Dependencies.FirstParty.openpgp_ktx) implementation(Dependencies.FirstParty.zxing_android_embedded) diff --git a/app/src/main/assets/publicsuffixes b/app/src/main/assets/publicsuffixes deleted file mode 100644 index 814d4969..00000000 Binary files a/app/src/main/assets/publicsuffixes and /dev/null differ diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index 4e4bf85e..eb339ac3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -35,6 +35,8 @@ import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.i import com.github.ajalt.timberkt.w +import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel +import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel import com.github.michaelbull.result.fold import com.github.michaelbull.result.getOr import com.github.michaelbull.result.onFailure @@ -43,8 +45,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputEditText import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher -import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel -import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel import com.zeapo.pwdstore.crypto.BasePgpActivity.Companion.getLongName import com.zeapo.pwdstore.crypto.DecryptActivity import com.zeapo.pwdstore.crypto.PasswordCreationActivity diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt index d41a7fe7..7aeec148 100644 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt @@ -36,13 +36,13 @@ import androidx.preference.SwitchPreferenceCompat import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.w +import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel +import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel import com.github.michaelbull.result.getOr import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.zeapo.pwdstore.autofill.AutofillPreferenceActivity -import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel -import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel import com.zeapo.pwdstore.crypto.BasePgpActivity import com.zeapo.pwdstore.git.GitConfigActivity import com.zeapo.pwdstore.git.GitServerConfigActivity diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt deleted file mode 100644 index 502c9423..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -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.RemoteViews -import android.widget.Toast -import androidx.annotation.RequiresApi -import com.github.ajalt.timberkt.Timber.tag -import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.model.PasswordEntry -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File -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 Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) - -private fun stableHash(array: Collection): 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" - } - -data class Credentials(val username: String?, val password: String?, val otp: String?) { - companion object { - - fun fromStoreEntry( - context: Context, - file: File, - entry: PasswordEntry, - directoryStructure: DirectoryStructure - ): Credentials { - // Always give priority to a username stored in the encrypted extras - val username = entry.username - ?: directoryStructure.getUsernameFor(file) - ?: context.getDefaultUsername() - return Credentials(username, entry.password, entry.calculateTotpCode()) - } - } -} - -private fun makeRemoteView( - context: Context, - title: String, - summary: String, - iconRes: Int -): RemoteViews { - return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply { - setTextViewText(R.id.title, title) - setTextViewText(R.id.summary, summary) - setImageViewResource(R.id.icon, iconRes) - } -} - -fun makeFillMatchRemoteView(context: Context, file: File, formOrigin: FormOrigin): RemoteViews { - val title = formOrigin.getPrettyIdentifier(context, untrusted = false) - val directoryStructure = AutofillPreferences.directoryStructure(context) - val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) - val summary = directoryStructure.getUsernameFor(relativeFile) - ?: directoryStructure.getPathToIdentifierFor(relativeFile) ?: "" - val iconRes = R.drawable.ic_person_black_24dp - return makeRemoteView(context, title, summary, iconRes) -} - -fun makeSearchAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { - val title = formOrigin.getPrettyIdentifier(context, untrusted = true) - val summary = context.getString(R.string.oreo_autofill_search_in_store) - val iconRes = R.drawable.ic_search_black_24dp - return makeRemoteView(context, title, summary, iconRes) -} - -fun makeGenerateAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { - val title = formOrigin.getPrettyIdentifier(context, untrusted = true) - val summary = context.getString(R.string.oreo_autofill_generate_password) - val iconRes = R.drawable.ic_autofill_new_password - return makeRemoteView(context, title, summary, iconRes) -} - -fun makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { - val title = formOrigin.getPrettyIdentifier(context, untrusted = true) - val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms) - val iconRes = R.drawable.ic_autofill_sms - return makeRemoteView(context, title, summary, iconRes) -} - -fun makePlaceholderRemoteView(context: Context): RemoteViews { - return makeRemoteView(context, "PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher) -} - -fun makeWarningRemoteView(context: Context): RemoteViews { - val title = context.getString(R.string.oreo_autofill_warning_publisher_dataset_title) - val summary = context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary) - val iconRes = R.drawable.ic_warning_red_24dp - return makeRemoteView(context, title, summary, iconRes) -} - -@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/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt index 1b0ff35d..601be34a 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt @@ -14,6 +14,8 @@ import com.github.ajalt.timberkt.w import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash import com.zeapo.pwdstore.R import java.io.File diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt index 9c84e1ad..cc0875f3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt @@ -7,6 +7,8 @@ package com.zeapo.pwdstore.autofill.oreo import android.content.Context import android.os.Build import androidx.annotation.RequiresApi +import com.github.androidpasswordstore.autofillparser.Credentials +import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.utils.sharedPrefs import java.io.File import java.nio.file.Paths @@ -121,4 +123,17 @@ object AutofillPreferences { val value = context.sharedPrefs.getString(DirectoryStructure.PREFERENCE, null) return DirectoryStructure.fromValue(value) } + + fun credentialsFromStoreEntry( + context: Context, + file: File, + entry: PasswordEntry, + directoryStructure: DirectoryStructure + ): Credentials { + // Always give priority to a username stored in the encrypted extras + val username = entry.username + ?: directoryStructure.getUsernameFor(file) + ?: context.getDefaultUsername() + return Credentials(username, entry.password, entry.calculateTotpCode()) + } } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt new file mode 100644 index 00000000..0e73b908 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt @@ -0,0 +1,192 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.autofill.oreo + +import android.content.Context +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillResponse +import android.service.autofill.SaveInfo +import android.widget.RemoteViews +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.fold +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.fillWith +import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity +import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView +import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity +import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity +import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity +import java.io.File + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillResponseBuilder(form: FillableForm) { + private val formOrigin = form.formOrigin + private val scenario = form.scenario + private val ignoredIds = form.ignoredIds + private val saveFlags = form.saveFlags + private val clientState = form.toClientState() + + // We do not offer save when the only relevant field is a username field or there is no field. + private val scenarioSupportsSave = + scenario.fieldsToSave.minus(listOfNotNull(scenario.username)).isNotEmpty() + private val canBeSaved = saveFlags != null && scenarioSupportsSave + + private fun makePlaceholderDataset( + remoteView: RemoteViews, + intentSender: IntentSender, + action: AutofillAction + ): Dataset { + return Dataset.Builder(remoteView).run { + fillWith(scenario, action, credentials = null) + setAuthentication(intentSender) + build() + } + } + + private fun makeMatchDataset(context: Context, file: File): Dataset? { + if (scenario.fieldsToFillOn(AutofillAction.Match).isEmpty()) return null + val remoteView = makeFillMatchRemoteView(context, file, formOrigin) + val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) + return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match) + } + + private fun makeSearchDataset(context: Context): Dataset? { + if (scenario.fieldsToFillOn(AutofillAction.Search).isEmpty()) return null + val remoteView = makeSearchAndFillRemoteView(context, formOrigin) + val intentSender = + AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) + return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Search) + } + + private fun makeGenerateDataset(context: Context): Dataset? { + if (scenario.fieldsToFillOn(AutofillAction.Generate).isEmpty()) return null + val remoteView = makeGenerateAndFillRemoteView(context, formOrigin) + val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) + return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Generate) + } + + private fun makeFillOtpFromSmsDataset(context: Context): Dataset? { + if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null + if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null + val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin) + val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) + return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms) + } + + private fun makePublisherChangedDataset( + context: Context, + publisherChangedException: AutofillPublisherChangedException + ): Dataset { + val remoteView = makeWarningRemoteView(context) + val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( + context, publisherChangedException + ) + return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match) + } + + private fun makePublisherChangedResponse( + context: Context, + publisherChangedException: AutofillPublisherChangedException + ): FillResponse { + return FillResponse.Builder().run { + addDataset(makePublisherChangedDataset(context, publisherChangedException)) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE + // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE + private fun makeSaveInfo(): SaveInfo? { + if (!canBeSaved) return null + check(saveFlags != null) + val idsToSave = scenario.fieldsToSave.map { it.autofillId }.toTypedArray() + if (idsToSave.isEmpty()) return null + var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD + if (scenario.username != null) { + saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME + } + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + setFlags(saveFlags) + build() + } + } + + private fun makeFillResponse(context: Context, matchedFiles: List): FillResponse? { + var hasDataset = false + return FillResponse.Builder().run { + for (file in matchedFiles) { + makeMatchDataset(context, file)?.let { + hasDataset = true + addDataset(it) + } + } + makeSearchDataset(context)?.let { + hasDataset = true + addDataset(it) + } + makeGenerateDataset(context)?.let { + hasDataset = true + addDataset(it) + } + makeFillOtpFromSmsDataset(context)?.let { + hasDataset = true + addDataset(it) + } + if (!hasDataset) return null + makeSaveInfo()?.let { setSaveInfo(it) } + setClientState(clientState) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + /** + * Creates and returns a suitable [FillResponse] to the Autofill framework. + */ + fun fillCredentials(context: Context, callback: FillCallback) { + AutofillMatcher.getMatchesFor(context, formOrigin).fold( + success = { matchedFiles -> + callback.onSuccess(makeFillResponse(context, matchedFiles)) + }, + failure = { e -> + e(e) + callback.onSuccess(makePublisherChangedResponse(context, e)) + } + ) + } + + companion object { + fun makeFillInDataset( + context: Context, + credentials: Credentials, + clientState: Bundle, + action: AutofillAction + ): Dataset { + val remoteView = makePlaceholderRemoteView(context) + val scenario = AutofillScenario.fromBundle(clientState) + // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even + // though they are never shown. + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Dataset.Builder() + } else { + Dataset.Builder(remoteView) + } + return builder.run { + if (scenario != null) fillWith(scenario, action, credentials) + else e { "Failed to recover scenario from client state" } + build() + } + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt deleted file mode 100644 index f4fd5cf1..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -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 -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching - -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 { - - 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? { - return runCatching { - Builder().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() - }.getOrElse { e -> - e(e) - null - } - } - } - - class Builder { - - var username: T? = null - var fillUsername = false - var otp: T? = null - val currentPassword = mutableListOf() - val newPassword = mutableListOf() - val genericPassword = mutableListOf() - - fun build(): AutofillScenario { - 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 - abstract val passwordFieldsToFillOnMatch: List - abstract val passwordFieldsToFillOnSearch: List - abstract val passwordFieldsToFillOnGenerate: List - abstract val passwordFieldsToSave: List - - val fieldsToSave - get() = listOfNotNull(username) + passwordFieldsToSave - - val allFields - get() = listOfNotNull(username, otp) + allPasswordFields - - fun fieldsToFillOn(action: AutofillAction): List { - 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( - override val username: T?, - override val fillUsername: Boolean, - override val otp: T?, - val currentPassword: List, - val newPassword: List -) : AutofillScenario() { - - 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( - override val username: T?, - override val fillUsername: Boolean, - override val otp: T?, - val genericPassword: List -) : AutofillScenario() { - - 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.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, - 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, - action: AutofillAction, - credentials: Credentials? -) { - fillWith(scenario.map { it.autofillId }, action, credentials) -} - -inline fun AutofillScenario.map(transform: (T) -> S): AutofillScenario { - val builder = AutofillScenario.Builder() - 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.toBundle(): Bundle = when (this) { - is ClassifiedAutofillScenario -> { - 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 -> { - 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.toBundle(): Bundle = map { it.autofillId }.toBundle() - -@RequiresApi(Build.VERSION_CODES.O) -fun AutofillScenario.recoverNodes(structure: AssistStructure): AutofillScenario? { - return map { autofillId -> - structure.findNodeByAutofillId(autofillId) ?: return null - } -} - -val AutofillScenario.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.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/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt deleted file mode 100644 index 90bb7051..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -import android.os.Build -import androidx.annotation.RequiresApi -import com.zeapo.pwdstore.autofill.oreo.CertaintyLevel.Certain -import com.zeapo.pwdstore.autofill.oreo.CertaintyLevel.Likely - -private inline fun Pair.all(predicate: T.() -> Boolean) = - predicate(first) && predicate(second) - -private inline fun Pair.any(predicate: T.() -> Boolean) = - predicate(first) || predicate(second) - -private inline fun Pair.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/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt deleted file mode 100644 index cae84d54..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt +++ /dev/null @@ -1,381 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -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, alreadyMatched: List): List? - - @AutofillDsl - class Builder { - - private var takeSingle: (FormField.(List) -> Boolean)? = null - private val tieBreakersSingle: MutableList) -> Boolean> = - mutableListOf() - - private var takePair: (Pair.(List) -> Boolean)? = null - private var tieBreakersPair: MutableList.(List) -> Boolean> = - mutableListOf() - - fun takeSingle(block: FormField.(alreadyMatched: List) -> 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) -> 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.(alreadyMatched: List) -> 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.(alreadyMatched: List) -> 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) -> Boolean, - private val tieBreakers: List<(FormField, List) -> Boolean> -) : FieldMatcher { - - @AutofillDsl - class Builder { - - private var takeSingle: (FormField.(List) -> Boolean)? = null - private val tieBreakersSingle: MutableList) -> Boolean> = - mutableListOf() - - fun takeSingle(block: FormField.(alreadyMatched: List) -> Boolean = { true }) { - check(takeSingle == null) { "Every block can only have at most one takeSingle block" } - takeSingle = block - } - - fun breakTieOnSingle(block: FormField.(alreadyMatched: List) -> 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, alreadyMatched: List): List? { - 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, List) -> Boolean, - private val tieBreakers: List<(Pair, List) -> Boolean> -) : FieldMatcher { - - override fun match(fields: List, alreadyMatched: List): List? { - 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, - 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() - 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, - allUsername: List, - allOtp: List, - singleOriginMode: Boolean, - isManualRequest: Boolean - ): AutofillScenario? { - 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() - val alreadyMatched = mutableListOf() - 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) { - - @AutofillDsl - class Builder { - - private val rules: MutableList = 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, - singleOriginMode: Boolean, - isManualRequest: Boolean - ): AutofillScenario? { - 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/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt new file mode 100644 index 00000000..5e8061a2 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt @@ -0,0 +1,67 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.autofill.oreo + +import android.content.Context +import android.widget.RemoteViews +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.utils.PasswordRepository +import java.io.File + +private fun makeRemoteView( + context: Context, + title: String, + summary: String, + iconRes: Int +): RemoteViews { + return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply { + setTextViewText(R.id.title, title) + setTextViewText(R.id.summary, summary) + setImageViewResource(R.id.icon, iconRes) + } +} + +fun makeFillMatchRemoteView(context: Context, file: File, formOrigin: FormOrigin): RemoteViews { + val title = formOrigin.getPrettyIdentifier(context, untrusted = false) + val directoryStructure = AutofillPreferences.directoryStructure(context) + val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) + val summary = directoryStructure.getUsernameFor(relativeFile) + ?: directoryStructure.getPathToIdentifierFor(relativeFile) ?: "" + val iconRes = R.drawable.ic_person_black_24dp + return makeRemoteView(context, title, summary, iconRes) +} + +fun makeSearchAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { + val title = formOrigin.getPrettyIdentifier(context, untrusted = true) + val summary = context.getString(R.string.oreo_autofill_search_in_store) + val iconRes = R.drawable.ic_search_black_24dp + return makeRemoteView(context, title, summary, iconRes) +} + +fun makeGenerateAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { + val title = formOrigin.getPrettyIdentifier(context, untrusted = true) + val summary = context.getString(R.string.oreo_autofill_generate_password) + val iconRes = R.drawable.ic_autofill_new_password + return makeRemoteView(context, title, summary, iconRes) +} + +fun makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { + val title = formOrigin.getPrettyIdentifier(context, untrusted = true) + val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms) + val iconRes = R.drawable.ic_autofill_sms + return makeRemoteView(context, title, summary, iconRes) +} + +fun makePlaceholderRemoteView(context: Context): RemoteViews { + return makeRemoteView(context, "PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher) +} + +fun makeWarningRemoteView(context: Context): RemoteViews { + val title = context.getString(R.string.oreo_autofill_warning_publisher_dataset_title) + val summary = context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary) + val iconRes = R.drawable.ic_warning_red_24dp + return makeRemoteView(context, title, summary, iconRes) +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt deleted file mode 100644 index e49f7e81..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -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> { - 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/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt deleted file mode 100644 index 5d24c882..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt +++ /dev/null @@ -1,383 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -import android.app.assist.AssistStructure -import android.content.Context -import android.content.IntentSender -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.service.autofill.Dataset -import android.service.autofill.FillCallback -import android.service.autofill.FillResponse -import android.service.autofill.SaveInfo -import android.view.autofill.AutofillId -import android.widget.RemoteViews -import androidx.annotation.RequiresApi -import androidx.core.os.bundleOf -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.fold -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity -import java.io.File - -/** - * 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 Form(context: Context, structure: AssistStructure, isManualRequest: Boolean) { - - companion object { - - private val SUPPORTED_SCHEMES = listOf("http", "https") - } - - private val relevantFields = mutableListOf() - val ignoredIds = mutableListOf() - private var fieldIndex = 0 - - private var appPackage = structure.activityComponent.packageName - - private val trustedBrowserInfo = - getBrowserAutofillSupportInfoIfTrusted(context, appPackage) - val saveFlags = trustedBrowserInfo?.saveFlags - - private val webOrigins = mutableSetOf() - - 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)) - } - - 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 - ) - } - } - } -} - -/** - * 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( - private val formOrigin: FormOrigin, - private val scenario: AutofillScenario, - private val ignoredIds: List, - private val saveFlags: Int? -) { - - companion object { - - fun makeFillInDataset( - context: Context, - credentials: Credentials, - clientState: Bundle, - action: AutofillAction - ): Dataset { - val remoteView = makePlaceholderRemoteView(context) - val scenario = AutofillScenario.fromBundle(clientState) - // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even - // though they are never shown. - val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - Dataset.Builder() - } else { - Dataset.Builder(remoteView) - } - return builder.run { - if (scenario != null) fillWith(scenario, action, credentials) - else e { "Failed to recover scenario from client state" } - build() - } - } - - /** - * Returns a [FillableForm] if a login form could be detected in [structure]. - */ - fun parseAssistStructure( - context: Context, - structure: AssistStructure, - isManualRequest: Boolean - ): FillableForm? { - val form = Form(context, structure, isManualRequest) - if (form.formOrigin == null || form.scenario == null) return null - return FillableForm( - form.formOrigin, - form.scenario, - form.ignoredIds, - form.saveFlags - ) - } - } - - private val clientState = scenario.toBundle().apply { - putAll(formOrigin.toBundle()) - } - - // We do not offer save when the only relevant field is a username field or there is no field. - private val scenarioSupportsSave = - scenario.fieldsToSave.minus(listOfNotNull(scenario.username)).isNotEmpty() - private val canBeSaved = saveFlags != null && scenarioSupportsSave - - private fun makePlaceholderDataset( - remoteView: RemoteViews, - intentSender: IntentSender, - action: AutofillAction - ): Dataset { - return Dataset.Builder(remoteView).run { - fillWith(scenario, action, credentials = null) - setAuthentication(intentSender) - build() - } - } - - private fun makeMatchDataset(context: Context, file: File): Dataset? { - if (scenario.fieldsToFillOn(AutofillAction.Match).isEmpty()) return null - val remoteView = makeFillMatchRemoteView(context, file, formOrigin) - val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match) - } - - private fun makeSearchDataset(context: Context): Dataset? { - if (scenario.fieldsToFillOn(AutofillAction.Search).isEmpty()) return null - val remoteView = makeSearchAndFillRemoteView(context, formOrigin) - val intentSender = - AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Search) - } - - private fun makeGenerateDataset(context: Context): Dataset? { - if (scenario.fieldsToFillOn(AutofillAction.Generate).isEmpty()) return null - val remoteView = makeGenerateAndFillRemoteView(context, formOrigin) - val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Generate) - } - - private fun makeFillOtpFromSmsDataset(context: Context): Dataset? { - if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null - if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null - val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin) - val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms) - } - - private fun makePublisherChangedDataset( - context: Context, - publisherChangedException: AutofillPublisherChangedException - ): Dataset { - val remoteView = makeWarningRemoteView(context) - val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( - context, publisherChangedException - ) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match) - } - - private fun makePublisherChangedResponse( - context: Context, - publisherChangedException: AutofillPublisherChangedException - ): FillResponse { - return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException)) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE - // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE - private fun makeSaveInfo(): SaveInfo? { - if (!canBeSaved) return null - check(saveFlags != null) - val idsToSave = scenario.fieldsToSave.map { it.autofillId }.toTypedArray() - if (idsToSave.isEmpty()) return null - var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD - if (scenario.username != null) { - saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME - } - return SaveInfo.Builder(saveDataTypes, idsToSave).run { - setFlags(saveFlags) - build() - } - } - - private fun makeFillResponse(context: Context, matchedFiles: List): FillResponse? { - var hasDataset = false - return FillResponse.Builder().run { - for (file in matchedFiles) { - makeMatchDataset(context, file)?.let { - hasDataset = true - addDataset(it) - } - } - makeSearchDataset(context)?.let { - hasDataset = true - addDataset(it) - } - makeGenerateDataset(context)?.let { - hasDataset = true - addDataset(it) - } - makeFillOtpFromSmsDataset(context)?.let { - hasDataset = true - addDataset(it) - } - if (!hasDataset) return null - makeSaveInfo()?.let { setSaveInfo(it) } - setClientState(clientState) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - /** - * Creates and returns a suitable [FillResponse] to the Autofill framework. - */ - fun fillCredentials(context: Context, callback: FillCallback) { - AutofillMatcher.getMatchesFor(context, formOrigin).fold( - success = { matchedFiles -> - callback.onSuccess(makeFillResponse(context, matchedFiles)) - }, - failure = { e -> - e(e) - callback.onSuccess(makePublisherChangedResponse(context, e)) - } - ) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt deleted file mode 100644 index 6435a83f..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -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.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 = - 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): 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): 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/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt index fd2997d8..ecec6747 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt @@ -4,6 +4,7 @@ */ package com.zeapo.pwdstore.autofill.oreo +import android.content.Context import android.os.Build import android.os.CancellationSignal import android.service.autofill.AutofillService @@ -15,10 +16,22 @@ import android.service.autofill.SaveRequest import androidx.annotation.RequiresApi import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.FixedSaveCallback +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList +import com.github.androidpasswordstore.autofillparser.passwordValue +import com.github.androidpasswordstore.autofillparser.recoverNodes +import com.github.androidpasswordstore.autofillparser.usernameValue import com.zeapo.pwdstore.BuildConfig import com.zeapo.pwdstore.R import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity +import com.zeapo.pwdstore.utils.PreferenceKeys +import com.zeapo.pwdstore.utils.getString import com.zeapo.pwdstore.utils.hasFlag +import com.zeapo.pwdstore.utils.sharedPrefs @RequiresApi(Build.VERSION_CODES.O) class OreoAutofillService : AutofillService() { @@ -66,13 +79,14 @@ class OreoAutofillService : AutofillService() { } val formToFill = FillableForm.parseAssistStructure( this, structure, - isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST + isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST, + getCustomSuffixes(), ) ?: run { d { "Form cannot be filled" } callback.onSuccess(null) return } - formToFill.fillCredentials(this, callback) + AutofillResponseBuilder(formToFill).fillCredentials(this, callback) } override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { @@ -113,3 +127,12 @@ class OreoAutofillService : AutofillService() { ) } } + +fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) + +fun Context.getCustomSuffixes(): Sequence { + return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) + ?.splitToSequence('\n') + ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } + ?: emptySequence() +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt deleted file mode 100644 index 8107248e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -import android.content.Context -import android.util.Patterns -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -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) = 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) - } -} - -/** - * 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" -} - -fun getCustomSuffixes(context: Context): Sequence { - return context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) - ?.splitToSequence('\n') - ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } - ?: emptySequence() -} - -suspend fun getCanonicalSuffix(context: Context, domain: String): String { - val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context) - val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await() - ?: return domain - var longestSuffix = publicSuffixPlusOne - for (customSuffix in getCustomSuffixes(context)) { - 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/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt index 2c63c5c3..9bab9e6f 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt @@ -22,11 +22,11 @@ import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.autofill.oreo.AutofillAction +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences -import com.zeapo.pwdstore.autofill.oreo.Credentials +import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure -import com.zeapo.pwdstore.autofill.oreo.FillableForm import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER import java.io.ByteArrayOutputStream @@ -109,7 +109,7 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { setResult(RESULT_CANCELED) } else { val fillInDataset = - FillableForm.makeFillInDataset( + AutofillResponseBuilder.makeFillInDataset( this@AutofillDecryptActivity, credentials, clientState, @@ -185,7 +185,7 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { @Suppress("BlockingMethodInNonBlockingContext") PasswordEntry(decryptedOutput) } - Credentials.fromStoreEntry(this, file, entry, directoryStructure) + AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) }.getOrElse { e -> e(e) { "Failed to parse password entry" } return null diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt index 7e29b061..95e49fdd 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt @@ -24,6 +24,7 @@ import androidx.core.widget.addTextChangedListener import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.FormOrigin import com.zeapo.pwdstore.FilterMode import com.zeapo.pwdstore.ListMode import com.zeapo.pwdstore.R @@ -33,7 +34,6 @@ import com.zeapo.pwdstore.SearchableRepositoryViewModel import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure -import com.zeapo.pwdstore.autofill.oreo.FormOrigin import com.zeapo.pwdstore.databinding.ActivityOreoAutofillFilterBinding import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.viewBinding diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt index bcb27e65..44ed3446 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt @@ -16,13 +16,13 @@ import android.text.format.DateUtils import android.view.View import androidx.appcompat.app.AppCompatActivity import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching import com.zeapo.pwdstore.R import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillPublisherChangedException -import com.zeapo.pwdstore.autofill.oreo.FormOrigin -import com.zeapo.pwdstore.autofill.oreo.computeCertificatesHash import com.zeapo.pwdstore.databinding.ActivityOreoAutofillPublisherChangedBinding import com.zeapo.pwdstore.utils.viewBinding diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt index 0052ff65..7f83d483 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt @@ -16,12 +16,12 @@ import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.autofill.oreo.AutofillAction +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FormOrigin import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences -import com.zeapo.pwdstore.autofill.oreo.Credentials -import com.zeapo.pwdstore.autofill.oreo.FillableForm -import com.zeapo.pwdstore.autofill.oreo.FormOrigin +import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder import com.zeapo.pwdstore.crypto.PasswordCreationActivity import com.zeapo.pwdstore.utils.PasswordRepository import java.io.File @@ -126,7 +126,7 @@ class AutofillSaveActivity : AppCompatActivity() { return@registerForActivityResult } val credentials = Credentials(username, password, null) - val fillInDataset = FillableForm.makeFillInDataset( + val fillInDataset = AutofillResponseBuilder.makeFillInDataset( this, credentials, clientState, diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt deleted file mode 100644 index 0fb59002..00000000 --- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only 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 = 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 = 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 = 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/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt deleted file mode 100644 index 778e9fee..00000000 --- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only 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, labelIndex: Int): String? { - return rules.binarySearch(labels, labelIndex) - } - - private fun binarySearchExceptions(labels: List, 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): List { - // 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): 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): 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, 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() - const val EXCEPTION_MARKER = '!' - } -} - -internal sealed class PublicSuffixOffset { - data class Offset(val value: Int) : PublicSuffixOffset() - object PublicSuffix : PublicSuffixOffset() - object PrevailingRule : PublicSuffixOffset() -} diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt deleted file mode 100644 index 65caeae5..00000000 --- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only 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/app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt deleted file mode 100644 index 43fb7ab1..00000000 --- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only 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, 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 -} diff --git a/app/src/nonFree/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt b/app/src/nonFree/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt index 25a5ef93..43498c66 100644 --- a/app/src/nonFree/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt +++ b/app/src/nonFree/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt @@ -19,6 +19,8 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.w +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching import com.google.android.gms.auth.api.phone.SmsCodeRetriever @@ -27,9 +29,7 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.api.ResolvableApiException import com.google.android.gms.tasks.Task -import com.zeapo.pwdstore.autofill.oreo.AutofillAction -import com.zeapo.pwdstore.autofill.oreo.Credentials -import com.zeapo.pwdstore.autofill.oreo.FillableForm +import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder import com.zeapo.pwdstore.databinding.ActivityOreoAutofillSmsBinding import com.zeapo.pwdstore.utils.viewBinding import java.util.concurrent.ExecutionException @@ -145,13 +145,12 @@ class AutofillSmsActivity : AppCompatActivity() { private val smsCodeRetrievedReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val smsCode = intent.getStringExtra(SmsCodeRetriever.EXTRA_SMS_CODE) - val fillInDataset = - FillableForm.makeFillInDataset( - this@AutofillSmsActivity, - Credentials(null, null, smsCode), - clientState, - AutofillAction.FillOtpFromSms - ) + val fillInDataset = AutofillResponseBuilder.makeFillInDataset( + this@AutofillSmsActivity, + Credentials(null, null, smsCode), + clientState, + AutofillAction.FillOtpFromSms + ) setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }) diff --git a/autofill-parser/.gitignore b/autofill-parser/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/autofill-parser/.gitignore @@ -0,0 +1 @@ +/build diff --git a/autofill-parser/build.gradle.kts b/autofill-parser/build.gradle.kts new file mode 100644 index 00000000..be90cd0a --- /dev/null +++ b/autofill-parser/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + kotlin("android") +} + +android { + defaultConfig { + versionCode = 1 + versionName = "1.0" + consumerProguardFiles("consumer-rules.pro") + } +} + +dependencies { + implementation(Dependencies.AndroidX.core_ktx) + implementation(Dependencies.AndroidX.autofill) + implementation(Dependencies.Kotlin.Coroutines.android) + implementation(Dependencies.Kotlin.Coroutines.core) + implementation(Dependencies.ThirdParty.timberkt) +} diff --git a/autofill-parser/consumer-rules.pro b/autofill-parser/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/autofill-parser/proguard-rules.pro b/autofill-parser/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/autofill-parser/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/autofill-parser/src/main/AndroidManifest.xml b/autofill-parser/src/main/AndroidManifest.xml new file mode 100644 index 00000000..42f0878d --- /dev/null +++ b/autofill-parser/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/autofill-parser/src/main/assets/publicsuffixes b/autofill-parser/src/main/assets/publicsuffixes new file mode 100644 index 00000000..814d4969 Binary files /dev/null and b/autofill-parser/src/main/assets/publicsuffixes differ 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 +) { + + companion object { + private val SUPPORTED_SCHEMES = listOf("http", "https") + } + + private val relevantFields = mutableListOf() + val ignoredIds = mutableListOf() + private var fieldIndex = 0 + + private var appPackage = structure.activityComponent.packageName + + private val trustedBrowserInfo = + getBrowserAutofillSupportInfoIfTrusted(context, appPackage) + val saveFlags = trustedBrowserInfo?.saveFlags + + private val webOrigins = mutableSetOf() + + 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, + val ignoredIds: List, + 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 = 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): 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 { + + 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? { + return try { + Builder().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 { + + var username: T? = null + var fillUsername = false + var otp: T? = null + val currentPassword = mutableListOf() + val newPassword = mutableListOf() + val genericPassword = mutableListOf() + + fun build(): AutofillScenario { + 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 + abstract val passwordFieldsToFillOnMatch: List + abstract val passwordFieldsToFillOnSearch: List + abstract val passwordFieldsToFillOnGenerate: List + abstract val passwordFieldsToSave: List + + val fieldsToSave + get() = listOfNotNull(username) + passwordFieldsToSave + + val allFields + get() = listOfNotNull(username, otp) + allPasswordFields + + fun fieldsToFillOn(action: AutofillAction): List { + 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( + override val username: T?, + override val fillUsername: Boolean, + override val otp: T?, + val currentPassword: List, + val newPassword: List +) : AutofillScenario() { + + 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( + override val username: T?, + override val fillUsername: Boolean, + override val otp: T?, + val genericPassword: List +) : AutofillScenario() { + + 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.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, + 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, + action: AutofillAction, + credentials: Credentials? +) { + fillWith(scenario.map { it.autofillId }, action, credentials) +} + +inline fun AutofillScenario.map(transform: (T) -> S): AutofillScenario { + val builder = AutofillScenario.Builder() + 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.toBundle(): Bundle = when (this) { + is ClassifiedAutofillScenario -> { + 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 -> { + 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.toBundle(): Bundle = map { it.autofillId }.toBundle() + +@RequiresApi(Build.VERSION_CODES.O) +fun AutofillScenario.recoverNodes(structure: AssistStructure): AutofillScenario? { + return map { autofillId -> + structure.findNodeByAutofillId(autofillId) ?: return null + } +} + +val AutofillScenario.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.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 Pair.all(predicate: T.() -> Boolean) = + predicate(first) && predicate(second) + +private inline fun Pair.any(predicate: T.() -> Boolean) = + predicate(first) || predicate(second) + +private inline fun Pair.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, alreadyMatched: List): List? + + @AutofillDsl + class Builder { + + private var takeSingle: (FormField.(List) -> Boolean)? = null + private val tieBreakersSingle: MutableList) -> Boolean> = + mutableListOf() + + private var takePair: (Pair.(List) -> Boolean)? = null + private var tieBreakersPair: MutableList.(List) -> Boolean> = + mutableListOf() + + fun takeSingle(block: FormField.(alreadyMatched: List) -> 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) -> 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.(alreadyMatched: List) -> 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.(alreadyMatched: List) -> 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) -> Boolean, + private val tieBreakers: List<(FormField, List) -> Boolean> +) : FieldMatcher { + + @AutofillDsl + class Builder { + + private var takeSingle: (FormField.(List) -> Boolean)? = null + private val tieBreakersSingle: MutableList) -> Boolean> = + mutableListOf() + + fun takeSingle(block: FormField.(alreadyMatched: List) -> Boolean = { true }) { + check(takeSingle == null) { "Every block can only have at most one takeSingle block" } + takeSingle = block + } + + fun breakTieOnSingle(block: FormField.(alreadyMatched: List) -> 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, alreadyMatched: List): List? { + 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, List) -> Boolean, + private val tieBreakers: List<(Pair, List) -> Boolean> +) : FieldMatcher { + + override fun match(fields: List, alreadyMatched: List): List? { + 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, + 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() + 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, + allUsername: List, + allOtp: List, + singleOriginMode: Boolean, + isManualRequest: Boolean + ): AutofillScenario? { + 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() + val alreadyMatched = mutableListOf() + 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) { + + @AutofillDsl + class Builder { + + private val rules: MutableList = 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, + singleOriginMode: Boolean, + isManualRequest: Boolean + ): AutofillScenario? { + 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> { + 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.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 = + 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): 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): 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) = 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 { + 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 = 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 = 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 = 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, labelIndex: Int): String? { + return rules.binarySearch(labels, labelIndex) + } + + private fun binarySearchExceptions(labels: List, 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): List { + // 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): 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): 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, 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() + 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, 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 +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 0c42107c..52c79bc0 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -49,6 +49,7 @@ object Dependencies { const val openpgp_ktx = "com.github.android-password-store:openpgp-ktx:2.1.0" const val zxing_android_embedded = "com.github.android-password-store:zxing-android-embedded:4.1.0-aps" + const val autofill_parser = ":autofill-parser" } object ThirdParty { diff --git a/settings.gradle.kts b/settings.gradle.kts index a418994b..04d7465d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,4 +2,5 @@ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * SPDX-License-Identifier: GPL-3.0-only */ +include(":autofill-parser") include(":app") -- cgit v1.2.3