/* * Copyright 2019 Jeremy Jamet / Kunzisoft. * * This file is part of KeePassDX. * * KeePassDX is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * KeePassDX is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with KeePassDX. If not, see . */ package com.kunzisoft.keepass.autofill import android.app.assist.AssistStructure import android.os.Build import android.text.InputType import android.util.Log import android.view.View import android.view.autofill.AutofillId import android.view.autofill.AutofillValue import androidx.annotation.RequiresApi import java.util.* import kotlin.collections.ArrayList /** * Parse AssistStructure and guess username and password fields. */ @RequiresApi(api = Build.VERSION_CODES.O) class StructureParser(private val structure: AssistStructure) { private var result: Result? = null private var usernameIdCandidate: AutofillId? = null private var usernameValueCandidate: AutofillValue? = null fun parse(saveValue: Boolean = false): Result? { try { result = Result() result?.apply { allowSaveValues = saveValue usernameIdCandidate = null usernameValueCandidate = null mainLoop@ for (i in 0 until structure.windowNodeCount) { val windowNode = structure.getWindowNodeAt(i) applicationId = windowNode.title.toString().split("/")[0] Log.d(TAG, "Autofill applicationId: $applicationId") if (parseViewNode(windowNode.rootViewNode)) break@mainLoop } // If not explicit username field found, add the field just before password field. if (usernameId == null && passwordId != null && usernameIdCandidate != null) { usernameId = usernameIdCandidate if (allowSaveValues) { usernameValue = usernameValueCandidate } } } return if (result?.passwordId != null || result?.ccnId != null) result else null } catch (e: Exception) { return null } } private fun parseViewNode(node: AssistStructure.ViewNode): Boolean { // remember this if (node.className == "android.webkit.WebView") { result?.isWebView = true } // Get the domain of a web app node.webDomain?.let { webDomain -> if (webDomain.isNotEmpty()) { result?.webDomain = webDomain Log.d(TAG, "Autofill domain: $webDomain") } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { node.webScheme?.let { webScheme -> if (webScheme.isNotEmpty()) { result?.webScheme = webScheme Log.d(TAG, "Autofill scheme: $webScheme") } } } val domainNotEmpty = result?.webDomain?.isNotEmpty() == true var returnValue = false // Only parse visible nodes if (node.visibility == View.VISIBLE) { if (node.autofillId != null) { // Parse methods val hints = node.autofillHints if (hints != null && hints.isNotEmpty()) { if (parseNodeByAutofillHint(node)) returnValue = true } else if (parseNodeByHtmlAttributes(node)) returnValue = true else if (parseNodeByAndroidInput(node)) returnValue = true } // Optimized return but only if domain not empty if (domainNotEmpty && returnValue) return true // Recursive method to process each node for (i in 0 until node.childCount) { if (parseViewNode(node.getChildAt(i))) returnValue = true if (domainNotEmpty && returnValue) return true } } return returnValue } private fun parseNodeByAutofillHint(node: AssistStructure.ViewNode): Boolean { val autofillId = node.autofillId node.autofillHints?.forEach { when { it.contains(View.AUTOFILL_HINT_USERNAME, true) || it.contains(View.AUTOFILL_HINT_EMAIL_ADDRESS, true) || it.contains("email", true) || it.contains(View.AUTOFILL_HINT_PHONE, true) -> { result?.usernameId = autofillId result?.usernameValue = node.autofillValue Log.d(TAG, "Autofill username hint") } it.contains(View.AUTOFILL_HINT_PASSWORD, true) -> { result?.passwordId = autofillId result?.passwordValue = node.autofillValue Log.d(TAG, "Autofill password hint") return true } it == "cc-name" -> { Log.d(TAG, "AUTOFILL cc-name hint") result?.ccNameId = autofillId result?.ccName = node.autofillValue?.textValue?.toString() } it == View.AUTOFILL_HINT_CREDIT_CARD_NUMBER || it == "cc-number" -> { Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_NUMBER hint") result?.ccnId = autofillId result?.ccNumber = node.autofillValue?.textValue?.toString() } // expect date string as defined in https://html.spec.whatwg.org, e.g. 2014-12 it == "cc-exp" -> { Log.d(TAG, "AUTOFILL cc-exp hint") result?.ccExpDateId = autofillId node.autofillValue?.let { value -> if (value.isText && value.textValue.length == 7) { value.textValue.let { date -> result?.ccExpirationValue = date.substring(5, 7) + date.substring(2, 4) } } } } it == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE -> { Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE hint") result?.ccExpDateId = autofillId node.autofillValue?.let { value -> if (value.isDate) { val calendar = Calendar.getInstance() calendar.clear() calendar.timeInMillis = value.dateValue val year = calendar.get(Calendar.YEAR).toString().substring(2,4) val month = calendar.get(Calendar.MONTH).inc().toString().padStart(2, '0') result?.ccExpirationValue = month + year } } } it == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR || it == "cc-exp-year" -> { Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR hint") result?.ccExpDateYearId = autofillId if (node.autofillOptions != null) { result?.ccExpYearOptions = node.autofillOptions } node.autofillValue?.let { value -> var year = 0 try { if (value.isText) { year = value.textValue.toString().toInt() } if (value.isList) { year = node.autofillOptions?.get(value.listValue).toString().toInt() } } catch (e: Exception) { year = 0 } result?.ccExpDateYearValue = year % 100 } } it == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH || it == "cc-exp-month" -> { Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH hint") result?.ccExpDateMonthId = autofillId if (node.autofillOptions != null) { result?.ccExpMonthOptions = node.autofillOptions } node.autofillValue?.let { value -> var month = 0 if (value.isText) { try { month = value.textValue.toString().toInt() } catch (e: Exception) { month = 0 } } if (value.isList) { // assume list starts with January (index 0) month = value.listValue + 1 } result?.ccExpDateMonthValue = month } } it == View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE || it == "cc-csc" -> { Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE hint") result?.cvvId = autofillId result?.cvv = node.autofillValue?.textValue?.toString() } // Ignore autocomplete="off" // https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion it.equals("off", true) || it.equals("on", true) -> { Log.d(TAG, "Autofill web hint") return parseNodeByHtmlAttributes(node) } else -> Log.d(TAG, "Autofill unsupported hint $it") } } return false } private fun parseNodeByHtmlAttributes(node: AssistStructure.ViewNode): Boolean { val autofillId = node.autofillId val nodHtml = node.htmlInfo when (nodHtml?.tag?.toLowerCase(Locale.ENGLISH)) { "input" -> { nodHtml.attributes?.forEach { pairAttribute -> when (pairAttribute.first.toLowerCase(Locale.ENGLISH)) { "type" -> { when (pairAttribute.second.toLowerCase(Locale.ENGLISH)) { "tel", "email" -> { result?.usernameId = autofillId result?.usernameValue = node.autofillValue Log.d(TAG, "Autofill username web type: ${node.htmlInfo?.tag} ${node.htmlInfo?.attributes}") } "text" -> { usernameIdCandidate = autofillId usernameValueCandidate = node.autofillValue Log.d(TAG, "Autofill username candidate web type: ${node.htmlInfo?.tag} ${node.htmlInfo?.attributes}") } "password" -> { result?.passwordId = autofillId result?.passwordValue = node.autofillValue Log.d(TAG, "Autofill password web type: ${node.htmlInfo?.tag} ${node.htmlInfo?.attributes}") return true } } } } } } } return false } private fun inputIsVariationType(inputType: Int, vararg type: Int): Boolean { type.forEach { if (inputType and InputType.TYPE_MASK_VARIATION == it) return true } return false } private fun showHexInputType(inputType: Int): String { return "0x${"%08x".format(inputType)}" } private fun parseNodeByAndroidInput(node: AssistStructure.ViewNode): Boolean { val autofillId = node.autofillId val inputType = node.inputType when (inputType and InputType.TYPE_MASK_CLASS) { InputType.TYPE_CLASS_TEXT -> { when { inputIsVariationType(inputType, InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS) -> { result?.usernameId = autofillId result?.usernameValue = node.autofillValue Log.d(TAG, "Autofill username android text type: ${showHexInputType(inputType)}") } inputIsVariationType(inputType, InputType.TYPE_TEXT_VARIATION_NORMAL, InputType.TYPE_TEXT_VARIATION_PERSON_NAME, InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT) -> { usernameIdCandidate = autofillId usernameValueCandidate = node.autofillValue Log.d(TAG, "Autofill username candidate android text type: ${showHexInputType(inputType)}") } inputIsVariationType(inputType, InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) -> { // Some forms used visible password as username if (usernameCandidate == null && usernameValueCandidate == null) { usernameCandidate = autofillId usernameValueCandidate = node.autofillValue Log.d(TAG, "Autofill visible password android text type (as username): ${showHexInputType(inputType)}") } else if (result?.passwordId == null && result?.passwordValue == null) { result?.passwordId = autofillId result?.passwordValue = node.autofillValue Log.d(TAG, "Autofill visible password android text type (as password): ${showHexInputType(inputType)}") usernameNeeded = false } } inputIsVariationType(inputType, InputType.TYPE_TEXT_VARIATION_PASSWORD, InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) -> { result?.passwordId = autofillId result?.passwordValue = node.autofillValue Log.d(TAG, "Autofill password android text type: ${showHexInputType(inputType)}") return true } inputIsVariationType(inputType, InputType.TYPE_TEXT_VARIATION_EMAIL_SUBJECT, InputType.TYPE_TEXT_VARIATION_FILTER, InputType.TYPE_TEXT_VARIATION_LONG_MESSAGE, InputType.TYPE_TEXT_VARIATION_PHONETIC, InputType.TYPE_TEXT_VARIATION_POSTAL_ADDRESS, InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE, InputType.TYPE_TEXT_VARIATION_URI) -> { // Type not used } else -> { Log.d(TAG, "Autofill unknown android text type: ${showHexInputType(inputType)}") } } } InputType.TYPE_CLASS_NUMBER -> { when { inputIsVariationType(inputType, InputType.TYPE_NUMBER_VARIATION_NORMAL) -> { usernameIdCandidate = autofillId usernameValueCandidate = node.autofillValue Log.d(TAG, "Autofill username candidate android number type: ${showHexInputType(inputType)}") } inputIsVariationType(inputType, InputType.TYPE_NUMBER_VARIATION_PASSWORD) -> { result?.passwordId = autofillId result?.passwordValue = node.autofillValue Log.d(TAG, "Autofill password android number type: ${showHexInputType(inputType)}") return true } else -> { Log.d(TAG, "Autofill unknown android number type: ${showHexInputType(inputType)}") } } } } return false } @RequiresApi(api = Build.VERSION_CODES.O) class Result { var isWebView: Boolean = false var applicationId: String? = null var webDomain: String? = null set(value) { if (field == null) field = value } var webScheme: String? = null set(value) { if (field == null) field = value } // if the user selects the credit card expiration date from a list of options // all options are stored here var ccExpMonthOptions: Array? = null var ccExpYearOptions: Array? = null var usernameId: AutofillId? = null set(value) { if (field == null) field = value } var passwordId: AutofillId? = null set(value) { if (field == null) field = value } var ccNameId: AutofillId? = null set(value) { if (field == null) field = value } var ccnId: AutofillId? = null set(value) { if (field == null) field = value } var ccExpDateId: AutofillId? = null set(value) { if (field == null) field = value } var ccExpDateYearId: AutofillId? = null set(value) { if (field == null) field = value } var ccExpDateMonthId: AutofillId? = null set(value) { if (field == null) field = value } var cvvId: AutofillId? = null set(value) { if (field == null) field = value } fun allAutofillIds(): Array { val all = ArrayList() usernameId?.let { all.add(it) } passwordId?.let { all.add(it) } ccNameId?.let { all.add(it) } ccnId?.let { all.add(it) } cvvId?.let { all.add(it) } return all.toTypedArray() } // Only in registration mode var allowSaveValues = false var usernameValue: AutofillValue? = null set(value) { if (allowSaveValues && field == null) field = value } var passwordValue: AutofillValue? = null set(value) { if (allowSaveValues && field == null) field = value } var ccName: String? = null set(value) { if (allowSaveValues) field = value } var ccNumber: String? = null set(value) { if (allowSaveValues) field = value } // format MMYY var ccExpirationValue: String? = null set(value) { if (allowSaveValues) field = value } // for year of CC expiration date: YY var ccExpDateYearValue = 0 set(value) { if (allowSaveValues) field = value } // for month of CC expiration date: MM var ccExpDateMonthValue = 0 set(value) { if (allowSaveValues) field = value } // the security code for the credit card (also called CVV) var cvv: String? = null set(value) { if (allowSaveValues) field = value } } companion object { private val TAG = StructureParser::class.java.name } }