Biometric Transaction Protection in Mobile Crypto Wallet
Biometry for transaction confirmation — not just "add Face ID before sending." Wrong implementation gives false security: app asks for Face ID, but after rejection can confirm transaction differently, or biometry checked only locally without crypto binding to key.
Two Approaches — Fundamentally Different
Approach 1: Biometry as UI Gate. Show LAContext/BiometricPrompt, on success unlock "Confirm" button. Private key in Keychain without biometric protection. Weakness: bypass via hooking (Frida, Objection) — patch evaluatePolicy method and return true. Unacceptable in production wallet.
Approach 2: Biometry Bound to Key. Private key (or encryption key) in Keychain/KeyStore with SecAccessControl.biometryCurrentSet (iOS) or setUserAuthenticationRequired(true) (Android). Cryptographic operation impossible without successful biometry — guaranteed by OS, not app. Frida helpless — key physically inaccessible without biometry at SE/TEE level.
Use only second approach for wallet.
iOS: Cryptographically Bound Biometry
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
Attempting to use this key without biometry — errSecUserCanceled or errSecAuthFailed. App can't bypass this programmatically. Can pass context explicitly for custom UI:
let context = LAContext()
context.localizedReason = "Confirm transaction for \(amount) ETH"
context.localizedCancelTitle = "Cancel"
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationLabel as String: "wallet-key",
kSecUseAuthenticationContext as String: context,
kSecReturnRef as String: true
]
localizedReason text should contain transaction details — recipient address, amount. User must see what exactly they confirm.
Android: BiometricPrompt with CryptoObject
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, iv))
}
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Confirm Transaction")
.setSubtitle("Send ${amount} ETH to ${shortAddress}")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(BIOMETRIC_STRONG)
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
CryptoObject binds cryptographic operation to biometry. BIOMETRIC_STRONG excludes weak biometry (face recognition on devices without depth sensor). After success authenticationResult.cryptoObject?.cipher contains unlocked Cipher — only then decrypt and use key.
What Else Matters
Re-authentication timeout: iOS caches successful biometry in LAContext for its lifetime. For high-risk operations create new LAContext per transaction. Android: setUserAuthenticationValidityDurationSeconds(-1) requires biometry each key use.
Fallback to PIN: if biometry unavailable (Face ID disabled, device without sensor), user must confirm transaction via PIN. .userPresence instead of .biometryCurrentSet allows both.
Timeline — 2–3 days. If implementing first time on specific platform — add day for edge case testing (biometry lockout after 5 failed attempts, biometry change in settings).







