Skip to content
This repository has been archived by the owner on Oct 15, 2024. It is now read-only.

Offer TOTP Autofill for OTP fields #899

Merged
merged 1 commit into from
Jun 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
### Added

- TOTP support is reintroduced by popular demand. HOTP continues to be unsupported and heavily discouraged.
- Initial support for detecting and filling OTP fields with Autofill

## [1.9.1] - 2020-06-28

Expand Down
3 changes: 2 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ android {
}

dependencies {
implementation deps.androidx.annotation
implementation deps.androidx.activity_ktx
implementation deps.androidx.annotation
implementation deps.androidx.autofill
implementation deps.androidx.appcompat
implementation deps.androidx.biometric
implementation deps.androidx.constraint_layout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ val AssistStructure.ViewNode.webOrigin: String?
"$scheme://$domain"
}

data class Credentials(val username: String?, val password: String) {
data class Credentials(val username: String?, val password: String, val otp: String?) {
companion object {
fun fromStoreEntry(
context: Context,
Expand All @@ -98,7 +98,7 @@ data class Credentials(val username: String?, val password: String) {
val username = entry.username
?: directoryStructure.getUsernameFor(file)
?: context.getDefaultUsername()
return Credentials(username, entry.password)
return Credentials(username, entry.password, entry.calculateTotpCode())
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ sealed class AutofillScenario<out T : Any> {
companion object {
const val BUNDLE_KEY_USERNAME_ID = "usernameId"
const val BUNDLE_KEY_FILL_USERNAME = "fillUsername"
const val BUNDLE_KEY_OTP_ID = "otpId"
const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds"
const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds"
const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds"
Expand All @@ -38,6 +39,7 @@ sealed class AutofillScenario<out T : Any> {
Builder<AutofillId>().apply {
username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID)
fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME)
otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID)
currentPassword.addAll(
clientState.getParcelableArrayList(
BUNDLE_KEY_CURRENT_PASSWORD_IDS
Expand All @@ -64,6 +66,7 @@ sealed class AutofillScenario<out T : Any> {
class Builder<T : Any> {
var username: T? = null
var fillUsername = false
var otp: T? = null
val currentPassword = mutableListOf<T>()
val newPassword = mutableListOf<T>()
val genericPassword = mutableListOf<T>()
Expand All @@ -74,13 +77,15 @@ sealed class AutofillScenario<out T : Any> {
ClassifiedAutofillScenario(
username = username,
fillUsername = fillUsername,
otp = otp,
currentPassword = currentPassword,
newPassword = newPassword
)
} else {
GenericAutofillScenario(
username = username,
fillUsername = fillUsername,
otp = otp,
genericPassword = genericPassword
)
}
Expand All @@ -89,6 +94,7 @@ sealed class AutofillScenario<out T : Any> {

abstract val username: T?
abstract val fillUsername: Boolean
abstract val otp: T?
abstract val allPasswordFields: List<T>
abstract val passwordFieldsToFillOnMatch: List<T>
abstract val passwordFieldsToFillOnSearch: List<T>
Expand All @@ -99,19 +105,19 @@ sealed class AutofillScenario<out T : Any> {
get() = listOfNotNull(username) + passwordFieldsToSave

val allFields
get() = listOfNotNull(username) + allPasswordFields
get() = listOfNotNull(username, otp) + allPasswordFields

fun fieldsToFillOn(action: AutofillAction): List<T> {
val passwordFieldsToFill = when (action) {
AutofillAction.Match -> passwordFieldsToFillOnMatch
AutofillAction.Search -> passwordFieldsToFillOnSearch
val credentialFieldsToFill = when (action) {
AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp)
AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp)
AutofillAction.Generate -> passwordFieldsToFillOnGenerate
}
return when {
passwordFieldsToFill.isNotEmpty() -> {
credentialFieldsToFill.isNotEmpty() -> {
// If the current action would fill into any password field, we also fill into the
// username field if possible.
listOfNotNull(username.takeIf { fillUsername }) + passwordFieldsToFill
listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill
}
allPasswordFields.isEmpty() && action != AutofillAction.Generate -> {
// If there no password fields at all, we still offer to fill the username, e.g. in
Expand All @@ -127,6 +133,7 @@ sealed class AutofillScenario<out T : Any> {
data class ClassifiedAutofillScenario<T : Any>(
override val username: T?,
override val fillUsername: Boolean,
override val otp: T?,
val currentPassword: List<T>,
val newPassword: List<T>
) : AutofillScenario<T>() {
Expand All @@ -147,6 +154,7 @@ data class ClassifiedAutofillScenario<T : Any>(
data class GenericAutofillScenario<T : Any>(
override val username: T?,
override val fillUsername: Boolean,
override val otp: T?,
val genericPassword: List<T>
) : AutofillScenario<T>() {

Expand Down Expand Up @@ -183,14 +191,15 @@ fun Dataset.Builder.fillWith(
) {
val credentialsToFill = credentials ?: Credentials(
"USERNAME",
"PASSWORD"
"PASSWORD",
"OTP"
)
for (field in scenario.fieldsToFillOn(action)) {
val value = if (field == scenario.username) {
credentialsToFill.username
} else {
credentialsToFill.password
} ?: continue
msfjarvis marked this conversation as resolved.
Show resolved Hide resolved
val value = when (field) {
scenario.username -> credentialsToFill.username
scenario.otp -> credentialsToFill.otp
else -> credentialsToFill.password
}
setValue(field, AutofillValue.forText(value))
}
}
Expand All @@ -209,6 +218,7 @@ inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): Auto
val builder = AutofillScenario.Builder<S>()
builder.username = username?.let(transform)
builder.fillUsername = fillUsername
builder.otp = otp?.let(transform)
when (this) {
is ClassifiedAutofillScenario -> {
builder.currentPassword.addAll(currentPassword.map(transform))
Expand All @@ -225,9 +235,10 @@ inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): Auto
@JvmName("toBundleAutofillId")
private fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) {
is ClassifiedAutofillScenario<AutofillId> -> {
Bundle(4).apply {
Bundle(5).apply {
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
putParcelableArrayList(
AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword)
)
Expand All @@ -237,9 +248,10 @@ private fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) {
}
}
is GenericAutofillScenario<AutofillId> -> {
Bundle(3).apply {
Bundle(4).apply {
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
putParcelableArrayList(
AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ val autofillStrategy = strategy {
}
}

// Match a single focused OTP field.
rule(applyInSingleOriginMode = true) {
otp {
takeSingle { otpCertainty >= Likely && isFocused }
}
}

// Match a single focused username field without a password field.
rule(applyInSingleOriginMode = true) {
username {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ class AutofillRule private constructor(
)

enum class FillableFieldType {
Username, CurrentPassword, NewPassword, GenericPassword,
Username, Otp, CurrentPassword, NewPassword, GenericPassword,
}

@AutofillDsl
Expand Down Expand Up @@ -192,6 +192,18 @@ class AutofillRule private constructor(
)
}

fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.Otp }) { "Every rule block can only have at most one otp block" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.Otp,
matcher = SingleFieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = false
)
)
}

fun currentPassword(optional: Boolean = false, matchHidden: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
matchers.add(
Expand Down Expand Up @@ -247,6 +259,7 @@ class AutofillRule private constructor(
fun match(
allPassword: List<FormField>,
allUsername: List<FormField>,
allOtp: List<FormField>,
singleOriginMode: Boolean,
isManualRequest: Boolean
): AutofillScenario<FormField>? {
Expand All @@ -264,6 +277,7 @@ class AutofillRule private constructor(
for ((type, matcher, optional, matchHidden) in matchers) {
val fieldsToMatchOn = when (type) {
FillableFieldType.Username -> allUsername
FillableFieldType.Otp -> allOtp
else -> allPassword
}.filter { matchHidden || it.isVisible }
val matchResult = matcher.match(fieldsToMatchOn, alreadyMatched) ?: if (optional) {
Expand All @@ -281,6 +295,10 @@ class AutofillRule private constructor(
// Hidden username fields should be saved but not filled.
scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true
}
FillableFieldType.Otp -> {
check(matchResult.size == 1 && scenarioBuilder.otp == null)
scenarioBuilder.otp = matchResult.single()
}
FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll(
matchResult
)
Expand Down Expand Up @@ -338,12 +356,16 @@ class AutofillStrategy private constructor(private val rules: List<AutofillRule>
val possibleUsernameFields =
fields.filter { it.usernameCertainty >= CertaintyLevel.Possible }
d { "Possible username fields: ${possibleUsernameFields.size}" }
val possibleOtpFields =
fields.filter { it.otpCertainty >= CertaintyLevel.Possible }
d { "Possible otp fields: ${possibleOtpFields.size}" }
// Return the result of the first rule that matches
d { "Rules: ${rules.size}" }
for (rule in rules) {
return rule.match(
possiblePasswordFields,
possibleUsernameFields,
possibleOtpFields,
singleOriginMode = singleOriginMode,
isManualRequest = isManualRequest
)
Expand Down
43 changes: 34 additions & 9 deletions app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.text.InputType
import android.view.View
import android.view.autofill.AutofillId
import androidx.annotation.RequiresApi
import androidx.autofill.HintConstants
import java.util.Locale

enum class CertaintyLevel {
Expand All @@ -31,14 +32,21 @@ class FormField(
companion object {

@RequiresApi(Build.VERSION_CODES.O)
private val HINTS_USERNAME = listOf(View.AUTOFILL_HINT_USERNAME)
private val HINTS_USERNAME = listOf(HintConstants.AUTOFILL_HINT_USERNAME)

@RequiresApi(Build.VERSION_CODES.O)
private val HINTS_PASSWORD = listOf(View.AUTOFILL_HINT_PASSWORD)
private val HINTS_PASSWORD = listOf(HintConstants.AUTOFILL_HINT_PASSWORD)

@RequiresApi(Build.VERSION_CODES.O)
private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + listOf(
View.AUTOFILL_HINT_EMAIL_ADDRESS, View.AUTOFILL_HINT_NAME, View.AUTOFILL_HINT_PHONE
private val HINTS_OTP = listOf(HintConstants.AUTOFILL_HINT_SMS_OTP)

@RequiresApi(Build.VERSION_CODES.O)
private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf(
HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS,
HintConstants.AUTOFILL_HINT_NAME,
HintConstants.AUTOFILL_HINT_PERSON_NAME,
HintConstants.AUTOFILL_HINT_PHONE,
HintConstants.AUTOFILL_HINT_PHONE_NUMBER
)

private val ANDROID_TEXT_FIELD_CLASS_NAMES = listOf(
Expand Down Expand Up @@ -67,11 +75,12 @@ class FormField(

private val HTML_INPUT_FIELD_TYPES_USERNAME = listOf("email", "tel", "text")
private val HTML_INPUT_FIELD_TYPES_PASSWORD = listOf("password")
private val HTML_INPUT_FIELD_TYPES_OTP = listOf("tel", "text")
private val HTML_INPUT_FIELD_TYPES_FILLABLE =
HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD
(HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList()

@RequiresApi(Build.VERSION_CODES.O)
private fun isSupportedHint(hint: String) = hint in HINTS_USERNAME + HINTS_PASSWORD
private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE

private val EXCLUDED_TERMS = listOf(
"url_bar", // Chrome/Edge/Firefox address bar
Expand All @@ -85,6 +94,9 @@ class FormField(
private val USERNAME_HEURISTIC_TERMS = listOf(
"alias", "e-mail", "email", "login", "user"
)
private val OTP_HEURISTIC_TERMS = listOf(
"code", "otp"
)
}

val autofillId: AutofillId = node.autofillId!!
Expand Down Expand Up @@ -120,6 +132,7 @@ class FormField(
htmlAttributes.entries.joinToString { "${it.key}=${it.value}" }
private val htmlInputType = htmlAttributes["type"]
private val htmlName = htmlAttributes["name"] ?: ""
private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull()
private val isHtmlField = htmlTag == "input"
private val isHtmlPasswordField =
isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD
Expand All @@ -140,6 +153,7 @@ class FormField(
if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty()
private val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty()
private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty()
private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty()

// W3C autocomplete hint detection for HTML fields
private val htmlAutocomplete = htmlAttributes["autocomplete"]
Expand All @@ -151,6 +165,7 @@ class FormField(
val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
private val hasAutocompleteHintPassword =
hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code"

// Basic autofill exclusion checks
private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT
Expand Down Expand Up @@ -193,8 +208,18 @@ class FormField(
val passwordCertainty =
if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible

// OTP field heuristics (based only on the current field)
private val isPossibleOtpField = notExcluded && !isPossiblePasswordField && isTextField
private val isCertainOtpField =
isPossibleOtpField && (hasAutofillHintOtp || hasAutocompleteHintOtp || htmlMaxLength in 6..8)
private val isLikelyOtpField = isPossibleOtpField && (isCertainOtpField || OTP_HEURISTIC_TERMS.any {
fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
})
val otpCertainty =
if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible

// Username field heuristics (based only on the current field)
private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField
private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField && isTextField
private val isCertainUsernameField =
isPossibleUsernameField && (hasAutofillHintUsername || hasAutocompleteHintUsername)
private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.any {
Expand Down Expand Up @@ -224,8 +249,8 @@ class FormField(
override fun toString(): String {
val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className
val description =
"\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug"
return "$field ($description): password=$passwordCertainty, username=$usernameCertainty"
"\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug, $autofillHints"
return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty"
}

override fun equals(other: Any?): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ class OreoAutofillService : AutofillService() {
callback.onSuccess(
AutofillSaveActivity.makeSaveIntentSender(
this,
credentials = Credentials(username, password),
credentials = Credentials(username, password, null),
formOrigin = formOrigin
)
)
Expand Down
Loading