Android biometric-gated keys — хэрэгжүүлэх заавар (H-1 / finding #6)¶
Хэнд: Android SDK хөгжүүлэгч (build + жинхэнэ төхөөрөмж дээр тест хийх боломжтой). Юуг: Утасны signing/auth түлхүүрийг Android Keystore-ийн user-authentication-required (биометр) түлхүүрээр нэмж wrap хийж, offline PIN brute-force-ийг хаах — iOS-ийн Secure Enclave
.userPresenceзагвартай parity. Яагаад заавар: энэ өөрчлөлт нь signing API-г async болгож (BiometricPrompt → FragmentActivity), хуучин хэрэглэгчдийн бүртгэлийг migrate хийх шаардлагатай тул жинхэнэ төхөөрөмж дээр build+тест хийсний дараа merge хийх ёстой.
1. Асуудал (gap)¶
Одоогийн байдал — android-sdk/.../AndroidSecureKeyStore.kt:
- Identity (
xClient+ Paillier private key + cert) ньsalt + iv + AES-GCM(PBKDF2(PIN, salt, 210k), serialize(id))болж шифрлэгдэнEncryptedSharedPreferences-д хадгалагдана. maxPinAttempts = 10— 10 удаа буруу PIN → blob устгана. Гэхдээ энэ тоологч зөвхөнload()-оор дамжсан ONLINE (апп доторх) оролдлогод л ажиллана.
Гэмтэл (rooted төхөөрөмж): root эрхтэй халдагч апп-ийн EncryptedSharedPreferences-ийг задалж (апп өөрөө
уншиж чаддаг тул root-as-app мөн уншина) дотоод PIN-blob-ыг гаргаж авна. Дотоод AES түлхүүр нь цэвэр PBKDF2(PIN)
— төхөөрөмжөөс гадна (GPU кластер дээр) brute-force хийж болно. PIN зай ердөө 4–6 орон (10⁴–10⁶). PBKDF2 210k
удаашруулна ч GPU дээр цөөн минут–цаг. maxPinAttempts тоологч offline замд огт саад болохгүй.
→ Хууль ёсны (non-repudiation) гарын үсгийг хуурамчаар үүсгэх боломжтой.
iOS-д яагаад байхгүй вэ: iOS нь inner PIN-blob дээр нэмж Secure Enclave түлхүүрээр wrap хийдэг. SE хувийн түлхүүр нь (а) hardware-аас гарч авах боломжгүй (rooted/JB device дээр ч), (б) ашиглах бүрд user-presence (Face ID/Touch ID/passcode) шаарддаг. Тиймээс blob-ыг ГАДНА задлах боломжгүй, дотор нь ч бүр brute-force бүрт биометр хэрэгтэй → offline/scripted халдлага боломжгүй.
iOS parity эх сурвалж: ios/ios-sdk/Sources/GeregeSmartID/SecureKeyStore.swift
- secureWrap(blob) (мөр 128): SE public key-ээр ECIES шифрлэж [1|wrapped].
- secureUnwrap(stored, reason) (мөр 151): [1|wrapped] бол SE private key-ээр (LAContext = биометр prompt)
задална; хэрэглэгч цуцалбал signing зогсоно.
- secureEnclaveKey() (мөр 168): access control = [.privateKeyUsage, .userPresence], kSecAttrTokenIDSecureEnclave.
- Simulator дээр SE байхгүй → [0|blob] fallback (dev only).
2. Өөрчлөх файлууд¶
| Файл | Өөрчлөлт |
|---|---|
eIDMongolia/app/build.gradle.kts |
androidx.biometric:biometric dependency нэмэх (SDK-ийн BiometricAuthenticator нь цэвэр javax.crypto.Cipher тул SDK-д хэрэггүй) |
android-sdk/.../BiometricAuthenticator.kt |
Шинэ interface (SDK↔апп гүүр; UI framework-аас хамааралгүй) |
android-sdk/.../AndroidSecureKeyStore.kt |
Keystore auth-key wrap (secureWrap/secureUnwrap) + versioned blob + load async |
android-sdk/.../GeregeSmartIdClient.kt |
approve()/enroll()/changePIN()/verifyPIN()/pinUnlocksSlot() зэрэгт biometric authenticator дамжуулах |
eIDMongolia/.../core/BiometricAuth.kt (+ MainActivity = FragmentActivity) |
ActivityBiometricAuthenticator / rememberBiometricAuthenticator() provider |
3. Хийх өөрчлөлтүүд¶
3.1 Dependency (eIDMongolia/app/build.gradle.kts)¶
implementation("androidx.biometric:biometric:1.1.0")
// BiometricPrompt нь FragmentActivity шаарддаг тул апп-ийн Activity нь FragmentActivity/AppCompatActivity байх ёстой.
3.2 Keystore auth-required wrap түлхүүр¶
iOS-ийн ECIES-тэй хамгийн ойр parity нь асимметр RSA-OAEP түлхүүр: public key-ээр шифрлэх (auth ХЭРЭГГҮЙ,
тиймээс save()/enroll дээр prompt гарахгүй), private key-ээр задлах (auth ШААРДЛАГАТАЙ → BiometricPrompt).
Энэ нь iOS-той яг таарна (enroll дээр биометр асуухгүй, зөвхөн load/sign дээр).
// AndroidSecureKeyStore дотор — slot тус бүрд тусдаа 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) }
/** Auth-required RSA wrap key-г олох эсвэл нэг удаа үүсгэх. StrongBox → TEE fallback. */
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-ийг мөн digest-д зөвшөөрнө (доорх 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+: auth хугацаа 0 = үйлдэл бүрд шинэ auth (BiometricPrompt CryptoObject-оор).
setUserAuthenticationParameters(
0, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
)
} else {
@Suppress("DEPRECATION")
setUserAuthenticationValidityDurationSeconds(-1) // -1 = үйлдэл бүрд auth
}
// SIGNING (PIN2, non-repudiation) slot дээр: шинэ биометр бүртгэвэл түлхүүрийг хүчингүй болго.
// AUTH (PIN1) slot дээр UX-ийн улмаас орхиж болно (доорх pitfalls-ыг үз).
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 parity).
throw SecureHardwareUnavailableException()
}
}
return kp.public to kp.private
}
3.3 secureWrap / secureUnwrap (iOS-той parity)¶
// Хадгалахад: RSA-OAEP public-key encrypt (auth ХЭРЭГГҮЙ). Blob том бол AES дундын түлхүүрээр
// (hybrid) шифрлэ — RSA-2048-OAEP-SHA256 нь ~190 байт л шифрлэж чадна, харин identity blob том.
// ⇒ Hybrid: random AES-256 key-ээр blob-ыг GCM-шифрлэ, тэр AES key-г RSA-OAEP-ээр wrap хий.
private fun secureWrap(blob: ByteArray): ByteArray {
val (pub, _) = wrapKeyPair()
val dek = ByteArray(32).also { random.nextBytes(it) } // data encryption key
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).
// Encrypt/decrypt тал MGF1 digest дээр зөрвөл OAEP алдаа өгдөг нь мэдэгдсэн pitfall.
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) // public → auth хэрэггүй
}
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 private-key decrypt нь auth-required → 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)
// ⚠ RSA decrypt Cipher-ийг CryptoObject-д боож BiometricPrompt-аар зөвшөөрүүлнэ.
val authed = auth.authenticate(rsa, "eID түлхүүрээ нээхэд баталгаажуулна уу")
val dek = authed.doFinal(wrappedDek) // auth хийгдсэний дараа л ажиллана
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 Async load + BiometricPrompt abstraction¶
load()-ийг suspend болгож, biometric authenticator-ийг дамжуулна. SDK нь Activity-д шууд хамаарахгүйн
тулд interface-ээр тусгаарла:
/** BiometricPrompt-ийг апп талаас өгнө (SDK-г Activity-д шууд холбохгүй). */
interface BiometricAuthenticator {
/** CryptoObject(cipher)-ийг биометрээр зөвшөөрүүлж, зөвшөөрөгдсөн Cipher-ийг буцаана.
* Хэрэглэгч цуцалбал throw (→ signing/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 давхцлыг шалгах read-only probe (тоологч/lockout/migrate ХӨНДӨХГҮЙ).
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 auth-wrap задал (биометр prompt)
} catch (e: KeyPermanentlyInvalidatedException) {
// Шинэ биометр нэмсэн/дэлгэцийн түгжээ салгаснаас wrap key бүрмөсөн хүчингүй → v2-г цаашид
// задлах боломжгүй. Бүртгэлийг цэвэрлээд re-enroll шаардана ("буруу PIN" БИШ; iOS SE parity).
deleteIdentity(); throw KeyInvalidatedException()
}
} else {
// v1 (legacy) migration: хуучин формат PBKDF2-only. Дараа нь v2 болгож re-save (доор).
val legacy = prefs.getString(identityKey, null) ?: error("Бүртгэл хийгдээгүй")
b64d(legacy)
}
// ... энэ доороос ЦААШ өмнөх PBKDF2(PIN) decrypt логик ХЭВЭЭР ...
// амжилттай decrypt болсны дараа v1 байсан бол v2 болгож re-wrap:
// if (storedB64 == null) save(pin, identity, auth) // migrate-on-load
}
3.5 GeregeSmartIdClient — authenticator дамжуулах¶
suspend fun approve(
sessionId: String,
pin: CharArray,
auth: BiometricAuthenticator, // ← ШИНЭ (заавал; default-тай параметрүүдээс ӨМНӨ)
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) // biometric prompt энд гарна
...
}
enroll(...) дээр save() нь public-key encrypt тул biometric prompt гарахгүй — гэхдээ migrate-on-load
болон changePIN-д authenticator хэрэгтэй тул тэдгээрт мөн дамжуул.
3.6 Example app — BiometricPrompt provider¶
MainActivity-гFragmentActivity(эсвэлAppCompatActivity) болго.- Адаптерийг
core/BiometricAuth.kt-д тавьж, Compose-оосrememberBiometricAuthenticator()-оор ав. - BiometricPrompt-ийг ЗААВАЛ MAIN thread дээр дуудна (
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("Биометр амжилтгүй: $msg"))
}
// onAuthenticationFailed — дахин оролдоно (prompt өөрөө барина), resume ХИЙХГҮЙ.
})
val builder = BiometricPrompt.PromptInfo.Builder()
.setTitle("eID")
.setSubtitle(reason)
// CryptoObject + DEVICE_CREDENTIAL-ийг зөвхөн API 30+ дэмждэг. Доор нь BIOMETRIC_STRONG
// + negative товч (заавал).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
} else {
builder.setAllowedAuthenticators(BIOMETRIC_STRONG)
builder.setNegativeButtonText("Болих")
}
prompt.authenticate(builder.build(), BiometricPrompt.CryptoObject(cipher))
cont.invokeOnCancellation { /* prompt cancel хийх боломжгүй — орхино */ }
}
}
}
4. Migration (заавал — хуучин хэрэглэгчдийг бүү гац)¶
- v1 (одоогийн):
prefs["identity.$account"]= base64(salt+iv+ct), Keystore-wrap-гүй. - v2 (шинэ):
prefs["identity.v2.$account"]= base64(secureWrap(salt+iv+ct)). load(): эхлээдidentity.v2.$account-ыг үз → байвал v2 зам. Байхгүй болidentity.$account(v1) → PBKDF2-only decrypt → амжилттай болсны дарааsave()-аар v2 болгож бичээд v1 түлхүүрийг устга (migrate-on-successful-load, PIN аль хэдийн батлагдсан тул нэмэлт prompt хэрэггүй).- v2 руу шилжсэний дараа тухайн хэрэглэгч дараагийн load бүрд biometric prompt авна.
- Санамж: Keystore auth-key-г APP-ийн эхний enroll/updateдээр л ҮҮСГЭ.
wrapKeyPair()нь lazy — анх дуудагдахад үүснэ.
5. Аюулгүй байдлын шаардлага (acceptance checklist)¶
- [ ] Wrap key нь
setUserAuthenticationRequired(true)— Keystore-д баталгаажсан (KeyInfo.isUserAuthenticationRequired). - [ ] Wrap key нь non-exportable (AndroidKeyStore-ийн key material экспортлогдохгүй — default).
- [ ] StrongBox дэмждэг төхөөрөмжид
isInsideSecureHardware/StrongBox backed; эс бөгөөс TEE. - [ ] SIGNING (PIN2) slot дээр
setInvalidatedByBiometricEnrollment(true)— шинэ хурууны хээ нэмбэл signing key хүчингүй (attacker биометрээ нэмэх халдлагыг хаана).loadэнэ үедKeyPermanentlyInvalidatedException-ийг барьжKeyInvalidatedException(буруу PIN БИШ) шидэж, бүртгэлийг цэвэрлэн re-enroll руу чиглүүлнэ. AUTH slot дээр pitfalls-ыг үз. - [ ] Биометр цуцлах/алдах →
loadthrow (fail-closed): гарын үсэг/enroll зогсоно, silent bypass БАЙХГҮЙ. - [ ]
maxPinAttemptsлогик ХЭВЭЭР (online). - [ ] v1→v2 migration хэрэглэгчийг гацаахгүй (жинхэнэ device дээр v1 blob-той тест).
- [ ] Golden-vector/serialization ФОРМАТ өөрчлөгдөөгүй (inner PBKDF2 blob хэвээр; зөвхөн ГАДНА wrap нэмэгдсэн).
6. Тест кейс (жинхэнэ төхөөрөмж)¶
- Шинэ enroll → sign: биометр prompt гарч, амжилттай гарын үсэг. Cert/threshold sig хэвээр зөв.
- Биометр цуцлах: sign зогсоно, алдаа харагдана, session хэвээр (дахин оролдож болно).
- v1 migration: хуучин build-ээр enroll → шинэ build суулгах → эхний sign дээр PIN-ээр амжилттай,
дараа нь v2 болж хувирсан (
identity.v2.*үүссэн,identity.*устсан), 2 дахь sign биометр асуусан. - Шинэ хурууны хээ нэмэх (SIGNING): signing key хүчингүй болсон → дахин enroll шаардсан (эсвэл тодорхой алдаа). AUTH slot гацаагүй.
- StrongBox-гүй төхөөрөмж: TEE fallback-аар ажилласан.
- Rooted/emulator sanity: attestation тал (өмнөх
PlayIntegrityAttestorfix) fail-closed хэвээр.
7. Болгоомжлох зүйл (pitfalls)¶
- BiometricPrompt = FragmentActivity. Compose-only
ComponentActivityхангалтгүй;AppCompatActivityболго. UI thread дээр prompt дуудагдана. setInvalidatedByBiometricEnrollment(true)нь AUTH slot-д UX-ийн эрсдэлтэй: хэрэглэгч хурууны хээ нэмэхэд нэвтрэх түлхүүр устаж, дахин enroll шаардана. SIGNING (non-repudiation)-д ЗӨВ (хамгаалалт), AUTH-д сонголт — эхлээдfalse-оор туршиж, бодлого тодорхойл.- DEVICE_CREDENTIAL fallback: зарим төхөөрөмжид биометргүй хэрэглэгч бий — PIN/pattern (device credential)
зөвшөөрснөөр гацахгүй. Гэхдээ
AUTH_DEVICE_CREDENTIALньsetInvalidatedByBiometricEnrollment-тэй зэрэгцэхэд API-ийн хязгаарлалттай (API 30+ шаардаж болзошгүй) — тест хий. - Migration давхар prompt: migrate-on-load үед PIN аль хэдийн батлагдсан тул
save()(public-key encrypt) дээр prompt БАЙХ ЁСГҮЙ. Хэрэв prompt гарвал асимметр биш симметр key ашиглаж байна гэсэн үг — RSA-OAEP асимметр байдгийг шалга. - Backup/
allowBackup: AndroidKeyStore key-үүд backup-д ордоггүй; migrate хийсэн v2 blob нь тухайн төхөөрөмжид л задлагдана (device-bound) — энэ нь ЗӨВ (iOSThisDeviceOnly-той адил).
Товч API diff (дуудагчид)¶
- 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 helper
+ KeyInvalidatedException / SecureHardwareUnavailableException — шинэ fail-closed алдаанууд
iOS parity эх сурвалж (заавал харьцуул): ios/ios-sdk/Sources/GeregeSmartID/SecureKeyStore.swift
(secureWrap/secureUnwrap/secureEnclaveKey).