انتقل إلى المحتوى

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

  • MainActivityFragmentActivity (эсвэл 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-ыг үз.
  • [ ] Биометр цуцлах/алдах → load throw (fail-closed): гарын үсэг/enroll зогсоно, silent bypass БАЙХГҮЙ.
  • [ ] maxPinAttempts логик ХЭВЭЭР (online).
  • [ ] v1→v2 migration хэрэглэгчийг гацаахгүй (жинхэнэ device дээр v1 blob-той тест).
  • [ ] Golden-vector/serialization ФОРМАТ өөрчлөгдөөгүй (inner PBKDF2 blob хэвээр; зөвхөн ГАДНА wrap нэмэгдсэн).

6. Тест кейс (жинхэнэ төхөөрөмж)

  1. Шинэ enroll → sign: биометр prompt гарч, амжилттай гарын үсэг. Cert/threshold sig хэвээр зөв.
  2. Биометр цуцлах: sign зогсоно, алдаа харагдана, session хэвээр (дахин оролдож болно).
  3. v1 migration: хуучин build-ээр enroll → шинэ build суулгах → эхний sign дээр PIN-ээр амжилттай, дараа нь v2 болж хувирсан (identity.v2.* үүссэн, identity.* устсан), 2 дахь sign биометр асуусан.
  4. Шинэ хурууны хээ нэмэх (SIGNING): signing key хүчингүй болсон → дахин enroll шаардсан (эсвэл тодорхой алдаа). AUTH slot гацаагүй.
  5. StrongBox-гүй төхөөрөмж: TEE fallback-аар ажилласан.
  6. Rooted/emulator sanity: attestation тал (өмнөх PlayIntegrityAttestor fix) 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) — энэ нь ЗӨВ (iOS ThisDeviceOnly-той адил).

Товч 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).