Реализация биометрической защиты транзакций мобильного криптокошелька
Биометрия при подтверждении транзакций — не просто «добавить Face ID перед отправкой». Неправильная реализация даёт ложное ощущение безопасности: приложение спрашивает Face ID, но после отклонения всё равно можно подтвердить транзакцию через другой путь, или биометрия проверяется только локально без криптографической привязки к ключу.
Два подхода — принципиально разные
Подход 1: биометрия как UI-gate. Показываем LAContext/BiometricPrompt, при успехе разблокируем кнопку «Подтвердить». Приватный ключ лежит в Keychain без биометрической защиты. Слабость: обход возможен через hooking (Frida, Objection) — патчим метод evaluatePolicy и возвращаем true. В продакшн-кошельке это недопустимо.
Подход 2: биометрия привязана к ключу. Приватный ключ (или ключ шифрования) в Keychain/KeyStore с SecAccessControl.biometryCurrentSet (iOS) или setUserAuthenticationRequired(true) (Android). Cryptographic operation невозможна без успешной биометрии — это гарантирует ОС, не приложение. 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 неудачных попыток, смена биометрии в настройках).







