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
|
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package dev.msfjarvis.aps.util.autofill
import android.content.Context
import android.content.SharedPreferences
import android.widget.Toast
import androidx.core.content.edit
import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import dev.msfjarvis.aps.R
import java.io.File
import logcat.LogPriority.ERROR
import logcat.LogPriority.WARN
import logcat.logcat
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) {
logcat(ERROR) { "$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
): Result<List<File>, AutofillPublisherChangedException> {
if (hasFormOriginHashChanged(context, formOrigin)) {
return Err(AutofillPublisherChangedException(formOrigin))
}
val matchPreferences = context.matchPreferences(formOrigin)
val matchedFiles =
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
return Ok(
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.
logcat(ERROR) { "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)
logcat { "Stored match for $formOrigin" }
}
/**
* Goes through all existing matches and updates their associated entries by using [moveFromTo]
* as a lookup table and deleting the matches for files in [delete].
*/
fun updateMatches(
context: Context,
moveFromTo: Map<File, File> = emptyMap(),
delete: Collection<File> = emptyList()
) {
val deletePathList = delete.map { it.absolutePath }
val oldNewPathMap =
moveFromTo.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
// We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
// created with `putStringSet`.
@Suppress("UNCHECKED_CAST") val oldMatches = value as? Set<String>
if (oldMatches == null) {
logcat(WARN) { "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(deletePathList)
.minus(oldNewPathMap.values)
.map { match ->
val newPath = oldNewPathMap[match] ?: return@map match
logcat { "Updating match for $key: $match --> $newPath" }
newPath
}
.toSet()
if (newMatches != oldMatches) prefs.edit { putStringSet(key, newMatches) }
}
}
}
}
}
|