Перейти к содержимому
Все записи

Биометрическая аутентификация в Android в 2026 году: BiometricPrompt, Keystore и блокировка local-first приложения

Практическое руководство по androidx.biometric в Android — BiometricPrompt, ключи на основе CryptoObject, резервный вариант с учётными данными устройства и ошибки, из-за которых проверка отпечатка не защищает ничего.

MFKAPPS 5 мин чтения

Запрос отпечатка пальца, который просто блокирует экран, — это украшение. Если лежащие в основе данные всё это время хранятся в обычной базе Room в открытом виде, любой, у кого есть adb и root-доступ к устройству — или инструмент извлечения резервной копии — прочитает их, ни разу не коснувшись датчика. Настоящая биометрическая защита в Android означает, что запрос разблокирует криптографический ключ, и именно этот ключ реально стоит между злоумышленником и данными. Это как раз та часть, которую пропускает большинство руководств по BiometricPrompt, и единственная часть, которая имеет значение.

Я добавил это в Granyn, чтобы заблокировать данные бюджета на устройстве, и тот же паттерн защищает журнал приёма лекарств или список подписок в любом local-first приложении. Вот версия, которая действительно несёт нагрузку.

Две разные вещи, называемые «биометрической аутентификацией»

androidx.biometric поддерживает два режима аутентификации, и они защищают совершенно разные вещи:

  • Только аутентификация. BiometricPrompt.authenticate(PromptInfo) без CryptoObject просто спрашивает «совпала ли зарегистрированная биометрия?» и возвращает да или нет. Это интерфейсный шлюз. Ничего не шифруется и не расшифровывается — устройство с root-доступом или баг в content provider полностью обходят это, потому что данные никогда не зависели от успеха запроса.
  • Аутентификация на основе криптографии. BiometricPrompt.authenticate(PromptInfo, CryptoObject) привязывает запрос к Cipher, построенному на основе ключа Android Keystore, помеченного setUserAuthenticationRequired(true). Сама ОС отказывается позволить этому ключу делать что-либо — шифровать, расшифровывать, подписывать — пока соответствующая биометрическая аутентификация или аутентификация по учётным данным устройства только что не завершилась успешно. Ключ становится физически непригодным для использования без запроса, а не просто условно заблокированным.

Если цель — «сделать цифры бюджета нечитаемыми без разблокировки», этого добивается только второй вариант. Первый подходит для чего-то менее критичного, например для повторного подтверждения перед разрушительным действием на экране, который не работает с чувствительными данными в состоянии покоя.

Создание ключа Keystore, требующего аутентификации

private const val KEY_ALIAS = "granyn_db_key"

fun getOrCreateKey(): SecretKey {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }

    val keyGenerator = KeyGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
    )
    val spec = KeyGenParameterSpec.Builder(
        KEY_ALIAS,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setUserAuthenticationRequired(true)
        .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
        .build()
    keyGenerator.init(spec)
    return keyGenerator.generateKey()
}

setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) — это важная строка: длительность 0 означает, что ключ можно использовать ровно для одной операции на одну аутентификацию, а не в течение скользящего временного окна. Это намеренно строже, чем старый setUserAuthenticationValidityDurationSeconds, который позволял ключу оставаться разблокированным N секунд после любой успешной проверки — удобно, и именно поэтому многие «защищённые биометрией» приложения незаметно переставали защищать хоть что-либо, как только фоновый сервис второй раз обращался к ключу. Требуйте именно BIOMETRIC_STRONG; BIOMETRIC_WEAK охватывает такие вещи, как разблокировка по лицу под углом, который иногда может обмануть фотография, — а это не та планка, которую вы хотите для защиты финансовых данных.

Связывание запроса с шифром

suspend fun authenticateAndDecrypt(
    activity: FragmentActivity,
    ciphertext: ByteArray,
    iv: ByteArray,
): ByteArray = suspendCancellableCoroutine { cont ->
    val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
        init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
    }

    val callback = object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            val decrypted = result.cryptoObject?.cipher?.doFinal(ciphertext)
            if (decrypted != null) cont.resume(decrypted) else cont.cancel()
        }
        override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
            cont.cancel()
        }
    }

    val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
    val promptInfo = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Unlock Granyn")
        .setSubtitle("Confirm it's you to view your budget")
        .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
        .build()

    prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}

Здесь спотыкаются на двух вещах. Во-первых, setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) — сочетание строгой биометрии с резервным PIN-кодом/графическим ключом/паролем устройства — взаимно исключает setNegativeButtonText(). Вызов обоих одновременно приводит к исключению при построении запроса. Опция учётных данных устройства уже даёт пользователям выход, когда датчик не срабатывает или ничего не зарегистрировано, поэтому ручная кнопка отмены не дополняет её, а дублирует.

Во-вторых, CryptoObject, который вы получаете обратно в onAuthenticationSucceeded, — это не копия для вежливости, а тот же самый экземпляр Cipher, теперь разблокированный, на котором нужно немедленно вызвать doFinal(). Пересоздание нового Cipher после срабатывания коллбэка сводит на нет весь смысл: этот новый шифр никогда не был одобрен проверкой аутентификации, поэтому попытка использовать его выбрасывает UserNotAuthenticatedException — или того хуже, если вы построили его без требования аутентификации, он молча работает и незаметно вновь открывает ту самую дыру, которую вы пытались закрыть.

Проверка доступности до показа запроса

val biometricManager = BiometricManager.from(context)
when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
    BiometricManager.BIOMETRIC_SUCCESS -> { /* продолжить */ }
    BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
        // Ни отпечатка/лица, НИ PIN-кода/графического ключа/пароля не задано.
        // Направьте на Settings.ACTION_BIOMETRIC_ENROLL, а не просто вызывайте
        // authenticate() и дайте ему молча провалиться.
    }
    BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
    BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
        // Переключитесь на PIN-код уровня приложения, которым вы управляете
        // сами, или просто пропустите функцию блокировки на этом устройстве,
        // а не блокируйте доступ.
    }
    else -> { /* обработайте остальные коды ошибок */ }
}

canAuthenticate() — это не необязательный шаблонный код: это разница между понятным запросом «настройте блокировку экрана, чтобы включить это» и запутанным сбоем при первом же нажатии переключателя пользователем без зарегистрированных учётных данных. Тестируйте этот путь намеренно: удалите зарегистрированные отпечатки в эмуляторе (Настройки → Безопасность → Отпечаток пальца, либо в расширенных элементах управления эмулятора — панель Fingerprint) и убедитесь, что приложение корректно деградирует, а не падает.

Что на самом деле нуждается в блокировке

Не всё выигрывает от этого. Ключ на основе криптографии добавляет реальные задержки — одна аутентификация на каждую расшифровку, одно обращение к Keystore на каждое чтение, — так что блокировка этим всего пути чтения из базы данных превращает «открыть приложение» в заметно более медленную операцию. Для Granyn я остановился на следующем: шифровать только те поля, которые действительно важны в случае утери телефона, — остатки на счетах и заметки к транзакциям, — а названия категорий и настройки интерфейса оставить в обычных столбцах Room. Блокировка защищает то, что реально чувствительно; всё остальное остаётся быстрым.

Привычка, которую стоит вынести из всего этого: прежде чем выпускать любую функцию «защищено вашим отпечатком пальца», спросите себя, что произойдёт, если запрос будет полностью пропущен — root-доступ, отладчик, обход content provider. Если ответ «ничего, данные всё ещё читаемы», значит запрос никогда не выполнял ту работу, которую, казалось, выполнял.

// По теме

Ещё из журнала

MFKAPPS 5 мин чтения

Health Connect в Android в 2026 году: как читать и писать данные, не отказываясь от local-first

Практическое руководство по API Health Connect в Android в 2026 году — разрешения, фоновое чтение и почему local-first-приложениям стоит относиться к нему как к опциональному источнику, а не как к истине.

#android #engineering #kotlin
MFKAPPS 4 мин чтения

In-App Review API в Android: как просить оценку, не раздражая пользователя

Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.

#android #engineering #kotlin
MFKAPPS 5 мин чтения

Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения

Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.

#android #engineering #kotlin