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
import android.content.Context
import android.os.Build
import androidx.biometric.BiometricManager
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
@ -34,6 +32,15 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
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)
class CardanoKeyStorageImpl @Inject constructor(
@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 {
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)
// On emulators or devices without biometric auth, skip user authentication requirement
// This is acceptable for debug/test builds; production builds should enforce it
val isEmulatorDevice = isEmulator()
val hasBiometricAuth = canUseBiometricAuth()
val requireUserAuth = !isEmulatorDevice && hasBiometricAuth
Timber.d("Keystore auth check: isEmulator=$isEmulatorDevice, hasBiometricAuth=$hasBiometricAuth, requireUserAuth=$requireUserAuth")
val keySpecBuilder = KeyGenParameterSpec.Builder(
// Key spec: device-protected, no additional user authentication required
// This allows wallet operations when device is unlocked
// Transaction signing should use BiometricPrompt separately for confirmation
val keySpec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(AES_KEY_SIZE)
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)")
}
.build()
keyGenerator.init(keySpecBuilder.build())
keyGenerator.init(keySpec)
Timber.d("Created new keystore key for wallet: $alias")
return keyGenerator.generateKey()
}
@ -281,7 +253,7 @@ class CardanoKeyStorageImpl @Inject constructor(
val secretKey = try {
getOrCreateSecretKey(sessionId)
} 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
}