Реалізація біометричної захисту транзакцій мобільного криптокошелька
Біометрія при підтвердженні транзакцій — не просто "додати Face ID перед відправкою". Неправильна реалізація дає ложне ощущение безпеки: програма запитує Face ID, але після відклонення все рівно можна підтвердити транзакцію через інший шлях, або біометрія перевіряється тільки локально без криптографічної прив'язки до ключа.
Два підходи — принципово різні
Підхід 1: біометрія як UI-gate. Показуємо LAContext/BiometricPrompt, при успіху розблоковуємо кнопку "Підтвердити". Приватний ключ лежить у Keychain без біометричної захисту. Слабість: обхід можливий через hooking (Frida, Objection) — патчимо метод evaluatePolicy та повертаємо true. У production-кошельку це недопустимо.
Підхід 2: біометрія прив'язана до ключа. Приватний ключ (або ключ шифрування) у Keychain/KeyStore з SecAccessControl.biometryCurrentSet (iOS) або setUserAuthenticationRequired(true) (Android). Криптографічна операція неможлива без успішної біометрії — це гарантує ОС, не програма. Frida не допомагає — ключ фізично недоступен без біометрії на рівні SE/TEE.
Для кошелька використовуємо тільки другий підхід.
iOS: криптографічно прив'язана біометрія
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
При спробі використовувати цей ключ без біометрії — errSecUserCanceled або errSecAuthFailed. Програма не може обійти це програмно. Контекст можна передати явно для кастомного UI:
let context = LAContext()
context.localizedReason = "Підтвердіть транзакцію на \(amount) ETH"
context.localizedCancelTitle = "Скасувати"
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationLabel as String: "wallet-key",
kSecUseAuthenticationContext as String: context,
kSecReturnRef as String: true
]
Текст у localizedReason повинен містити деталі транзакції — адреса одержувача, сума. Користувач повинен видіти, що саме він підтверджує.
Android: BiometricPrompt з 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("Підтвердіть транзакцію")
.setSubtitle("Відправити ${amount} ETH на ${shortAddress}")
.setNegativeButtonText("Скасувати")
.setAllowedAuthenticators(BIOMETRIC_STRONG)
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
CryptoObject прив'язує криптографічну операцію до біометрії. BIOMETRIC_STRONG виключає слабку біометрію (розпізнавання обличчя на пристроях без depth sensor). Після успіху authenticationResult.cryptoObject?.cipher містить розблокований Cipher — тільки тоді розшифровуємо та використовуємо ключ.
Що ще важливо
Таймаут переаутентифікації: iOS кешує успішну біометрію в LAContext на час його життя. Для високорисковних операцій створюйте новий LAContext для кожної транзакції. Android: setUserAuthenticationValidityDurationSeconds(-1) вимагає біометрію при кожному використанні ключа.
Fallback на PIN: якщо біометрія недоступна (Face ID відключена, пристрій без сенсора), користувач повинен мати можливість підтвердити транзакцію через PIN. .userPresence замість .biometryCurrentSet дозволяє обидва методи.
Часова шкала — 2–3 дня. Якщо реалізуєте вперше на конкретній платформі — додайте день на тестування edge cases (biometry lockout після 5 невдалих спроб, зміна біометрії у налаштуваннях).







