Биометрически защищённые ключи Android — руководство по реализации (H-1 / finding #6)¶
Для кого: разработчики Android SDK (способные собрать и протестировать на реальном устройстве). Что: дополнительно обернуть ключ подписания/аутентификации телефона ключом Android Keystore с требованием аутентификации пользователя (биометрия), чтобы заблокировать офлайн-перебор PIN — паритет с моделью
.userPresenceSecure 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. Тест-кейсы (на реальном устройстве)¶
- Свежий enroll → подписание: появляется биометрический запрос, подпись успешна. Сертификат и пороговая подпись по-прежнему корректны.
- Отмена биометрии: подписание прекращается, показана ошибка, сессия цела (можно повторить).
- Миграция v1: зарегистрироваться на старой сборке → установить новую → первое подписание
проходит по PIN, затем происходит конвертация в v2 (
identity.v2.*создан,identity.*удалён), и второе подписание запрашивает биометрию. - Добавление нового отпечатка (ПОДПИСАНИЕ): ключ подписания становится инвалидированным → требуется повторный enroll (или показывается понятная ошибка). Слот AUTH не блокируется.
- Устройство без StrongBox: сработал fallback на TEE.
- Проверка 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).