Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 7dd4037f authored by Joe Bolinger's avatar Joe Bolinger
Browse files

Refactor biometric prompt credential screens - domain layer

Step 2/3, add domain layer for business logic wrapping credential verification.

Bug: 251476085
Test: atest CredentialInteractorImplTest PromptCredentialInteractorTest BiometricPromptRequestTest
Test: manual (use test app and verify credential screens)
Change-Id: Ia26a19be35d466390aaaa7144203393a8197dc14
parent edefc7a7
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -18,6 +18,8 @@ package com.android.systemui.biometrics.dagger

import com.android.systemui.biometrics.data.repository.PromptRepository
import com.android.systemui.biometrics.data.repository.PromptRepositoryImpl
import com.android.systemui.biometrics.domain.interactor.CredentialInteractor
import com.android.systemui.biometrics.domain.interactor.CredentialInteractorImpl
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.util.concurrency.ThreadFactory
import dagger.Binds
@@ -34,6 +36,10 @@ interface BiometricsModule {
    @SysUISingleton
    fun biometricPromptRepository(impl: PromptRepositoryImpl): PromptRepository

    @Binds
    @SysUISingleton
    fun providesCredentialInteractor(impl: CredentialInteractorImpl): CredentialInteractor

    companion object {
        /** Background [Executor] for HAL related operations. */
        @Provides
+282 −0
Original line number Diff line number Diff line
package com.android.systemui.biometrics.domain.interactor

import android.app.admin.DevicePolicyManager
import android.app.admin.DevicePolicyResources
import android.content.Context
import android.os.UserManager
import com.android.internal.widget.LockPatternUtils
import com.android.internal.widget.LockscreenCredential
import com.android.internal.widget.VerifyCredentialResponse
import com.android.systemui.R
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.util.time.SystemClock
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

/**
 * A wrapper for [LockPatternUtils] to verify PIN, pattern, or password credentials.
 *
 * This class also uses the [DevicePolicyManager] to generate appropriate error messages when policy
 * exceptions are raised (i.e. wipe device due to excessive failed attempts, etc.).
 */
interface CredentialInteractor {
    /** If the user's pattern credential should be hidden */
    fun isStealthModeActive(userId: Int): Boolean

    /** Get the effective user id (profile owner, if one exists) */
    fun getCredentialOwnerOrSelfId(userId: Int): Int

    /**
     * Verifies a credential and returns a stream of results.
     *
     * The final emitted value will either be a [CredentialStatus.Fail.Error] or a
     * [CredentialStatus.Success.Verified].
     */
    fun verifyCredential(
        request: BiometricPromptRequest.Credential,
        credential: LockscreenCredential,
    ): Flow<CredentialStatus>
}

/** Standard implementation of [CredentialInteractor]. */
class CredentialInteractorImpl
@Inject
constructor(
    @Application private val applicationContext: Context,
    private val lockPatternUtils: LockPatternUtils,
    private val userManager: UserManager,
    private val devicePolicyManager: DevicePolicyManager,
    private val systemClock: SystemClock,
) : CredentialInteractor {

    override fun isStealthModeActive(userId: Int): Boolean =
        !lockPatternUtils.isVisiblePatternEnabled(userId)

    override fun getCredentialOwnerOrSelfId(userId: Int): Int =
        userManager.getCredentialOwnerProfile(userId)

    override fun verifyCredential(
        request: BiometricPromptRequest.Credential,
        credential: LockscreenCredential,
    ): Flow<CredentialStatus> = flow {
        // Request LockSettingsService to return the Gatekeeper Password in the
        // VerifyCredentialResponse so that we can request a Gatekeeper HAT with the
        // Gatekeeper Password and operationId.
        val effectiveUserId = request.userInfo.deviceCredentialOwnerId
        val response =
            lockPatternUtils.verifyCredential(
                credential,
                effectiveUserId,
                LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE
            )

        if (response.isMatched) {
            lockPatternUtils.userPresent(effectiveUserId)

            // The response passed into this method contains the Gatekeeper
            // Password. We still have to request Gatekeeper to create a
            // Hardware Auth Token with the Gatekeeper Password and Challenge
            // (keystore operationId in this case)
            val pwHandle = response.gatekeeperPasswordHandle
            val gkResponse: VerifyCredentialResponse =
                lockPatternUtils.verifyGatekeeperPasswordHandle(
                    pwHandle,
                    request.operationInfo.gatekeeperChallenge,
                    effectiveUserId
                )
            val hat = gkResponse.gatekeeperHAT
            lockPatternUtils.removeGatekeeperPasswordHandle(pwHandle)
            emit(CredentialStatus.Success.Verified(hat))
        } else if (response.timeout > 0) {
            // if requests are being throttled, update the error message every
            // second until the temporary lock has expired
            val deadline: Long =
                lockPatternUtils.setLockoutAttemptDeadline(effectiveUserId, response.timeout)
            val interval = LockPatternUtils.FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS
            var remaining = deadline - systemClock.elapsedRealtime()
            while (remaining > 0) {
                emit(
                    CredentialStatus.Fail.Throttled(
                        applicationContext.getString(
                            R.string.biometric_dialog_credential_too_many_attempts,
                            remaining / 1000
                        )
                    )
                )
                delay(interval)
                remaining -= interval
            }
            emit(CredentialStatus.Fail.Error(""))
        } else { // bad request, but not throttled
            val numAttempts = lockPatternUtils.getCurrentFailedPasswordAttempts(effectiveUserId) + 1
            val maxAttempts = lockPatternUtils.getMaximumFailedPasswordsForWipe(effectiveUserId)
            if (maxAttempts <= 0 || numAttempts <= 0) {
                // use a generic message if there's no maximum number of attempts
                emit(CredentialStatus.Fail.Error())
            } else {
                val remainingAttempts = (maxAttempts - numAttempts).coerceAtLeast(0)
                emit(
                    CredentialStatus.Fail.Error(
                        applicationContext.getString(
                            R.string.biometric_dialog_credential_attempts_before_wipe,
                            numAttempts,
                            maxAttempts
                        ),
                        remainingAttempts,
                        fetchFinalAttemptMessageOrNull(request, remainingAttempts)
                    )
                )
            }
            lockPatternUtils.reportFailedPasswordAttempt(effectiveUserId)
        }
    }

    private fun fetchFinalAttemptMessageOrNull(
        request: BiometricPromptRequest.Credential,
        remainingAttempts: Int?,
    ): String? =
        if (remainingAttempts != null && remainingAttempts <= 1) {
            applicationContext.getFinalAttemptMessageOrBlank(
                request,
                devicePolicyManager,
                userManager.getUserTypeForWipe(
                    devicePolicyManager,
                    request.userInfo.deviceCredentialOwnerId
                ),
                remainingAttempts
            )
        } else {
            null
        }
}

private enum class UserType {
    PRIMARY,
    MANAGED_PROFILE,
    SECONDARY,
}

private fun UserManager.getUserTypeForWipe(
    devicePolicyManager: DevicePolicyManager,
    effectiveUserId: Int,
): UserType {
    val userToBeWiped =
        getUserInfo(
            devicePolicyManager.getProfileWithMinimumFailedPasswordsForWipe(effectiveUserId)
        )
    return when {
        userToBeWiped == null || userToBeWiped.isPrimary -> UserType.PRIMARY
        userToBeWiped.isManagedProfile -> UserType.MANAGED_PROFILE
        else -> UserType.SECONDARY
    }
}

private fun Context.getFinalAttemptMessageOrBlank(
    request: BiometricPromptRequest.Credential,
    devicePolicyManager: DevicePolicyManager,
    userType: UserType,
    remaining: Int,
): String =
    when {
        remaining == 1 -> getLastAttemptBeforeWipeMessage(request, devicePolicyManager, userType)
        remaining <= 0 -> getNowWipingMessage(devicePolicyManager, userType)
        else -> ""
    }

private fun Context.getLastAttemptBeforeWipeMessage(
    request: BiometricPromptRequest.Credential,
    devicePolicyManager: DevicePolicyManager,
    userType: UserType,
): String =
    when (userType) {
        UserType.PRIMARY -> getLastAttemptBeforeWipeDeviceMessage(request)
        UserType.MANAGED_PROFILE ->
            getLastAttemptBeforeWipeProfileMessage(request, devicePolicyManager)
        UserType.SECONDARY -> getLastAttemptBeforeWipeUserMessage(request)
    }

private fun Context.getLastAttemptBeforeWipeDeviceMessage(
    request: BiometricPromptRequest.Credential,
): String {
    val id =
        when (request) {
            is BiometricPromptRequest.Credential.Pin ->
                R.string.biometric_dialog_last_pin_attempt_before_wipe_device
            is BiometricPromptRequest.Credential.Pattern ->
                R.string.biometric_dialog_last_pattern_attempt_before_wipe_device
            is BiometricPromptRequest.Credential.Password ->
                R.string.biometric_dialog_last_password_attempt_before_wipe_device
        }
    return getString(id)
}

private fun Context.getLastAttemptBeforeWipeProfileMessage(
    request: BiometricPromptRequest.Credential,
    devicePolicyManager: DevicePolicyManager,
): String {
    val id =
        when (request) {
            is BiometricPromptRequest.Credential.Pin ->
                DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PIN_LAST_ATTEMPT
            is BiometricPromptRequest.Credential.Pattern ->
                DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PATTERN_LAST_ATTEMPT
            is BiometricPromptRequest.Credential.Password ->
                DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PASSWORD_LAST_ATTEMPT
        }
    return devicePolicyManager.resources.getString(id) {
        // use fallback a string if not found
        val defaultId =
            when (request) {
                is BiometricPromptRequest.Credential.Pin ->
                    R.string.biometric_dialog_last_pin_attempt_before_wipe_profile
                is BiometricPromptRequest.Credential.Pattern ->
                    R.string.biometric_dialog_last_pattern_attempt_before_wipe_profile
                is BiometricPromptRequest.Credential.Password ->
                    R.string.biometric_dialog_last_password_attempt_before_wipe_profile
            }
        getString(defaultId)
    }
}

private fun Context.getLastAttemptBeforeWipeUserMessage(
    request: BiometricPromptRequest.Credential,
): String {
    val resId =
        when (request) {
            is BiometricPromptRequest.Credential.Pin ->
                R.string.biometric_dialog_last_pin_attempt_before_wipe_user
            is BiometricPromptRequest.Credential.Pattern ->
                R.string.biometric_dialog_last_pattern_attempt_before_wipe_user
            is BiometricPromptRequest.Credential.Password ->
                R.string.biometric_dialog_last_password_attempt_before_wipe_user
        }
    return getString(resId)
}

private fun Context.getNowWipingMessage(
    devicePolicyManager: DevicePolicyManager,
    userType: UserType,
): String {
    val id =
        when (userType) {
            UserType.MANAGED_PROFILE ->
                DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_LOCK_FAILED_ATTEMPTS
            else -> DevicePolicyResources.UNDEFINED
        }
    return devicePolicyManager.resources.getString(id) {
        // use fallback a string if not found
        val defaultId =
            when (userType) {
                UserType.PRIMARY ->
                    com.android.settingslib.R.string.failed_attempts_now_wiping_device
                UserType.MANAGED_PROFILE ->
                    com.android.settingslib.R.string.failed_attempts_now_wiping_profile
                UserType.SECONDARY ->
                    com.android.settingslib.R.string.failed_attempts_now_wiping_user
            }
        getString(defaultId)
    }
}
+23 −0
Original line number Diff line number Diff line
package com.android.systemui.biometrics.domain.interactor

/** Result of a [CredentialInteractor.verifyCredential] check. */
sealed interface CredentialStatus {
    /** A successful result. */
    sealed interface Success : CredentialStatus {
        /** The credential is valid and a [hat] has been generated. */
        data class Verified(val hat: ByteArray) : Success
    }
    /** A failed result. */
    sealed interface Fail : CredentialStatus {
        val error: String?

        /** The credential check failed with an [error]. */
        data class Error(
            override val error: String? = null,
            val remainingAttempts: Int? = null,
            val urgentMessage: String? = null,
        ) : Fail
        /** The credential check failed with an [error] and is temporarily locked out. */
        data class Throttled(override val error: String) : Fail
    }
}
+189 −0
Original line number Diff line number Diff line
package com.android.systemui.biometrics.domain.interactor

import android.hardware.biometrics.PromptInfo
import com.android.internal.widget.LockPatternView
import com.android.internal.widget.LockscreenCredential
import com.android.systemui.biometrics.Utils
import com.android.systemui.biometrics.data.model.PromptKind
import com.android.systemui.biometrics.data.repository.PromptRepository
import com.android.systemui.biometrics.domain.model.BiometricOperationInfo
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
import com.android.systemui.biometrics.domain.model.BiometricUserInfo
import com.android.systemui.dagger.qualifiers.Background
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext

/**
 * Business logic for BiometricPrompt's CredentialViews, which primarily includes checking a users
 * PIN, pattern, or password credential instead of a biometric.
 */
class BiometricPromptCredentialInteractor
@Inject
constructor(
    @Background private val bgDispatcher: CoroutineDispatcher,
    private val biometricPromptRepository: PromptRepository,
    private val credentialInteractor: CredentialInteractor,
) {
    /** If the prompt is currently showing. */
    val isShowing: Flow<Boolean> = biometricPromptRepository.isShowing

    /** Metadata about the current credential prompt, including app-supplied preferences. */
    val prompt: Flow<BiometricPromptRequest?> =
        combine(
                biometricPromptRepository.promptInfo,
                biometricPromptRepository.challenge,
                biometricPromptRepository.userId,
                biometricPromptRepository.kind
            ) { promptInfo, challenge, userId, kind ->
                if (promptInfo == null || userId == null || challenge == null) {
                    return@combine null
                }

                when (kind) {
                    PromptKind.PIN ->
                        BiometricPromptRequest.Credential.Pin(
                            info = promptInfo,
                            userInfo = userInfo(userId),
                            operationInfo = operationInfo(challenge)
                        )
                    PromptKind.PATTERN ->
                        BiometricPromptRequest.Credential.Pattern(
                            info = promptInfo,
                            userInfo = userInfo(userId),
                            operationInfo = operationInfo(challenge),
                            stealthMode = credentialInteractor.isStealthModeActive(userId)
                        )
                    PromptKind.PASSWORD ->
                        BiometricPromptRequest.Credential.Password(
                            info = promptInfo,
                            userInfo = userInfo(userId),
                            operationInfo = operationInfo(challenge)
                        )
                    else -> null
                }
            }
            .distinctUntilChanged()

    private fun userInfo(userId: Int): BiometricUserInfo =
        BiometricUserInfo(
            userId = userId,
            deviceCredentialOwnerId = credentialInteractor.getCredentialOwnerOrSelfId(userId)
        )

    private fun operationInfo(challenge: Long): BiometricOperationInfo =
        BiometricOperationInfo(gatekeeperChallenge = challenge)

    /** Most recent error due to [verifyCredential]. */
    private val _verificationError = MutableStateFlow<CredentialStatus.Fail?>(null)
    val verificationError: Flow<CredentialStatus.Fail?> = _verificationError.asStateFlow()

    /** Update the current request to use credential-based authentication instead of biometrics. */
    fun useCredentialsForAuthentication(
        promptInfo: PromptInfo,
        @Utils.CredentialType kind: Int,
        userId: Int,
        challenge: Long,
    ) {
        biometricPromptRepository.setPrompt(
            promptInfo,
            userId,
            challenge,
            kind.asBiometricPromptCredential()
        )
    }

    /** Unset the current authentication request. */
    fun resetPrompt() {
        biometricPromptRepository.unsetPrompt()
    }

    /**
     * Check a credential and return the attestation token (HAT) if successful.
     *
     * This method will not return if credential checks are being throttled until the throttling has
     * expired and the user can try again. It will periodically update the [verificationError] until
     * cancelled or the throttling has completed. If the request is not throttled, but unsuccessful,
     * the [verificationError] will be set and an optional
     * [CredentialStatus.Fail.Error.urgentMessage] message may be provided to indicate additional
     * hints to the user (i.e. device will be wiped on next failure, etc.).
     *
     * The check happens on the background dispatcher given in the constructor.
     */
    suspend fun checkCredential(
        request: BiometricPromptRequest.Credential,
        text: CharSequence? = null,
        pattern: List<LockPatternView.Cell>? = null,
    ): CredentialStatus =
        withContext(bgDispatcher) {
            val credential =
                when (request) {
                    is BiometricPromptRequest.Credential.Pin ->
                        LockscreenCredential.createPinOrNone(text ?: "")
                    is BiometricPromptRequest.Credential.Password ->
                        LockscreenCredential.createPasswordOrNone(text ?: "")
                    is BiometricPromptRequest.Credential.Pattern ->
                        LockscreenCredential.createPattern(pattern ?: listOf())
                }

            credential.use { c -> verifyCredential(request, c) }
        }

    private suspend fun verifyCredential(
        request: BiometricPromptRequest.Credential,
        credential: LockscreenCredential?
    ): CredentialStatus {
        if (credential == null || credential.isNone) {
            return CredentialStatus.Fail.Error()
        }

        val finalStatus =
            credentialInteractor
                .verifyCredential(request, credential)
                .onEach { status ->
                    when (status) {
                        is CredentialStatus.Success -> _verificationError.value = null
                        is CredentialStatus.Fail -> _verificationError.value = status
                    }
                }
                .lastOrNull()

        return finalStatus ?: CredentialStatus.Fail.Error()
    }

    /**
     * Report a user-visible error.
     *
     * Use this instead of calling [verifyCredential] when it is not necessary because the check
     * will obviously fail (i.e. too short, empty, etc.)
     */
    fun setVerificationError(error: CredentialStatus.Fail.Error?) {
        if (error != null) {
            _verificationError.value = error
        } else {
            resetVerificationError()
        }
    }

    /** Clear the current error message, if any. */
    fun resetVerificationError() {
        _verificationError.value = null
    }
}

// TODO(b/251476085): remove along with Utils.CredentialType
/** Convert a [Utils.CredentialType] to the corresponding [PromptKind]. */
private fun @receiver:Utils.CredentialType Int.asBiometricPromptCredential(): PromptKind =
    when (this) {
        Utils.CREDENTIAL_PIN -> PromptKind.PIN
        Utils.CREDENTIAL_PASSWORD -> PromptKind.PASSWORD
        Utils.CREDENTIAL_PATTERN -> PromptKind.PATTERN
        else -> PromptKind.ANY_BIOMETRIC
    }
+4 −0
Original line number Diff line number Diff line
package com.android.systemui.biometrics.domain.model

/** Metadata about an in-progress biometric operation. */
data class BiometricOperationInfo(val gatekeeperChallenge: Long = -1)
Loading