feat(wallet): require biometric/PIN auth before transaction signing

Use BIOMETRIC_WEAK | DEVICE_CREDENTIAL to support:
- Fingerprint/face → biometric prompt
- PIN only → PIN prompt
- No auth set up → allow through (dont block tx)

Auth fires when user taps Send on confirmation screen,
before tx is built/signed/submitted. On failure/cancel,
user stays on confirmation screen.
This commit is contained in:
Kayos 2026-03-29 08:44:09 -07:00
parent 2b93236229
commit d975d7d761

View file

@ -16,7 +16,12 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* Helper class for biometric authentication.
* Helper class for biometric authentication at transaction signing.
*
* Uses BIOMETRIC_WEAK | DEVICE_CREDENTIAL to support:
* - Fingerprint/face biometric prompt
* - PIN only PIN prompt
* - No auth set up skips auth (doesn't block transactions)
*/
class BiometricAuthenticator @Inject constructor() {
@ -26,63 +31,95 @@ class BiometricAuthenticator @Inject constructor() {
data object Cancelled : AuthResult
}
/**
* Check if any authentication method is available.
* Returns true if biometric OR device credential (PIN/pattern/password) is available.
*/
fun canAuthenticate(context: Context): Boolean {
val biometricManager = BiometricManager.from(context)
return biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
val result = biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
) == BiometricManager.BIOMETRIC_SUCCESS
)
return result == BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Check if device has any form of security (biometric, PIN, pattern, password).
* If false, authentication will be skipped to avoid blocking transactions.
*/
fun isDeviceSecured(context: Context): Boolean {
val biometricManager = BiometricManager.from(context)
// Check both weak biometric and device credential
val weakResult = biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
val credentialResult = biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL)
return weakResult == BiometricManager.BIOMETRIC_SUCCESS ||
credentialResult == BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Authenticate the user before a sensitive action (e.g., signing a transaction).
*
* - If device has biometric shows biometric prompt
* - If device has only PIN/pattern/password shows device credential prompt
* - If device has no security returns Success immediately (don't block the tx)
*/
suspend fun authenticate(
activity: FragmentActivity,
title: String = "Authenticate",
subtitle: String = "Confirm your identity to continue",
): AuthResult = suspendCancellableCoroutine { continuation ->
val executor = ContextCompat.getMainExecutor(activity)
title: String = "Confirm Payment",
subtitle: String = "Authenticate to send ADA",
): AuthResult {
// If device has no security set up, allow through
if (!isDeviceSecured(activity)) {
return AuthResult.Success
}
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
if (continuation.isActive) {
continuation.resume(AuthResult.Success)
return suspendCancellableCoroutine { continuation ->
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
if (continuation.isActive) {
continuation.resume(AuthResult.Success)
}
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (continuation.isActive) {
when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
BiometricPrompt.ERROR_CANCELED -> {
continuation.resume(AuthResult.Cancelled)
}
else -> {
continuation.resume(AuthResult.Error(errorCode, errString.toString()))
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (continuation.isActive) {
when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
BiometricPrompt.ERROR_CANCELED -> {
continuation.resume(AuthResult.Cancelled)
}
else -> {
continuation.resume(AuthResult.Error(errorCode, errString.toString()))
}
}
}
}
override fun onAuthenticationFailed() {
// User can retry, don't complete the continuation
}
}
override fun onAuthenticationFailed() {
// User can retry
val biometricPrompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build()
biometricPrompt.authenticate(promptInfo)
continuation.invokeOnCancellation {
biometricPrompt.cancelAuthentication()
}
}
val biometricPrompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build()
biometricPrompt.authenticate(promptInfo)
continuation.invokeOnCancellation {
biometricPrompt.cancelAuthentication()
}
}
}