diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7a3d63ad..84d75594 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,7 @@ # General rule for all code base * @msfjarvis @zidhuss @Skrilltrax + +# Oreo Autofill +app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ @FabianHenneke +*oreo_autofill* @FabianHenneke +scripts/hash_browser_app.sh @FabianHenneke diff --git a/app/build.gradle b/app/build.gradle index 619323e9..0112c661 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,8 +101,11 @@ dependencies { } implementation deps.third_party.jsch implementation deps.third_party.openpgp_ktx + implementation deps.third_party.publicsuffixlist + implementation deps.third_party.recyclical implementation deps.third_party.ssh_auth implementation deps.third_party.timber + implementation deps.third_party.timberkt implementation deps.third_party.whatthestack if (isSnapshot()) { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e05d5b8..db187ed7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,13 @@ + + + + + + + + + + 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) diff --git a/app/src/main/res/drawable/ic_autofill_new_password.xml b/app/src/main/res/drawable/ic_autofill_new_password.xml new file mode 100644 index 00000000..958de190 --- /dev/null +++ b/app/src/main/res/drawable/ic_autofill_new_password.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_black_24dp.xml b/app/src/main/res/drawable/ic_person_black_24dp.xml new file mode 100644 index 00000000..b2cb337b --- /dev/null +++ b/app/src/main/res/drawable/ic_person_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_black_24dp.xml b/app/src/main/res/drawable/ic_search_black_24dp.xml new file mode 100644 index 00000000..affc7ba2 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_warning_red_24dp.xml b/app/src/main/res/drawable/ic_warning_red_24dp.xml new file mode 100644 index 00000000..cea44306 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_red_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_oreo_autofill_filter.xml b/app/src/main/res/layout/activity_oreo_autofill_filter.xml new file mode 100644 index 00000000..17eb0e29 --- /dev/null +++ b/app/src/main/res/layout/activity_oreo_autofill_filter.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml b/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml new file mode 100644 index 00000000..21184e84 --- /dev/null +++ b/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + +