diff --git a/.idea/.gitignore b/.idea/.gitignore index 26d33521..8f00030d 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -1,3 +1,5 @@ # Default ignored files /shelf/ /workspace.xml +# GitHub Copilot persisted chat sessions +/copilot/chatSessions diff --git a/tangem-sdk-android/src/main/java/com/tangem/sdk/authentication/AndroidAuthenticationManager.kt b/tangem-sdk-android/src/main/java/com/tangem/sdk/authentication/AndroidAuthenticationManager.kt index ea4f4bbe..823fd940 100644 --- a/tangem-sdk-android/src/main/java/com/tangem/sdk/authentication/AndroidAuthenticationManager.kt +++ b/tangem-sdk-android/src/main/java/com/tangem/sdk/authentication/AndroidAuthenticationManager.kt @@ -5,7 +5,6 @@ import androidx.annotation.RequiresApi import androidx.biometric.BiometricPrompt import androidx.fragment.app.FragmentActivity import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.tangem.Log @@ -13,17 +12,19 @@ import com.tangem.common.authentication.AuthenticationManager import com.tangem.common.core.TangemError import com.tangem.common.core.TangemSdkError import com.tangem.sdk.R +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import javax.crypto.Cipher import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlin.time.Duration +import kotlin.time.ExperimentalTime import androidx.biometric.BiometricManager as SystemBiometricManager @RequiresApi(Build.VERSION_CODES.M) @@ -38,204 +39,227 @@ internal class AndroidAuthenticationManager( .setTitle(activity.getString(R.string.biometric_prompt_title)) .setNegativeButtonText(activity.getString(R.string.common_cancel)) .setAllowedAuthenticators(SystemBiometricManager.Authenticators.BIOMETRIC_STRONG) + .setConfirmationRequired(false) .build() } - private val authenticationMutex = Mutex() - private val canAuthenticateInternal: MutableStateFlow = MutableStateFlow(value = null) - private val needEnrollBiometricsInternal: MutableStateFlow = MutableStateFlow(value = null) + private val biometricsStatus = MutableStateFlow(BiometricsStatus.UNINITIALIZED) override val canAuthenticate: Boolean - get() = requireNotNull(canAuthenticateInternal.value) { - "`canAuthenticate` has not been initialized" + get() { + if (biometricsStatus.value == BiometricsStatus.UNAVAILABLE) { + error("Biometrics status must be initialized before checking if biometrics can authenticate") + } + + return biometricsStatus.value == BiometricsStatus.READY } + override val needEnrollBiometrics: Boolean - get() = requireNotNull(needEnrollBiometricsInternal.value) { - "`needEnrollBiometrics` has not been initialized" + get() { + if (biometricsStatus.value == BiometricsStatus.UNAVAILABLE) { + error("Biometrics status must be initialized before checking if biometrics need to be enrolled") + } + + return biometricsStatus.value == BiometricsStatus.NEED_ENROLL } override fun onResume(owner: LifecycleOwner) { owner.lifecycleScope.launch { - val (available, canBeEnrolled) = checkBiometricsAvailabilityStatus() + biometricsStatus.value = getBiometricsAvailabilityStatus() - canAuthenticateInternal.value = available - needEnrollBiometricsInternal.value = canBeEnrolled + Log.biometric { "Owner has been resumed, biometrics status: ${biometricsStatus.value}" } } } - override suspend fun authenticate() { - if (lifecycle.currentState != Lifecycle.State.RESUMED) return + override fun onPause(owner: LifecycleOwner) { + Log.biometric { "Owner has been paused, biometrics was uninitialized" } - if (authenticationMutex.isLocked) { - Log.warning { "$TAG - A user authentication has already been launched" } - Log.biometric { "A user authentication has already been launched" } - return - } + biometricsStatus.value = BiometricsStatus.UNINITIALIZED + } - authenticationMutex.withLock { - Log.debug { "$TAG - Trying to authenticate a user" } - Log.biometric { "Try to authenticate a user" } + override suspend fun authenticate( + params: AuthenticationManager.AuthenticationParams, + ): AuthenticationManager.AuthenticationResult { + return when (biometricsStatus.value) { + BiometricsStatus.READY -> { + Log.biometric { "Authenticating a user: $params" } - val canAuthenticate = canAuthenticateInternal.filterNotNull().first() - if (canAuthenticate) { - withContext(Dispatchers.Main) { - authenticateInternal() + val timeout = params.timeout + if (timeout != null) { + authenticateWithTimeout(timeout, params.cipher) + } else { + authenticate(params.cipher) } - } else { - Log.warning { "$TAG - Unable to authenticate the user as the biometrics feature is unavailable" } + } + BiometricsStatus.AUTHENTICATING -> { + Log.biometric { "A user authentication has already been launched" } + + throw TangemSdkError.AuthenticationAlreadyInProgress() + } + BiometricsStatus.NEED_ENROLL -> { + Log.biometric { "Unable to authenticate the user as the biometrics feature must be enrolled" } + + throw TangemSdkError.AuthenticationUnavailable() + } + BiometricsStatus.UNAVAILABLE -> { Log.biometric { "Unable to authenticate the user as the biometrics feature is unavailable" } throw TangemSdkError.AuthenticationUnavailable() } + BiometricsStatus.UNINITIALIZED -> { + Log.biometric { "Awaiting for the biometrics status to be initialized" } + + awaitBiometricsInititialization() + + authenticate(params) + } } } - private suspend fun authenticateInternal() { - return suspendCancellableCoroutine { continuation -> - val biometricPrompt = BiometricPrompt( - activity, - createAuthenticationCallback { biometricResult -> - when (biometricResult) { - is BiometricAuthenticationResult.Failure -> { - continuation.resumeWithException(biometricResult.error) - } - is BiometricAuthenticationResult.Success -> { - continuation.resume(Unit) - } - } - }, - ) + @OptIn(ExperimentalTime::class) + private suspend fun authenticateWithTimeout(timeout: Duration, cipher: Cipher?): AndroidAuthenticationResult { + return withTimeoutOrNull(timeout) { authenticate(cipher) } + ?: throw TangemSdkError.AuthenticationCanceled() + } + + private suspend fun authenticate(cipher: Cipher?): AndroidAuthenticationResult = withContext(Dispatchers.Main) { + biometricsStatus.value = BiometricsStatus.AUTHENTICATING - biometricPrompt.authenticate(biometricPromptInfo) + val promptResult = suspendCancellableCoroutine { continuation -> + val biometricPrompt = createBiometricPrompt(continuation) + + if (cipher == null) { + biometricPrompt.authenticate(biometricPromptInfo) + } else { + biometricPrompt.authenticate(biometricPromptInfo, BiometricPrompt.CryptoObject(cipher)) + } + + continuation.invokeOnCancellation { + Log.biometric { "User authentication has been canceled" } + + biometricPrompt.cancelAuthentication() + + biometricsStatus.value = BiometricsStatus.READY + } } + + AndroidAuthenticationResult(promptResult.cryptoObject?.cipher) + } + + private fun createBiometricPrompt(continuation: CancellableContinuation) = + BiometricPrompt( + activity, + createAuthenticationCallback { biometricResult -> + biometricsStatus.value = BiometricsStatus.READY + + when (biometricResult) { + is BiometricAuthenticationResult.Failure -> { + continuation.resumeWithException(biometricResult.error) + } + is BiometricAuthenticationResult.Success -> { + continuation.resume(biometricResult.result) + } + } + }, + ) + + private suspend fun awaitBiometricsInititialization() { + biometricsStatus.first { it != BiometricsStatus.UNINITIALIZED } } @Suppress("LongMethod") - private suspend fun checkBiometricsAvailabilityStatus(): BiometricsAvailability { + private suspend fun getBiometricsAvailabilityStatus(): BiometricsStatus { val biometricManager = SystemBiometricManager.from(activity) return suspendCancellableCoroutine { continuation -> when (biometricManager.canAuthenticate(AUTHENTICATORS)) { SystemBiometricManager.BIOMETRIC_SUCCESS -> { - Log.debug { "$TAG - Biometric features are available" } Log.biometric { "Biometric features are available" } - continuation.resume( - BiometricsAvailability( - available = true, - canBeEnrolled = false, - ), - ) + continuation.resume(BiometricsStatus.READY) } - SystemBiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { - Log.debug { "$TAG - No biometric features enrolled" } Log.biometric { "No biometric features enrolled" } - continuation.resume( - BiometricsAvailability( - available = false, - canBeEnrolled = true, - ), - ) + continuation.resume(BiometricsStatus.NEED_ENROLL) } SystemBiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> { - Log.debug { "$TAG - No biometric features available on this device" } Log.biometric { "No biometric features available on this device" } - continuation.resume( - BiometricsAvailability( - available = false, - canBeEnrolled = false, - ), - ) + continuation.resume(BiometricsStatus.UNAVAILABLE) } SystemBiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> { - Log.debug { "$TAG - Biometric features are currently unavailable" } Log.biometric { "Biometric features are currently unavailable" } - continuation.resume( - BiometricsAvailability( - available = false, - canBeEnrolled = false, - ), - ) + continuation.resume(BiometricsStatus.UNAVAILABLE) } SystemBiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> { - Log.debug { "$TAG - Biometric features are currently unavailable, security update required" } Log.biometric { "Biometric features are currently unavailable, security update required" } - continuation.resume( - BiometricsAvailability( - available = false, - canBeEnrolled = false, - ), - ) + continuation.resume(BiometricsStatus.UNAVAILABLE) } SystemBiometricManager.BIOMETRIC_STATUS_UNKNOWN -> { - Log.debug { "$TAG - Biometric features are in unknown status" } Log.biometric { "Biometric features are in unknown status" } - continuation.resume( - BiometricsAvailability( - available = false, - canBeEnrolled = false, - ), - ) + continuation.resume(BiometricsStatus.UNAVAILABLE) } SystemBiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> { - Log.debug { "$TAG - Biometric features are unsupported" } Log.biometric { "Biometric features are unsupported" } - continuation.resume( - BiometricsAvailability( - available = false, - canBeEnrolled = false, - ), - ) + continuation.resume(BiometricsStatus.UNAVAILABLE) } } } } - private fun createAuthenticationCallback( - result: (BiometricAuthenticationResult) -> Unit, + private inline fun createAuthenticationCallback( + crossinline result: (BiometricAuthenticationResult) -> Unit, ): BiometricPrompt.AuthenticationCallback { return object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { val error = when (errorCode) { BiometricPrompt.ERROR_LOCKOUT -> TangemSdkError.AuthenticationLockout() BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> TangemSdkError.AuthenticationPermanentLockout() BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON, - -> TangemSdkError.UserCanceledAuthentication() + -> TangemSdkError.AuthenticationCanceled() else -> TangemSdkError.AuthenticationFailed(errorCode, errString.toString()) } - Log.warning { + Log.biometric { """ - $TAG - Biometric authentication error + Biometric authentication error |- Code: $errorCode |- Message: $errString |- Cause: $error """.trimIndent() } - Log.biometric { "Biometric authentication error:\n$error" } result(BiometricAuthenticationResult.Failure(error)) } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - Log.debug { "$TAG - Biometric authentication succeed" } Log.biometric { "Biometric authentication succeed" } result(BiometricAuthenticationResult.Success(result)) } override fun onAuthenticationFailed() { - Log.warning { "$TAG - Biometric authentication failed" } Log.biometric { "Biometric authentication failed" } } } } - private data class BiometricsAvailability( - val available: Boolean, - val canBeEnrolled: Boolean, - ) + data class AndroidAuthenticationParams( + override val cipher: Cipher? = null, + override val timeout: Duration? = null, + ) : AuthenticationManager.AuthenticationParams + + data class AndroidAuthenticationResult( + override val cipher: Cipher?, + ) : AuthenticationManager.AuthenticationResult + + private enum class BiometricsStatus { + READY, + AUTHENTICATING, + UNAVAILABLE, + NEED_ENROLL, + UNINITIALIZED, + } private sealed interface BiometricAuthenticationResult { data class Failure( @@ -249,7 +273,5 @@ internal class AndroidAuthenticationManager( private companion object { const val AUTHENTICATORS = SystemBiometricManager.Authenticators.BIOMETRIC_STRONG - - const val TAG = "Android Authentication Manager" } } diff --git a/tangem-sdk-android/src/main/java/com/tangem/sdk/authentication/AndroidKeystoreManager.kt b/tangem-sdk-android/src/main/java/com/tangem/sdk/authentication/AndroidKeystoreManager.kt index d034284c..26629587 100644 --- a/tangem-sdk-android/src/main/java/com/tangem/sdk/authentication/AndroidKeystoreManager.kt +++ b/tangem-sdk-android/src/main/java/com/tangem/sdk/authentication/AndroidKeystoreManager.kt @@ -2,19 +2,17 @@ package com.tangem.sdk.authentication import android.os.Build import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties -import android.security.keystore.UserNotAuthenticatedException import androidx.annotation.RequiresApi import com.tangem.Log import com.tangem.common.authentication.AuthenticationManager -import com.tangem.common.authentication.KeystoreManager +import com.tangem.common.authentication.keystore.KeystoreManager import com.tangem.common.core.TangemSdkError import com.tangem.common.services.secure.SecureStorage import com.tangem.crypto.operations.AESCipherOperations import com.tangem.crypto.operations.RSACipherOperations -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.security.InvalidKeyException +import com.tangem.sdk.authentication.AndroidAuthenticationManager.AndroidAuthenticationParams import java.security.KeyPair import java.security.KeyStore import java.security.PrivateKey @@ -31,48 +29,43 @@ internal class AndroidKeystoreManager( private val keyStore: KeyStore = KeyStore.getInstance(KEY_STORE_PROVIDER) .apply { load(null) } - private val masterPublicKey: PublicKey - get() = keyStore.getCertificate(MASTER_KEY_ALIAS)?.publicKey ?: generateMasterKey().public - - private val masterPrivateKey: PrivateKey - get() = keyStore.getKey(MASTER_KEY_ALIAS, null) as? PrivateKey - ?: throw TangemSdkError.KeystoreInvalidated( - cause = IllegalStateException("The master key is not stored in the keystore"), - ) - - override suspend fun get(keyAlias: String): SecretKey? = withContext(Dispatchers.IO) { + override suspend fun get(masterKeyConfig: KeystoreManager.MasterKeyConfig, keyAlias: String): SecretKey? { val wrappedKeyBytes = secureStorage.get(getStorageKeyForWrappedSecretKey(keyAlias)) ?.takeIf { it.isNotEmpty() } if (wrappedKeyBytes == null) { - Log.warning { + Log.biometric { """ - $TAG - The secret key is not stored + The secret key is not stored |- Key alias: $keyAlias """.trimIndent() } - return@withContext null + return null } - val cipher = authenticateAndInitUnwrapCipher() + val privateKey = getPrivateMasterKey(masterKeyConfig) ?: return null + val cipher = authenticateAndInitUnwrapCipher(privateKey, masterKeyConfig) val unwrappedKey = RSACipherOperations.unwrapKey( cipher = cipher, wrappedKeyBytes = wrappedKeyBytes, wrappedKeyAlgorithm = AESCipherOperations.KEY_ALGORITHM, ) - Log.debug { + Log.biometric { """ - $TAG - The secret key was retrieved + The secret key was retrieved |- Key alias: $keyAlias """.trimIndent() } - unwrappedKey + return unwrappedKey } - override suspend fun get(keyAliases: Collection): Map = withContext(Dispatchers.IO) { + override suspend fun get( + masterKeyConfig: KeystoreManager.MasterKeyConfig, + keyAliases: Set, + ): Map { val wrappedKeysBytes = keyAliases .mapNotNull { keyAlias -> val wrappedKeyBytes = secureStorage.get(getStorageKeyForWrappedSecretKey(keyAlias)) @@ -84,17 +77,18 @@ internal class AndroidKeystoreManager( .toMap() if (wrappedKeysBytes.isEmpty()) { - Log.warning { + Log.biometric { """ - $TAG - The secret keys are not stored + The secret keys are not stored |- Key aliases: $keyAliases """.trimIndent() } - return@withContext emptyMap() + return emptyMap() } - val cipher = authenticateAndInitUnwrapCipher() + val privateKey = getPrivateMasterKey(masterKeyConfig) ?: return emptyMap() + val cipher = authenticateAndInitUnwrapCipher(privateKey, masterKeyConfig) val unwrappedKeys = wrappedKeysBytes .mapValues { (_, wrappedKeyBytes) -> RSACipherOperations.unwrapKey( @@ -104,74 +98,165 @@ internal class AndroidKeystoreManager( ) } - Log.debug { + Log.biometric { """ - $TAG - The secret keys were retrieved + The secret keys were retrieved |- Key aliases: $keyAliases """.trimIndent() } - unwrappedKeys + return unwrappedKeys + } + + private fun getPrivateMasterKey(masterKeyConfig: KeystoreManager.MasterKeyConfig): PrivateKey? { + val key = keyStore.getKey(masterKeyConfig.alias, null) as? PrivateKey + + if (key == null) { + Log.biometric { + """ + The private master key is not generated + |- Alias: ${masterKeyConfig.alias} + """.trimIndent() + } + } + + return key } - override suspend fun store(keyAlias: String, key: SecretKey) = withContext(Dispatchers.IO) { - val masterCipher = RSACipherOperations.initWrapKeyCipher(masterPublicKey) - val wrappedKey = RSACipherOperations.wrapKey(masterCipher, key) + override suspend fun store(masterKeyConfig: KeystoreManager.MasterKeyConfig, keyAlias: String, key: SecretKey) { + val publicKey = getPublicMasterKey(masterKeyConfig) + val cipher = RSACipherOperations.initWrapKeyCipher(publicKey) + val wrappedKey = RSACipherOperations.wrapKey(cipher, key) secureStorage.store(wrappedKey, getStorageKeyForWrappedSecretKey(keyAlias)) - Log.debug { + Log.biometric { """ - $TAG - The secret key was stored + The secret key was stored |- Key alias: $keyAlias """.trimIndent() } } - /** - * If the master key has been invalidated due to new biometric enrollment, the [UserNotAuthenticatedException] - * will be thrown anyway because the master key has the positive timeout. - * - * @see KeyGenParameterSpec.Builder.setInvalidatedByBiometricEnrollment - * */ - private suspend fun authenticateAndInitUnwrapCipher(): Cipher { - Log.debug { "$TAG - Initializing the unwrap cipher" } + private fun getPublicMasterKey(masterKeyConfig: KeystoreManager.MasterKeyConfig): PublicKey { + var key = keyStore.getCertificate(masterKeyConfig.alias)?.publicKey + + if (key == null) { + Log.biometric { "The public master key is not generated" } + + generateInvalidationCheckKey() + key = generateMasterKey(masterKeyConfig).public!! + } + + return key + } + + private suspend fun authenticateAndInitUnwrapCipher( + privateKey: PrivateKey, + masterKeyConfig: KeystoreManager.MasterKeyConfig, + ): Cipher { + Log.biometric { "Initializing the unwrap cipher" } + + authenticateUser(masterKeyConfig) return try { - authenticationManager.authenticate() + RSACipherOperations.initUnwrapKeyCipher(privateKey) + } catch (e: Throwable) { + Log.biometric { + """ + Unable to initialize the unwrap cipher + |- Cause: $e + """.trimIndent() + } - RSACipherOperations.initUnwrapKeyCipher(masterPrivateKey) - } catch (e: InvalidKeyException) { - handleInvalidKeyException(e) + throw e } } - private fun handleInvalidKeyException(e: InvalidKeyException): Nothing { - Log.error { + private suspend fun authenticateUser(masterKeyConfig: KeystoreManager.MasterKeyConfig) { + try { + /** + * Authentication timeout is reduced by [AUTHENTICATION_TIMEOUT_MULTIPLIER] of the master key timeout + * to avoid the situation when the master key is invalidated due to the timeout while the user is authenticating. + * */ + authenticationManager.authenticate( + params = AndroidAuthenticationParams( + cipher = getInvalidationCheckCipher(), + timeout = masterKeyConfig.securityDelay * AUTHENTICATION_TIMEOUT_MULTIPLIER, + ), + ) + } catch (e: KeyPermanentlyInvalidatedException) { + handleKeyInvalidationException(masterKeyConfig.alias, e) + } catch (e: Throwable) { + Log.biometric { + """ + Unable to authenticate the user + |- Cause: $e + """.trimIndent() + } + + throw e + } + } + + private fun getInvalidationCheckCipher(): Cipher { + var key = keyStore.getKey(INVALIDATION_CHECK_KEY_ALIAS, null) as? SecretKey + + if (key == null) { + Log.biometric { "The invalidation check key is not generated" } + + key = generateInvalidationCheckKey() + } + + return AESCipherOperations.initEncryptionCipher(key) + } + + private fun handleKeyInvalidationException( + privateKeyAlias: String, + e: KeyPermanentlyInvalidatedException, + ): Nothing { + Log.biometric { """ - $TAG - Unable to initialize the unwrap cipher because the master key is invalidated, - master key will be deleted + Unable to initialize the unwrap cipher because the key is permanently invalidated |- Cause: $e """.trimIndent() } - keyStore.deleteEntry(MASTER_KEY_ALIAS) + keyStore.deleteEntry(privateKeyAlias) keyStore.load(null) throw TangemSdkError.KeystoreInvalidated(e) } - private fun generateMasterKey(): KeyPair { + private fun generateMasterKey(masterKeyConfig: KeystoreManager.MasterKeyConfig): KeyPair { + Log.biometric { + """ + Generating the master key + |- Alias: ${masterKeyConfig.alias} + """.trimIndent() + } + return RSACipherOperations.generateKeyPair( keyStoreProvider = keyStore.provider.name, - keyGenSpec = buildMasterKeyGenSpec(), + keyGenSpec = buildMasterKeySpec(masterKeyConfig), + ) + } + + private fun generateInvalidationCheckKey(): SecretKey { + Log.biometric { "Generating the key for invalidation check" } + + return AESCipherOperations.generateKey( + keyStoreProvider = keyStore.provider.name, + keyGetSpec = buildInvalidationCheckKeySpec(), ) } /** Key regeneration is required to edit these parameters */ - private fun buildMasterKeyGenSpec(): KeyGenParameterSpec { + private fun buildMasterKeySpec(masterKeyConfig: KeystoreManager.MasterKeyConfig): KeyGenParameterSpec { + val securityDelaySeconds = masterKeyConfig.securityDelay.inWholeSeconds.toInt() + return KeyGenParameterSpec.Builder( - MASTER_KEY_ALIAS, + masterKeyConfig.alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setKeySize(MASTER_KEY_SIZE) @@ -189,12 +274,45 @@ internal class AndroidKeystoreManager( .let { builder -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { builder + .setUserConfirmationRequired(masterKeyConfig.userConfirmationRequired) + .setUserAuthenticationParameters( + securityDelaySeconds, + KeyProperties.AUTH_BIOMETRIC_STRONG, + ) + } else { + builder.setUserAuthenticationValidityDurationSeconds(securityDelaySeconds) + } + } + .build() + } + + /** Key regeneration is required to edit these parameters */ + private fun buildInvalidationCheckKeySpec(): KeyGenParameterSpec { + return KeyGenParameterSpec.Builder( + INVALIDATION_CHECK_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT, + ) + .setKeySize(AUTHENTICATION_CHECK_KEY_SIZE) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .setUserAuthenticationRequired(true) + .let { builder -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setInvalidatedByBiometricEnrollment(true) + } else { + builder + } + } + .let { builder -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder + .setUserConfirmationRequired(false) .setUserAuthenticationParameters( - MASTER_KEY_TIMEOUT_SECONDS, + 0, KeyProperties.AUTH_BIOMETRIC_STRONG, ) } else { - builder.setUserAuthenticationValidityDurationSeconds(MASTER_KEY_TIMEOUT_SECONDS) + builder.setUserAuthenticationValidityDurationSeconds(-1) } } .build() @@ -206,11 +324,10 @@ internal class AndroidKeystoreManager( private companion object { const val KEY_STORE_PROVIDER = "AndroidKeyStore" - - const val MASTER_KEY_ALIAS = "master_key" const val MASTER_KEY_SIZE = 1024 - const val MASTER_KEY_TIMEOUT_SECONDS = 5 + const val AUTHENTICATION_TIMEOUT_MULTIPLIER = 0.9 - const val TAG = "Keystore Manager" + const val INVALIDATION_CHECK_KEY_ALIAS = "invalidation_check_key" + const val AUTHENTICATION_CHECK_KEY_SIZE = 128 } } diff --git a/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdk.kt b/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdk.kt index 40dcd8c4..4154e37d 100644 --- a/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdk.kt +++ b/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdk.kt @@ -12,8 +12,8 @@ import com.tangem.TangemSdk import com.tangem.TangemSdkLogger import com.tangem.common.authentication.AuthenticationManager import com.tangem.common.authentication.DummyAuthenticationManager -import com.tangem.common.authentication.DummyKeystoreManager -import com.tangem.common.authentication.KeystoreManager +import com.tangem.common.authentication.keystore.DummyKeystoreManager +import com.tangem.common.authentication.keystore.KeystoreManager import com.tangem.common.core.Config import com.tangem.common.services.secure.SecureStorage import com.tangem.crypto.bip39.Wordlist diff --git a/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdkError.kt b/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdkError.kt index 5336301f..8b25ae24 100644 --- a/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdkError.kt +++ b/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdkError.kt @@ -59,7 +59,8 @@ fun TangemSdkError.localizedDescriptionRes(): TangemSdkErrorDescription { is TangemSdkError.AuthenticationUnavailable, is TangemSdkError.AuthenticationLockout, is TangemSdkError.AuthenticationPermanentLockout, - is TangemSdkError.UserCanceledAuthentication, + is TangemSdkError.AuthenticationCanceled, + is TangemSdkError.AuthenticationAlreadyInProgress, is TangemSdkError.KeyGenerationException, is TangemSdkError.MnemonicException, is TangemSdkError.KeysImportDisabled, diff --git a/tangem-sdk-core/src/main/java/com/tangem/TangemSdk.kt b/tangem-sdk-core/src/main/java/com/tangem/TangemSdk.kt index 6071879b..35b10cd7 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/TangemSdk.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/TangemSdk.kt @@ -6,8 +6,8 @@ import com.tangem.common.UserCode import com.tangem.common.UserCodeType import com.tangem.common.authentication.AuthenticationManager import com.tangem.common.authentication.DummyAuthenticationManager -import com.tangem.common.authentication.DummyKeystoreManager -import com.tangem.common.authentication.KeystoreManager +import com.tangem.common.authentication.keystore.DummyKeystoreManager +import com.tangem.common.authentication.keystore.KeystoreManager import com.tangem.common.card.Card import com.tangem.common.card.EllipticCurve import com.tangem.common.core.* diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/AuthenticationManager.kt b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/AuthenticationManager.kt index b45b9eac..b5aea273 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/AuthenticationManager.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/AuthenticationManager.kt @@ -1,6 +1,8 @@ package com.tangem.common.authentication import com.tangem.common.core.TangemSdkError +import javax.crypto.Cipher +import kotlin.time.Duration /** * Represents a manager that handles authentication processes. @@ -23,9 +25,35 @@ interface AuthenticationManager { * Initiates the authentication process. Depending on the implementation, * this might trigger biometric prompts or other authentication mechanisms. * + * @param params [AuthenticationParams] that specify the authentication process. + * + * @return [AuthenticationResult] that contains the successful result of the authentication process. + * + * @throws TangemSdkError.AuthenticationAlreadyInProgress if another authentication process is already in progress. * @throws TangemSdkError.AuthenticationUnavailable if authentication is unavailable. - * @throws TangemSdkError.UserCanceledAuthentication if the user cancels the authentication process. + * @throws TangemSdkError.AuthenticationCanceled if the authentication was cancelled. * @throws TangemSdkError.AuthenticationFailed if authentication fails for any other reason. */ - suspend fun authenticate() + suspend fun authenticate(params: AuthenticationParams): AuthenticationResult + + /** + * Parameters that specify the authentication process. + * + * @property cipher The cipher that will be checked with authentication and can be used to encrypt or decrypt data. + * @property timeout The maximum time to wait for the authentication to complete. If it's reached, the + * authentication process will be cancelled. + */ + interface AuthenticationParams { + val cipher: Cipher? + val timeout: Duration? + } + + /** + * The successful result of the authentication process. + * + * @property cipher The cipher that can be used to encrypt or decrypt data. + */ + interface AuthenticationResult { + val cipher: Cipher? + } } diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/DummyAuthenticationManager.kt b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/DummyAuthenticationManager.kt index 2f69ddef..adae91b6 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/DummyAuthenticationManager.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/DummyAuthenticationManager.kt @@ -1,5 +1,7 @@ package com.tangem.common.authentication +import javax.crypto.Cipher + class DummyAuthenticationManager : AuthenticationManager { override val canAuthenticate: Boolean get() = false @@ -7,5 +9,9 @@ class DummyAuthenticationManager : AuthenticationManager { override val needEnrollBiometrics: Boolean get() = false - override suspend fun authenticate() = Unit + override suspend fun authenticate( + params: AuthenticationManager.AuthenticationParams, + ): AuthenticationManager.AuthenticationResult = object : AuthenticationManager.AuthenticationResult { + override val cipher: Cipher? = null + } } diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/DummyKeystoreManager.kt b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/DummyKeystoreManager.kt deleted file mode 100644 index d81ab24b..00000000 --- a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/DummyKeystoreManager.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.common.authentication - -import javax.crypto.SecretKey - -class DummyKeystoreManager : KeystoreManager { - - override suspend fun get(keyAlias: String): SecretKey? = null - - override suspend fun get(keyAliases: Collection): Map = emptyMap() - - override suspend fun store(keyAlias: String, key: SecretKey) = Unit -} diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/keystore/DummyKeystoreManager.kt b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/keystore/DummyKeystoreManager.kt new file mode 100644 index 00000000..ff267dad --- /dev/null +++ b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/keystore/DummyKeystoreManager.kt @@ -0,0 +1,16 @@ +package com.tangem.common.authentication.keystore + +import javax.crypto.SecretKey + +class DummyKeystoreManager : KeystoreManager { + + override suspend fun get(masterKeyConfig: KeystoreManager.MasterKeyConfig, keyAlias: String): SecretKey? = null + + override suspend fun get( + masterKeyConfig: KeystoreManager.MasterKeyConfig, + keyAliases: Set, + ): Map = emptyMap() + + override suspend fun store(masterKeyConfig: KeystoreManager.MasterKeyConfig, keyAlias: String, key: SecretKey) = + Unit +} diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/KeystoreManager.kt b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/keystore/KeystoreManager.kt similarity index 54% rename from tangem-sdk-core/src/main/java/com/tangem/common/authentication/KeystoreManager.kt rename to tangem-sdk-core/src/main/java/com/tangem/common/authentication/keystore/KeystoreManager.kt index a9310df7..b63f9f2d 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/KeystoreManager.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/keystore/KeystoreManager.kt @@ -1,7 +1,8 @@ -package com.tangem.common.authentication +package com.tangem.common.authentication.keystore import com.tangem.common.core.TangemSdkError import javax.crypto.SecretKey +import kotlin.time.Duration /** * Represents a manager for managing and accessing encrypted keys in a secure manner. @@ -13,31 +14,47 @@ interface KeystoreManager { * * This operation requires user authentication. * + * @param masterKeyConfig The configuration for the master key. * @param keyAlias The alias of the key to be retrieved. * @return The [SecretKey] if found. If the key cannot be found, then `null` will be returned. * * @throws TangemSdkError.KeystoreInvalidated if the keystore is invalidated. */ - suspend fun get(keyAlias: String): SecretKey? + suspend fun get(masterKeyConfig: MasterKeyConfig, keyAlias: String): SecretKey? /** * Retrieves the map of key alias to [SecretKey] for a given [keyAliases]. * * This operation requires user authentication. * + * @param masterKeyConfig The configuration for the master key. * @param keyAliases The aliases of the keys to be retrieved. * @return The map of key alias to [SecretKey] if found. If the key cannot be found, then the key will not be * included in the map. * * @throws TangemSdkError.KeystoreInvalidated if the keystore is invalidated. */ - suspend fun get(keyAliases: Collection): Map + suspend fun get(masterKeyConfig: MasterKeyConfig, keyAliases: Set): Map /** * Stores the given [SecretKey] with a specified [keyAlias] in the keystore. * + * @param masterKeyConfig The configuration for the master key. * @param keyAlias The alias under which the key should be stored. * @param key The [SecretKey] to be stored. */ - suspend fun store(keyAlias: String, key: SecretKey) + suspend fun store(masterKeyConfig: MasterKeyConfig, keyAlias: String, key: SecretKey) + + /** + * The configuration for the master key. + * + * @property alias The alias of the master key. + * @property securityDelaySeconds The delay in seconds before the user is required to authenticate again. + * @property userConfirmationRequired Whether the user confirmation is required for the authentication. + * */ + interface MasterKeyConfig { + val alias: String + val securityDelay: Duration + val userConfirmationRequired: Boolean + } } diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/keystore/MasterKeyConfigs.kt b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/keystore/MasterKeyConfigs.kt new file mode 100644 index 00000000..7c33c49b --- /dev/null +++ b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/keystore/MasterKeyConfigs.kt @@ -0,0 +1,28 @@ +package com.tangem.common.authentication.keystore + +import kotlin.time.Duration + +internal enum class MasterKeyConfigs : KeystoreManager.MasterKeyConfig { + V1 { + override val alias = "master_key" + override val securityDelay: Duration = with(Duration) { 5.seconds } + override val userConfirmationRequired = true + }, + V2 { + override val alias = "master_key_v2" + override val securityDelay: Duration = with(Duration) { 90.seconds } + override val userConfirmationRequired = false + }, ; + + val isDeprecated: Boolean + get() = alias != current.alias + + companion object { + + val all: List + get() = values().toList() + + val current: MasterKeyConfigs + get() = all.last() + } +} diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/AuthenticatedStorage.kt b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/storage/AuthenticatedStorage.kt similarity index 64% rename from tangem-sdk-core/src/main/java/com/tangem/common/authentication/AuthenticatedStorage.kt rename to tangem-sdk-core/src/main/java/com/tangem/common/authentication/storage/AuthenticatedStorage.kt index cbb08b6e..8a027588 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/common/authentication/AuthenticatedStorage.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/common/authentication/storage/AuthenticatedStorage.kt @@ -1,6 +1,8 @@ -package com.tangem.common.authentication +package com.tangem.common.authentication.storage import com.tangem.Log +import com.tangem.common.authentication.keystore.KeystoreManager +import com.tangem.common.authentication.keystore.MasterKeyConfigs import com.tangem.common.services.secure.SecureStorage import com.tangem.crypto.operations.AESCipherOperations import kotlinx.coroutines.Dispatchers @@ -40,7 +42,38 @@ class AuthenticatedStorage( return@withContext null } - decrypt(keyAlias, encryptedData) + val (config, key) = getSecretKey(keyAlias) ?: run { + Log.warning { + """ + $TAG - The secret key is not stored + |- Key alias: $keyAlias + """.trimIndent() + } + + return@withContext null + } + + val decryptedData = decrypt(keyAlias, key, encryptedData) + + migrateIfNeeded(keyAlias, decryptedData, config) + + decryptedData + } + + private suspend fun getSecretKey(keyAlias: String): Pair? { + return MasterKeyConfigs.all.reversed() + .firstNotNullOfOrNull { masterKeyConfig -> + keystoreManager.get(masterKeyConfig, keyAlias)?.let { secretKey -> + masterKeyConfig to secretKey + } + } + } + + private suspend fun migrateIfNeeded(keyAlias: String, decryptedData: ByteArray, config: MasterKeyConfigs) { + if (!config.isDeprecated) return + + val encryptedData = encrypt(keyAlias, decryptedData) + secureStorage.store(encryptedData, keyAlias) } /** @@ -51,7 +84,7 @@ class AuthenticatedStorage( * @return The decrypted data as a map of key-alias to [ByteArray] or an empty map if data is not found. */ suspend fun get(keysAliases: Collection): Map = withContext(Dispatchers.IO) { - val encryptedData = keysAliases + val keyToEncryptedData = keysAliases .mapNotNull { keyAlias -> val data = secureStorage.get(keyAlias) ?.takeIf(ByteArray::isNotEmpty) @@ -59,9 +92,10 @@ class AuthenticatedStorage( keyAlias to data } - .toMap() + .takeIf { it.isNotEmpty() } + ?.toMap() - if (encryptedData.isEmpty()) { + if (keyToEncryptedData == null) { Log.warning { """ $TAG - Data not found in storage @@ -72,7 +106,58 @@ class AuthenticatedStorage( return@withContext emptyMap() } - decrypt(encryptedData) + val (config, keys) = getSecretKeys(keyToEncryptedData.keys) ?: run { + Log.warning { + """ + $TAG - The secret keys are not stored + |- Key aliases: $keysAliases + """.trimIndent() + } + + return@withContext emptyMap() + } + + val decryptedData = keyToEncryptedData + .mapNotNull { (keyAlias, encryptedData) -> + val key = keys[keyAlias] ?: return@mapNotNull null + val decryptedData = decrypt(keyAlias, key, encryptedData) + + keyAlias to decryptedData + } + .toMap() + + migrateIfNeeded(decryptedData, config) + + decryptedData + } + + private suspend fun getSecretKeys( + keysAliases: Collection, + ): Pair>? { + return MasterKeyConfigs.all.reversed() + .firstNotNullOfOrNull { masterKeyConfig -> + val keys = keystoreManager.get(masterKeyConfig, keysAliases.toSet()) + .takeIf { it.isNotEmpty() } + ?: return@firstNotNullOfOrNull null + + masterKeyConfig to keys + } + } + + private fun decrypt(keyAlias: String, key: SecretKey, encryptedData: ByteArray): ByteArray { + val iv = getDataIv(keyAlias) + val decryptionCipher = AESCipherOperations.initDecryptionCipher(key, iv) + + return AESCipherOperations.decrypt(decryptionCipher, encryptedData) + } + + private suspend fun migrateIfNeeded(decryptedData: Map, config: MasterKeyConfigs) { + if (!config.isDeprecated) return + + decryptedData.forEach { (keyAlias, data) -> + val encryptedData = encrypt(keyAlias, data) + secureStorage.store(encryptedData, keyAlias) + } } /** @@ -106,56 +191,10 @@ class AuthenticatedStorage( return encryptedData } - private suspend fun decrypt(keyAlias: String, encryptedData: ByteArray): ByteArray? { - val key = keystoreManager.get(keyAlias) - - if (key == null) { - Log.warning { - """ - $TAG - The data key is not stored - |- Key alias: $keyAlias - """.trimIndent() - } - - return null - } - - val iv = getDataIv(keyAlias) - val decryptionCipher = AESCipherOperations.initDecryptionCipher(key, iv) - - return AESCipherOperations.decrypt(decryptionCipher, encryptedData) - } - - private suspend fun decrypt(keyAliasToEncryptedData: Map): Map { - val keys = keystoreManager.get(keyAliasToEncryptedData.keys) - - if (keys.isEmpty()) { - Log.warning { - """ - $TAG - The data keys are not stored - |- Key aliases: ${keyAliasToEncryptedData.keys} - """.trimIndent() - } - - return emptyMap() - } - - return keyAliasToEncryptedData - .mapNotNull { (keyAlias, encryptedData) -> - val key = keys[keyAlias] ?: return@mapNotNull null - val iv = getDataIv(keyAlias) - val decryptionCipher = AESCipherOperations.initDecryptionCipher(key, iv) - val decryptedData = AESCipherOperations.decrypt(decryptionCipher, encryptedData) - - keyAlias to decryptedData - } - .toMap() - } - private suspend fun generateAndStoreDataKey(keyAlias: String): SecretKey { val dataKey = AESCipherOperations.generateKey() - keystoreManager.store(keyAlias, dataKey) + keystoreManager.store(MasterKeyConfigs.current, keyAlias, dataKey) return dataKey } diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/core/TangemError.kt b/tangem-sdk-core/src/main/java/com/tangem/common/core/TangemError.kt index e3559ba2..67a7b53f 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/common/core/TangemError.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/common/core/TangemError.kt @@ -282,7 +282,7 @@ sealed class TangemSdkError(code: Int) : TangemError(code) { * * @see AuthenticationLockout * @see AuthenticationPermanentLockout - * @see UserCanceledAuthentication + * @see AuthenticationCanceled */ class AuthenticationFailed( val errorCode: Int, @@ -302,10 +302,11 @@ sealed class TangemSdkError(code: Int) : TangemError(code) { */ class AuthenticationPermanentLockout : TangemSdkError(code = 50018) - /** - * The user canceled the operation. - */ - class UserCanceledAuthentication : TangemSdkError(code = 50019) { + class AuthenticationCanceled : TangemSdkError(code = 50019) { + override val silent: Boolean = true + } + + class AuthenticationAlreadyInProgress : TangemSdkError(code = 50025) { override val silent: Boolean = true } diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/usersCode/UserCodeRepository.kt b/tangem-sdk-core/src/main/java/com/tangem/common/usersCode/UserCodeRepository.kt index f7d8107c..9c0cfcb4 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/common/usersCode/UserCodeRepository.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/common/usersCode/UserCodeRepository.kt @@ -5,8 +5,8 @@ import com.squareup.moshi.Moshi import com.squareup.moshi.Types import com.tangem.common.CompletionResult import com.tangem.common.UserCode -import com.tangem.common.authentication.AuthenticatedStorage -import com.tangem.common.authentication.KeystoreManager +import com.tangem.common.authentication.keystore.KeystoreManager +import com.tangem.common.authentication.storage.AuthenticatedStorage import com.tangem.common.catching import com.tangem.common.extensions.calculateSha256 import com.tangem.common.extensions.mapNotNullValues diff --git a/tangem-sdk-core/src/main/java/com/tangem/crypto/operations/AESCipherOperations.kt b/tangem-sdk-core/src/main/java/com/tangem/crypto/operations/AESCipherOperations.kt index 371d4381..05909be2 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/crypto/operations/AESCipherOperations.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/crypto/operations/AESCipherOperations.kt @@ -1,6 +1,7 @@ package com.tangem.crypto.operations import com.tangem.Log +import java.security.spec.AlgorithmParameterSpec import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -97,4 +98,10 @@ object AESCipherOperations { .also { it.init(keySize) } .generateKey() } + + fun generateKey(keyStoreProvider: String, keyGetSpec: AlgorithmParameterSpec): SecretKey { + return KeyGenerator.getInstance(KEY_ALGORITHM, keyStoreProvider) + .also { it.init(keyGetSpec) } + .generateKey() + } }