aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
blob: 955aa0470756520c548c632dad25a8067b764980 (plain)
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
/*
 * 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.ajalt.timberkt.Timber.e
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.w
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

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): 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.
                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
         * [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) {
                        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(deletePathList)
                            .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) }
                }
            }
        }
    }
}