1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
|
/*
* Copyright © 2014-2024 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.content.pm.PackageManager.ApplicationInfoFlags
import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillId
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import logcat.logcat
/**
* A unique identifier for either an Android app (package name) or a website (origin minus port).
*/
public sealed class FormOrigin(public open val identifier: String) {
public data class Web(override val identifier: String) : FormOrigin(identifier)
public data class App(override val identifier: String) : FormOrigin(identifier)
public companion object {
private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier"
private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier"
public 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)
}
}
}
public fun getPrettyIdentifier(context: Context, untrusted: Boolean = true): String =
when (this) {
is Web -> identifier
is App -> {
val info =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.getApplicationInfo(
identifier,
ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()),
)
} else {
context.packageManager.getApplicationInfo(identifier, PackageManager.GET_META_DATA)
}
val label = context.packageManager.getApplicationLabel(info)
if (untrusted) "“$label”" else "$label"
}
}
public fun toBundle(): Bundle =
Bundle().apply {
when (this@FormOrigin) {
is Web -> putString(BUNDLE_KEY_WEB_IDENTIFIER, identifier)
is App -> putString(BUNDLE_KEY_APP_IDENTIFIER, 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 {
logcat { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
parseStructure(structure)
}
val scenario = detectFieldsToFill(isManualRequest)
val formOrigin = determineFormOrigin(context)
init {
logcat { "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) { "'inheritedWebOrigin' should be null here" }
FormField(node, fieldIndex, false)
}
if (field.relevantField) {
logcat { "Relevant: $field" }
relevantFields.add(field)
fieldIndex++
} else {
logcat { "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) {
logcat { "Origin encountered: $it" }
webOrigins.add(it)
}
}
}
private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? {
val uri = origin.toUri()
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,
)
}
}
}
}
public 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)
public class FillableForm
private constructor(
public val formOrigin: FormOrigin,
public val scenario: AutofillScenario<AutofillId>,
public val ignoredIds: List<AutofillId>,
public val saveFlags: Int?,
) {
public companion object {
/** Returns a [FillableForm] if a login form could be detected in [structure]. */
public 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.map { it.autofillId },
form.ignoredIds,
form.saveFlags,
)
}
}
public fun toClientState(): Bundle = scenario.toBundle().apply { putAll(formOrigin.toBundle()) }
}
|