From bebe43468330f182ea35e01ace555ce8d0512aeb Mon Sep 17 00:00:00 2001 From: Fabian Henneke Date: Tue, 24 Mar 2020 14:03:40 +0100 Subject: Add support for Oreo Autofill (#653) Adds support for the Autofill feature first available in Android Oreo. In compatible apps and browsers, login forms are automatically detected and the user is presented with options to fill or generate credentials. In most apps and certain browsers, the service will also offer to create new Password Store entries from credentials entered into login forms. Signed-off-by: Harsh Shandilya Co-authored-by: Harsh Shandilya --- .../main/java/com/zeapo/pwdstore/PasswordEntry.kt | 31 +- .../main/java/com/zeapo/pwdstore/PasswordStore.kt | 12 + .../main/java/com/zeapo/pwdstore/UserPreference.kt | 126 ++++++-- .../zeapo/pwdstore/autofill/oreo/AutofillHelper.kt | 178 +++++++++++ .../pwdstore/autofill/oreo/AutofillMatcher.kt | 178 +++++++++++ .../pwdstore/autofill/oreo/AutofillScenario.kt | 275 ++++++++++++++++ .../pwdstore/autofill/oreo/AutofillStrategy.kt | 179 +++++++++++ .../pwdstore/autofill/oreo/AutofillStrategyDsl.kt | 328 +++++++++++++++++++ .../autofill/oreo/FeatureAndTrustDetection.kt | 199 ++++++++++++ .../java/com/zeapo/pwdstore/autofill/oreo/Form.kt | 354 +++++++++++++++++++++ .../com/zeapo/pwdstore/autofill/oreo/FormField.kt | 240 ++++++++++++++ .../pwdstore/autofill/oreo/OreoAutofillService.kt | 110 +++++++ .../autofill/oreo/PublicSuffixListCache.kt | 39 +++ .../autofill/oreo/ui/AutofillDecryptActivity.kt | 238 ++++++++++++++ .../autofill/oreo/ui/AutofillFilterActivity.kt | 196 ++++++++++++ .../oreo/ui/AutofillPublisherChangedActivity.kt | 96 ++++++ .../autofill/oreo/ui/AutofillSaveActivity.kt | 139 ++++++++ .../autofill/oreo/ui/PasswordViewHolder.kt | 15 + .../java/com/zeapo/pwdstore/crypto/PgpActivity.kt | 98 +++++- .../java/com/zeapo/pwdstore/utils/Extensions.kt | 7 + 20 files changed, 2983 insertions(+), 55 deletions(-) create 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/AutofillMatcher.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt create 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/FeatureAndTrustDetection.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt (limited to 'app/src/main/java') diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt index e692c5af..c75fb02d 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt @@ -20,7 +20,7 @@ class PasswordEntry(private val content: String) { val totpAlgorithm: String val hotpSecret: String? val hotpCounter: Long? - var extraContent: String? = null + var extraContent: String private set private var isIncremented = false @@ -41,7 +41,7 @@ class PasswordEntry(private val content: String) { } fun hasExtraContent(): Boolean { - return !extraContent.isNullOrEmpty() + return extraContent.isNotEmpty() } fun hasUsername(): Boolean { @@ -63,19 +63,30 @@ class PasswordEntry(private val content: String) { fun incrementHotp() { content.split("\n".toRegex()).forEach { line -> if (line.startsWith("otpauth://hotp/")) { - extraContent = extraContent?.replaceFirst("counter=[0-9]+".toRegex(), "counter=${hotpCounter!! + 1}") + extraContent = extraContent.replaceFirst("counter=[0-9]+".toRegex(), "counter=${hotpCounter!! + 1}") isIncremented = true } } } + val extraContentWithoutUsername by lazy { + var usernameFound = false + extraContent.splitToSequence("\n").filter { line -> + if (usernameFound) + return@filter true + if (USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) }) { + usernameFound = true + return@filter false + } + true + }.joinToString(separator = "\n") + } + private fun findUsername(): String? { - val extraLines = extraContent!!.split("\n".toRegex()) - for (line in extraLines) { - for (field in USERNAME_FIELDS) { - if (line.toLowerCase().startsWith("$field:", ignoreCase = true)) { - return line.split(": *".toRegex(), 2).toTypedArray()[1] - } + extraContent.splitToSequence("\n").forEach { line -> + for (prefix in USERNAME_FIELDS) { + if (line.startsWith(prefix, ignoreCase = true)) + return line.substring(prefix.length).trimStart() } } return null @@ -152,6 +163,6 @@ class PasswordEntry(private val content: String) { companion object { - private val USERNAME_FIELDS = arrayOf("login", "username") + private val USERNAME_FIELDS = arrayOf("login:", "username:") } } diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index e4c94a56..4744b259 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -32,6 +32,7 @@ import androidx.fragment.app.FragmentManager import androidx.preference.PreferenceManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.crypto.PgpActivity import com.zeapo.pwdstore.crypto.PgpActivity.Companion.getLongName import com.zeapo.pwdstore.git.GitActivity @@ -650,10 +651,21 @@ class PasswordStore : AppCompatActivity() { .setPositiveButton("Okay", null) .show() } + val sourceDestinationMap = if (source.isDirectory) { + check(destinationFile.isDirectory) { "Moving a directory to a file" } + // Recursively list all files (not directories) below `source`, then + // obtain the corresponding target file by resolving the relative path + // starting at the destination folder. + val sourceFiles = FileUtils.listFiles(source, null, true) + sourceFiles.associateWith { destinationFile.resolve(it.relativeTo(source)) } + } else { + mapOf(source to destinationFile) + } if (!source.renameTo(destinationFile)) { // TODO this should show a warning to the user Timber.tag(TAG).e("Something went wrong while moving.") } else { + AutofillMatcher.updateMatchesFor(this, sourceDestinationMap) commitChange(this.resources .getString( R.string.git_commit_move_text, diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt index 8667110a..895338e9 100644 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt @@ -6,7 +6,6 @@ package com.zeapo.pwdstore import android.accessibilityservice.AccessibilityServiceInfo import android.app.Activity -import android.content.Context import android.content.Intent import android.content.pm.ShortcutManager import android.net.Uri @@ -20,6 +19,7 @@ import android.view.MenuItem import android.view.accessibility.AccessibilityManager import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatTextView import androidx.biometric.BiometricManager import androidx.core.content.getSystemService import androidx.documentfile.provider.DocumentFile @@ -32,6 +32,8 @@ import androidx.preference.SwitchPreferenceCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar 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.PgpActivity import com.zeapo.pwdstore.git.GitActivity import com.zeapo.pwdstore.pwgenxkpwd.XkpwdDictionary @@ -40,6 +42,7 @@ import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.auth.AuthenticationResult import com.zeapo.pwdstore.utils.auth.Authenticator +import com.zeapo.pwdstore.utils.autofillManager import java.io.File import java.io.IOException import java.time.LocalDateTime @@ -127,9 +130,7 @@ class UserPreference : AppCompatActivity() { openkeystoreIdPreference?.isVisible = sharedPreferences.getString("ssh_openkeystore_keyid", null)?.isNotEmpty() ?: false - // see if the autofill service is enabled and check the preference accordingly - autoFillEnablePreference?.isChecked = callingActivity.isServiceEnabled - autofillDependencies.forEach { it?.isVisible = callingActivity.isServiceEnabled } + updateAutofillSettings() appVersionPreference?.summary = "Version: ${BuildConfig.VERSION_NAME}" @@ -242,24 +243,7 @@ class UserPreference : AppCompatActivity() { } autoFillEnablePreference?.onPreferenceClickListener = ClickListener { - var isEnabled = callingActivity.isServiceEnabled - if (isEnabled) { - startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) - } else { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(R.string.pref_autofill_enable_title) - .setView(R.layout.autofill_instructions) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) - } - .setNegativeButton(R.string.dialog_cancel, null) - .setOnDismissListener { - isEnabled = callingActivity.isServiceEnabled - autoFillEnablePreference?.isChecked = isEnabled - autofillDependencies.forEach { it?.isVisible = isEnabled } - } - .show() - } + onEnableAutofillClick() true } @@ -370,11 +354,79 @@ class UserPreference : AppCompatActivity() { } } + private fun updateAutofillSettings() { + val isAccessibilityServiceEnabled = callingActivity.isAccessibilityServiceEnabled + autoFillEnablePreference?.isChecked = + isAccessibilityServiceEnabled || callingActivity.isAutofillServiceEnabled + autofillDependencies.forEach { + it?.isVisible = isAccessibilityServiceEnabled + } + } + + private fun onEnableAutofillClick() { + if (callingActivity.isAccessibilityServiceEnabled) { + startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } else if (callingActivity.isAutofillServiceEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + callingActivity.autofillManager!!.disableAutofillServices() + else + throw IllegalStateException("isAutofillServiceEnabled == true, but Build.VERSION.SDK_INT < Build.VERSION_CODES.O") + } else { + val enableOreoAutofill = callingActivity.isAutofillServiceSupported + MaterialAlertDialogBuilder(callingActivity).run { + setTitle(R.string.pref_autofill_enable_title) + if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val layout = + layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) + val supportedBrowsersTextView = + layout.findViewById(R.id.supportedBrowsers) + supportedBrowsersTextView.text = + getInstalledBrowsersWithAutofillSupportLevel(context).joinToString( + separator = "\n" + ) { + val appLabel = it.first + val supportDescription = when (it.second) { + BrowserAutofillSupportLevel.None -> getString(R.string.oreo_autofill_no_support) + BrowserAutofillSupportLevel.FlakyFill -> getString(R.string.oreo_autofill_flaky_fill_support) + BrowserAutofillSupportLevel.Fill -> getString(R.string.oreo_autofill_fill_support) + BrowserAutofillSupportLevel.FillAndSave -> getString(R.string.oreo_autofill_fill_and_save_support) + } + "$appLabel: $supportDescription" + } + setView(layout) + } else { + setView(R.layout.autofill_instructions) + } + setPositiveButton(R.string.dialog_ok) { _, _ -> + val intent = + if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { + data = Uri.parse("package:${BuildConfig.APPLICATION_ID}") + } + } else { + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + } + startActivity(intent) + } + setNegativeButton(R.string.dialog_cancel, null) + setOnDismissListener { + val isEnabled = + if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + callingActivity.isAutofillServiceEnabled + } else { + callingActivity.isAccessibilityServiceEnabled + } + autoFillEnablePreference?.isChecked = isEnabled + autofillDependencies.forEach { it?.isVisible = isEnabled } + } + show() + } + } + } + override fun onResume() { super.onResume() - val isEnabled = callingActivity.isServiceEnabled - autoFillEnablePreference?.isChecked = isEnabled - autofillDependencies.forEach { it?.isVisible = isEnabled } + updateAutofillSettings() } } @@ -487,16 +539,26 @@ class UserPreference : AppCompatActivity() { } } - // Returns whether the autofill service is enabled - private val isServiceEnabled: Boolean + private val isAccessibilityServiceEnabled: Boolean get() { - val am = this - .getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager + val am = getSystemService(AccessibilityManager::class.java) val runningServices = am - .getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_GENERIC) + .getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_GENERIC) return runningServices - .map { it.id.substringBefore("/") } - .any { it == BuildConfig.APPLICATION_ID } + .map { it.id.substringBefore("/") } + .any { it == BuildConfig.APPLICATION_ID } + } + + private val isAutofillServiceSupported: Boolean + get() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + return autofillManager?.isAutofillSupported != null + } + + private val isAutofillServiceEnabled: Boolean + get() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + return autofillManager?.hasEnabledAutofillServices() == true } override fun onActivityResult( 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 new file mode 100644 index 00000000..4b7b47e2 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt @@ -0,0 +1,178 @@ +/* + * 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.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.PasswordEntry +import com.zeapo.pwdstore.R +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 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 { + 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) { + companion object { + fun fromStoreEntry(file: File, entry: PasswordEntry): Credentials { + return if (entry.hasUsername()) Credentials(entry.username, entry.password) + else Credentials(file.nameWithoutExtension, entry.password) + } + } +} + +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 summary = file.nameWithoutExtension + 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 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 new file mode 100644 index 00000000..5f9c081d --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt @@ -0,0 +1,178 @@ +/* + * 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.SharedPreferences +import android.widget.Toast +import androidx.core.content.edit +import com.github.ajalt.timberkt.Timber.e +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w +import com.zeapo.pwdstore.R +import java.io.File + +private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches" +private val Context.autofillAppMatches + get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE) + +private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches" +private val Context.autofillWebMatches + get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE) + +private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences { + return when (formOrigin) { + is FormOrigin.App -> autofillAppMatches + is FormOrigin.Web -> autofillWebMatches + } +} + +class AutofillPublisherChangedException(val formOrigin: FormOrigin) : + Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") { + init { + require(formOrigin is FormOrigin.App) + } +} + +/** + * Manages "matches", i.e., associations between apps or websites and Password Store entries. + */ +class AutofillMatcher { + companion object { + private const val MAX_NUM_MATCHES = 10 + + private const val PREFERENCE_PREFIX_TOKEN = "token;" + private fun tokenKey(formOrigin: FormOrigin.App) = + "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}" + + private const val PREFERENCE_PREFIX_MATCHES = "matches;" + private fun matchesKey(formOrigin: FormOrigin) = + "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}" + + private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean { + return when (formOrigin) { + is FormOrigin.Web -> false + is FormOrigin.App -> { + val packageName = formOrigin.identifier + val certificatesHash = computeCertificatesHash(context, packageName) + val storedCertificatesHash = + context.autofillAppMatches.getString(tokenKey(formOrigin), null) + ?: return false + val hashHasChanged = certificatesHash != storedCertificatesHash + if (hashHasChanged) { + e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" } + true + } else { + false + } + } + } + } + + private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) { + if (formOrigin is FormOrigin.App) { + val packageName = formOrigin.identifier + val certificatesHash = computeCertificatesHash(context, packageName) + context.autofillAppMatches.edit { + putString(tokenKey(formOrigin), certificatesHash) + } + } + // We don't need to store a hash for FormOrigin.Web since it can only originate from + // browsers we trust to verify the origin. + } + + /** + * Get all Password Store entries that have already been associated with [formOrigin] by the + * user. + * + * If [formOrigin] represents an app and that app's certificates have changed since the + * first time the user associated an entry with it, an [AutofillPublisherChangedException] + * will be thrown. + */ + fun getMatchesFor(context: Context, formOrigin: FormOrigin): List { + if (hasFormOriginHashChanged(context, formOrigin)) { + throw AutofillPublisherChangedException(formOrigin) + } + val matchPreferences = context.matchPreferences(formOrigin) + val matchedFiles = + matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } + return matchedFiles.filter { it.exists() }.also { validFiles -> + matchPreferences.edit { + putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) + } + } + } + + fun clearMatchesFor(context: Context, formOrigin: FormOrigin) { + context.matchPreferences(formOrigin).edit { + remove(matchesKey(formOrigin)) + if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin)) + } + } + + /** + * Associates the store entry [file] with [formOrigin], such that future Autofill responses + * to requests from this app or website offer this entry as a dataset. + * + * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of + * Android may crash when too many datasets are offered. + */ + fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) { + if (!file.exists()) return + if (hasFormOriginHashChanged(context, formOrigin)) { + // This should never happen since we already verified the publisher in + // getMatchesFor. + e { "App publisher changed between getMatchesFor and addMatchFor" } + throw AutofillPublisherChangedException(formOrigin) + } + val matchPreferences = context.matchPreferences(formOrigin) + val matchedFiles = + matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } + val newFiles = setOf(file.absoluteFile).union(matchedFiles) + if (newFiles.size > MAX_NUM_MATCHES) { + Toast.makeText( + context, + context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES), + Toast.LENGTH_LONG + ).show() + return + } + matchPreferences.edit { + putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) + } + storeFormOriginHash(context, formOrigin) + d { "Stored match for $formOrigin" } + } + + /** + * Goes through all existing matches and updates their associated entries by using + * [sourceDestinationMap] as a lookup table. + */ + fun updateMatchesFor(context: Context, sourceDestinationMap: Map) { + val oldNewPathMap = sourceDestinationMap.mapValues { it.value.absolutePath } + .mapKeys { it.key.absolutePath } + for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) { + for ((key, value) in prefs.all) { + if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue + val oldMatches = value as? Set + if (oldMatches == null) { + w { "Failed to read matches for $key" } + continue + } + // Delete all matches for file locations that are going to be overwritten, then + // transfer matches over to the files at their new locations. + val newMatches = + oldMatches.asSequence().minus(oldNewPathMap.values).map { match -> + val newPath = oldNewPathMap[match] ?: return@map match + d { "Updating match for $key: $match --> $newPath" } + newPath + }.toSet() + if (newMatches != oldMatches) + prefs.edit { putStringSet(key, newMatches) } + } + } + } + } +} 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 new file mode 100644 index 00000000..0536c507 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt @@ -0,0 +1,275 @@ +/* + * 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 + +enum class AutofillAction { + Match, Search, Generate +} + +/** + * 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_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) + 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 (exception: IllegalArgumentException) { + e(exception) + null + } + } + } + + class Builder { + var username: T? = null + var fillUsername = false + 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, + currentPassword = currentPassword, + newPassword = newPassword + ) + } else { + GenericAutofillScenario( + username = username, + fillUsername = fillUsername, + genericPassword = genericPassword + ) + } + } + } + + abstract val username: T? + abstract val fillUsername: Boolean + 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) + allPasswordFields + + fun fieldsToFillOn(action: AutofillAction): List { + val passwordFieldsToFill = when (action) { + AutofillAction.Match -> passwordFieldsToFillOnMatch + AutofillAction.Search -> passwordFieldsToFillOnSearch + AutofillAction.Generate -> passwordFieldsToFillOnGenerate + } + return when { + passwordFieldsToFill.isNotEmpty() -> { + // If the current action would fill into any password field, we also fill into the + // username field if possible. + listOfNotNull(username.takeIf { fillUsername }) + passwordFieldsToFill + } + 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, + 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, + 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" + ) + for (field in scenario.fieldsToFillOn(action)) { + val value = if (field == scenario.username) { + credentialsToFill.username + } else { + credentialsToFill.password + } ?: continue + 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 + 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(4).apply { + putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) + putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) + putParcelableArrayList( + AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword) + ) + putParcelableArrayList( + AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword) + ) + } + } + is GenericAutofillScenario -> { + Bundle(3).apply { + putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) + putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) + 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 new file mode 100644 index 00000000..e1b157d5 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt @@ -0,0 +1,179 @@ +/* + * 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 { hasAutocompleteHintNewPassword } } + breakTieOnPair { any { isFocused } } + } + currentPassword(optional = true) { + takeSingle { alreadyMatched -> + val adjacentToNewPasswords = + directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched) + hasAutocompleteHintCurrentPassword && adjacentToNewPasswords + } + } + username(optional = true) { + takeSingle { hasAutocompleteHintUsername } + 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 { alreadyMatched -> + 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 { hasAutocompleteHintUsername } + 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() + breakTieOnSingle { 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() + breakTieOnSingle { 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 { hasAutocompleteHintNewPassword && 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 { hasAutocompleteHintUsername && isFocused } + } + currentPassword(matchHidden = true) { + takeSingle { alreadyMatched -> + directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword + } + } + } + + // Match a single focused username field without a password field. + rule(applyInSingleOriginMode = true) { + username { + takeSingle { usernameCertainty >= Likely && isFocused } + breakTieOnSingle { usernameCertainty >= Certain } + breakTieOnSingle { hasAutocompleteHintUsername } + } + } +} 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 new file mode 100644 index 00000000..32ffaa2a --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt @@ -0,0 +1,328 @@ +/* + * 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 name: String +) { + + data class AutofillRuleMatcher( + val type: FillableFieldType, + val matcher: FieldMatcher, + val optional: Boolean, + val matchHidden: Boolean + ) + + enum class FillableFieldType { + Username, CurrentPassword, NewPassword, GenericPassword, + } + + @AutofillDsl + class Builder(private val applyInSingleOriginMode: 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 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, name ?: "Rule #$ruleId" + ).also { ruleId++ } + } + } + + fun apply( + allPassword: List, + allUsername: List, + singleOriginMode: Boolean + ): AutofillScenario? { + if (singleOriginMode && !applyInSingleOriginMode) { + d { "$name: Skipped in single origin mode" } + 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 + 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.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, + block: AutofillRule.Builder.() -> Unit + ) { + rules.add(AutofillRule.Builder(applyInSingleOriginMode).apply(block).build()) + } + + fun build() = AutofillStrategy(rules) + } + + fun apply(fields: List, multiOriginSupport: 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}" } + // Return the result of the first rule that matches + d { "Rules: ${rules.size}" } + for (rule in rules) { + return rule.apply(possiblePasswordFields, possibleUsernameFields, multiOriginSupport) + ?: 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/FeatureAndTrustDetection.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt new file mode 100644 index 00000000..fdd862ad --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt @@ -0,0 +1,199 @@ +/* + * 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.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=" +) + +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.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 saveFlag: Int? +) + +@RequiresApi(Build.VERSION_CODES.O) +fun getBrowserAutofillSupportInfoIfTrusted( + context: Context, + appPackage: String +): BrowserAutofillSupportInfo? { + if (!isTrustedBrowser(context, appPackage)) return null + return BrowserAutofillSupportInfo( + multiOriginMethod = getBrowserMultiOriginMethod(appPackage), + saveFlag = getBrowserSaveFlag(appPackage) + ) +} + +private val FLAKY_BROWSERS = listOf( + "com.android.chrome" +) + +enum class BrowserAutofillSupportLevel { + None, + FlakyFill, + Fill, + FillAndSave +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun getBrowserAutofillSupportLevel( + context: Context, + appPackage: String +): BrowserAutofillSupportLevel { + val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage) + return when { + browserInfo == null -> BrowserAutofillSupportLevel.None + browserInfo.saveFlag != null -> BrowserAutofillSupportLevel.FillAndSave + appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill + else -> BrowserAutofillSupportLevel.Fill + } +} + +@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 new file mode 100644 index 00000000..308391ec --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt @@ -0,0 +1,354 @@ +/* + * 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.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 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) { + + 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 browserAutofillSupportInfo = + getBrowserAutofillSupportInfoIfTrusted(context, appPackage) + private val isTrustedBrowser = browserAutofillSupportInfo != null + + private val browserMultiOriginMethod = + browserAutofillSupportInfo?.multiOriginMethod ?: BrowserMultiOriginMethod.None + private val singleOriginMode = browserMultiOriginMethod == BrowserMultiOriginMethod.None + + val saveFlags = browserAutofillSupportInfo?.saveFlag + + private val webOrigins = mutableSetOf() + + init { + d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" } + parseStructure(structure) + } + + val scenario = detectFieldsToFill() + 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 (browserMultiOriginMethod == 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() = autofillStrategy.apply(relevantFields, singleOriginMode) + + private fun trackOrigin(node: AssistStructure.ViewNode) { + if (!isTrustedBrowser) 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 (!isTrustedBrowser || 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 (browserMultiOriginMethod) { + 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) + return Dataset.Builder(remoteView).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): FillableForm? { + val form = Form(context, structure) + 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 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) + } + 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) { + val matchedFiles = try { + AutofillMatcher.getMatchesFor(context, formOrigin) + } catch (publisherChangedException: AutofillPublisherChangedException) { + e(publisherChangedException) + callback.onSuccess(makePublisherChangedResponse(context, publisherChangedException)) + return + } + callback.onSuccess(makeFillResponse(context, matchedFiles)) + } +} 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 new file mode 100644 index 00000000..cf2937f3 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt @@ -0,0 +1,240 @@ +/* + * 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 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 { + + @RequiresApi(Build.VERSION_CODES.O) + private val HINTS_USERNAME = listOf(View.AUTOFILL_HINT_USERNAME) + + @RequiresApi(Build.VERSION_CODES.O) + private val HINTS_PASSWORD = listOf(View.AUTOFILL_HINT_PASSWORD) + + @RequiresApi(Build.VERSION_CODES.O) + private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + listOf( + View.AUTOFILL_HINT_EMAIL_ADDRESS, View.AUTOFILL_HINT_NAME, View.AUTOFILL_HINT_PHONE + ) + + 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_FILLABLE = + HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + + @RequiresApi(Build.VERSION_CODES.O) + private fun isSupportedHint(hint: String) = hint in HINTS_USERNAME + HINTS_PASSWORD + + 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" + ) + private val PASSWORD_HEURISTIC_TERMS = listOf( + "password", "pwd", "pswd", "passwort" + ) + private val USERNAME_HEURISTIC_TERMS = listOf( + "user", "name", "email" + ) + } + + 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 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() + private val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty() + private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty() + + // W3C autocomplete hint detection for HTML fields + private val htmlAutocomplete = htmlAttributes["autocomplete"] + + // Ignored for now, see excludedByHints + private val excludedByAutocompleteHint = htmlAutocomplete == "off" + val hasAutocompleteHintUsername = htmlAutocomplete == "username" + val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password" + val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password" + private val hasAutocompleteHintPassword = + hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword + + // 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 + + val relevantField = isTextField && hasAutofillTypeText && !excludedByHints + + // Exclude fields based on hint and resource ID + // 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.any { fieldId.contains(it) || hint.contains(it) } + private val notExcluded = relevantField && !hasExcludedTerm + + // Password field heuristics (based only on the current field) + private val isPossiblePasswordField = + notExcluded && (isAndroidPasswordField || isHtmlPasswordField) + private val isCertainPasswordField = + isPossiblePasswordField && (isHtmlPasswordField || hasAutofillHintPassword || hasAutocompleteHintPassword) + private val isLikelyPasswordField = isPossiblePasswordField && (isCertainPasswordField || (PASSWORD_HEURISTIC_TERMS.any { + fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) + })) + val passwordCertainty = + if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible + + // Username field heuristics (based only on the current field) + private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField + private val isCertainUsernameField = + isPossibleUsernameField && (hasAutofillHintUsername || hasAutocompleteHintUsername) + private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.any { + fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) + })) + 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 }.min() ?: 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 }.max() ?: 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" + return "$field ($description): password=$passwordCertainty, username=$usernameCertainty" + } + + 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 new file mode 100644 index 00000000..00fa3aa4 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt @@ -0,0 +1,110 @@ +/* + * 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 android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.zeapo.pwdstore.BuildConfig +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity + +@RequiresApi(Build.VERSION_CODES.O) +class OreoAutofillService : AutofillService() { + + companion object { + // TODO: Provide a user-configurable denylist + private val DENYLISTED_PACKAGES = listOf( + BuildConfig.APPLICATION_ID, + "android", + "com.android.settings", + "com.android.settings.intelligence", + "com.android.systemui", + "com.oneplus.applocker", + "org.sufficientlysecure.keychain" + ) + + private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L + } + + override fun onCreate() { + super.onCreate() + cachePublicSuffixList(applicationContext) + } + + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + val structure = request.fillContexts.lastOrNull()?.structure ?: run { + callback.onSuccess(null) + return + } + if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) { + if (Build.VERSION.SDK_INT >= 28) { + callback.onSuccess(FillResponse.Builder().run { + disableAutofill(DISABLE_AUTOFILL_DURATION_MS) + build() + }) + } else { + callback.onSuccess(null) + } + return + } + val formToFill = FillableForm.parseAssistStructure(this, structure) ?: run { + d { "Form cannot be filled" } + callback.onSuccess(null) + return + } + formToFill.fillCredentials(this, callback) + } + + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + // SaveCallback's behavior and feature set differs based on both target and device SDK, so + // we replace it with a wrapper that works the same in all situations. + @Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback) + val structure = request.fillContexts.lastOrNull()?.structure ?: run { + callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported)) + return + } + val clientState = request.clientState ?: run { + e { "Received save request without client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + val scenario = AutofillScenario.fromBundle(clientState)?.recoverNodes(structure) ?: run { + e { "Failed to recover client state or nodes from client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + val formOrigin = FormOrigin.fromBundle(clientState) ?: run { + e { "Failed to recover form origin from client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + + val username = scenario.usernameValue + val password = scenario.passwordValue ?: run { + callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match)) + return + } + callback.onSuccess( + AutofillSaveActivity.makeSaveIntentSender( + this, + credentials = Credentials(username, password), + formOrigin = formOrigin + ) + ) + } +} 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 new file mode 100644 index 00000000..c4f80f1a --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt @@ -0,0 +1,39 @@ +/* + * 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 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 { + PublicSuffixListCache.getOrCachePublicSuffixList(context).getPublicSuffixPlusOne(domain) + .await() ?: domain +} 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 new file mode 100644 index 00000000..2f3824f9 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt @@ -0,0 +1,238 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.autofill.oreo.ui + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillManager +import android.widget.Toast +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.zeapo.pwdstore.PasswordEntry +import com.zeapo.pwdstore.autofill.oreo.AutofillAction +import com.zeapo.pwdstore.autofill.oreo.Credentials +import com.zeapo.pwdstore.autofill.oreo.FillableForm +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileNotFoundException +import java.io.InputStream +import java.io.OutputStream +import java.io.UnsupportedEncodingException +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillDecryptActivity : Activity(), CoroutineScope { + + companion object { + private const val EXTRA_FILE_PATH = "com.zeapo.pwdstore.autofill.oreo.EXTRA_FILE_PATH" + private const val EXTRA_SEARCH_ACTION = + "com.zeapo.pwdstore.autofill.oreo.EXTRA_SEARCH_ACTION" + private const val REQUEST_CODE_CONTINUE_AFTER_USER_INTERACTION = 1 + private const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain" + + private var decryptFileRequestCode = 1 + + fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { + return Intent(context, AutofillDecryptActivity::class.java).apply { + putExtras(forwardedExtras) + putExtra(EXTRA_SEARCH_ACTION, true) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + } + + fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { + val intent = Intent(context, AutofillDecryptActivity::class.java).apply { + putExtra(EXTRA_SEARCH_ACTION, false) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + return PendingIntent.getActivity( + context, + decryptFileRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private var continueAfterUserInteraction: Continuation? = null + + override val coroutineContext + get() = Dispatchers.IO + SupervisorJob() + + override fun onStart() { + super.onStart() + val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run { + e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } + finish() + return + } + val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { + e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! + val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match + d { action.toString() } + launch { + val credentials = decryptUsernameAndPassword(File(filePath)) + if (credentials == null) { + setResult(RESULT_CANCELED) + } else { + val fillInDataset = + FillableForm.makeFillInDataset( + this@AutofillDecryptActivity, + credentials, + clientState, + action + ) + withContext(Dispatchers.Main) { + setResult(RESULT_OK, Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + }) + } + } + withContext(Dispatchers.Main) { + finish() + } + } + } + + override fun onDestroy() { + super.onDestroy() + coroutineContext.cancelChildren() + } + + private suspend fun executeOpenPgpApi( + data: Intent, + input: InputStream, + output: OutputStream + ): Intent? { + var openPgpServiceConnection: OpenPgpServiceConnection? = null + val openPgpService = suspendCoroutine { cont -> + openPgpServiceConnection = OpenPgpServiceConnection( + this, + OPENPGP_PROVIDER, + object : OpenPgpServiceConnection.OnBound { + override fun onBound(service: IOpenPgpService2) { + cont.resume(service) + } + + override fun onError(e: Exception) { + cont.resumeWithException(e) + } + }).also { it.bindToService() } + } + return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also { + openPgpServiceConnection?.unbindFromService() + } + } + + private suspend fun decryptUsernameAndPassword( + file: File, + resumeIntent: Intent? = null + ): Credentials? { + val command = resumeIntent ?: Intent().apply { + action = OpenPgpApi.ACTION_DECRYPT_VERIFY + } + val encryptedInput = try { + file.inputStream() + } catch (e: FileNotFoundException) { + e(e) { "File to decrypt not found" } + return null + } + val decryptedOutput = ByteArrayOutputStream() + val result = try { + executeOpenPgpApi(command, encryptedInput, decryptedOutput) + } catch (e: Exception) { + e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" } + return null + } + return when (val resultCode = + result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + try { + val entry = withContext(Dispatchers.IO) { + PasswordEntry(decryptedOutput) + } + Credentials.fromStoreEntry(file, entry) + } catch (e: UnsupportedEncodingException) { + e(e) { "Failed to parse password entry" } + null + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = + result.getParcelableExtra(OpenPgpApi.RESULT_INTENT) + try { + val intentToResume = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + continueAfterUserInteraction = cont + startIntentSenderForResult( + pendingIntent.intentSender, + REQUEST_CODE_CONTINUE_AFTER_USER_INTERACTION, + null, + 0, + 0, + 0 + ) + } + } + decryptUsernameAndPassword(file, intentToResume) + } catch (e: Exception) { + e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" } + null + } + } + OpenPgpApi.RESULT_CODE_ERROR -> { + val error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + if (error != null) { + Toast.makeText( + applicationContext, + "Error from OpenKeyChain: ${error.message}", + Toast.LENGTH_LONG + ).show() + e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" } + } + null + } + else -> { + e { "Unrecognized OpenPgpApi result: $resultCode" } + null + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_CODE_CONTINUE_AFTER_USER_INTERACTION && continueAfterUserInteraction != null) { + if (resultCode == RESULT_OK && data != null) { + continueAfterUserInteraction?.resume(data) + } else { + continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")) + } + continueAfterUserInteraction = 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 new file mode 100644 index 00000000..8c77fff8 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt @@ -0,0 +1,196 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.autofill.oreo.ui + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillManager +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.addTextChangedListener +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import com.afollestad.recyclical.datasource.dataSourceOf +import com.afollestad.recyclical.setup +import com.afollestad.recyclical.withItem +import com.github.ajalt.timberkt.e +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher +import com.zeapo.pwdstore.autofill.oreo.FormOrigin +import com.zeapo.pwdstore.utils.PasswordItem +import com.zeapo.pwdstore.utils.PasswordRepository +import java.io.File +import java.util.Locale +import kotlinx.android.synthetic.main.activity_oreo_autofill_filter.* + +@TargetApi(Build.VERSION_CODES.O) +class AutofillFilterView : AppCompatActivity() { + + companion object { + private const val HEIGHT_PERCENTAGE = 0.9 + private const val WIDTH_PERCENTAGE = 0.75 + private const val DECRYPT_FILL_REQUEST_CODE = 1 + + private const val EXTRA_FORM_ORIGIN_WEB = + "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB" + private const val EXTRA_FORM_ORIGIN_APP = + "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP" + private var matchAndDecryptFileRequestCode = 1 + + fun makeMatchAndDecryptFileIntentSender( + context: Context, + formOrigin: FormOrigin + ): IntentSender { + val intent = Intent(context, AutofillFilterView::class.java).apply { + when (formOrigin) { + is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier) + is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier) + } + } + return PendingIntent.getActivity( + context, + matchAndDecryptFileRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private val dataSource = dataSourceOf() + private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val sortOrder + get() = PasswordRepository.PasswordSortOrder.getSortOrder(preferences) + + private lateinit var formOrigin: FormOrigin + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_oreo_autofill_filter) + setFinishOnTouchOutside(true) + + val params = window.attributes + params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt() + params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt() + window.attributes = params + + if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) { + e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + formOrigin = when { + intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> { + FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!) + } + intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> { + FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!) + } + else -> { + e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" } + finish() + return + } + } + + supportActionBar?.hide() + bindUI() + setResult(RESULT_CANCELED) + } + + private fun bindUI() { + // setup is an extension method provided by recyclical + rvPassword.setup { + withDataSource(dataSource) + withItem(R.layout.oreo_autofill_filter_row) { + onBind(::PasswordViewHolder) { _, item -> + title.text = item.fullPathToParent + // drop the .gpg extension + subtitle.text = item.name.dropLast(4) + } + onClick { decryptAndFill(item) } + } + } + rvPassword.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + + search.addTextChangedListener { recursiveFilter(it.toString(), strict = false) } + val initialFilter = + formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) + search.setText(initialFilter, TextView.BufferType.EDITABLE) + recursiveFilter(initialFilter, strict = formOrigin is FormOrigin.Web) + + shouldMatch.text = getString( + R.string.oreo_autofill_match_with, + formOrigin.getPrettyIdentifier(applicationContext) + ) + } + + private fun decryptAndFill(item: PasswordItem) { + if (shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) + if (shouldMatch.isChecked) AutofillMatcher.addMatchFor( + applicationContext, + formOrigin, + item.file + ) + // intent?.extras? is checked to be non-null in onCreate + startActivityForResult( + AutofillDecryptActivity.makeDecryptFileIntent( + item.file, + intent!!.extras!!, + this + ), DECRYPT_FILL_REQUEST_CODE + ) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == DECRYPT_FILL_REQUEST_CODE) { + if (resultCode == RESULT_OK) setResult(RESULT_OK, data) + finish() + } + } + + private fun recursiveFilter(filter: String, dir: File? = null, strict: Boolean = true) { + val root = PasswordRepository.getRepositoryDirectory(this) + // on the root the pathStack is empty + val passwordItems = if (dir == null) { + PasswordRepository.getPasswords( + PasswordRepository.getRepositoryDirectory(this), + sortOrder + ) + } else { + PasswordRepository.getPasswords( + dir, + PasswordRepository.getRepositoryDirectory(this), + sortOrder + ) + } + + for (item in passwordItems) { + if (item.type == PasswordItem.TYPE_CATEGORY) { + recursiveFilter(filter, item.file, strict = strict) + } + + // TODO: Implement fuzzy search if strict == false? + val matches = if (strict) item.file.parentFile.name.let { + it == filter || it.endsWith(".$filter") || it.endsWith("://$filter") + } + else "${item.file.relativeTo(root).path}/${item.file.nameWithoutExtension}".toLowerCase( + Locale.getDefault() + ).contains(filter.toLowerCase(Locale.getDefault())) + + val inAdapter = dataSource.contains(item) + if (item.type == PasswordItem.TYPE_PASSWORD && matches && !inAdapter) { + dataSource.add(item) + } else if (!matches && inAdapter) { + dataSource.remove(item) + } + } + } +} 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 new file mode 100644 index 00000000..bef0e536 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt @@ -0,0 +1,96 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.autofill.oreo.ui + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.text.format.DateUtils +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import com.github.ajalt.timberkt.e +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 kotlinx.android.synthetic.main.activity_oreo_autofill_publisher_changed.* + +@TargetApi(Build.VERSION_CODES.O) +class AutofillPublisherChangedActivity : AppCompatActivity() { + + companion object { + private const val EXTRA_APP_PACKAGE = + "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_APP_PACKAGE" + private var publisherChangedRequestCode = 1 + + fun makePublisherChangedIntentSender( + context: Context, + publisherChangedException: AutofillPublisherChangedException + ): IntentSender { + val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply { + putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier) + } + return PendingIntent.getActivity( + context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private lateinit var appPackage: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_oreo_autofill_publisher_changed) + setFinishOnTouchOutside(true) + + appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run { + e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } + finish() + return + } + supportActionBar?.hide() + showPackageInfo() + + okButton.setOnClickListener { finish() } + advancedButton.setOnClickListener { + advancedButton.visibility = View.INVISIBLE + warningAppAdvancedInfo.visibility = View.VISIBLE + resetButton.visibility = View.VISIBLE + } + resetButton.setOnClickListener { + AutofillMatcher.clearMatchesFor(this, FormOrigin.App(appPackage)) + finish() + } + } + + private fun showPackageInfo() { + try { + val packageInfo = + packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA) + val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) + warningAppInstallDate.text = + getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) + val appInfo = + packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA) + warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”" + + val currentHash = computeCertificatesHash(this, appPackage) + warningAppAdvancedInfo.text = getString( + R.string.oreo_autofill_warning_publisher_advanced_info_template, + appPackage, + currentHash + ) + } catch (exception: Exception) { + e(exception) { "Failed to retrieve package info for $appPackage" } + finish() + } + } +} 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 new file mode 100644 index 00000000..e5368b73 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt @@ -0,0 +1,139 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.autofill.oreo.ui + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillManager +import androidx.annotation.RequiresApi +import androidx.core.os.bundleOf +import com.github.ajalt.timberkt.e +import com.zeapo.pwdstore.PasswordStore +import com.zeapo.pwdstore.autofill.oreo.AutofillAction +import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher +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.crypto.PgpActivity +import com.zeapo.pwdstore.utils.PasswordRepository +import java.io.File + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillSaveActivity : Activity() { + + companion object { + private const val EXTRA_FOLDER_NAME = + "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FOLDER_NAME" + private const val EXTRA_PASSWORD = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_PASSWORD" + private const val EXTRA_USERNAME = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_USERNAME" + private const val EXTRA_SHOULD_MATCH_APP = + "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP" + private const val EXTRA_SHOULD_MATCH_WEB = + "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB" + private const val EXTRA_GENERATE_PASSWORD = + "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD" + + private var saveRequestCode = 1 + + fun makeSaveIntentSender( + context: Context, + credentials: Credentials?, + formOrigin: FormOrigin + ): IntentSender { + val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) + val sanitizedIdentifier = identifier.replace("""[\\\/]""", "") + val folderName = + sanitizedIdentifier.takeUnless { it.isBlank() } ?: formOrigin.identifier + val intent = Intent(context, AutofillSaveActivity::class.java).apply { + putExtras( + bundleOf( + EXTRA_FOLDER_NAME to folderName, + EXTRA_PASSWORD to credentials?.password, + EXTRA_USERNAME to credentials?.username, + EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App }, + EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web }, + EXTRA_GENERATE_PASSWORD to (credentials == null) + ) + ) + } + return PendingIntent.getActivity( + context, + saveRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private val formOrigin: FormOrigin? by lazy { + val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP) + val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB) + if (shouldMatchApp != null && shouldMatchWeb == null) { + FormOrigin.App(shouldMatchApp) + } else if (shouldMatchApp == null && shouldMatchWeb != null) { + FormOrigin.Web(shouldMatchWeb) + } else { + null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val repo = PasswordRepository.getRepositoryDirectory(applicationContext) + val username = intent.getStringExtra(EXTRA_USERNAME) + + val saveIntent = Intent(this, PgpActivity::class.java).apply { + putExtras( + bundleOf( + "REPO_PATH" to repo.absolutePath, + "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)).absolutePath, + "OPERATION" to "ENCRYPT", + "SUGGESTED_NAME" to username, + "SUGGESTED_PASS" to intent.getStringExtra(EXTRA_PASSWORD), + "GENERATE_PASSWORD" to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) + ) + ) + } + startActivityForResult(saveIntent, PasswordStore.REQUEST_CODE_ENCRYPT) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == PasswordStore.REQUEST_CODE_ENCRYPT && resultCode == RESULT_OK && data != null) { + val createdPath = data.getStringExtra("CREATED_FILE") + if (createdPath != null) { + formOrigin?.let { + AutofillMatcher.addMatchFor(this, it, File(createdPath)) + } + } + val password = data.getStringExtra("PASSWORD") + val username = data.getStringExtra("USERNAME") + if (password != null) { + val clientState = + intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { + e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + val credentials = Credentials(username, password) + val fillInDataset = FillableForm.makeFillInDataset( + this, + credentials, + clientState, + AutofillAction.Generate + ) + setResult(RESULT_OK, Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + }) + } + } + finish() + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt new file mode 100644 index 00000000..f6ad7a4d --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt @@ -0,0 +1,15 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.autofill.oreo.ui + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.zeapo.pwdstore.R + +class PasswordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val title: TextView = itemView.findViewById(R.id.title) + val subtitle: TextView = itemView.findViewById(R.id.subtitle) +} diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt index 6f09ca5a..90efd583 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt @@ -16,6 +16,7 @@ import android.content.SharedPreferences import android.graphics.Typeface import android.os.Build import android.os.Bundle +import android.text.InputType import android.text.TextUtils import android.text.format.DateUtils import android.text.method.PasswordTransformationMethod @@ -30,6 +31,7 @@ import android.widget.CheckBox import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.preference.PreferenceManager @@ -48,11 +50,7 @@ import java.io.File import java.nio.charset.Charset import java.util.Date import kotlinx.android.synthetic.main.decrypt_layout.* -import kotlinx.android.synthetic.main.encrypt_layout.crypto_extra_edit -import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_category -import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_edit -import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_file_edit -import kotlinx.android.synthetic.main.encrypt_layout.generate_password +import kotlinx.android.synthetic.main.encrypt_layout.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import me.msfjarvis.openpgpktx.util.OpenPgpApi @@ -81,6 +79,11 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { private var editPass: String? = null private var editExtra: String? = null + private val suggestedName by lazy { intent.getStringExtra("SUGGESTED_NAME") } + private val suggestedPass by lazy { intent.getStringExtra("SUGGESTED_PASS") } + private val suggestedExtra by lazy { intent.getStringExtra("SUGGESTED_EXTRA") } + private val shouldGeneratePassword by lazy { intent.getBooleanExtra("GENERATE_PASSWORD", false) } + private val operation: String by lazy { intent.getStringExtra("OPERATION") } private val repoPath: String by lazy { intent.getStringExtra("REPO_PATH") } @@ -150,25 +153,85 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { setContentView(R.layout.encrypt_layout) generate_password?.setOnClickListener { - when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) { - KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() - .show(supportFragmentManager, "generator") - KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment() - .show(supportFragmentManager, "xkpwgenerator") - } + generatePassword() } title = getString(R.string.new_password_title) crypto_password_category.text = getRelativePath(fullPath, repoPath) + suggestedName?.let { + crypto_password_file_edit.setText(it) + encrypt_username.apply { + visibility = View.VISIBLE + setOnClickListener { + if (isChecked) { + // User wants to enable username encryption, so we add it to the + // encrypted extras as the first line. + val username = crypto_password_file_edit.text!!.toString() + val extras = "username:$username\n${crypto_extra_edit.text!!}" + + crypto_password_file_edit.setText("") + crypto_extra_edit.setText(extras) + } else { + // User wants to disable username encryption, so we extract the + // username from the encrypted extras and use it as the filename. + val entry = PasswordEntry("PASSWORD\n${crypto_extra_edit.text!!}") + val username = entry.username + + // username should not be null here by the logic in + // updateEncryptUsernameState, but it could still happen due to + // input lag. + if (username != null) { + crypto_password_file_edit.setText(username) + crypto_extra_edit.setText(entry.extraContentWithoutUsername) + } + } + updateEncryptUsernameState() + } + } + crypto_password_file_edit.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() } + crypto_extra_edit.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() } + updateEncryptUsernameState() + } + suggestedPass?.let { + crypto_password_edit.setText(it) + crypto_password_edit.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + suggestedExtra?.let { crypto_extra_edit.setText(it) } + if (shouldGeneratePassword) { + generatePassword() + crypto_password_edit.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } } } } + fun updateEncryptUsernameState() { + encrypt_username.apply { + if (visibility != View.VISIBLE) + return + val hasUsernameInFileName = crypto_password_file_edit.text!!.toString().isNotBlank() + // Use PasswordEntry to parse extras for username + val entry = PasswordEntry("PLACEHOLDER\n${crypto_extra_edit.text!!}") + val hasUsernameInExtras = entry.hasUsername() + isEnabled = hasUsernameInFileName xor hasUsernameInExtras + isChecked = hasUsernameInExtras + } + } + override fun onResume() { super.onResume() LocalBroadcastManager.getInstance(this).registerReceiver(receiver, IntentFilter(ACTION_CLEAR)) } + private fun generatePassword() { + when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) { + KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() + .show(supportFragmentManager, "generator") + KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment() + .show(supportFragmentManager, "xkpwgenerator") + } + } + override fun onStop() { LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) super.onStop() @@ -482,7 +545,8 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { data.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true) // TODO Check if we could use PasswordEntry to generate the file - val iStream = ByteArrayInputStream("$editPass\n$editExtra".toByteArray(Charset.forName("UTF-8"))) + val content = "$editPass\n$editExtra" + val iStream = ByteArrayInputStream(content.toByteArray(Charset.forName("UTF-8"))) val oStream = ByteArrayOutputStream() val path = if (intent.getBooleanExtra("fromDecrypt", false)) fullPath else "$fullPath/$editName.gpg" @@ -494,7 +558,8 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { RESULT_CODE_SUCCESS -> { try { // TODO This might fail, we should check that the write is successful - val outputStream = FileUtils.openOutputStream(File(path)) + val file = File(path) + val outputStream = FileUtils.openOutputStream(file) outputStream.write(oStream.toByteArray()) outputStream.close() @@ -508,6 +573,13 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { returnIntent.putExtra("OPERATION", "EDIT") returnIntent.putExtra("needCommit", true) } + + if (shouldGeneratePassword) { + val entry = PasswordEntry(content) + returnIntent.putExtra("PASSWORD", entry.password) + returnIntent.putExtra("USERNAME", entry.username ?: file.nameWithoutExtension) + } + setResult(RESULT_OK, returnIntent) finish() } catch (e: Exception) { diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt index ff4fab69..68f8df27 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt @@ -5,7 +5,10 @@ package com.zeapo.pwdstore.utils import android.content.Context +import android.os.Build import android.util.TypedValue +import android.view.autofill.AutofillManager +import androidx.annotation.RequiresApi fun String.splitLines(): Array { return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() @@ -16,3 +19,7 @@ fun Context.resolveAttribute(attr: Int): Int { this.theme.resolveAttribute(attr, typedValue, true) return typedValue.data } + +val Context.autofillManager: AutofillManager? + @RequiresApi(Build.VERSION_CODES.O) + get() = getSystemService(AutofillManager::class.java) -- cgit v1.2.3