/* * 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, ) { companion object { private val SUPPORTED_SCHEMES = listOf("http", "https") } private val relevantFields = mutableListOf() val ignoredIds = mutableListOf() private var fieldIndex = 0 private var appPackage = structure.activityComponent.packageName private val trustedBrowserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage) val saveFlags = trustedBrowserInfo?.saveFlags private val webOrigins = mutableSetOf() 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, public val ignoredIds: List, 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 = 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()) } }