Fix wallet keystore auth: remove biometric requirement from mnemonic key

The mnemonic encryption key should be device-protected (unlocked when device
is unlocked), not require biometric/PIN at time of use. This was breaking:
- Wallet creation on devices without biometrics
- Emulator testing entirely

Changes:
- Remove setUserAuthenticationRequired(true) from keystore key spec
- Remove setUserAuthenticationValidityDurationSeconds()
- Remove setInvalidatedByBiometricEnrollment()
- Remove emulator detection hacks (isEmulator, canUseBiometricAuth)
- Remove unused Build and BiometricManager imports
- Add documentation explaining security model

Security model:
- Mnemonic encrypted with AES-256-GCM using Android Keystore key
- Key is device-bound (cannot be extracted)
- Key is accessible when device is unlocked
- Transaction signing should use BiometricPrompt separately (future enhancement)
This commit is contained in:
Kayos 2026-03-28 12:49:39 -07:00
parent 02ecbfda83
commit 9e9192dd3b

View file

@ -7,8 +7,6 @@
package io.element.android.features.wallet.impl.storage package io.element.android.features.wallet.impl.storage
import android.content.Context import android.content.Context
import android.os.Build
import androidx.biometric.BiometricManager
import android.content.SharedPreferences import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyPermanentlyInvalidatedException
@ -34,6 +32,15 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
/**
* Secure storage for Cardano wallet mnemonics using Android Keystore.
*
* Security model:
* - Mnemonic is encrypted with AES-256-GCM using a key stored in Android Keystore
* - The keystore key is device-bound (cannot be extracted)
* - The key is accessible when the device is unlocked (no additional biometric required for storage)
* - Transaction signing should use BiometricPrompt separately for user confirmation
*/
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class CardanoKeyStorageImpl @Inject constructor( class CardanoKeyStorageImpl @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@ -176,37 +183,16 @@ class CardanoKeyStorageImpl @Inject constructor(
} }
/** /**
* Check if the device is an emulator. * Get or create the AES secret key for encrypting the mnemonic.
*
* The key is:
* - Stored in Android Keystore (hardware-backed when available)
* - Device-bound (cannot be extracted)
* - Accessible when device is unlocked (no additional auth required)
*
* Note: Biometric/PIN confirmation for transactions should be handled separately
* at the transaction signing layer, not at the storage layer.
*/ */
private fun isEmulator(): Boolean {
return (Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.FINGERPRINT.contains("sdk_gphone")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.MODEL.contains("sdk_gphone")
|| Build.MANUFACTURER.contains("Genymotion")
|| Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
|| Build.DEVICE.startsWith("emu")
|| Build.PRODUCT.contains("sdk_gphone")
|| Build.HARDWARE.contains("goldfish")
|| Build.HARDWARE.contains("ranchu")
|| "google_sdk" == Build.PRODUCT)
}
/**
* Check if biometric/credential authentication is available.
*/
private fun canUseBiometricAuth(): Boolean {
val biometricManager = BiometricManager.from(context)
// Check for both biometric and device credential (PIN/pattern/password)
val biometricResult = biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
val credentialResult = biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL)
return biometricResult == BiometricManager.BIOMETRIC_SUCCESS ||
credentialResult == BiometricManager.BIOMETRIC_SUCCESS
}
private fun getOrCreateSecretKey(sessionId: SessionId): SecretKey { private fun getOrCreateSecretKey(sessionId: SessionId): SecretKey {
val alias = KEYSTORE_ALIAS_PREFIX + sanitizeSessionId(sessionId) val alias = KEYSTORE_ALIAS_PREFIX + sanitizeSessionId(sessionId)
@ -217,34 +203,20 @@ class CardanoKeyStorageImpl @Inject constructor(
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
// On emulators or devices without biometric auth, skip user authentication requirement // Key spec: device-protected, no additional user authentication required
// This is acceptable for debug/test builds; production builds should enforce it // This allows wallet operations when device is unlocked
val isEmulatorDevice = isEmulator() // Transaction signing should use BiometricPrompt separately for confirmation
val hasBiometricAuth = canUseBiometricAuth() val keySpec = KeyGenParameterSpec.Builder(
val requireUserAuth = !isEmulatorDevice && hasBiometricAuth
Timber.d("Keystore auth check: isEmulator=$isEmulatorDevice, hasBiometricAuth=$hasBiometricAuth, requireUserAuth=$requireUserAuth")
val keySpecBuilder = KeyGenParameterSpec.Builder(
alias, alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
) )
.setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(AES_KEY_SIZE) .setKeySize(AES_KEY_SIZE)
.build()
if (requireUserAuth) {
keySpecBuilder
.setUserAuthenticationRequired(true)
.setUserAuthenticationValidityDurationSeconds(30)
.setInvalidatedByBiometricEnrollment(true)
Timber.i("Creating keystore key with user authentication required")
} else {
keySpecBuilder.setUserAuthenticationRequired(false)
Timber.i("Creating keystore key WITHOUT user authentication (emulator or no biometrics)")
}
keyGenerator.init(keySpecBuilder.build()) keyGenerator.init(keySpec)
Timber.d("Created new keystore key for wallet: $alias")
return keyGenerator.generateKey() return keyGenerator.generateKey()
} }
@ -281,7 +253,7 @@ class CardanoKeyStorageImpl @Inject constructor(
val secretKey = try { val secretKey = try {
getOrCreateSecretKey(sessionId) getOrCreateSecretKey(sessionId)
} catch (e: KeyPermanentlyInvalidatedException) { } catch (e: KeyPermanentlyInvalidatedException) {
Timber.e(e, "Key invalidated due to biometric change for session: ${sessionId.value}") Timber.e(e, "Key invalidated for session: ${sessionId.value}")
throw e throw e
} }