Autofill: Extract AutofillParser into separate subproject (#1101)

Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
Co-authored-by: Fabian Henneke <fabian@henneke.me>
This commit is contained in:
Vincent Breitmoser 2020-09-16 20:17:55 +02:00 committed by GitHub
parent 4ba3b75f85
commit 0810273444
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 623 additions and 536 deletions

View file

@ -16,7 +16,7 @@ jobs:
git config user.email noreply@github.com git config user.email noreply@github.com
- name: Download new publicsuffix data - name: Download new publicsuffix data
run: curl -L https://github.com/mozilla-mobile/android-components/raw/master/components/lib/publicsuffixlist/src/main/assets/publicsuffixes -o app/src/main/assets/publicsuffixes run: curl -L https://github.com/mozilla-mobile/android-components/raw/master/components/lib/publicsuffixlist/src/main/assets/publicsuffixes -o autofill-parser/src/main/assets/publicsuffixes
- name: Compare list changes - name: Compare list changes
run: if [[ $(git diff --binary --stat) != '' ]]; then echo "::set-env name=UPDATED::true"; fi run: if [[ $(git diff --binary --stat) != '' ]]; then echo "::set-env name=UPDATED::true"; fi

View file

@ -12,11 +12,11 @@
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/autofill-parser" />
<option value="$PROJECT_DIR$/buildSrc" /> <option value="$PROJECT_DIR$/buildSrc" />
</set> </set>
</option> </option>
<option name="resolveModulePerSourceSet" value="false" /> <option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View file

@ -89,7 +89,6 @@ android {
dependencies { dependencies {
compileOnly(Dependencies.AndroidX.annotation) compileOnly(Dependencies.AndroidX.annotation)
implementation(Dependencies.AndroidX.activity_ktx) implementation(Dependencies.AndroidX.activity_ktx)
implementation(Dependencies.AndroidX.autofill)
implementation(Dependencies.AndroidX.appcompat) implementation(Dependencies.AndroidX.appcompat)
implementation(Dependencies.AndroidX.biometric) implementation(Dependencies.AndroidX.biometric)
implementation(Dependencies.AndroidX.constraint_layout) implementation(Dependencies.AndroidX.constraint_layout)
@ -109,6 +108,7 @@ dependencies {
implementation(Dependencies.Kotlin.Coroutines.android) implementation(Dependencies.Kotlin.Coroutines.android)
implementation(Dependencies.Kotlin.Coroutines.core) implementation(Dependencies.Kotlin.Coroutines.core)
implementation(project(Dependencies.FirstParty.autofill_parser))
implementation(Dependencies.FirstParty.openpgp_ktx) implementation(Dependencies.FirstParty.openpgp_ktx)
implementation(Dependencies.FirstParty.zxing_android_embedded) implementation(Dependencies.FirstParty.zxing_android_embedded)

View file

@ -35,6 +35,8 @@ import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.github.ajalt.timberkt.i import com.github.ajalt.timberkt.i
import com.github.ajalt.timberkt.w import com.github.ajalt.timberkt.w
import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
import com.github.michaelbull.result.fold import com.github.michaelbull.result.fold
import com.github.michaelbull.result.getOr import com.github.michaelbull.result.getOr
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
@ -43,8 +45,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel
import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel
import com.zeapo.pwdstore.crypto.BasePgpActivity.Companion.getLongName import com.zeapo.pwdstore.crypto.BasePgpActivity.Companion.getLongName
import com.zeapo.pwdstore.crypto.DecryptActivity import com.zeapo.pwdstore.crypto.DecryptActivity
import com.zeapo.pwdstore.crypto.PasswordCreationActivity import com.zeapo.pwdstore.crypto.PasswordCreationActivity

View file

@ -36,13 +36,13 @@ import androidx.preference.SwitchPreferenceCompat
import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.Timber.tag
import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.w import com.github.ajalt.timberkt.w
import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
import com.github.michaelbull.result.getOr import com.github.michaelbull.result.getOr
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.runCatching
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.zeapo.pwdstore.autofill.AutofillPreferenceActivity import com.zeapo.pwdstore.autofill.AutofillPreferenceActivity
import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel
import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel
import com.zeapo.pwdstore.crypto.BasePgpActivity import com.zeapo.pwdstore.crypto.BasePgpActivity
import com.zeapo.pwdstore.git.GitConfigActivity import com.zeapo.pwdstore.git.GitConfigActivity
import com.zeapo.pwdstore.git.GitServerConfigActivity import com.zeapo.pwdstore.git.GitServerConfigActivity

View file

@ -14,6 +14,8 @@ import com.github.ajalt.timberkt.w
import com.github.michaelbull.result.Err import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result import com.github.michaelbull.result.Result
import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
import java.io.File import java.io.File

View file

@ -7,6 +7,8 @@ package com.zeapo.pwdstore.autofill.oreo
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.github.androidpasswordstore.autofillparser.Credentials
import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.sharedPrefs
import java.io.File import java.io.File
import java.nio.file.Paths import java.nio.file.Paths
@ -121,4 +123,17 @@ object AutofillPreferences {
val value = context.sharedPrefs.getString(DirectoryStructure.PREFERENCE, null) val value = context.sharedPrefs.getString(DirectoryStructure.PREFERENCE, null)
return DirectoryStructure.fromValue(value) return DirectoryStructure.fromValue(value)
} }
fun credentialsFromStoreEntry(
context: Context,
file: File,
entry: PasswordEntry,
directoryStructure: DirectoryStructure
): Credentials {
// Always give priority to a username stored in the encrypted extras
val username = entry.username
?: directoryStructure.getUsernameFor(file)
?: context.getDefaultUsername()
return Credentials(username, entry.password, entry.calculateTotpCode())
}
} }

View file

@ -0,0 +1,192 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.content.Context
import android.content.IntentSender
import android.os.Build
import android.os.Bundle
import android.service.autofill.Dataset
import android.service.autofill.FillCallback
import android.service.autofill.FillResponse
import android.service.autofill.SaveInfo
import android.widget.RemoteViews
import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.e
import com.github.michaelbull.result.fold
import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.AutofillScenario
import com.github.androidpasswordstore.autofillparser.Credentials
import com.github.androidpasswordstore.autofillparser.FillableForm
import com.github.androidpasswordstore.autofillparser.fillWith
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity
import java.io.File
@RequiresApi(Build.VERSION_CODES.O)
class AutofillResponseBuilder(form: FillableForm) {
private val formOrigin = form.formOrigin
private val scenario = form.scenario
private val ignoredIds = form.ignoredIds
private val saveFlags = form.saveFlags
private val clientState = form.toClientState()
// We do not offer save when the only relevant field is a username field or there is no field.
private val scenarioSupportsSave =
scenario.fieldsToSave.minus(listOfNotNull(scenario.username)).isNotEmpty()
private val canBeSaved = saveFlags != null && scenarioSupportsSave
private fun makePlaceholderDataset(
remoteView: RemoteViews,
intentSender: IntentSender,
action: AutofillAction
): Dataset {
return Dataset.Builder(remoteView).run {
fillWith(scenario, action, credentials = null)
setAuthentication(intentSender)
build()
}
}
private fun makeMatchDataset(context: Context, file: File): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.Match).isEmpty()) return null
val remoteView = makeFillMatchRemoteView(context, file, formOrigin)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
}
private fun makeSearchDataset(context: Context): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.Search).isEmpty()) return null
val remoteView = makeSearchAndFillRemoteView(context, formOrigin)
val intentSender =
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Search)
}
private fun makeGenerateDataset(context: Context): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.Generate).isEmpty()) return null
val remoteView = makeGenerateAndFillRemoteView(context, formOrigin)
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Generate)
}
private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin)
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms)
}
private fun makePublisherChangedDataset(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): Dataset {
val remoteView = makeWarningRemoteView(context)
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
context, publisherChangedException
)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
}
private fun makePublisherChangedResponse(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): FillResponse {
return FillResponse.Builder().run {
addDataset(makePublisherChangedDataset(context, publisherChangedException))
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? {
if (!canBeSaved) return null
check(saveFlags != null)
val idsToSave = scenario.fieldsToSave.map { it.autofillId }.toTypedArray()
if (idsToSave.isEmpty()) return null
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
if (scenario.username != null) {
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
setFlags(saveFlags)
build()
}
}
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
var hasDataset = false
return FillResponse.Builder().run {
for (file in matchedFiles) {
makeMatchDataset(context, file)?.let {
hasDataset = true
addDataset(it)
}
}
makeSearchDataset(context)?.let {
hasDataset = true
addDataset(it)
}
makeGenerateDataset(context)?.let {
hasDataset = true
addDataset(it)
}
makeFillOtpFromSmsDataset(context)?.let {
hasDataset = true
addDataset(it)
}
if (!hasDataset) return null
makeSaveInfo()?.let { setSaveInfo(it) }
setClientState(clientState)
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
/**
* Creates and returns a suitable [FillResponse] to the Autofill framework.
*/
fun fillCredentials(context: Context, callback: FillCallback) {
AutofillMatcher.getMatchesFor(context, formOrigin).fold(
success = { matchedFiles ->
callback.onSuccess(makeFillResponse(context, matchedFiles))
},
failure = { e ->
e(e)
callback.onSuccess(makePublisherChangedResponse(context, e))
}
)
}
companion object {
fun makeFillInDataset(
context: Context,
credentials: Credentials,
clientState: Bundle,
action: AutofillAction
): Dataset {
val remoteView = makePlaceholderRemoteView(context)
val scenario = AutofillScenario.fromBundle(clientState)
// Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
// though they are never shown.
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Dataset.Builder()
} else {
Dataset.Builder(remoteView)
}
return builder.run {
if (scenario != null) fillWith(scenario, action, credentials)
else e { "Failed to recover scenario from client state" }
build()
}
}
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.content.Context
import android.widget.RemoteViews
import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File
private fun makeRemoteView(
context: Context,
title: String,
summary: String,
iconRes: Int
): RemoteViews {
return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
setTextViewText(R.id.title, title)
setTextViewText(R.id.summary, summary)
setImageViewResource(R.id.icon, iconRes)
}
}
fun makeFillMatchRemoteView(context: Context, file: File, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = false)
val directoryStructure = AutofillPreferences.directoryStructure(context)
val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
val summary = directoryStructure.getUsernameFor(relativeFile)
?: directoryStructure.getPathToIdentifierFor(relativeFile) ?: ""
val iconRes = R.drawable.ic_person_black_24dp
return makeRemoteView(context, title, summary, iconRes)
}
fun makeSearchAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
val summary = context.getString(R.string.oreo_autofill_search_in_store)
val iconRes = R.drawable.ic_search_black_24dp
return makeRemoteView(context, title, summary, iconRes)
}
fun makeGenerateAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
val summary = context.getString(R.string.oreo_autofill_generate_password)
val iconRes = R.drawable.ic_autofill_new_password
return makeRemoteView(context, title, summary, iconRes)
}
fun makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms)
val iconRes = R.drawable.ic_autofill_sms
return makeRemoteView(context, title, summary, iconRes)
}
fun makePlaceholderRemoteView(context: Context): RemoteViews {
return makeRemoteView(context, "PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
}
fun makeWarningRemoteView(context: Context): RemoteViews {
val title = context.getString(R.string.oreo_autofill_warning_publisher_dataset_title)
val summary = context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary)
val iconRes = R.drawable.ic_warning_red_24dp
return makeRemoteView(context, title, summary, iconRes)
}

View file

@ -1,383 +0,0 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.app.assist.AssistStructure
import android.content.Context
import android.content.IntentSender
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.service.autofill.Dataset
import android.service.autofill.FillCallback
import android.service.autofill.FillResponse
import android.service.autofill.SaveInfo
import android.view.autofill.AutofillId
import android.widget.RemoteViews
import androidx.annotation.RequiresApi
import androidx.core.os.bundleOf
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e
import com.github.michaelbull.result.fold
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity
import java.io.File
/**
* A unique identifier for either an Android app (package name) or a website (origin minus port).
*/
sealed class FormOrigin(open val identifier: String) {
data class Web(override val identifier: String) : FormOrigin(identifier)
data class App(override val identifier: String) : FormOrigin(identifier)
companion object {
private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier"
private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier"
fun fromBundle(bundle: Bundle): FormOrigin? {
val webIdentifier = bundle.getString(BUNDLE_KEY_WEB_IDENTIFIER)
if (webIdentifier != null) {
return Web(webIdentifier)
} else {
return App(bundle.getString(BUNDLE_KEY_APP_IDENTIFIER) ?: return null)
}
}
}
fun getPrettyIdentifier(context: Context, untrusted: Boolean = true) = when (this) {
is Web -> identifier
is App -> {
val info = context.packageManager.getApplicationInfo(
identifier, PackageManager.GET_META_DATA
)
val label = context.packageManager.getApplicationLabel(info)
if (untrusted) "$label" else "$label"
}
}
fun toBundle() = when (this) {
is Web -> bundleOf(BUNDLE_KEY_WEB_IDENTIFIER to identifier)
is App -> bundleOf(BUNDLE_KEY_APP_IDENTIFIER to identifier)
}
}
/**
* Manages the detection of fields to fill in an [AssistStructure] and determines the [FormOrigin].
*/
@RequiresApi(Build.VERSION_CODES.O)
private class Form(context: Context, structure: AssistStructure, isManualRequest: Boolean) {
companion object {
private val SUPPORTED_SCHEMES = listOf("http", "https")
}
private val relevantFields = mutableListOf<FormField>()
val ignoredIds = mutableListOf<AutofillId>()
private var fieldIndex = 0
private var appPackage = structure.activityComponent.packageName
private val trustedBrowserInfo =
getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
val saveFlags = trustedBrowserInfo?.saveFlags
private val webOrigins = mutableSetOf<String>()
init {
d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
parseStructure(structure)
}
val scenario = detectFieldsToFill(isManualRequest)
val formOrigin = determineFormOrigin(context)
init {
d { "Origin: $formOrigin" }
}
private fun parseStructure(structure: AssistStructure) {
for (i in 0 until structure.windowNodeCount) {
visitFormNode(structure.getWindowNodeAt(i).rootViewNode)
}
}
private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) {
trackOrigin(node)
val field =
if (trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.WebView) {
FormField(node, fieldIndex, true, inheritedWebOrigin)
} else {
check(inheritedWebOrigin == null)
FormField(node, fieldIndex, false)
}
if (field.relevantField) {
d { "Relevant: $field" }
relevantFields.add(field)
fieldIndex++
} else {
d { "Ignored : $field" }
ignoredIds.add(field.autofillId)
}
for (i in 0 until node.childCount) {
visitFormNode(node.getChildAt(i), field.webOriginToPassDown)
}
}
private fun detectFieldsToFill(isManualRequest: Boolean) = autofillStrategy.match(
relevantFields,
singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None,
isManualRequest = isManualRequest
)
private fun trackOrigin(node: AssistStructure.ViewNode) {
if (trustedBrowserInfo == null) return
node.webOrigin?.let {
if (it !in webOrigins) {
d { "Origin encountered: $it" }
webOrigins.add(it)
}
}
}
private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? {
val uri = Uri.parse(origin) ?: return null
val scheme = uri.scheme ?: return null
if (scheme !in SUPPORTED_SCHEMES) return null
val host = uri.host ?: return null
return FormOrigin.Web(getPublicSuffixPlusOne(context, host))
}
private fun determineFormOrigin(context: Context): FormOrigin? {
if (scenario == null) return null
if (trustedBrowserInfo == null || webOrigins.isEmpty()) {
// Security assumption: If a trusted browser includes no web origin in the provided
// AssistStructure, then the form is a native browser form (e.g. for a sync password).
// TODO: Support WebViews in apps via Digital Asset Links
// See: https://developer.android.com/reference/android/service/autofill/AutofillService#web-security
return FormOrigin.App(appPackage)
}
return when (trustedBrowserInfo.multiOriginMethod) {
BrowserMultiOriginMethod.None -> {
// Security assumption: If a browser is trusted but does not support tracking
// multiple origins, it is expected to annotate a single field, in most cases its
// URL bar, with a webOrigin. We err on the side of caution and only trust the
// reported web origin if no other web origin appears on the page.
webOriginToFormOrigin(context, webOrigins.singleOrNull() ?: return null)
}
BrowserMultiOriginMethod.WebView,
BrowserMultiOriginMethod.Field -> {
// Security assumption: For browsers with full autofill support (the `Field` case),
// every form field is annotated with its origin. For browsers based on WebView,
// this is true after the web origins of WebViews are passed down to their children.
//
// For browsers with the WebView or Field method of multi origin support, we take
// the single origin among the detected fillable or saveable fields. If this origin
// is null, but we encountered web origins elsewhere in the AssistStructure, the
// situation is uncertain and Autofill should not be offered.
webOriginToFormOrigin(
context,
scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null
)
}
}
}
}
/**
* Represents a collection of fields in a specific app that can be filled or saved. This is the
* entry point to all fill and save features.
*/
@RequiresApi(Build.VERSION_CODES.O)
class FillableForm private constructor(
private val formOrigin: FormOrigin,
private val scenario: AutofillScenario<FormField>,
private val ignoredIds: List<AutofillId>,
private val saveFlags: Int?
) {
companion object {
fun makeFillInDataset(
context: Context,
credentials: Credentials,
clientState: Bundle,
action: AutofillAction
): Dataset {
val remoteView = makePlaceholderRemoteView(context)
val scenario = AutofillScenario.fromBundle(clientState)
// Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
// though they are never shown.
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Dataset.Builder()
} else {
Dataset.Builder(remoteView)
}
return builder.run {
if (scenario != null) fillWith(scenario, action, credentials)
else e { "Failed to recover scenario from client state" }
build()
}
}
/**
* Returns a [FillableForm] if a login form could be detected in [structure].
*/
fun parseAssistStructure(
context: Context,
structure: AssistStructure,
isManualRequest: Boolean
): FillableForm? {
val form = Form(context, structure, isManualRequest)
if (form.formOrigin == null || form.scenario == null) return null
return FillableForm(
form.formOrigin,
form.scenario,
form.ignoredIds,
form.saveFlags
)
}
}
private val clientState = scenario.toBundle().apply {
putAll(formOrigin.toBundle())
}
// We do not offer save when the only relevant field is a username field or there is no field.
private val scenarioSupportsSave =
scenario.fieldsToSave.minus(listOfNotNull(scenario.username)).isNotEmpty()
private val canBeSaved = saveFlags != null && scenarioSupportsSave
private fun makePlaceholderDataset(
remoteView: RemoteViews,
intentSender: IntentSender,
action: AutofillAction
): Dataset {
return Dataset.Builder(remoteView).run {
fillWith(scenario, action, credentials = null)
setAuthentication(intentSender)
build()
}
}
private fun makeMatchDataset(context: Context, file: File): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.Match).isEmpty()) return null
val remoteView = makeFillMatchRemoteView(context, file, formOrigin)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
}
private fun makeSearchDataset(context: Context): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.Search).isEmpty()) return null
val remoteView = makeSearchAndFillRemoteView(context, formOrigin)
val intentSender =
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Search)
}
private fun makeGenerateDataset(context: Context): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.Generate).isEmpty()) return null
val remoteView = makeGenerateAndFillRemoteView(context, formOrigin)
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Generate)
}
private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin)
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms)
}
private fun makePublisherChangedDataset(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): Dataset {
val remoteView = makeWarningRemoteView(context)
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
context, publisherChangedException
)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
}
private fun makePublisherChangedResponse(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): FillResponse {
return FillResponse.Builder().run {
addDataset(makePublisherChangedDataset(context, publisherChangedException))
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? {
if (!canBeSaved) return null
check(saveFlags != null)
val idsToSave = scenario.fieldsToSave.map { it.autofillId }.toTypedArray()
if (idsToSave.isEmpty()) return null
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
if (scenario.username != null) {
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
setFlags(saveFlags)
build()
}
}
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
var hasDataset = false
return FillResponse.Builder().run {
for (file in matchedFiles) {
makeMatchDataset(context, file)?.let {
hasDataset = true
addDataset(it)
}
}
makeSearchDataset(context)?.let {
hasDataset = true
addDataset(it)
}
makeGenerateDataset(context)?.let {
hasDataset = true
addDataset(it)
}
makeFillOtpFromSmsDataset(context)?.let {
hasDataset = true
addDataset(it)
}
if (!hasDataset) return null
makeSaveInfo()?.let { setSaveInfo(it) }
setClientState(clientState)
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
/**
* Creates and returns a suitable [FillResponse] to the Autofill framework.
*/
fun fillCredentials(context: Context, callback: FillCallback) {
AutofillMatcher.getMatchesFor(context, formOrigin).fold(
success = { matchedFiles ->
callback.onSuccess(makeFillResponse(context, matchedFiles))
},
failure = { e ->
e(e)
callback.onSuccess(makePublisherChangedResponse(context, e))
}
)
}
}

View file

@ -4,6 +4,7 @@
*/ */
package com.zeapo.pwdstore.autofill.oreo package com.zeapo.pwdstore.autofill.oreo
import android.content.Context
import android.os.Build import android.os.Build
import android.os.CancellationSignal import android.os.CancellationSignal
import android.service.autofill.AutofillService import android.service.autofill.AutofillService
@ -15,10 +16,22 @@ import android.service.autofill.SaveRequest
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.github.androidpasswordstore.autofillparser.AutofillScenario
import com.github.androidpasswordstore.autofillparser.Credentials
import com.github.androidpasswordstore.autofillparser.FillableForm
import com.github.androidpasswordstore.autofillparser.FixedSaveCallback
import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList
import com.github.androidpasswordstore.autofillparser.passwordValue
import com.github.androidpasswordstore.autofillparser.recoverNodes
import com.github.androidpasswordstore.autofillparser.usernameValue
import com.zeapo.pwdstore.BuildConfig import com.zeapo.pwdstore.BuildConfig
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.getString
import com.zeapo.pwdstore.utils.hasFlag import com.zeapo.pwdstore.utils.hasFlag
import com.zeapo.pwdstore.utils.sharedPrefs
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
class OreoAutofillService : AutofillService() { class OreoAutofillService : AutofillService() {
@ -66,13 +79,14 @@ class OreoAutofillService : AutofillService() {
} }
val formToFill = FillableForm.parseAssistStructure( val formToFill = FillableForm.parseAssistStructure(
this, structure, this, structure,
isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
getCustomSuffixes(),
) ?: run { ) ?: run {
d { "Form cannot be filled" } d { "Form cannot be filled" }
callback.onSuccess(null) callback.onSuccess(null)
return return
} }
formToFill.fillCredentials(this, callback) AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
} }
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
@ -113,3 +127,12 @@ class OreoAutofillService : AutofillService() {
) )
} }
} }
fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
fun Context.getCustomSuffixes(): Sequence<String> {
return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
?.splitToSequence('\n')
?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
?: emptySequence()
}

View file

@ -22,11 +22,11 @@ import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.runCatching
import com.zeapo.pwdstore.autofill.oreo.AutofillAction import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.Credentials
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.Credentials import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
import com.zeapo.pwdstore.autofill.oreo.FillableForm
import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@ -109,7 +109,7 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
setResult(RESULT_CANCELED) setResult(RESULT_CANCELED)
} else { } else {
val fillInDataset = val fillInDataset =
FillableForm.makeFillInDataset( AutofillResponseBuilder.makeFillInDataset(
this@AutofillDecryptActivity, this@AutofillDecryptActivity,
credentials, credentials,
clientState, clientState,
@ -185,7 +185,7 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
PasswordEntry(decryptedOutput) PasswordEntry(decryptedOutput)
} }
Credentials.fromStoreEntry(this, file, entry, directoryStructure) AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
}.getOrElse { e -> }.getOrElse { e ->
e(e) { "Failed to parse password entry" } e(e) { "Failed to parse password entry" }
return null return null

View file

@ -24,6 +24,7 @@ import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.zeapo.pwdstore.FilterMode import com.zeapo.pwdstore.FilterMode
import com.zeapo.pwdstore.ListMode import com.zeapo.pwdstore.ListMode
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
@ -33,7 +34,6 @@ import com.zeapo.pwdstore.SearchableRepositoryViewModel
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
import com.zeapo.pwdstore.autofill.oreo.FormOrigin
import com.zeapo.pwdstore.databinding.ActivityOreoAutofillFilterBinding import com.zeapo.pwdstore.databinding.ActivityOreoAutofillFilterBinding
import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.viewBinding import com.zeapo.pwdstore.utils.viewBinding

View file

@ -16,13 +16,13 @@ import android.text.format.DateUtils
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.runCatching
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.AutofillPublisherChangedException import com.zeapo.pwdstore.autofill.oreo.AutofillPublisherChangedException
import com.zeapo.pwdstore.autofill.oreo.FormOrigin
import com.zeapo.pwdstore.autofill.oreo.computeCertificatesHash
import com.zeapo.pwdstore.databinding.ActivityOreoAutofillPublisherChangedBinding import com.zeapo.pwdstore.databinding.ActivityOreoAutofillPublisherChangedBinding
import com.zeapo.pwdstore.utils.viewBinding import com.zeapo.pwdstore.utils.viewBinding

View file

@ -16,12 +16,12 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.autofill.oreo.AutofillAction import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.Credentials
import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.Credentials import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder
import com.zeapo.pwdstore.autofill.oreo.FillableForm
import com.zeapo.pwdstore.autofill.oreo.FormOrigin
import com.zeapo.pwdstore.crypto.PasswordCreationActivity import com.zeapo.pwdstore.crypto.PasswordCreationActivity
import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File import java.io.File
@ -126,7 +126,7 @@ class AutofillSaveActivity : AppCompatActivity() {
return@registerForActivityResult return@registerForActivityResult
} }
val credentials = Credentials(username, password, null) val credentials = Credentials(username, password, null)
val fillInDataset = FillableForm.makeFillInDataset( val fillInDataset = AutofillResponseBuilder.makeFillInDataset(
this, this,
credentials, credentials,
clientState, clientState,

View file

@ -19,6 +19,8 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.github.ajalt.timberkt.w import com.github.ajalt.timberkt.w
import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.Credentials
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.runCatching
import com.google.android.gms.auth.api.phone.SmsCodeRetriever import com.google.android.gms.auth.api.phone.SmsCodeRetriever
@ -27,9 +29,7 @@ import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.ResolvableApiException import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Task
import com.zeapo.pwdstore.autofill.oreo.AutofillAction import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder
import com.zeapo.pwdstore.autofill.oreo.Credentials
import com.zeapo.pwdstore.autofill.oreo.FillableForm
import com.zeapo.pwdstore.databinding.ActivityOreoAutofillSmsBinding import com.zeapo.pwdstore.databinding.ActivityOreoAutofillSmsBinding
import com.zeapo.pwdstore.utils.viewBinding import com.zeapo.pwdstore.utils.viewBinding
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
@ -145,13 +145,12 @@ class AutofillSmsActivity : AppCompatActivity() {
private val smsCodeRetrievedReceiver = object : BroadcastReceiver() { private val smsCodeRetrievedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val smsCode = intent.getStringExtra(SmsCodeRetriever.EXTRA_SMS_CODE) val smsCode = intent.getStringExtra(SmsCodeRetriever.EXTRA_SMS_CODE)
val fillInDataset = val fillInDataset = AutofillResponseBuilder.makeFillInDataset(
FillableForm.makeFillInDataset( this@AutofillSmsActivity,
this@AutofillSmsActivity, Credentials(null, null, smsCode),
Credentials(null, null, smsCode), clientState,
clientState, AutofillAction.FillOtpFromSms
AutofillAction.FillOtpFromSms )
)
setResult(RESULT_OK, Intent().apply { setResult(RESULT_OK, Intent().apply {
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
}) })

1
autofill-parser/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,19 @@
plugins {
kotlin("android")
}
android {
defaultConfig {
versionCode = 1
versionName = "1.0"
consumerProguardFiles("consumer-rules.pro")
}
}
dependencies {
implementation(Dependencies.AndroidX.core_ktx)
implementation(Dependencies.AndroidX.autofill)
implementation(Dependencies.Kotlin.Coroutines.android)
implementation(Dependencies.Kotlin.Coroutines.core)
implementation(Dependencies.ThirdParty.timberkt)
}

View file

21
autofill-parser/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
~ SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
-->
<manifest package="com.github.androidpasswordstore.autofillparser">
</manifest>

View file

@ -0,0 +1,217 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
*/
package com.github.androidpasswordstore.autofillparser
import android.app.assist.AssistStructure
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillId
import androidx.annotation.RequiresApi
import androidx.core.os.bundleOf
import com.github.ajalt.timberkt.d
/**
* A unique identifier for either an Android app (package name) or a website (origin minus port).
*/
sealed class FormOrigin(open val identifier: String) {
data class Web(override val identifier: String) : FormOrigin(identifier)
data class App(override val identifier: String) : FormOrigin(identifier)
companion object {
private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier"
private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier"
fun fromBundle(bundle: Bundle): FormOrigin? {
val webIdentifier = bundle.getString(BUNDLE_KEY_WEB_IDENTIFIER)
if (webIdentifier != null) {
return Web(webIdentifier)
} else {
return App(bundle.getString(BUNDLE_KEY_APP_IDENTIFIER) ?: return null)
}
}
}
fun getPrettyIdentifier(context: Context, untrusted: Boolean = true) = when (this) {
is Web -> identifier
is App -> {
val info = context.packageManager.getApplicationInfo(
identifier, PackageManager.GET_META_DATA
)
val label = context.packageManager.getApplicationLabel(info)
if (untrusted) "$label" else "$label"
}
}
fun toBundle() = when (this) {
is Web -> bundleOf(BUNDLE_KEY_WEB_IDENTIFIER to identifier)
is App -> bundleOf(BUNDLE_KEY_APP_IDENTIFIER to identifier)
}
}
/**
* Manages the detection of fields to fill in an [AssistStructure] and determines the [FormOrigin].
*/
@RequiresApi(Build.VERSION_CODES.O)
private class AutofillFormParser(
context: Context,
structure: AssistStructure,
isManualRequest: Boolean,
private val customSuffixes: Sequence<String>
) {
companion object {
private val SUPPORTED_SCHEMES = listOf("http", "https")
}
private val relevantFields = mutableListOf<FormField>()
val ignoredIds = mutableListOf<AutofillId>()
private var fieldIndex = 0
private var appPackage = structure.activityComponent.packageName
private val trustedBrowserInfo =
getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
val saveFlags = trustedBrowserInfo?.saveFlags
private val webOrigins = mutableSetOf<String>()
init {
d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
parseStructure(structure)
}
val scenario = detectFieldsToFill(isManualRequest)
val formOrigin = determineFormOrigin(context)
init {
d { "Origin: $formOrigin" }
}
private fun parseStructure(structure: AssistStructure) {
for (i in 0 until structure.windowNodeCount) {
visitFormNode(structure.getWindowNodeAt(i).rootViewNode)
}
}
private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) {
trackOrigin(node)
val field =
if (trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.WebView) {
FormField(node, fieldIndex, true, inheritedWebOrigin)
} else {
check(inheritedWebOrigin == null)
FormField(node, fieldIndex, false)
}
if (field.relevantField) {
d { "Relevant: $field" }
relevantFields.add(field)
fieldIndex++
} else {
d { "Ignored : $field" }
ignoredIds.add(field.autofillId)
}
for (i in 0 until node.childCount) {
visitFormNode(node.getChildAt(i), field.webOriginToPassDown)
}
}
private fun detectFieldsToFill(isManualRequest: Boolean) = autofillStrategy.match(
relevantFields,
singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None,
isManualRequest = isManualRequest
)
private fun trackOrigin(node: AssistStructure.ViewNode) {
if (trustedBrowserInfo == null) return
node.webOrigin?.let {
if (it !in webOrigins) {
d { "Origin encountered: $it" }
webOrigins.add(it)
}
}
}
private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? {
val uri = Uri.parse(origin) ?: return null
val scheme = uri.scheme ?: return null
if (scheme !in SUPPORTED_SCHEMES) return null
val host = uri.host ?: return null
return FormOrigin.Web(getPublicSuffixPlusOne(context, host, customSuffixes))
}
private fun determineFormOrigin(context: Context): FormOrigin? {
if (scenario == null) return null
if (trustedBrowserInfo == null || webOrigins.isEmpty()) {
// Security assumption: If a trusted browser includes no web origin in the provided
// AssistStructure, then the form is a native browser form (e.g. for a sync password).
// TODO: Support WebViews in apps via Digital Asset Links
// See: https://developer.android.com/reference/android/service/autofill/AutofillService#web-security
return FormOrigin.App(appPackage)
}
return when (trustedBrowserInfo.multiOriginMethod) {
BrowserMultiOriginMethod.None -> {
// Security assumption: If a browser is trusted but does not support tracking
// multiple origins, it is expected to annotate a single field, in most cases its
// URL bar, with a webOrigin. We err on the side of caution and only trust the
// reported web origin if no other web origin appears on the page.
webOriginToFormOrigin(context, webOrigins.singleOrNull() ?: return null)
}
BrowserMultiOriginMethod.WebView,
BrowserMultiOriginMethod.Field -> {
// Security assumption: For browsers with full autofill support (the `Field` case),
// every form field is annotated with its origin. For browsers based on WebView,
// this is true after the web origins of WebViews are passed down to their children.
//
// For browsers with the WebView or Field method of multi origin support, we take
// the single origin among the detected fillable or saveable fields. If this origin
// is null, but we encountered web origins elsewhere in the AssistStructure, the
// situation is uncertain and Autofill should not be offered.
webOriginToFormOrigin(
context,
scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null
)
}
}
}
}
data class Credentials(val username: String?, val password: String?, val otp: String?)
/**
* Represents a collection of fields in a specific app that can be filled or saved. This is the
* entry point to all fill and save features.
*/
@RequiresApi(Build.VERSION_CODES.O)
class FillableForm private constructor(
val formOrigin: FormOrigin,
val scenario: AutofillScenario<FormField>,
val ignoredIds: List<AutofillId>,
val saveFlags: Int?
) {
companion object {
/**
* Returns a [FillableForm] if a login form could be detected in [structure].
*/
fun parseAssistStructure(
context: Context,
structure: AssistStructure,
isManualRequest: Boolean,
customSuffixes: Sequence<String> = emptySequence(),
): FillableForm? {
val form = AutofillFormParser(context, structure, isManualRequest, customSuffixes)
if (form.formOrigin == null || form.scenario == null) return null
return FillableForm(form.formOrigin, form.scenario, form.ignoredIds, form.saveFlags)
}
}
fun toClientState() = scenario.toBundle().apply {
putAll(formOrigin.toBundle())
}
}

View file

@ -1,8 +1,8 @@
/* /*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
*/ */
package com.zeapo.pwdstore.autofill.oreo package com.github.androidpasswordstore.autofillparser
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.assist.AssistStructure import android.app.assist.AssistStructure
@ -13,18 +13,10 @@ import android.os.Build
import android.service.autofill.SaveCallback import android.service.autofill.SaveCallback
import android.util.Base64 import android.util.Base64
import android.view.autofill.AutofillId import android.view.autofill.AutofillId
import android.widget.RemoteViews
import android.widget.Toast import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.Timber.tag
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.utils.PasswordRepository
import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.getString
import com.zeapo.pwdstore.utils.sharedPrefs
import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
private fun ByteArray.sha256(): ByteArray { private fun ByteArray.sha256(): ByteArray {
@ -38,8 +30,6 @@ private fun ByteArray.base64(): String {
return Base64.encodeToString(this, Base64.NO_WRAP) return Base64.encodeToString(this, Base64.NO_WRAP)
} }
private fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
private fun stableHash(array: Collection<ByteArray>): String { private fun stableHash(array: Collection<ByteArray>): String {
val hashes = array.map { it.sha256().base64() } val hashes = array.map { it.sha256().base64() }
return hashes.sorted().joinToString(separator = ";") return hashes.sorted().joinToString(separator = ";")
@ -84,79 +74,6 @@ val AssistStructure.ViewNode.webOrigin: String?
"$scheme://$domain" "$scheme://$domain"
} }
data class Credentials(val username: String?, val password: String?, val otp: String?) {
companion object {
fun fromStoreEntry(
context: Context,
file: File,
entry: PasswordEntry,
directoryStructure: DirectoryStructure
): Credentials {
// Always give priority to a username stored in the encrypted extras
val username = entry.username
?: directoryStructure.getUsernameFor(file)
?: context.getDefaultUsername()
return Credentials(username, entry.password, entry.calculateTotpCode())
}
}
}
private fun makeRemoteView(
context: Context,
title: String,
summary: String,
iconRes: Int
): RemoteViews {
return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
setTextViewText(R.id.title, title)
setTextViewText(R.id.summary, summary)
setImageViewResource(R.id.icon, iconRes)
}
}
fun makeFillMatchRemoteView(context: Context, file: File, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = false)
val directoryStructure = AutofillPreferences.directoryStructure(context)
val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
val summary = directoryStructure.getUsernameFor(relativeFile)
?: directoryStructure.getPathToIdentifierFor(relativeFile) ?: ""
val iconRes = R.drawable.ic_person_black_24dp
return makeRemoteView(context, title, summary, iconRes)
}
fun makeSearchAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
val summary = context.getString(R.string.oreo_autofill_search_in_store)
val iconRes = R.drawable.ic_search_black_24dp
return makeRemoteView(context, title, summary, iconRes)
}
fun makeGenerateAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
val summary = context.getString(R.string.oreo_autofill_generate_password)
val iconRes = R.drawable.ic_autofill_new_password
return makeRemoteView(context, title, summary, iconRes)
}
fun makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms)
val iconRes = R.drawable.ic_autofill_sms
return makeRemoteView(context, title, summary, iconRes)
}
fun makePlaceholderRemoteView(context: Context): RemoteViews {
return makeRemoteView(context, "PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
}
fun makeWarningRemoteView(context: Context): RemoteViews {
val title = context.getString(R.string.oreo_autofill_warning_publisher_dataset_title)
val summary = context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary)
val iconRes = R.drawable.ic_warning_red_24dp
return makeRemoteView(context, title, summary, iconRes)
}
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
class FixedSaveCallback(context: Context, private val callback: SaveCallback) { class FixedSaveCallback(context: Context, private val callback: SaveCallback) {

View file

@ -1,8 +1,8 @@
/* /*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
*/ */
package com.zeapo.pwdstore.autofill.oreo package com.github.androidpasswordstore.autofillparser
import android.app.assist.AssistStructure import android.app.assist.AssistStructure
import android.os.Build import android.os.Build
@ -12,8 +12,6 @@ import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue import android.view.autofill.AutofillValue
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
enum class AutofillAction { enum class AutofillAction {
Match, Search, Generate, FillOtpFromSms Match, Search, Generate, FillOtpFromSms
@ -38,7 +36,7 @@ sealed class AutofillScenario<out T : Any> {
const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds" const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds"
fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? { fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? {
return runCatching { return try {
Builder<AutofillId>().apply { Builder<AutofillId>().apply {
username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID) username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID)
fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME) fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME)
@ -59,7 +57,7 @@ sealed class AutofillScenario<out T : Any> {
) ?: emptyList() ) ?: emptyList()
) )
}.build() }.build()
}.getOrElse { e -> } catch(e: Throwable) {
e(e) e(e)
null null
} }

View file

@ -1,13 +1,13 @@
/* /*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
*/ */
package com.zeapo.pwdstore.autofill.oreo package com.github.androidpasswordstore.autofillparser
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.zeapo.pwdstore.autofill.oreo.CertaintyLevel.Certain import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Certain
import com.zeapo.pwdstore.autofill.oreo.CertaintyLevel.Likely import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Likely
private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) = private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) =
predicate(first) && predicate(second) predicate(first) && predicate(second)

View file

@ -1,8 +1,8 @@
/* /*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
*/ */
package com.zeapo.pwdstore.autofill.oreo package com.github.androidpasswordstore.autofillparser
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi

View file

@ -1,8 +1,8 @@
/* /*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
*/ */
package com.zeapo.pwdstore.autofill.oreo package com.github.androidpasswordstore.autofillparser
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent

View file

@ -1,8 +1,8 @@
/* /*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
*/ */
package com.zeapo.pwdstore.autofill.oreo package com.github.androidpasswordstore.autofillparser
import android.app.assist.AssistStructure import android.app.assist.AssistStructure
import android.os.Build import android.os.Build

View file

@ -1,14 +1,11 @@
/* /*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
*/ */
package com.zeapo.pwdstore.autofill.oreo package com.github.androidpasswordstore.autofillparser
import android.content.Context import android.content.Context
import android.util.Patterns import android.util.Patterns
import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.getString
import com.zeapo.pwdstore.utils.sharedPrefs
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import mozilla.components.lib.publicsuffixlist.PublicSuffixList import mozilla.components.lib.publicsuffixlist.PublicSuffixList
@ -38,7 +35,7 @@ fun cachePublicSuffixList(context: Context) {
* Note: Invalid domains, such as IP addresses, are returned unchanged and thus never collide with * Note: Invalid domains, such as IP addresses, are returned unchanged and thus never collide with
* the return value for valid domains. * the return value for valid domains.
*/ */
fun getPublicSuffixPlusOne(context: Context, domain: String) = runBlocking { fun getPublicSuffixPlusOne(context: Context, domain: String, customSuffixes: Sequence<String>) = runBlocking {
// We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne. // We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne.
// We do not check whether the domain actually exists (actually, not even whether its TLD // We do not check whether the domain actually exists (actually, not even whether its TLD
// exists). As long as we restrict ourselves to syntactically valid domain names, // exists). As long as we restrict ourselves to syntactically valid domain names,
@ -48,7 +45,7 @@ fun getPublicSuffixPlusOne(context: Context, domain: String) = runBlocking {
) { ) {
domain domain
} else { } else {
getCanonicalSuffix(context, domain) getCanonicalSuffix(context, domain, customSuffixes)
} }
} }
@ -68,19 +65,12 @@ fun getSuffixPlusUpToOne(domain: String, suffix: String): String? {
return "$lastPrefixPart.$suffix" return "$lastPrefixPart.$suffix"
} }
fun getCustomSuffixes(context: Context): Sequence<String> { suspend fun getCanonicalSuffix(context: Context, domain: String, customSuffixes: Sequence<String>): String {
return context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
?.splitToSequence('\n')
?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
?: emptySequence()
}
suspend fun getCanonicalSuffix(context: Context, domain: String): String {
val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context) val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context)
val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await() val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await()
?: return domain ?: return domain
var longestSuffix = publicSuffixPlusOne var longestSuffix = publicSuffixPlusOne
for (customSuffix in getCustomSuffixes(context)) { for (customSuffix in customSuffixes) {
val suffixPlusUpToOne = getSuffixPlusUpToOne(domain, customSuffix) ?: continue val suffixPlusUpToOne = getSuffixPlusUpToOne(domain, customSuffix) ?: continue
// A shorter suffix is automatically a substring. // A shorter suffix is automatically a substring.
if (suffixPlusUpToOne.length > longestSuffix.length) if (suffixPlusUpToOne.length > longestSuffix.length)

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0
*/ */
/* This Source Code Form is subject to the terms of the Mozilla Public /* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0
*/ */
/* This Source Code Form is subject to the terms of the Mozilla Public /* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0
*/ */
/* This Source Code Form is subject to the terms of the Mozilla Public /* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0
*/ */
/* This Source Code Form is subject to the terms of the Mozilla Public /* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this

View file

@ -49,6 +49,7 @@ object Dependencies {
const val openpgp_ktx = "com.github.android-password-store:openpgp-ktx:2.1.0" const val openpgp_ktx = "com.github.android-password-store:openpgp-ktx:2.1.0"
const val zxing_android_embedded = "com.github.android-password-store:zxing-android-embedded:4.1.0-aps" const val zxing_android_embedded = "com.github.android-password-store:zxing-android-embedded:4.1.0-aps"
const val autofill_parser = ":autofill-parser"
} }
object ThirdParty { object ThirdParty {

View file

@ -2,4 +2,5 @@
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: GPL-3.0-only
*/ */
include(":autofill-parser")
include(":app") include(":app")