fix: unlock manager #2098 #2101

This commit is contained in:
J-Jamet
2025-08-10 12:24:23 +02:00
parent 6c37f7b12c
commit 7be554a378
18 changed files with 1128 additions and 1108 deletions

View File

@@ -1,4 +1,5 @@
KeePassDX(4.1.4)
* Fix UnlockManager #2098 #2101
KeePassDX(4.1.3)
* Fix Autofill Registration #2089

View File

@@ -55,8 +55,8 @@ import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity
import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.biometric.AdvancedUnlockFragment
import com.kunzisoft.keepass.biometric.AdvancedUnlockManager
import com.kunzisoft.keepass.biometric.DeviceUnlockFragment
import com.kunzisoft.keepass.biometric.DeviceUnlockManager
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.MainCredential
import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException
@@ -81,7 +81,7 @@ import com.kunzisoft.keepass.utils.getParcelableExtraCompat
import com.kunzisoft.keepass.view.MainCredentialView
import com.kunzisoft.keepass.view.asError
import com.kunzisoft.keepass.view.showActionErrorIfNeeded
import com.kunzisoft.keepass.viewmodels.AdvancedUnlockViewModel
import com.kunzisoft.keepass.viewmodels.DeviceUnlockViewModel
import com.kunzisoft.keepass.viewmodels.DatabaseFileViewModel
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
@@ -98,10 +98,10 @@ class MainCredentialActivity : DatabaseModeActivity() {
private var confirmButtonView: Button? = null
private var infoContainerView: ViewGroup? = null
private lateinit var coordinatorLayout: CoordinatorLayout
private var advancedUnlockFragment: AdvancedUnlockFragment? = null
private var deviceUnlockFragment: DeviceUnlockFragment? = null
private val mDatabaseFileViewModel: DatabaseFileViewModel by viewModels()
private val mAdvancedUnlockViewModel: AdvancedUnlockViewModel by viewModels()
private val mDeviceUnlockViewModel: DeviceUnlockViewModel by viewModels()
private val mPasswordActivityEducation = PasswordActivityEducation(this)
@@ -170,8 +170,9 @@ class MainCredentialActivity : DatabaseModeActivity() {
// Listen password checkbox to init advanced unlock and confirmation button
mainCredentialView?.onConditionToStoreCredentialChanged = { _, verified ->
mAdvancedUnlockViewModel.checkUnlockAvailability(
conditionToStoreCredentialVerified = verified
mDeviceUnlockViewModel.checkConditionToStoreCredential(
condition = verified,
databaseFileUri = mDatabaseFileUri
)
// TODO Async by ViewModel
enableConfirmationButton()
@@ -226,20 +227,22 @@ class MainCredentialActivity : DatabaseModeActivity() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
mAdvancedUnlockViewModel.uiState.collect { uiState ->
mDeviceUnlockViewModel.uiState.collect { uiState ->
// New value received
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (uiState.isCredentialRequired) {
mAdvancedUnlockViewModel.provideCredentialForEncryption(
mDeviceUnlockViewModel.encryptCredential(
getCredentialForEncryption()
)
}
}
uiState.cipherEncryptDatabase?.let { cipherEncryptDatabase ->
onCredentialEncrypted(cipherEncryptDatabase)
mAdvancedUnlockViewModel.consumeCredentialEncrypted()
mDeviceUnlockViewModel.consumeCredentialEncrypted()
}
uiState.cipherDecryptDatabase?.let { cipherDecryptDatabase ->
onCredentialDecrypted(cipherDecryptDatabase)
mAdvancedUnlockViewModel.consumeCredentialDecrypted()
mDeviceUnlockViewModel.consumeCredentialDecrypted()
}
}
}
@@ -250,11 +253,12 @@ class MainCredentialActivity : DatabaseModeActivity() {
super.onResume()
// Init Biometric elements only if allowed
if (PreferencesUtil.isAdvancedUnlockEnable(this)) {
advancedUnlockFragment = supportFragmentManager
.findFragmentByTag(UNLOCK_FRAGMENT_TAG) as? AdvancedUnlockFragment?
if (advancedUnlockFragment == null) {
advancedUnlockFragment = AdvancedUnlockFragment().also {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& PreferencesUtil.isAdvancedUnlockEnable(this)) {
deviceUnlockFragment = supportFragmentManager
.findFragmentByTag(UNLOCK_FRAGMENT_TAG) as? DeviceUnlockFragment?
if (deviceUnlockFragment == null) {
deviceUnlockFragment = DeviceUnlockFragment().also {
supportFragmentManager.commit {
replace(
R.id.fragment_advanced_unlock_container_view,
@@ -276,7 +280,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
// Don't allow auto open prompt if lock become when UI visible
if (DatabaseLockActivity.LOCKING_ACTIVITY_UI_VISIBLE_DURING_LOCK == true) {
mAdvancedUnlockViewModel.allowAutoOpenBiometricPrompt = false
mDeviceUnlockViewModel.allowAutoOpenBiometricPrompt = false
}
mDatabaseFileUri?.let { databaseFileUri ->
@@ -494,7 +498,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
loadDatabase()
} else {
// Init Biometric elements
mAdvancedUnlockViewModel.databaseFileLoaded(databaseFileUri)
mDeviceUnlockViewModel.databaseFileLoaded(databaseFileUri)
}
enableConfirmationButton()
@@ -654,7 +658,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& !readOnlyEducationPerformed) {
val biometricCanAuthenticate = AdvancedUnlockManager.canAuthenticate(this)
val biometricCanAuthenticate = DeviceUnlockManager.canAuthenticate(this)
if ((biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_SUCCESS)
&& advancedUnlockButton != null) {

View File

@@ -177,14 +177,18 @@ class CipherDatabaseAction(context: Context) {
}
}
fun containsCipherDatabase(databaseUri: Uri,
fun containsCipherDatabase(databaseUri: Uri?,
contains: (Boolean) -> Unit) {
if (databaseUri == null) {
contains.invoke(false)
} else {
getCipherDatabase(databaseUri) {
contains.invoke(it != null)
}
}
}
fun resetCipherParameters(databaseUri: Uri) {
fun resetCipherParameters(databaseUri: Uri?) {
containsCipherDatabase(databaseUri) { contains ->
if (contains) {
mBinder?.resetTimer()

View File

@@ -1,10 +0,0 @@
package com.kunzisoft.keepass.biometric
import androidx.annotation.StringRes
import javax.crypto.Cipher
data class AdvancedUnlockCryptoPrompt(var cipher: Cipher,
@StringRes var promptTitleId: Int,
@StringRes var promptDescriptionId: Int? = null,
var isDeviceCredentialOperation: Boolean,
var isBiometricOperation: Boolean)

View File

@@ -1,667 +0,0 @@
/*
* Copyright 2020 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 <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.biometric
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
import com.kunzisoft.keepass.database.exception.UnknownDatabaseLocationException
import com.kunzisoft.keepass.model.CipherDecryptDatabase
import com.kunzisoft.keepass.model.CipherEncryptDatabase
import com.kunzisoft.keepass.model.CredentialStorage
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.view.AdvancedUnlockInfoView
import com.kunzisoft.keepass.view.hideByFading
import com.kunzisoft.keepass.view.showByFading
import com.kunzisoft.keepass.viewmodels.AdvancedUnlockViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCallback {
private var mAdvancedUnlockEnabled = false
private var mAutoOpenPromptEnabled = false
private var advancedUnlockManager: AdvancedUnlockManager? = null
private var biometricMode: Mode = Mode.BIOMETRIC_UNAVAILABLE
private var mAdvancedUnlockInfoView: AdvancedUnlockInfoView? = null
var databaseFileUri: Uri? = null
private set
// TODO Retrieve credential storage from app database
var credentialDatabaseStorage: CredentialStorage = CredentialStorage.DEFAULT
// Variable to check if the prompt can be open (if the right activity is currently shown)
// checkBiometricAvailability() allows open biometric prompt and onDestroy() removes the authorization
private var allowOpenBiometricPrompt = false
private lateinit var cipherDatabaseAction : CipherDatabaseAction
private var cipherDatabaseListener: CipherDatabaseAction.CipherDatabaseListener? = null
private val mAdvancedUnlockViewModel: AdvancedUnlockViewModel by activityViewModels()
// Only to fix multiple fingerprint menu #332
private var mAllowAdvancedUnlockMenu = false
private var mAddBiometricMenuInProgress = false
// Only keep connection when we request a device credential activity
private var keepConnection = false
private var isConditionToStoreCredentialVerified = false
private var mDeviceCredentialResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
mAdvancedUnlockViewModel.allowAutoOpenBiometricPrompt = false
// To wait resume
if (keepConnection) {
mAdvancedUnlockViewModel.deviceCredentialAuthSucceeded =
result.resultCode == Activity.RESULT_OK
}
keepConnection = false
}
private val menuProvider: MenuProvider = object: MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// biometric menu
if (mAllowAdvancedUnlockMenu)
menuInflater.inflate(R.menu.advanced_unlock, menu)
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.menu_keystore_remove_key -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
deleteEncryptedDatabaseKey()
}
}
return false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
cipherDatabaseAction = CipherDatabaseAction.getInstance(requireContext().applicationContext)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
super.onCreateView(inflater, container, savedInstanceState)
val rootView = inflater.inflate(R.layout.fragment_advanced_unlock, container, false)
mAdvancedUnlockInfoView = rootView.findViewById(R.id.advanced_unlock_view)
return rootView
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
activity?.addMenuProvider(menuProvider, viewLifecycleOwner)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
mAdvancedUnlockViewModel.uiState.collect { uiState ->
// Database loaded
uiState.databaseFileLoaded?.let { databaseLoaded ->
onDatabaseLoaded(databaseLoaded)
mAdvancedUnlockViewModel.consumeDatabaseFileLoaded()
}
// New credential value received
uiState.credential?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
advancedUnlockManager?.encryptData(uiState.credential)
}
mAdvancedUnlockViewModel.consumeCredentialForEncryption()
}
// Condition to store credential verified
isConditionToStoreCredentialVerified = uiState.isConditionToStoreCredentialVerified
// Check unlock availability
if (uiState.onUnlockAvailabilityCheckRequested) {
checkUnlockAvailability()
mAdvancedUnlockViewModel.consumeCheckUnlockAvailability()
}
}
}
}
}
override fun onResume() {
super.onResume()
context?.let {
mAdvancedUnlockEnabled = PreferencesUtil.isAdvancedUnlockEnable(it)
mAutoOpenPromptEnabled = PreferencesUtil.isAdvancedUnlockPromptAutoOpenEnable(it)
}
keepConnection = false
}
private fun onDatabaseLoaded(databaseUri: Uri?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// To get device credential unlock result, only if same database uri
if (databaseUri != null
&& mAdvancedUnlockEnabled) {
mAdvancedUnlockViewModel.deviceCredentialAuthSucceeded?.let { authSucceeded ->
if (databaseUri == databaseFileUri) {
if (authSucceeded) {
advancedUnlockManager?.advancedUnlockCallback?.onAuthenticationSucceeded()
} else {
advancedUnlockManager?.advancedUnlockCallback?.onAuthenticationFailed()
}
} else {
disconnect()
}
} ?: run {
if (databaseUri != databaseFileUri) {
connect(databaseUri)
}
}
} else {
disconnect()
}
mAdvancedUnlockViewModel.deviceCredentialAuthSucceeded = null
}
}
/**
* Check unlock availability and change the current mode depending of device's state
*/
private fun checkUnlockAvailability() {
context?.let { context ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
allowOpenBiometricPrompt = true
if (PreferencesUtil.isBiometricUnlockEnable(context)) {
// biometric not supported (by API level or hardware) so keep option hidden
// or manually disable
val biometricCanAuthenticate = AdvancedUnlockManager.canAuthenticate(context)
if (!PreferencesUtil.isAdvancedUnlockEnable(context)
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE) {
toggleMode(Mode.BIOMETRIC_UNAVAILABLE)
} else if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED) {
toggleMode(Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED)
} else {
// biometric is available but not configured, show icon but in disabled state with some information
if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) {
toggleMode(Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
} else {
selectMode()
}
}
} else if (PreferencesUtil.isDeviceCredentialUnlockEnable(context)) {
if (AdvancedUnlockManager.isDeviceSecure(context)) {
selectMode()
} else {
toggleMode(Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun selectMode() {
// Check if fingerprint well init (be called the first time the fingerprint is configured
// and the activity still active)
if (advancedUnlockManager?.isKeyManagerInitialized != true) {
advancedUnlockManager = AdvancedUnlockManager { requireActivity() }
// callback for fingerprint findings
advancedUnlockManager?.advancedUnlockCallback = this
}
// Recheck to change the mode
if (advancedUnlockManager?.isKeyManagerInitialized != true) {
toggleMode(Mode.KEY_MANAGER_UNAVAILABLE)
} else {
if (isConditionToStoreCredentialVerified) {
// listen for encryption
toggleMode(Mode.STORE_CREDENTIAL)
} else {
databaseFileUri?.let { databaseUri ->
cipherDatabaseAction.containsCipherDatabase(databaseUri) { containsCipher ->
// biometric available but no stored password found yet for this DB so show info don't listen
toggleMode(if (containsCipher) {
// listen for decryption
Mode.EXTRACT_CREDENTIAL
} else {
if (isConditionToStoreCredentialVerified) {
// if condition OK, key manager in error
Mode.KEY_MANAGER_UNAVAILABLE
} else {
// wait for typing
Mode.WAIT_CREDENTIAL
}
})
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun toggleMode(newBiometricMode: Mode) {
if (newBiometricMode != biometricMode) {
biometricMode = newBiometricMode
initAdvancedUnlockMode()
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initNotAvailable() {
showViews(false)
mAdvancedUnlockInfoView?.setIconViewClickListener(null)
}
@RequiresApi(Build.VERSION_CODES.M)
private fun openBiometricSetting() {
mAdvancedUnlockInfoView?.setIconViewClickListener {
try {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
context?.startActivity(Intent(Settings.ACTION_BIOMETRIC_ENROLL))
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> {
@Suppress("DEPRECATION") context
?.startActivity(Intent(Settings.ACTION_FINGERPRINT_ENROLL))
}
else -> {
context?.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
}
}
} catch (e: Exception) {
// ACTION_SECURITY_SETTINGS does not contain fingerprint enrollment on some devices...
context?.startActivity(Intent(Settings.ACTION_SETTINGS))
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initSecurityUpdateRequired() {
showViews(true)
setAdvancedUnlockedTitleView(R.string.biometric_security_update_required)
openBiometricSetting()
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initNotConfigured() {
showViews(true)
setAdvancedUnlockedTitleView(R.string.configure_biometric)
setAdvancedUnlockedMessageView("")
openBiometricSetting()
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initKeyManagerNotAvailable() {
showViews(true)
setAdvancedUnlockedTitleView(R.string.keystore_not_accessible)
openBiometricSetting()
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initWaitData() {
showViews(true)
setAdvancedUnlockedTitleView(R.string.unavailable)
setAdvancedUnlockedMessageView("")
context?.let { context ->
mAdvancedUnlockInfoView?.setIconViewClickListener {
onAuthenticationError(BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
context.getString(R.string.credential_before_click_advanced_unlock_button))
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun openAdvancedUnlockPrompt(cryptoPrompt: AdvancedUnlockCryptoPrompt) {
lifecycleScope.launch(Dispatchers.Main) {
if (allowOpenBiometricPrompt) {
if (cryptoPrompt.isDeviceCredentialOperation)
keepConnection = true
try {
advancedUnlockManager?.openAdvancedUnlockPrompt(cryptoPrompt,
mDeviceCredentialResultLauncher)
} catch (e: Exception) {
Log.e(TAG, "Unable to open advanced unlock prompt", e)
setAdvancedUnlockedTitleView(R.string.advanced_unlock_prompt_not_initialized)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initEncryptData() {
showViews(true)
setAdvancedUnlockedTitleView(R.string.unlock_and_link_biometric)
setAdvancedUnlockedMessageView("")
advancedUnlockManager?.initEncryptData { cryptoPrompt ->
// Set listener to open the biometric dialog and save credential
mAdvancedUnlockInfoView?.setIconViewClickListener { _ ->
openAdvancedUnlockPrompt(cryptoPrompt)
}
} ?: throw Exception("AdvancedUnlockManager not initialized")
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initDecryptData() {
showViews(true)
setAdvancedUnlockedTitleView(R.string.unlock)
setAdvancedUnlockedMessageView("")
advancedUnlockManager?.let { unlockHelper ->
databaseFileUri?.let { databaseUri ->
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
cipherDatabase?.let {
unlockHelper.initDecryptData(it.specParameters) { cryptoPrompt ->
// Set listener to open the biometric dialog and check credential
mAdvancedUnlockInfoView?.setIconViewClickListener { _ ->
openAdvancedUnlockPrompt(cryptoPrompt)
}
// Auto open the biometric prompt
if (mAdvancedUnlockViewModel.allowAutoOpenBiometricPrompt
&& mAutoOpenPromptEnabled) {
mAdvancedUnlockViewModel.allowAutoOpenBiometricPrompt = false
openAdvancedUnlockPrompt(cryptoPrompt)
}
}
} ?: deleteEncryptedDatabaseKey()
}
} ?: throw UnknownDatabaseLocationException()
} ?: throw Exception("AdvancedUnlockManager not initialized")
}
private fun initAdvancedUnlockMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
mAllowAdvancedUnlockMenu = false
try {
when (biometricMode) {
Mode.BIOMETRIC_UNAVAILABLE -> initNotAvailable()
Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> initSecurityUpdateRequired()
Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED -> initNotConfigured()
Mode.KEY_MANAGER_UNAVAILABLE -> initKeyManagerNotAvailable()
Mode.WAIT_CREDENTIAL -> initWaitData()
Mode.STORE_CREDENTIAL -> initEncryptData()
Mode.EXTRACT_CREDENTIAL -> initDecryptData()
}
} catch (e: Exception) {
onGenericException(e)
}
invalidateBiometricMenu()
}
}
private fun invalidateBiometricMenu() {
// Show fingerprint key deletion
if (!mAddBiometricMenuInProgress) {
mAddBiometricMenuInProgress = true
databaseFileUri?.let { databaseUri ->
cipherDatabaseAction.containsCipherDatabase(databaseUri) { containsCipher ->
mAllowAdvancedUnlockMenu = containsCipher
&& (biometricMode != Mode.BIOMETRIC_UNAVAILABLE
&& biometricMode != Mode.KEY_MANAGER_UNAVAILABLE)
mAddBiometricMenuInProgress = false
activity?.invalidateOptionsMenu()
}
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
fun connect(databaseUri: Uri) {
showViews(true)
this.databaseFileUri = databaseUri
cipherDatabaseListener = object: CipherDatabaseAction.CipherDatabaseListener {
override fun onCipherDatabaseCleared() {
advancedUnlockManager?.closeBiometricPrompt()
checkUnlockAvailability()
}
}
cipherDatabaseAction.apply {
reloadPreferences()
cipherDatabaseListener?.let {
registerDatabaseListener(it)
}
}
checkUnlockAvailability()
}
@RequiresApi(Build.VERSION_CODES.M)
fun disconnect(hideViews: Boolean = true,
closePrompt: Boolean = true) {
this.databaseFileUri = null
// Close the biometric prompt
allowOpenBiometricPrompt = false
if (closePrompt)
advancedUnlockManager?.closeBiometricPrompt()
cipherDatabaseListener?.let {
cipherDatabaseAction.unregisterDatabaseListener(it)
}
biometricMode = Mode.BIOMETRIC_UNAVAILABLE
if (hideViews) {
showViews(false)
}
}
@RequiresApi(Build.VERSION_CODES.M)
fun deleteEncryptedDatabaseKey() {
mAllowAdvancedUnlockMenu = false
advancedUnlockManager?.closeBiometricPrompt()
databaseFileUri?.let { databaseUri ->
cipherDatabaseAction.deleteByDatabaseUri(databaseUri) {
checkUnlockAvailability()
}
} ?: checkUnlockAvailability()
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
lifecycleScope.launch(Dispatchers.Main) {
Log.e(TAG, "Biometric authentication error. Code : $errorCode Error : $errString")
setAdvancedUnlockedMessageView(errString.toString())
}
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onAuthenticationFailed() {
lifecycleScope.launch(Dispatchers.Main) {
Log.e(TAG, "Biometric authentication failed, biometric not recognized")
setAdvancedUnlockedMessageView(R.string.advanced_unlock_not_recognized)
}
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onAuthenticationSucceeded() {
lifecycleScope.launch(Dispatchers.Main) {
when (biometricMode) {
Mode.BIOMETRIC_UNAVAILABLE -> {
}
Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> {
}
Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED -> {
}
Mode.KEY_MANAGER_UNAVAILABLE -> {
}
Mode.WAIT_CREDENTIAL -> {
}
Mode.STORE_CREDENTIAL -> {
// newly store the entered password in encrypted way
mAdvancedUnlockViewModel.retrieveCredentialForEncryption()
}
Mode.EXTRACT_CREDENTIAL -> {
// retrieve the encrypted value from preferences
databaseFileUri?.let { databaseUri ->
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
cipherDatabase?.encryptedValue?.let { value ->
advancedUnlockManager?.decryptData(value)
} ?: deleteEncryptedDatabaseKey()
}
} ?: run {
onAuthenticationError(-1, getString(R.string.error_database_uri_null))
}
}
}
}
}
override fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray) {
databaseFileUri?.let { databaseUri ->
mAdvancedUnlockViewModel.onCredentialEncrypted(
CipherEncryptDatabase().apply {
this.databaseUri = databaseUri
this.credentialStorage = credentialDatabaseStorage
this.encryptedValue = encryptedValue
this.specParameters = ivSpec
}
)
}
}
override fun handleDecryptedResult(decryptedValue: ByteArray) {
// Load database directly with password retrieve
databaseFileUri?.let { databaseUri ->
mAdvancedUnlockViewModel.onCredentialDecrypted(
CipherDecryptDatabase().apply {
this.databaseUri = databaseUri
this.credentialStorage = credentialDatabaseStorage
this.decryptedValue = decryptedValue
}
)
cipherDatabaseAction.resetCipherParameters(databaseUri)
}
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onUnrecoverableKeyException(e: Exception) {
setAdvancedUnlockedMessageView(R.string.advanced_unlock_invalid_key)
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onInvalidKeyException(e: Exception) {
setAdvancedUnlockedMessageView(R.string.advanced_unlock_invalid_key)
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onGenericException(e: Exception) {
val errorMessage = e.cause?.localizedMessage ?: e.localizedMessage ?: ""
setAdvancedUnlockedMessageView(errorMessage)
}
private fun showViews(show: Boolean) {
lifecycleScope.launch(Dispatchers.Main) {
if (show) {
if (mAdvancedUnlockInfoView?.visibility != View.VISIBLE)
mAdvancedUnlockInfoView?.showByFading()
}
else {
if (mAdvancedUnlockInfoView?.visibility == View.VISIBLE)
mAdvancedUnlockInfoView?.hideByFading()
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setAdvancedUnlockedTitleView(textId: Int) {
lifecycleScope.launch(Dispatchers.Main) {
mAdvancedUnlockInfoView?.setTitle(textId)
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setAdvancedUnlockedMessageView(textId: Int) {
lifecycleScope.launch(Dispatchers.Main) {
mAdvancedUnlockInfoView?.setMessage(textId)
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setAdvancedUnlockedMessageView(text: CharSequence) {
lifecycleScope.launch(Dispatchers.Main) {
mAdvancedUnlockInfoView?.setMessage(text)
}
}
enum class Mode {
BIOMETRIC_UNAVAILABLE,
BIOMETRIC_SECURITY_UPDATE_REQUIRED,
DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED,
KEY_MANAGER_UNAVAILABLE,
WAIT_CREDENTIAL,
STORE_CREDENTIAL,
EXTRACT_CREDENTIAL
}
override fun onPause() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!keepConnection) {
// If close prompt, bug "user not authenticated in Android R"
disconnect(false)
advancedUnlockManager = null
}
}
super.onPause()
}
override fun onDestroyView() {
mAdvancedUnlockInfoView = null
super.onDestroyView()
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
disconnect()
advancedUnlockManager = null
}
super.onDestroy()
}
companion object {
private val TAG = AdvancedUnlockFragment::class.java.name
}
}

View File

@@ -0,0 +1,17 @@
package com.kunzisoft.keepass.biometric
import androidx.annotation.StringRes
import javax.crypto.Cipher
data class DeviceUnlockCryptoPrompt(
var type: DeviceUnlockCryptoPromptType,
var cipher: Cipher,
@StringRes var titleId: Int,
@StringRes var descriptionId: Int? = null,
var isDeviceCredentialOperation: Boolean,
var isBiometricOperation: Boolean
)
enum class DeviceUnlockCryptoPromptType {
CREDENTIAL_ENCRYPTION, CREDENTIAL_DECRYPTION
}

View File

@@ -0,0 +1,510 @@
/*
* Copyright 2020 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 <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.biometric
import android.app.Activity
import android.app.KeyguardManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.view.DeviceUnlockView
import com.kunzisoft.keepass.view.hideByFading
import com.kunzisoft.keepass.view.showByFading
import com.kunzisoft.keepass.viewmodels.DeviceUnlockViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.security.UnrecoverableKeyException
import java.util.concurrent.Executors
@RequiresApi(Build.VERSION_CODES.M)
class DeviceUnlockFragment: Fragment() {
private var mDeviceUnlockView: DeviceUnlockView? = null
private val mDeviceUnlockViewModel: DeviceUnlockViewModel by activityViewModels()
private var mBiometricPrompt: BiometricPrompt? = null
// Only to fix multiple fingerprint menu #332
private var mAllowAdvancedUnlockMenu = false
// Only keep connection when we request a device credential activity
private var keepConnection = false
private var storeCredentialButtonClickListener: View.OnClickListener? = null
private var extractCredentialButtonClickListener: View.OnClickListener? = null
private var mDeviceCredentialResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
mDeviceUnlockViewModel.allowAutoOpenBiometricPrompt = false
// To wait resume
if (keepConnection) {
mDeviceUnlockViewModel.deviceCredentialAuthSucceeded =
result.resultCode == Activity.RESULT_OK
}
keepConnection = false
}
private var storeAuthenticationCallback = object: BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
// newly store the entered password in encrypted way
mDeviceUnlockViewModel.retrieveCredentialForEncryption()
}
override fun onAuthenticationFailed() {
setAuthenticationFailed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
setAuthenticationError(errorCode, errString)
}
}
private var extractAuthenticationCallback = object: BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
mDeviceUnlockViewModel.decryptCredential()
}
override fun onAuthenticationFailed() {
setAuthenticationFailed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
setAuthenticationError(errorCode, errString)
}
}
private val menuProvider: MenuProvider = object: MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
// biometric menu
if (mAllowAdvancedUnlockMenu)
menuInflater.inflate(R.menu.advanced_unlock, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.menu_keystore_remove_key ->
deleteEncryptedDatabaseKey()
}
return false
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
super.onCreateView(inflater, container, savedInstanceState)
val rootView = inflater.inflate(R.layout.fragment_advanced_unlock, container, false)
mDeviceUnlockView = rootView.findViewById(R.id.advanced_unlock_view)
return rootView
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
activity?.addMenuProvider(menuProvider, viewLifecycleOwner)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
mDeviceUnlockViewModel.uiState.collect { uiState ->
// Change mode
toggleDeviceCredentialMode(uiState.deviceUnlockMode)
// Prompt
uiState.cryptoPrompt?.let { prompt ->
mDeviceUnlockViewModel.promptShown()
when (prompt.type) {
DeviceUnlockCryptoPromptType.CREDENTIAL_ENCRYPTION ->
manageEncryptionPrompt(prompt)
DeviceUnlockCryptoPromptType.CREDENTIAL_DECRYPTION ->
manageDecryptionPrompt(prompt)
}
}
if (uiState.closePromptRequested) {
closeBiometricPrompt()
mDeviceUnlockViewModel.biometricPromptClosed()
}
// Errors
setAdvancedUnlockedError(uiState.error)
// Advanced menu
mAllowAdvancedUnlockMenu = uiState.allowAdvancedUnlockMenu
activity?.invalidateOptionsMenu()
}
}
}
}
override fun onResume() {
super.onResume()
keepConnection = false
}
fun openAdvancedUnlockPrompt(
cryptoPrompt: DeviceUnlockCryptoPrompt,
authenticationCallback: BiometricPrompt.AuthenticationCallback
) {
// Init advanced unlock prompt
mBiometricPrompt = BiometricPrompt(
this@DeviceUnlockFragment,
Executors.newSingleThreadExecutor(),
authenticationCallback
)
val promptTitle = getString(cryptoPrompt.titleId)
val promptDescription = cryptoPrompt.descriptionId?.let { descriptionId ->
getString(descriptionId)
} ?: ""
if (cryptoPrompt.isBiometricOperation) {
val promptInfoExtractCredential = BiometricPrompt.PromptInfo.Builder().apply {
setTitle(promptTitle)
if (promptDescription.isNotEmpty())
setDescription(promptDescription)
setConfirmationRequired(false)
if (isDeviceCredentialBiometricOperation(context)) {
setAllowedAuthenticators(DEVICE_CREDENTIAL)
} else {
setNegativeButtonText(getString(android.R.string.cancel))
}
}.build()
mBiometricPrompt?.authenticate(
promptInfoExtractCredential,
BiometricPrompt.CryptoObject(cryptoPrompt.cipher))
}
else if (cryptoPrompt.isDeviceCredentialOperation) {
context?.let { context ->
val keyGuardManager = ContextCompat.getSystemService(
context,
KeyguardManager::class.java
)
@Suppress("DEPRECATION")
mDeviceCredentialResultLauncher.launch(
keyGuardManager?.createConfirmDeviceCredentialIntent(
promptTitle,
promptDescription
)
)
}
}
}
fun closeBiometricPrompt() {
mBiometricPrompt?.cancelAuthentication()
}
private var currentCredentialMode = DeviceUnlockMode.BIOMETRIC_UNAVAILABLE
private fun toggleDeviceCredentialMode(deviceUnlockMode: DeviceUnlockMode) {
if (currentCredentialMode == deviceUnlockMode) {
return
}
currentCredentialMode = deviceUnlockMode
try {
when (deviceUnlockMode) {
DeviceUnlockMode.BIOMETRIC_UNAVAILABLE -> setNotAvailableMode()
DeviceUnlockMode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> setSecurityUpdateRequiredMode()
DeviceUnlockMode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED -> setNotConfiguredMode()
DeviceUnlockMode.KEY_MANAGER_UNAVAILABLE -> setKeyManagerNotAvailableMode()
DeviceUnlockMode.WAIT_CREDENTIAL -> setWaitCredentialMode()
DeviceUnlockMode.STORE_CREDENTIAL -> setStoreCredentialMode()
DeviceUnlockMode.EXTRACT_CREDENTIAL -> setExtractCredentialMode()
}
} catch (e: Exception) {
showGenericException(e)
}
}
private fun manageEncryptionPrompt(cryptoPrompt: DeviceUnlockCryptoPrompt) {
if (cryptoPrompt.isDeviceCredentialOperation) {
keepConnection = true
}
storeCredentialButtonClickListener = View.OnClickListener { _ ->
try {
openAdvancedUnlockPrompt(
cryptoPrompt,
storeAuthenticationCallback
)
} catch (e: Exception) {
Log.e(TAG, "Unable to open encryption prompt", e)
storeCredentialButtonClickListener = null
setAdvancedUnlockedTitleView(R.string.advanced_unlock_prompt_not_initialized)
}
}
}
private fun openExtractPrompt(cryptoPrompt: DeviceUnlockCryptoPrompt) {
try {
openAdvancedUnlockPrompt(
cryptoPrompt,
extractAuthenticationCallback
)
} catch (e: Exception) {
Log.e(TAG, "Unable to open decryption prompt", e)
extractCredentialButtonClickListener = null
setAdvancedUnlockedTitleView(R.string.advanced_unlock_prompt_not_initialized)
}
}
private fun manageDecryptionPrompt(cryptoPrompt: DeviceUnlockCryptoPrompt) {
// Set listener to open the biometric dialog and check credential
extractCredentialButtonClickListener = View.OnClickListener { _ ->
openExtractPrompt(cryptoPrompt)
}
// Auto open the biometric prompt
if (mDeviceUnlockViewModel.allowAutoOpenBiometricPrompt
&& PreferencesUtil.isAdvancedUnlockPromptAutoOpenEnable(requireContext())) {
mDeviceUnlockViewModel.allowAutoOpenBiometricPrompt = false
openExtractPrompt(cryptoPrompt)
}
}
fun showGenericException(e: Exception) {
lifecycleScope.launch(Dispatchers.Main) {
val errorMessage = e.cause?.localizedMessage ?: e.localizedMessage ?: ""
setAdvancedUnlockedMessageView(errorMessage)
}
}
private fun setNotAvailableMode() {
lifecycleScope.launch(Dispatchers.Main) {
showViews(false)
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener(null)
}
}
private fun openBiometricSetting() {
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener {
try {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
context?.startActivity(Intent(Settings.ACTION_BIOMETRIC_ENROLL))
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> {
@Suppress("DEPRECATION") context
?.startActivity(Intent(Settings.ACTION_FINGERPRINT_ENROLL))
}
else -> {
context?.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
}
}
} catch (e: Exception) {
// ACTION_SECURITY_SETTINGS does not contain fingerprint enrollment on some devices...
context?.startActivity(Intent(Settings.ACTION_SETTINGS))
}
}
}
private fun setSecurityUpdateRequiredMode() {
lifecycleScope.launch(Dispatchers.Main) {
showViews(true)
setAdvancedUnlockedTitleView(R.string.biometric_security_update_required)
openBiometricSetting()
}
}
private fun setNotConfiguredMode() {
lifecycleScope.launch(Dispatchers.Main) {
showViews(true)
setAdvancedUnlockedTitleView(R.string.configure_biometric)
setAdvancedUnlockedMessageView("")
openBiometricSetting()
}
}
private fun setKeyManagerNotAvailableMode() {
lifecycleScope.launch(Dispatchers.Main) {
showViews(true)
setAdvancedUnlockedTitleView(R.string.keystore_not_accessible)
openBiometricSetting()
}
}
private fun setWaitCredentialMode() {
lifecycleScope.launch(Dispatchers.Main) {
showViews(true)
setAdvancedUnlockedTitleView(R.string.unavailable)
setAdvancedUnlockedMessageView("")
context?.let { context ->
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener {
showError(
BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
context.getString(R.string.credential_before_click_advanced_unlock_button)
)
}
}
}
}
private fun showError(errorCode: Int, errString: CharSequence) {
lifecycleScope.launch(Dispatchers.Main) {
Log.e(TAG, "Biometric authentication error. Code : $errorCode Error : $errString")
setAdvancedUnlockedMessageView(errString.toString())
}
}
private fun setStoreCredentialMode() {
lifecycleScope.launch(Dispatchers.Main) {
showViews(true)
setAdvancedUnlockedTitleView(R.string.unlock_and_link_biometric)
setAdvancedUnlockedMessageView("")
context?.let { context ->
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener { view ->
storeCredentialButtonClickListener?.onClick(view) ?: run {
showError(
BiometricPrompt.ERROR_HW_UNAVAILABLE,
context.getString(R.string.keystore_not_accessible)
)
}
storeCredentialButtonClickListener = null
}
}
}
}
private fun setExtractCredentialMode() {
lifecycleScope.launch(Dispatchers.Main) {
showViews(true)
setAdvancedUnlockedTitleView(R.string.unlock)
setAdvancedUnlockedMessageView("")
context?.let { context ->
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener { view ->
extractCredentialButtonClickListener?.onClick(view) ?: run {
showError(
BiometricPrompt.ERROR_HW_UNAVAILABLE,
context.getString(R.string.keystore_not_accessible)
)
}
extractCredentialButtonClickListener = null
}
}
}
}
fun deleteEncryptedDatabaseKey() {
mDeviceUnlockViewModel.deleteEncryptedDatabaseKey()
}
private fun showViews(show: Boolean) {
lifecycleScope.launch(Dispatchers.Main) {
if (show) {
if (mDeviceUnlockView?.visibility != View.VISIBLE)
mDeviceUnlockView?.showByFading()
}
else {
if (mDeviceUnlockView?.visibility == View.VISIBLE)
mDeviceUnlockView?.hideByFading()
}
}
}
private fun setAdvancedUnlockedTitleView(textId: Int) {
lifecycleScope.launch(Dispatchers.Main) {
mDeviceUnlockView?.setTitle(textId)
}
}
private fun setAdvancedUnlockedMessageView(textId: Int) {
lifecycleScope.launch(Dispatchers.Main) {
mDeviceUnlockView?.setMessage(textId)
}
}
private fun setAdvancedUnlockedMessageView(text: CharSequence?) {
lifecycleScope.launch(Dispatchers.Main) {
mDeviceUnlockView?.setMessage(text)
}
}
private fun setAdvancedUnlockedError(exception: Exception?) {
when (exception) {
is UnrecoverableKeyException -> {
setAdvancedUnlockedMessageView(R.string.advanced_unlock_invalid_key)
}
is KeyPermanentlyInvalidatedException -> {
setAdvancedUnlockedMessageView(R.string.advanced_unlock_invalid_key)
}
else -> {
setAdvancedUnlockedMessageView(
exception?.cause?.localizedMessage
?: exception?.localizedMessage
?: "")
}
}
}
private fun setAuthenticationError(errorCode: Int, errString: CharSequence) {
lifecycleScope.launch(Dispatchers.Main) {
Log.e(TAG, "Biometric authentication error. Code : $errorCode Error : $errString")
setAdvancedUnlockedMessageView(errString.toString())
}
}
private fun setAuthenticationFailed() {
lifecycleScope.launch(Dispatchers.Main) {
Log.e(TAG, "Biometric authentication failed, biometric not recognized")
setAdvancedUnlockedMessageView(R.string.advanced_unlock_not_recognized)
}
}
override fun onPause() {
if (!keepConnection) {
// If close prompt, bug "user not authenticated in Android R"
mDeviceUnlockViewModel.disconnect()
}
super.onPause()
}
override fun onDestroyView() {
mDeviceUnlockView = null
super.onDestroyView()
}
override fun onDestroy() {
mDeviceUnlockViewModel.disconnect()
super.onDestroy()
}
companion object {
private val TAG = DeviceUnlockFragment::class.java.name
}
}

View File

@@ -21,7 +21,6 @@ package com.kunzisoft.keepass.biometric
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
@@ -29,110 +28,70 @@ import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.*
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
import com.kunzisoft.keepass.settings.PreferencesUtil
import java.security.KeyStore
import java.security.UnrecoverableKeyException
import java.util.concurrent.Executors
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
@RequiresApi(api = Build.VERSION_CODES.M)
class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity) {
class DeviceUnlockManager(private var appContext: Context) {
private var keyStore: KeyStore? = null
private var keyGenerator: KeyGenerator? = null
private var cipher: Cipher? = null
private var biometricPrompt: BiometricPrompt? = null
private var authenticationCallback = object: BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
advancedUnlockCallback?.onAuthenticationSucceeded()
}
override fun onAuthenticationFailed() {
advancedUnlockCallback?.onAuthenticationFailed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
advancedUnlockCallback?.onAuthenticationError(errorCode, errString)
}
}
var advancedUnlockCallback: AdvancedUnlockCallback? = null
private var isKeyManagerInit = false
private val biometricUnlockEnable = PreferencesUtil.isBiometricUnlockEnable(retrieveContext())
private val deviceCredentialUnlockEnable = PreferencesUtil.isDeviceCredentialUnlockEnable(retrieveContext())
val isKeyManagerInitialized: Boolean
get() {
if (!isKeyManagerInit) {
advancedUnlockCallback?.onGenericException(Exception("Biometric not initialized"))
}
return isKeyManagerInit
}
private fun isBiometricOperation(): Boolean {
return biometricUnlockEnable || isDeviceCredentialBiometricOperation()
}
// Since Android 30, device credential is also a biometric operation
private fun isDeviceCredentialOperation(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R
&& deviceCredentialUnlockEnable
}
private fun isDeviceCredentialBiometricOperation(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
&& deviceCredentialUnlockEnable
}
private var biometricUnlockEnable = isBiometricUnlockEnable(appContext)
private var deviceCredentialUnlockEnable = isDeviceCredentialUnlockEnable(appContext)
init {
if (isDeviceSecure(retrieveContext())
&& (biometricUnlockEnable || deviceCredentialUnlockEnable)) {
if (biometricUnlockEnable || deviceCredentialUnlockEnable) {
if (isDeviceSecure(appContext)) {
try {
this.keyStore = KeyStore.getInstance(ADVANCED_UNLOCK_KEYSTORE)
this.keyGenerator = KeyGenerator.getInstance(ADVANCED_UNLOCK_KEY_ALGORITHM, ADVANCED_UNLOCK_KEYSTORE)
this.keyGenerator = KeyGenerator.getInstance(
ADVANCED_UNLOCK_KEY_ALGORITHM,
ADVANCED_UNLOCK_KEYSTORE
)
this.cipher = Cipher.getInstance(
ADVANCED_UNLOCK_KEY_ALGORITHM + "/"
+ ADVANCED_UNLOCK_BLOCKS_MODES + "/"
+ ADVANCED_UNLOCK_ENCRYPTION_PADDING)
isKeyManagerInit = (keyStore != null
&& keyGenerator != null
&& cipher != null)
+ ADVANCED_UNLOCK_ENCRYPTION_PADDING
)
if (keyStore == null) {
throw SecurityException("Unable to initialize the keystore")
}
if (keyGenerator == null) {
throw SecurityException("Unable to initialize the key generator")
}
if (cipher == null) {
throw SecurityException("Unable to initialize the cipher")
}
} catch (e: Exception) {
Log.e(TAG, "Unable to initialize the keystore", e)
isKeyManagerInit = false
advancedUnlockCallback?.onGenericException(e)
Log.e(TAG, "Unable to initialize the device unlock manager", e)
throw e
}
} else {
// really not much to do when no fingerprint support found
isKeyManagerInit = false
throw SecurityException("Device not secure enough")
}
}
}
@Synchronized private fun getSecretKey(): SecretKey? {
if (!isKeyManagerInitialized) {
return null
}
try {
// Create new key if needed
keyStore?.let { keyStore ->
keyStore.load(null)
try {
if (!keyStore.containsAlias(ADVANCED_UNLOCK_KEYSTORE_KEY)) {
// Set the alias of the entry in Android KeyStore where the key will appear
@@ -151,7 +110,7 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
}
// To store in the security chip
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
&& retrieveContext().packageManager.hasSystemFeature(
&& appContext.packageManager.hasSystemFeature(
PackageManager.FEATURE_STRONGBOX_KEYSTORE)) {
setIsStrongBoxBacked(true)
}
@@ -161,98 +120,111 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
}
} catch (e: Exception) {
Log.e(TAG, "Unable to create a key in keystore", e)
advancedUnlockCallback?.onGenericException(e)
throw e
}
return keyStore.getKey(ADVANCED_UNLOCK_KEYSTORE_KEY, null) as SecretKey?
}
} catch (e: Exception) {
Log.e(TAG, "Unable to retrieve the key in keystore", e)
advancedUnlockCallback?.onGenericException(e)
throw e
}
return null
}
@Synchronized fun initEncryptData(actionIfCypherInit: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit,) {
@Synchronized fun initEncryptData(
actionIfCypherInit: (cryptoPrompt: DeviceUnlockCryptoPrompt) -> Unit
) {
initEncryptData(actionIfCypherInit, true)
}
@Synchronized private fun initEncryptData(actionIfCypherInit: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit,
firstLaunch: Boolean) {
if (!isKeyManagerInitialized) {
return
}
@Synchronized private fun initEncryptData(
actionIfCypherInit: (cryptoPrompt: DeviceUnlockCryptoPrompt) -> Unit,
firstLaunch: Boolean
) {
try {
getSecretKey()?.let { secretKey ->
cipher?.let { cipher ->
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
actionIfCypherInit.invoke(
AdvancedUnlockCryptoPrompt(
cipher,
R.string.advanced_unlock_prompt_store_credential_title,
R.string.advanced_unlock_prompt_store_credential_message,
isDeviceCredentialOperation(), isBiometricOperation())
DeviceUnlockCryptoPrompt(
type = DeviceUnlockCryptoPromptType.CREDENTIAL_ENCRYPTION,
cipher = cipher,
titleId = R.string.advanced_unlock_prompt_store_credential_title,
descriptionId = R.string.advanced_unlock_prompt_store_credential_message,
isDeviceCredentialOperation = isDeviceCredentialOperation(
deviceCredentialUnlockEnable
),
isBiometricOperation = isBiometricOperation(
biometricUnlockEnable, deviceCredentialUnlockEnable
)
)
)
}
}
} catch (unrecoverableKeyException: UnrecoverableKeyException) {
Log.e(TAG, "Unable to initialize encrypt data", unrecoverableKeyException)
advancedUnlockCallback?.onUnrecoverableKeyException(unrecoverableKeyException)
throw unrecoverableKeyException
} catch (invalidKeyException: KeyPermanentlyInvalidatedException) {
Log.e(TAG, "Unable to initialize encrypt data", invalidKeyException)
if (firstLaunch) {
deleteAllEntryKeysInKeystoreForBiometric(retrieveContext())
deleteAllEntryKeysInKeystoreForBiometric(appContext)
initEncryptData(actionIfCypherInit, false)
} else {
advancedUnlockCallback?.onInvalidKeyException(invalidKeyException)
throw invalidKeyException
}
} catch (e: Exception) {
Log.e(TAG, "Unable to initialize encrypt data", e)
advancedUnlockCallback?.onGenericException(e)
throw e
}
}
@Synchronized fun encryptData(value: ByteArray) {
if (!isKeyManagerInitialized) {
return
}
@Synchronized fun encryptData(
value: ByteArray,
handleEncryptedResult: (encryptedValue: ByteArray, ivSpec: ByteArray) -> Unit
) {
try {
val encrypted = cipher?.doFinal(value) ?: byteArrayOf()
// passes updated iv spec on to callback so this can be stored for decryption
cipher?.parameters?.getParameterSpec(IvParameterSpec::class.java)?.let{ spec ->
advancedUnlockCallback?.handleEncryptedResult(encrypted, spec.iv)
handleEncryptedResult.invoke(encrypted, spec.iv)
}
} catch (e: Exception) {
Log.e(TAG, "Unable to encrypt data", e)
advancedUnlockCallback?.onGenericException(e)
throw e
}
}
@Synchronized fun initDecryptData(ivSpecValue: ByteArray,
actionIfCypherInit: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit) {
@Synchronized fun initDecryptData(
ivSpecValue: ByteArray,
actionIfCypherInit: (cryptoPrompt: DeviceUnlockCryptoPrompt) -> Unit
) {
initDecryptData(ivSpecValue, actionIfCypherInit, true)
}
@Synchronized private fun initDecryptData(ivSpecValue: ByteArray,
actionIfCypherInit: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit,
firstLaunch: Boolean = true) {
if (!isKeyManagerInitialized) {
return
}
@Synchronized private fun initDecryptData(
ivSpecValue: ByteArray,
actionIfCypherInit: (cryptoPrompt: DeviceUnlockCryptoPrompt) -> Unit,
firstLaunch: Boolean
) {
try {
// important to restore spec here that was used for decryption
val spec = IvParameterSpec(ivSpecValue)
getSecretKey()?.let { secretKey ->
cipher?.let { cipher ->
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
actionIfCypherInit.invoke(
AdvancedUnlockCryptoPrompt(
cipher,
R.string.advanced_unlock_prompt_extract_credential_title,
null,
isDeviceCredentialOperation(), isBiometricOperation())
DeviceUnlockCryptoPrompt(
type = DeviceUnlockCryptoPromptType.CREDENTIAL_DECRYPTION,
cipher = cipher,
titleId = R.string.advanced_unlock_prompt_extract_credential_title,
descriptionId = null,
isDeviceCredentialOperation = isDeviceCredentialOperation(
deviceCredentialUnlockEnable
),
isBiometricOperation = isBiometricOperation(
biometricUnlockEnable, deviceCredentialUnlockEnable
)
)
)
}
}
@@ -262,37 +234,34 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
deleteKeystoreKey()
initDecryptData(ivSpecValue, actionIfCypherInit, firstLaunch)
} else {
advancedUnlockCallback?.onUnrecoverableKeyException(unrecoverableKeyException)
throw unrecoverableKeyException
}
} catch (invalidKeyException: KeyPermanentlyInvalidatedException) {
Log.e(TAG, "Unable to initialize decrypt data", invalidKeyException)
if (firstLaunch) {
deleteAllEntryKeysInKeystoreForBiometric(retrieveContext())
deleteAllEntryKeysInKeystoreForBiometric(appContext)
initDecryptData(ivSpecValue, actionIfCypherInit, firstLaunch)
} else {
advancedUnlockCallback?.onInvalidKeyException(invalidKeyException)
throw invalidKeyException
}
} catch (e: Exception) {
Log.e(TAG, "Unable to initialize decrypt data", e)
advancedUnlockCallback?.onGenericException(e)
throw e
}
}
@Synchronized fun decryptData(encryptedValue: ByteArray) {
if (!isKeyManagerInitialized) {
return
}
@Synchronized fun decryptData(
encryptedValue: ByteArray,
handleDecryptedResult: (decryptedValue: ByteArray) -> Unit
) {
try {
// actual decryption here
cipher?.doFinal(encryptedValue)?.let { decrypted ->
advancedUnlockCallback?.handleDecryptedResult(decrypted)
handleDecryptedResult.invoke(decrypted)
}
} catch (badPaddingException: BadPaddingException) {
Log.e(TAG, "Unable to decrypt data", badPaddingException)
advancedUnlockCallback?.onInvalidKeyException(badPaddingException)
} catch (e: Exception) {
Log.e(TAG, "Unable to decrypt data", e)
advancedUnlockCallback?.onGenericException(e)
throw e
}
}
@@ -302,71 +271,13 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
keyStore?.deleteEntry(ADVANCED_UNLOCK_KEYSTORE_KEY)
} catch (e: Exception) {
Log.e(TAG, "Unable to delete entry key in keystore", e)
advancedUnlockCallback?.onGenericException(e)
throw e
}
}
@Synchronized fun openAdvancedUnlockPrompt(cryptoPrompt: AdvancedUnlockCryptoPrompt,
deviceCredentialResultLauncher: ActivityResultLauncher<Intent>
) {
// Init advanced unlock prompt
if (biometricPrompt == null) {
biometricPrompt = BiometricPrompt(retrieveContext(),
Executors.newSingleThreadExecutor(),
authenticationCallback)
}
val promptTitle = retrieveContext().getString(cryptoPrompt.promptTitleId)
val promptDescription = cryptoPrompt.promptDescriptionId?.let { descriptionId ->
retrieveContext().getString(descriptionId)
} ?: ""
if (cryptoPrompt.isBiometricOperation) {
val promptInfoExtractCredential = BiometricPrompt.PromptInfo.Builder().apply {
setTitle(promptTitle)
if (promptDescription.isNotEmpty())
setDescription(promptDescription)
setConfirmationRequired(false)
if (isDeviceCredentialBiometricOperation()) {
setAllowedAuthenticators(DEVICE_CREDENTIAL)
} else {
setNegativeButtonText(retrieveContext().getString(android.R.string.cancel))
}
}.build()
biometricPrompt?.authenticate(
promptInfoExtractCredential,
BiometricPrompt.CryptoObject(cryptoPrompt.cipher))
}
else if (cryptoPrompt.isDeviceCredentialOperation) {
val keyGuardManager = ContextCompat.getSystemService(retrieveContext(), KeyguardManager::class.java)
@Suppress("DEPRECATION")
deviceCredentialResultLauncher.launch(
keyGuardManager?.createConfirmDeviceCredentialIntent(promptTitle, promptDescription)
)
}
}
@Synchronized fun closeBiometricPrompt() {
biometricPrompt?.cancelAuthentication()
}
interface AdvancedUnlockErrorCallback {
fun onUnrecoverableKeyException(e: Exception)
fun onInvalidKeyException(e: Exception)
fun onGenericException(e: Exception)
}
interface AdvancedUnlockCallback : AdvancedUnlockErrorCallback {
fun onAuthenticationSucceeded()
fun onAuthenticationFailed()
fun onAuthenticationError(errorCode: Int, errString: CharSequence)
fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray)
fun handleDecryptedResult(decryptedValue: ByteArray)
}
companion object {
private val TAG = AdvancedUnlockManager::class.java.name
private val TAG = DeviceUnlockManager::class.java.name
private const val ADVANCED_UNLOCK_KEYSTORE = "AndroidKeyStore"
private const val ADVANCED_UNLOCK_KEYSTORE_KEY = "com.kunzisoft.keepass.biometric.key"
@@ -445,62 +356,65 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
/**
* Remove entry key in keystore
*/
fun deleteEntryKeyInKeystoreForBiometric(fragmentActivity: FragmentActivity,
advancedCallback: AdvancedUnlockErrorCallback) {
AdvancedUnlockManager{ fragmentActivity }.apply {
advancedUnlockCallback = object : AdvancedUnlockCallback {
override fun onAuthenticationSucceeded() {}
override fun onAuthenticationFailed() {}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {}
override fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray) {}
override fun handleDecryptedResult(decryptedValue: ByteArray) {}
override fun onUnrecoverableKeyException(e: Exception) {
advancedCallback.onUnrecoverableKeyException(e)
}
override fun onInvalidKeyException(e: Exception) {
advancedCallback.onInvalidKeyException(e)
}
override fun onGenericException(e: Exception) {
advancedCallback.onGenericException(e)
}
}
fun deleteEntryKeyInKeystoreForBiometric(
appContext: Context
) {
DeviceUnlockManager(appContext).apply {
deleteKeystoreKey()
}
}
fun deleteAllEntryKeysInKeystoreForBiometric(activity: FragmentActivity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
deleteEntryKeyInKeystoreForBiometric(
activity,
object : AdvancedUnlockErrorCallback {
fun showException(e: Exception) {
Toast.makeText(activity,
activity.getString(R.string.advanced_unlock_scanning_error, e.localizedMessage),
fun deleteAllEntryKeysInKeystoreForBiometric(appContext: Context) {
try {
deleteEntryKeyInKeystoreForBiometric(appContext)
} catch (e: Exception) {
Toast.makeText(appContext,
appContext.getString(
R.string.advanced_unlock_scanning_error,
e.localizedMessage
),
Toast.LENGTH_SHORT).show()
} finally {
CipherDatabaseAction.getInstance(appContext).deleteAll()
}
override fun onUnrecoverableKeyException(e: Exception) {
showException(e)
}
override fun onInvalidKeyException(e: Exception) {
showException(e)
}
override fun onGenericException(e: Exception) {
showException(e)
}
})
}
CipherDatabaseAction.getInstance(activity.applicationContext).deleteAll()
}
}
fun isBiometricUnlockEnable(appContext: Context) =
PreferencesUtil.isBiometricUnlockEnable(appContext)
fun isDeviceCredentialUnlockEnable(appContext: Context) =
PreferencesUtil.isDeviceCredentialUnlockEnable(appContext)
private fun isBiometricOperation(
biometricUnlockEnable: Boolean,
deviceCredentialUnlockEnable: Boolean
): Boolean {
return biometricUnlockEnable
|| isDeviceCredentialBiometricOperation(deviceCredentialUnlockEnable)
}
// Since Android 30, device credential is also a biometric operation
private fun isDeviceCredentialOperation(
deviceCredentialUnlockEnable: Boolean
): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R
&& deviceCredentialUnlockEnable
}
private fun isDeviceCredentialBiometricOperation(
deviceCredentialUnlockEnable: Boolean
): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
&& deviceCredentialUnlockEnable
}
fun isDeviceCredentialBiometricOperation(context: Context?): Boolean {
if (context == null) {
return false
}
return isDeviceCredentialBiometricOperation(
isDeviceCredentialUnlockEnable(context)
)
}

View File

@@ -0,0 +1,11 @@
package com.kunzisoft.keepass.biometric
enum class DeviceUnlockMode {
BIOMETRIC_UNAVAILABLE,
BIOMETRIC_SECURITY_UPDATE_REQUIRED,
DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED,
KEY_MANAGER_UNAVAILABLE,
WAIT_CREDENTIAL,
STORE_CREDENTIAL,
EXTRACT_CREDENTIAL
}

View File

@@ -41,7 +41,7 @@ import com.kunzisoft.keepass.activities.dialogs.ProFeatureDialogFragment
import com.kunzisoft.keepass.activities.dialogs.UnavailableFeatureDialogFragment
import com.kunzisoft.keepass.activities.stylish.Stylish
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
import com.kunzisoft.keepass.biometric.AdvancedUnlockManager
import com.kunzisoft.keepass.biometric.DeviceUnlockManager
import com.kunzisoft.keepass.education.Education
import com.kunzisoft.keepass.icons.IconPackChooser
import com.kunzisoft.keepass.services.ClipboardEntryNotificationService
@@ -251,7 +251,7 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
val tempAdvancedUnlockPreference: TwoStatePreference? = findPreference(getString(R.string.temp_advanced_unlock_enable_key))
val biometricUnlockSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
AdvancedUnlockManager.biometricUnlockSupported(activity)
DeviceUnlockManager.biometricUnlockSupported(activity)
} else false
biometricUnlockEnablePreference?.apply {
// False if under Marshmallow
@@ -296,7 +296,7 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
}
val deviceCredentialUnlockSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
AdvancedUnlockManager.deviceCredentialUnlockSupported(activity)
DeviceUnlockManager.deviceCredentialUnlockSupported(activity)
} else false
deviceCredentialUnlockEnablePreference?.apply {
// Biometric unlock already checked
@@ -395,7 +395,7 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
validate?.invoke()
warningAlertDialog?.setOnDismissListener(null)
if (deleteKeys && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
AdvancedUnlockManager.deleteAllEntryKeysInKeystoreForBiometric(activity)
DeviceUnlockManager.deleteAllEntryKeysInKeystoreForBiometric(activity)
}
}
.setNegativeButton(resources.getString(android.R.string.cancel)

View File

@@ -29,7 +29,7 @@ import androidx.preference.PreferenceManager
import com.kunzisoft.keepass.BuildConfig
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.stylish.Stylish
import com.kunzisoft.keepass.biometric.AdvancedUnlockManager
import com.kunzisoft.keepass.biometric.DeviceUnlockManager
import com.kunzisoft.keepass.database.element.SortNodeEnum
import com.kunzisoft.keepass.database.search.SearchParameters
import com.kunzisoft.keepass.education.Education
@@ -512,7 +512,7 @@ object PreferencesUtil {
return prefs.getBoolean(context.getString(R.string.biometric_unlock_enable_key),
context.resources.getBoolean(R.bool.biometric_unlock_enable_default))
&& (if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
AdvancedUnlockManager.biometricUnlockSupported(context)
DeviceUnlockManager.biometricUnlockSupported(context)
} else {
false
})

View File

@@ -31,7 +31,7 @@ import androidx.annotation.StringRes
import com.kunzisoft.keepass.R
@RequiresApi(api = Build.VERSION_CODES.M)
class AdvancedUnlockInfoView @JvmOverloads constructor(context: Context,
class DeviceUnlockView @JvmOverloads constructor(context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0)
: LinearLayout(context, attrs, defStyle) {
@@ -45,7 +45,7 @@ class AdvancedUnlockInfoView @JvmOverloads constructor(context: Context,
biometricButtonView = findViewById(R.id.biometric_button)
}
fun setIconViewClickListener(listener: OnClickListener?) {
fun setDeviceUnlockButtonViewClickListener(listener: OnClickListener?) {
biometricButtonView?.setOnClickListener(listener)
}
@@ -61,8 +61,8 @@ class AdvancedUnlockInfoView @JvmOverloads constructor(context: Context,
title = context.getString(textId)
}
fun setMessage(text: CharSequence) {
if (text.isNotEmpty())
fun setMessage(text: CharSequence?) {
if (!text.isNullOrEmpty())
Toast.makeText(context, text, Toast.LENGTH_LONG).show()
}

View File

@@ -1,152 +0,0 @@
package com.kunzisoft.keepass.viewmodels
import android.net.Uri
import androidx.lifecycle.ViewModel
import com.kunzisoft.keepass.model.CipherDecryptDatabase
import com.kunzisoft.keepass.model.CipherEncryptDatabase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class AdvancedUnlockViewModel : ViewModel() {
var allowAutoOpenBiometricPrompt : Boolean = true
var deviceCredentialAuthSucceeded: Boolean? = null
private val _uiState = MutableStateFlow(DeviceUnlockState())
val uiState: StateFlow<DeviceUnlockState> = _uiState
fun checkUnlockAvailability(conditionToStoreCredentialVerified: Boolean? = null) {
_uiState.update { currentState ->
currentState.copy(
onUnlockAvailabilityCheckRequested = true,
isConditionToStoreCredentialVerified = conditionToStoreCredentialVerified
?: _uiState.value.isConditionToStoreCredentialVerified
)
}
}
fun consumeCheckUnlockAvailability() {
_uiState.update { currentState ->
currentState.copy(
onUnlockAvailabilityCheckRequested = false
)
}
}
fun databaseFileLoaded(databaseUri: Uri?) {
_uiState.update { currentState ->
currentState.copy(
databaseFileLoaded = databaseUri
)
}
}
fun consumeDatabaseFileLoaded() {
_uiState.update { currentState ->
currentState.copy(
databaseFileLoaded = null
)
}
}
fun retrieveCredentialForEncryption() {
_uiState.update { currentState ->
currentState.copy(
isCredentialRequired = true,
credential = null
)
}
}
fun provideCredentialForEncryption(credential: ByteArray) {
_uiState.update { currentState ->
currentState.copy(
isCredentialRequired = false,
credential = credential
)
}
}
fun consumeCredentialForEncryption() {
_uiState.update { currentState ->
currentState.copy(
isCredentialRequired = false,
credential = null
)
}
}
fun onCredentialEncrypted(cipherEncryptDatabase: CipherEncryptDatabase) {
_uiState.update { currentState ->
currentState.copy(
cipherEncryptDatabase = cipherEncryptDatabase
)
}
}
fun consumeCredentialEncrypted() {
_uiState.update { currentState ->
currentState.copy(
cipherEncryptDatabase = null
)
}
}
fun onCredentialDecrypted(cipherDecryptDatabase: CipherDecryptDatabase) {
_uiState.update { currentState ->
currentState.copy(
cipherDecryptDatabase = cipherDecryptDatabase
)
}
}
fun consumeCredentialDecrypted() {
_uiState.update { currentState ->
currentState.copy(
cipherDecryptDatabase = null
)
}
}
}
data class DeviceUnlockState(
val initAdvancedUnlockMode: Boolean = false,
val databaseFileLoaded: Uri? = null,
val isCredentialRequired: Boolean = false,
val credential: ByteArray? = null,
val isConditionToStoreCredentialVerified: Boolean = false,
val onUnlockAvailabilityCheckRequested: Boolean = false,
val cipherEncryptDatabase: CipherEncryptDatabase? = null,
val cipherDecryptDatabase: CipherDecryptDatabase? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DeviceUnlockState
if (initAdvancedUnlockMode != other.initAdvancedUnlockMode) return false
if (isCredentialRequired != other.isCredentialRequired) return false
if (isConditionToStoreCredentialVerified != other.isConditionToStoreCredentialVerified) return false
if (onUnlockAvailabilityCheckRequested != other.onUnlockAvailabilityCheckRequested) return false
if (databaseFileLoaded != other.databaseFileLoaded) return false
if (!credential.contentEquals(other.credential)) return false
if (cipherEncryptDatabase != other.cipherEncryptDatabase) return false
if (cipherDecryptDatabase != other.cipherDecryptDatabase) return false
return true
}
override fun hashCode(): Int {
var result = initAdvancedUnlockMode.hashCode()
result = 31 * result + isCredentialRequired.hashCode()
result = 31 * result + isConditionToStoreCredentialVerified.hashCode()
result = 31 * result + onUnlockAvailabilityCheckRequested.hashCode()
result = 31 * result + (databaseFileLoaded?.hashCode() ?: 0)
result = 31 * result + (credential?.contentHashCode() ?: 0)
result = 31 * result + (cipherEncryptDatabase?.hashCode() ?: 0)
result = 31 * result + (cipherDecryptDatabase?.hashCode() ?: 0)
return result
}
}

View File

@@ -0,0 +1,388 @@
package com.kunzisoft.keepass.viewmodels
import android.app.Application
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.lifecycle.AndroidViewModel
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
import com.kunzisoft.keepass.biometric.DeviceUnlockCryptoPrompt
import com.kunzisoft.keepass.biometric.DeviceUnlockManager
import com.kunzisoft.keepass.biometric.DeviceUnlockMode
import com.kunzisoft.keepass.database.exception.UnknownDatabaseLocationException
import com.kunzisoft.keepass.model.CipherDecryptDatabase
import com.kunzisoft.keepass.model.CipherEncryptDatabase
import com.kunzisoft.keepass.model.CredentialStorage
import com.kunzisoft.keepass.settings.PreferencesUtil
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class DeviceUnlockViewModel(application: Application): AndroidViewModel(application) {
var allowAutoOpenBiometricPrompt : Boolean = true
var deviceCredentialAuthSucceeded: Boolean? = null
private var cipherDatabaseListener: CipherDatabaseAction.CipherDatabaseListener? = null
private var isConditionToStoreCredentialVerified: Boolean = false
private var deviceUnlockManager: DeviceUnlockManager? = null
private var databaseUri: Uri? = null
// TODO Retrieve credential storage from app database
var credentialDatabaseStorage: CredentialStorage = CredentialStorage.DEFAULT
val cipherDatabaseAction = CipherDatabaseAction.getInstance(getApplication())
private val _uiState = MutableStateFlow(DeviceUnlockState())
val uiState: StateFlow<DeviceUnlockState> = _uiState
fun checkConditionToStoreCredential(condition: Boolean, databaseFileUri: Uri?) {
isConditionToStoreCredentialVerified = condition
checkUnlockAvailability(databaseFileUri)
}
/**
* Check unlock availability and change the current mode depending of device's state
*/
fun checkUnlockAvailability(databaseFileUri: Uri?) {
databaseUri = databaseFileUri
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
cipherDatabaseAction.containsCipherDatabase(databaseFileUri) { containsCipherDatabase ->
if (PreferencesUtil.isBiometricUnlockEnable(getApplication())) {
// biometric not supported (by API level or hardware) so keep option hidden
// or manually disable
val biometricCanAuthenticate = DeviceUnlockManager.canAuthenticate(getApplication())
if (!PreferencesUtil.isAdvancedUnlockEnable(getApplication())
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE) {
changeMode(DeviceUnlockMode.BIOMETRIC_UNAVAILABLE)
} else if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED) {
changeMode(DeviceUnlockMode.BIOMETRIC_SECURITY_UPDATE_REQUIRED)
} else {
// biometric is available but not configured, show icon but in disabled state with some information
if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) {
changeMode(DeviceUnlockMode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
} else {
selectMode(containsCipherDatabase)
}
}
} else if (PreferencesUtil.isDeviceCredentialUnlockEnable(getApplication())) {
if (DeviceUnlockManager.isDeviceSecure(getApplication())) {
selectMode(containsCipherDatabase)
} else {
changeMode(DeviceUnlockMode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
}
}
}
}
}
private fun isModeChanging(newMode: DeviceUnlockMode): Boolean {
return _uiState.value.deviceUnlockMode != newMode
}
@RequiresApi(Build.VERSION_CODES.M)
fun selectMode(containsCipherDatabase: Boolean) {
try {
if (isConditionToStoreCredentialVerified) {
if (deviceUnlockManager == null
|| isModeChanging(DeviceUnlockMode.STORE_CREDENTIAL)) {
deviceUnlockManager = DeviceUnlockManager(getApplication())
}
// listen for encryption
changeMode(DeviceUnlockMode.STORE_CREDENTIAL)
initEncryptData()
} else if (containsCipherDatabase) {
if (deviceUnlockManager == null
|| isModeChanging(DeviceUnlockMode.EXTRACT_CREDENTIAL)) {
deviceUnlockManager = DeviceUnlockManager(getApplication())
}
// biometric available but no stored password found yet for this DB
// listen for decryption
changeMode(DeviceUnlockMode.EXTRACT_CREDENTIAL)
initDecryptData()
} else {
// wait for typing
changeMode(DeviceUnlockMode.WAIT_CREDENTIAL)
}
} catch (e: Exception) {
changeMode(DeviceUnlockMode.KEY_MANAGER_UNAVAILABLE)
setException(e)
}
}
fun connect(databaseUri: Uri) {
this.databaseUri = databaseUri
cipherDatabaseListener = object: CipherDatabaseAction.CipherDatabaseListener {
override fun onCipherDatabaseCleared() {
closeBiometricPrompt()
checkUnlockAvailability(databaseUri)
}
}
cipherDatabaseAction.apply {
reloadPreferences()
cipherDatabaseListener?.let {
registerDatabaseListener(it)
}
}
checkUnlockAvailability(databaseUri)
}
fun disconnect() {
this.databaseUri = null
closeBiometricPrompt()
cipherDatabaseListener?.let {
cipherDatabaseAction.unregisterDatabaseListener(it)
}
reset()
}
fun databaseFileLoaded(databaseUri: Uri?) {
// To get device credential unlock result, only if same database uri
if (databaseUri != null
&& PreferencesUtil.isAdvancedUnlockEnable(getApplication())) {
deviceCredentialAuthSucceeded?.let { authSucceeded ->
if (databaseUri == this.databaseUri) {
if (authSucceeded) {
retrieveCredentialForEncryption()
}
} else {
disconnect()
}
} ?: run {
if (databaseUri != this.databaseUri) {
connect(databaseUri)
}
}
} else {
disconnect()
}
deviceCredentialAuthSucceeded = null
}
fun retrieveCredentialForEncryption() {
_uiState.update { currentState ->
currentState.copy(
isCredentialRequired = true
)
}
}
@RequiresApi(Build.VERSION_CODES.M)
fun encryptCredential(
credential: ByteArray
) {
try {
deviceUnlockManager?.encryptData(
value = credential,
handleEncryptedResult = { encryptedValue, ivSpec ->
databaseUri?.let { databaseUri ->
onCredentialEncrypted(
CipherEncryptDatabase().apply {
this.databaseUri = databaseUri
this.credentialStorage = credentialDatabaseStorage
this.encryptedValue = encryptedValue
this.specParameters = ivSpec
}
)
}
}
)
} catch (e: Exception) {
setException(e)
}
_uiState.update { currentState ->
currentState.copy(
isCredentialRequired = false
)
}
}
@RequiresApi(Build.VERSION_CODES.M)
fun decryptCredential() {
// retrieve the encrypted value from preferences
databaseUri?.let { databaseUri ->
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
cipherDatabase?.encryptedValue?.let { encryptedCredential ->
try {
deviceUnlockManager?.decryptData(
encryptedValue = encryptedCredential,
handleDecryptedResult = { decryptedValue ->
// Load database directly with password retrieve
onCredentialDecrypted(
CipherDecryptDatabase().apply {
this.databaseUri = databaseUri
this.credentialStorage = credentialDatabaseStorage
this.decryptedValue = decryptedValue
}
)
cipherDatabaseAction.resetCipherParameters(databaseUri)
}
)
} catch (e: Exception) {
setException(e)
}
} ?: deleteEncryptedDatabaseKey()
}
} ?: run {
setException(UnknownDatabaseLocationException())
}
}
fun onCredentialEncrypted(cipherEncryptDatabase: CipherEncryptDatabase) {
_uiState.update { currentState ->
currentState.copy(
cipherEncryptDatabase = cipherEncryptDatabase
)
}
}
fun consumeCredentialEncrypted() {
_uiState.update { currentState ->
currentState.copy(
cipherEncryptDatabase = null
)
}
}
fun onCredentialDecrypted(cipherDecryptDatabase: CipherDecryptDatabase) {
_uiState.update { currentState ->
currentState.copy(
cipherDecryptDatabase = cipherDecryptDatabase
)
}
}
fun consumeCredentialDecrypted() {
_uiState.update { currentState ->
currentState.copy(
cipherDecryptDatabase = null
)
}
}
fun onPromptRequested(cryptoPrompt: DeviceUnlockCryptoPrompt) {
_uiState.update { currentState ->
currentState.copy(
cryptoPrompt = cryptoPrompt
)
}
}
fun promptShown() {
_uiState.update { currentState ->
currentState.copy(
cryptoPrompt = null
)
}
}
fun setException(value: Exception?) {
_uiState.update { currentState ->
currentState.copy(
error = value
)
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initEncryptData() {
try {
deviceUnlockManager?.initEncryptData { cryptoPrompt ->
onPromptRequested(cryptoPrompt)
} ?: setException(Exception("AdvancedUnlockManager not initialized"))
} catch (e: Exception) {
setException(e)
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initDecryptData() {
databaseUri?.let { databaseUri ->
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
cipherDatabase?.let {
try {
deviceUnlockManager?.initDecryptData(cipherDatabase.specParameters) { cryptoPrompt ->
onPromptRequested(cryptoPrompt)
} ?: setException(Exception("AdvancedUnlockManager not initialized"))
} catch (e: Exception) {
setException(e)
}
} ?: deleteEncryptedDatabaseKey()
}
} ?: setException(UnknownDatabaseLocationException())
}
@RequiresApi(Build.VERSION_CODES.M)
private fun changeMode(deviceUnlockMode: DeviceUnlockMode) {
cipherDatabaseAction.containsCipherDatabase(databaseUri) { containsCipher ->
_uiState.update { currentState ->
currentState.copy(
deviceUnlockMode = deviceUnlockMode,
allowAdvancedUnlockMenu = containsCipher
&& deviceUnlockMode != DeviceUnlockMode.BIOMETRIC_UNAVAILABLE
&& deviceUnlockMode != DeviceUnlockMode.KEY_MANAGER_UNAVAILABLE
)
}
}
}
fun deleteEncryptedDatabaseKey() {
closeBiometricPrompt()
databaseUri?.let { databaseUri ->
cipherDatabaseAction.deleteByDatabaseUri(databaseUri) {
checkUnlockAvailability(databaseUri)
}
} ?: checkUnlockAvailability(null)
_uiState.update { currentState ->
currentState.copy(
allowAdvancedUnlockMenu = false
)
}
}
fun closeBiometricPrompt() {
_uiState.update { currentState ->
currentState.copy(
cryptoPrompt = null,
closePromptRequested = true
)
}
}
fun biometricPromptClosed() {
_uiState.update { currentState ->
currentState.copy(
closePromptRequested = false
)
}
}
fun reset() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
changeMode(DeviceUnlockMode.BIOMETRIC_UNAVAILABLE)
}
}
override fun onCleared() {
super.onCleared()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
deviceUnlockManager = null
}
}
}
data class DeviceUnlockState(
val deviceUnlockMode: DeviceUnlockMode = DeviceUnlockMode.BIOMETRIC_UNAVAILABLE,
val allowAdvancedUnlockMenu: Boolean = false,
val isCredentialRequired: Boolean = false,
val cipherEncryptDatabase: CipherEncryptDatabase? = null,
val cipherDecryptDatabase: CipherDecryptDatabase? = null,
val cryptoPrompt: DeviceUnlockCryptoPrompt? = null,
val closePromptRequested: Boolean = false,
val error: Exception? = null
)

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<com.kunzisoft.keepass.view.AdvancedUnlockInfoView
<com.kunzisoft.keepass.view.DeviceUnlockView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/advanced_unlock_view"
android:layout_width="match_parent"

View File

@@ -5,8 +5,8 @@
<string name="colorize_password_summary">Colourise password characters by type</string>
<string name="content_description_entry_background_color">Entry background colour</string>
<string name="invalid_db_sig">Could not recognise the database format.</string>
<string name="advanced_unlock_not_recognized">Could not recognise advanced unlock print</string>
<string name="advanced_unlock_prompt_not_initialized">Unable to initialise advanced unlock prompt.</string>
<string name="advanced_unlock_not_recognized">Could not recognise device unlock print</string>
<string name="advanced_unlock_prompt_not_initialized">Unable to initialise device unlock prompt.</string>
<string name="download_initialization">Initialising…</string>
<string name="download_finalization">Finalising…</string>
<string name="download_canceled">Cancelled!</string>

View File

@@ -1 +1 @@
*
* Fix UnlockManager #2098 #2101

View File

@@ -1 +1 @@
*
* Correction UnlockManager #2098 #2101