Перейти к содержанию

Биометрически защищённые ключи Android — руководство по реализации (H-1 / finding #6)

Для кого: разработчики Android SDK (способные собрать и протестировать на реальном устройстве). Что: дополнительно обернуть ключ подписания/аутентификации телефона ключом Android Keystore с требованием аутентификации пользователя (биометрия), чтобы заблокировать офлайн-перебор PIN — паритет с моделью .userPresence Secure Enclave на iOS. Почему это руководство: изменение делает API подписания асинхронным (BiometricPrompt → FragmentActivity) и требует миграции регистраций существующих пользователей, поэтому вливать его можно только после сборки и тестирования на реальном устройстве.


1. Проблема (пробел)

Текущее состояниеandroid-sdk/.../AndroidSecureKeyStore.kt:

  • Идентичность (xClient + закрытый ключ Paillier + сертификат) шифруется как salt + iv + AES-GCM(PBKDF2(PIN, salt, 210k), serialize(id)) и хранится в EncryptedSharedPreferences.
  • maxPinAttempts = 10 — 10 неверных PIN → блоб удаляется. Но этот счётчик работает только для ОНЛАЙН-попыток (внутри приложения), проходящих через load().

Уязвимость (устройство с root): атакующий с root извлекает EncryptedSharedPreferences приложения (само приложение может их читать, значит и root-as-app тоже) и достаёт внутренний PIN-блоб. Внутренний ключ AES — это чистый PBKDF2(PIN), его можно перебрать вне устройства (на GPU-кластере). Пространство PIN составляет всего 4–6 цифр (10⁴–10⁶). PBKDF2 с 210k итераций замедляет перебор, но на GPU это минуты или часы. Счётчик maxPinAttempts в офлайн-сценарии не является препятствием вовсе. → Юридически значимые (неотказуемые) подписи могут быть подделаны.

Почему этой проблемы нет на iOS: iOS дополнительно оборачивает внутренний PIN-блоб ключом Secure Enclave. Закрытый ключ SE (а) неизвлекаем из аппаратуры (даже на устройстве с root/JB) и (б) требует присутствия пользователя (Face ID/Touch ID/код-пароль) при каждом использовании. Поэтому блоб нельзя вынести ЗА пределы устройства, и даже внутри него каждая попытка перебора требует биометрии → офлайн- или скриптовая атака невозможна.

Источник паритета с iOS: ios/ios-sdk/Sources/GeregeSmartID/SecureKeyStore.swift - secureWrap(blob) (строка 128): ECIES-шифрование публичным ключом SE → [1|wrapped]. - secureUnwrap(stored, reason) (строка 151): если [1|wrapped], расшифровать закрытым ключом SE (LAContext = биометрический запрос); при отмене пользователем подписание прекращается. - secureEnclaveKey() (строка 168): контроль доступа = [.privateKeyUsage, .userPresence], kSecAttrTokenIDSecureEnclave. - На симуляторе SE нет → fallback [0|blob] (только для dev).


2. Файлы, которые нужно изменить

Файл Изменение
eIDMongolia/app/build.gradle.kts Добавить зависимость androidx.biometric:biometric (интерфейс BiometricAuthenticator в SDK работает с обычным javax.crypto.Cipher, поэтому самому SDK она не нужна)
android-sdk/.../BiometricAuthenticator.kt Новый интерфейс (мост SDK↔приложение, не зависит от UI-фреймворка)
android-sdk/.../AndroidSecureKeyStore.kt Обёртка ключом Keystore (secureWrap/secureUnwrap) + версионированный блоб + асинхронный load
android-sdk/.../GeregeSmartIdClient.kt Передавать биометрический аутентификатор в approve()/enroll()/changePIN()/verifyPIN()/pinUnlocksSlot()
eIDMongolia/.../core/BiometricAuth.kt (+ MainActivity = FragmentActivity) Провайдер ActivityBiometricAuthenticator / rememberBiometricAuthenticator()

3. Что именно менять

3.1 Зависимость (eIDMongolia/app/build.gradle.kts)

implementation("androidx.biometric:biometric:1.1.0")
// BiometricPrompt требует FragmentActivity, поэтому Activity приложения должна быть FragmentActivity/AppCompatActivity.

3.2 Ключ-обёртка Keystore с требованием аутентификации

Ближе всего к ECIES на iOS стоит асимметричный ключ RSA-OAEP: шифрование публичным ключом (аутентификация НЕ требуется, поэтому при save()/enroll запроса нет), расшифровка закрытым ключом (аутентификация ТРЕБУЕТСЯ → BiometricPrompt). Это точно соответствует iOS (биометрия не запрашивается при enroll, только при load/подписании).

// Внутри AndroidSecureKeyStore — отдельный alias для каждого слота.
private val wrapAlias = "gsid.wrap.$account"   // например gsid.wrap.default / gsid.wrap.default.auth
private val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }

/** Найти либо однократно создать RSA-ключ обёртки с требованием аутентификации. StrongBox → fallback на TEE. */
private fun wrapKeyPair(): Pair<java.security.PublicKey, java.security.PrivateKey> {
    (ks.getEntry(wrapAlias, null) as? java.security.KeyStore.PrivateKeyEntry)?.let {
        return it.certificate.publicKey to it.privateKey
    }
    fun spec(strongBox: Boolean) = KeyGenParameterSpec.Builder(
        wrapAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        // OAEP hash=SHA-256, MGF1=SHA-1 (совместимость с minSdk 24 — до API 31 AndroidKeyStore
        // жёстко удерживает MGF1 на SHA-1). Поэтому SHA-1 также разрешён в digests (согласовано с oaepSpec ниже).
        .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
        .setKeySize(2048)
        .setUserAuthenticationRequired(true)                       // ← ключевое: биометрия при каждом использовании
        .apply {
            if (Build.VERSION.SDK_INT >= 30) {
                // API 30+: срок действия аутентификации 0 = свежая аутентификация на каждую операцию (через BiometricPrompt CryptoObject).
                setUserAuthenticationParameters(
                    0, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
                )
            } else {
                @Suppress("DEPRECATION")
                setUserAuthenticationValidityDurationSeconds(-1)   // -1 = аутентификация на каждую операцию
            }
            // На слоте ПОДПИСАНИЯ (PIN2, неотказуемость): инвалидировать ключ при добавлении новой биометрии.
            // На слоте AUTH (PIN1) это можно пропустить из соображений UX (см. подводные камни ниже).
            if (account.endsWith(".auth").not()) setInvalidatedByBiometricEnrollment(true)
            if (strongBox) setIsStrongBoxBacked(true)
        }
        .build()
    val gen = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore")
    val kp = try {
        gen.initialize(spec(true)); gen.generateKeyPair()          // StrongBox
    } catch (e: Exception) {                                        // Нет StrongBox / SHA-1 не поддерживается → TEE
        try {
            gen.initialize(spec(false)); gen.generateKeyPair()
        } catch (e2: Exception) {
            // Совсем нет блокировки экрана/биометрии → жёсткая ошибка (fail-closed, паритет с iOS).
            throw SecureHardwareUnavailableException()
        }
    }
    return kp.public to kp.private
}

3.3 secureWrap / secureUnwrap (паритет с iOS)

// При сохранении: шифрование публичным ключом RSA-OAEP (БЕЗ аутентификации). Если блоб большой, шифруем его
// промежуточным AES-ключом (гибридная схема) — RSA-2048-OAEP-SHA256 шифрует лишь ~190 байт, а блоб идентичности велик.
// ⇒ Гибрид: шифруем блоб GCM случайным ключом AES-256, затем оборачиваем этот AES-ключ через RSA-OAEP.
private fun secureWrap(blob: ByteArray): ByteArray {
    val (pub, _) = wrapKeyPair()
    val dek = ByteArray(32).also { random.nextBytes(it) }          // ключ шифрования данных
    val iv = ByteArray(12).also { random.nextBytes(it) }
    val gcm = Cipher.getInstance("AES/GCM/NoPadding").apply {
        init(Cipher.ENCRYPT_MODE, SecretKeySpec(dek, "AES"), GCMParameterSpec(128, iv))
    }
    val ct = gcm.doFinal(blob)
    // OAEP: ЯВНО указываем hash=SHA-256, MGF1=SHA-1 ("RSA/ECB/OAEPPadding" + OAEPParameterSpec).
    // Расхождение в digest для MGF1 между сторонами шифрования/расшифровки — известная ловушка, приводящая к ошибкам OAEP.
    val oaep = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
    val rsa = Cipher.getInstance("RSA/ECB/OAEPPadding").apply {
        init(Cipher.ENCRYPT_MODE, pub, oaep)                       // публичный → аутентификация не нужна
    }
    val wrappedDek = rsa.doFinal(dek)
    dek.fill(0)
    // формат: [ver=2][len(wrappedDek):2][wrappedDek][iv:12][ct]
    return byteArrayOf(2) + shortLen(wrappedDek.size) + wrappedDek + iv + ct
}

// При разворачивании: расшифровка закрытым ключом RSA-OAEP требует аутентификации → нужен BiometricPrompt(CryptoObject).
private suspend fun secureUnwrap(stored: ByteArray, auth: BiometricAuthenticator): ByteArray {
    require(stored[0].toInt() == 2) { "wrap version" }
    var o = 1
    val wlen = readShortLen(stored, o); o += 2
    val wrappedDek = stored.copyOfRange(o, o + wlen); o += wlen
    val iv = stored.copyOfRange(o, o + 12); o += 12
    val ct = stored.copyOfRange(o, stored.size)
    val (_, priv) = wrapKeyPair()
    val oaep = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
    val rsa = Cipher.getInstance("RSA/ECB/OAEPPadding")
    rsa.init(Cipher.DECRYPT_MODE, priv, oaep)
    // ⚠ Оборачиваем Cipher расшифровки RSA в CryptoObject и авторизуем через BiometricPrompt.
    val authed = auth.authenticate(rsa, "Please authenticate to unlock your eID key")
    val dek = authed.doFinal(wrappedDek)                           // выполняется только после успешной аутентификации
    val gcm = Cipher.getInstance("AES/GCM/NoPadding").apply {
        init(Cipher.DECRYPT_MODE, SecretKeySpec(dek, "AES"), GCMParameterSpec(128, iv))
    }
    return gcm.doFinal(ct).also { dek.fill(0) }
}

3.4 Асинхронный load + абстракция BiometricPrompt

Сделайте load() suspend и передавайте в него биометрический аутентификатор. Чтобы SDK не зависел напрямую от Activity, изолируйте его за интерфейсом:

/** BiometricPrompt предоставляет приложение (чтобы SDK не привязывался напрямую к Activity). */
interface BiometricAuthenticator {
    /** Авторизовать CryptoObject(cipher) биометрией и вернуть авторизованный Cipher.
     *  При отмене пользователем — выбросить исключение (→ подписание/enroll прекращается, fail-closed). */
    suspend fun authenticate(cipher: Cipher, reason: String): Cipher
}

Новая сигнатура AndroidSecureKeyStore.load:

suspend fun load(pin: CharArray, auth: BiometricAuthenticator, countAttempts: Boolean = true): Identity {
    // countAttempts=false → проба только на чтение, проверяющая корректность PIN (НЕ трогает счётчик/блокировку/миграцию).
    if (countAttempts && prefs.getInt(attemptsKey, 0) >= maxPinAttempts) { deleteIdentity(); throw KeyStoreLockedException() }
    val storedB64 = prefs.getString(identityKeyV2, null)
    val blob: ByteArray = if (storedB64 != null) {
        try {
            secureUnwrap(b64d(storedB64), auth)                    // v2: развернуть обёртку Keystore (биометрический запрос)
        } catch (e: KeyPermanentlyInvalidatedException) {
            // Добавление новой биометрии/снятие блокировки экрана навсегда инвалидировало ключ обёртки → v2 больше
            // не развернуть. Очищаем регистрацию и требуем повторный enroll (это НЕ «неверный PIN»; паритет с iOS SE).
            deleteIdentity(); throw KeyInvalidatedException()
        }
    } else {
        // Миграция v1 (легаси): старый формат — только PBKDF2. Позже пересохраняем как v2 (ниже).
        val legacy = prefs.getString(identityKey, null) ?: error("Not registered")
        b64d(legacy)
    }
    // ... отсюда и НИЖЕ прежняя логика расшифровки PBKDF2(PIN) НЕ ИЗМЕНЯЕТСЯ ...
    // после успешной расшифровки, если это была v1, пересохраняем как v2:
    // if (storedB64 == null) save(pin, identity, auth)   // migrate-on-load
}

3.5 GeregeSmartIdClient — передача аутентификатора

suspend fun approve(
    sessionId: String,
    pin: CharArray,
    auth: BiometricAuthenticator,                                  // ← НОВОЕ (обязательный; ПЕРЕД параметрами со значениями по умолчанию)
    authentication: Boolean = false,
    confirmVc: String? = null,
    progress: ProgressHandler? = null,
): String {
    val slot = if (authentication) PINSlot.AUTH else PINSlot.SIGN
    val id = store(slot).load(pin, auth)                           // здесь появляется биометрический запрос
    ...
}

В enroll(...) save() — это шифрование публичным ключом, поэтому биометрический запрос не появляется, но migrate-on-load и changePIN требуют аутентификатора, так что передавайте его и туда.

3.6 Пример приложения — провайдер BiometricPrompt

  • Сделайте MainActivity наследником FragmentActivity (или AppCompatActivity).
  • Поместите адаптер в core/BiometricAuth.kt и получайте его из Compose через rememberBiometricAuthenticator().
  • BiometricPrompt ОБЯЗАН вызываться в ГЛАВНОМ потоке (withContext(Dispatchers.Main.immediate)):
class ActivityBiometricAuthenticator(private val activity: FragmentActivity) : BiometricAuthenticator {
    override suspend fun authenticate(cipher: Cipher, reason: String): Cipher =
      withContext(Dispatchers.Main.immediate) {
        suspendCancellableCoroutine { cont ->
            val prompt = BiometricPrompt(activity,
                ContextCompat.getMainExecutor(activity),
                object : BiometricPrompt.AuthenticationCallback() {
                    override fun onAuthenticationSucceeded(r: BiometricPrompt.AuthenticationResult) {
                        cont.resume(r.cryptoObject!!.cipher!!)
                    }
                    override fun onAuthenticationError(code: Int, msg: CharSequence) {
                        cont.resumeWithException(SecurityException("Biometrics failed: $msg"))
                    }
                    // onAuthenticationFailed — повторные попытки (prompt обрабатывает их сам), НЕ вызывает resume.
                })
            val builder = BiometricPrompt.PromptInfo.Builder()
                .setTitle("eID")
                .setSubtitle(reason)
            // CryptoObject + DEVICE_CREDENTIAL поддерживается только на API 30+. Ниже — BIOMETRIC_STRONG
            // + кнопка отмены (обязательна).
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                builder.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
            } else {
                builder.setAllowedAuthenticators(BIOMETRIC_STRONG)
                builder.setNegativeButtonText("Cancel")
            }
            prompt.authenticate(builder.build(), BiometricPrompt.CryptoObject(cipher))
            cont.invokeOnCancellation { /* prompt нельзя отменить — оставляем как есть */ }
        }
      }
}

4. Миграция (обязательно — не оставляйте существующих пользователей без доступа)

  • v1 (текущая): prefs["identity.$account"] = base64(salt+iv+ct), без обёртки Keystore.
  • v2 (новая): prefs["identity.v2.$account"] = base64(secureWrap(salt+iv+ct)).
  • load(): сначала проверить identity.v2.$account → если есть, идти по пути v2. Если нет — identity.$account (v1) → расшифровка только через PBKDF2 → после успешной расшифровки записать как v2 через save() и удалить ключ v1 (migrate-on-successful-load; поскольку PIN уже проверен, дополнительный запрос не нужен).
  • После миграции на v2 этот пользователь при каждой последующей загрузке будет получать биометрический запрос.
  • Примечание: ключ Keystore создавайте только при первом enroll/обновлении ПРИЛОЖЕНИЯ. wrapKeyPair() ленив — ключ создаётся при первом вызове.

5. Требования безопасности (чек-лист приёмки)

  • [ ] У ключа обёртки установлен setUserAuthenticationRequired(true) — проверено в Keystore (KeyInfo.isUserAuthenticationRequired).
  • [ ] Ключ обёртки неэкспортируем (материал ключей AndroidKeyStore нельзя экспортировать — по умолчанию).
  • [ ] На устройствах с поддержкой StrongBox он isInsideSecureHardware/StrongBox-backed; иначе TEE.
  • [ ] На слоте ПОДПИСАНИЯ (PIN2) задан setInvalidatedByBiometricEnrollment(true) — добавление нового отпечатка инвалидирует ключ подписания (блокирует атаку, при которой злоумышленник добавляет собственную биометрию). В этом случае load перехватывает KeyPermanentlyInvalidatedException и выбрасывает KeyInvalidatedException (НЕ «неверный PIN»), очищая регистрацию и направляя на повторный enroll. Про слот AUTH см. подводные камни.
  • [ ] Отмена/сбой биометрии → load выбрасывает исключение (fail-closed): подписание/enroll прекращается, тихого обхода НЕТ.
  • [ ] Логика maxPinAttempts НЕ ИЗМЕНЕНА (онлайн).
  • [ ] Миграция v1→v2 не оставляет пользователей без доступа (проверить на реальном устройстве с блобом v1).
  • [ ] ФОРМАТ golden-векторов/сериализации не изменился (внутренний блоб PBKDF2 тот же; добавлена только ВНЕШНЯЯ обёртка).

6. Тест-кейсы (на реальном устройстве)

  1. Свежий enroll → подписание: появляется биометрический запрос, подпись успешна. Сертификат и пороговая подпись по-прежнему корректны.
  2. Отмена биометрии: подписание прекращается, показана ошибка, сессия цела (можно повторить).
  3. Миграция v1: зарегистрироваться на старой сборке → установить новую → первое подписание проходит по PIN, затем происходит конвертация в v2 (identity.v2.* создан, identity.* удалён), и второе подписание запрашивает биометрию.
  4. Добавление нового отпечатка (ПОДПИСАНИЕ): ключ подписания становится инвалидированным → требуется повторный enroll (или показывается понятная ошибка). Слот AUTH не блокируется.
  5. Устройство без StrongBox: сработал fallback на TEE.
  6. Проверка root/эмулятора: сторона аттестации (более ранняя правка PlayIntegrityAttestor) по-прежнему fail-closed.

7. Подводные камни

  • BiometricPrompt = FragmentActivity. Одного ComponentActivity для Compose недостаточно — сделайте AppCompatActivity. Prompt вызывается в UI-потоке.
  • setInvalidatedByBiometricEnrollment(true) несёт UX-риск на слоте AUTH: если пользователь добавит отпечаток, ключ входа будет уничтожен и потребуется повторный enroll. Для ПОДПИСАНИЯ (неотказуемость) это ПРАВИЛЬНО (защита), для AUTH — опционально: сначала протестируйте с false, затем определите политику.
  • Fallback на DEVICE_CREDENTIAL: у некоторых устройств есть пользователи без биометрии — разрешение PIN/графического ключа (device credential) не оставит их без доступа. Но сочетание AUTH_DEVICE_CREDENTIAL с setInvalidatedByBiometricEnrollment имеет ограничения API (может потребоваться API 30+) — протестируйте.
  • Двойной запрос при миграции: во время migrate-on-load PIN уже проверен, поэтому при save() запроса быть НЕ ДОЛЖНО (шифрование публичным ключом). Если запрос появляется, значит используется симметричный, а не асимметричный ключ — убедитесь, что RSA-OAEP асимметричен.
  • Резервные копии / allowBackup: ключи AndroidKeyStore никогда не попадают в бэкапы; мигрированный блоб v2 разворачивается только на этом устройстве (device-bound) — это ПРАВИЛЬНО (аналогично ThisDeviceOnly на iOS).

Краткий diff API (для вызывающего кода)

- fun load(pin): Identity                       →  suspend fun load(pin, auth: BiometricAuthenticator, countAttempts=true): Identity
- suspend fun approve(sessionId, pin, ...)       →  suspend fun approve(sessionId, pin, auth: BiometricAuthenticator, ...)
  verifyPIN / pinUnlocksSlot / changePIN         →  все получают параметр auth: BiometricAuthenticator
+ interface BiometricAuthenticator { suspend fun authenticate(cipher, reason): Cipher }
+ class ActivityBiometricAuthenticator(activity: FragmentActivity) : BiometricAuthenticator   // на стороне приложения (core/BiometricAuth.kt)
+ fun rememberBiometricAuthenticator(): BiometricAuthenticator                                 // помощник для Compose
+ KeyInvalidatedException / SecureHardwareUnavailableException — новые ошибки fail-closed

Источник паритета с iOS (обязательно сверяться): ios/ios-sdk/Sources/GeregeSmartID/SecureKeyStore.swift (secureWrap/secureUnwrap/secureEnclaveKey).