Skip to content

Android biometric-gated keys — implementation guide (H-1 / finding #6)

For whom: Android SDK developers (able to build + test on a real device). What: Additionally wrap the phone's signing/auth key with an Android Keystore user-authentication-required (biometric) key to block offline PIN brute-force — parity with iOS's Secure Enclave .userPresence model. Why a guide: this change makes the signing API async (BiometricPrompt → FragmentActivity) and requires migrating existing users' registrations, so it must be merged only after being built and tested on a real device.


1. The problem (gap)

Current stateandroid-sdk/.../AndroidSecureKeyStore.kt:

  • The identity (xClient + Paillier private key + cert) is encrypted as salt + iv + AES-GCM(PBKDF2(PIN, salt, 210k), serialize(id)) and stored in EncryptedSharedPreferences.
  • maxPinAttempts = 10 — 10 wrong PINs → the blob is deleted. But this counter only works for ONLINE (in-app) attempts that go through load().

Vulnerability (rooted device): an attacker with root extracts the app's EncryptedSharedPreferences (the app itself can read it, so root-as-app can too) and pulls out the inner PIN-blob. The inner AES key is pure PBKDF2(PIN) — it can be brute-forced off the device (on a GPU cluster). The PIN space is only 4–6 digits (10⁴–10⁶). PBKDF2 210k slows it down, but on a GPU it is a few minutes–hours. The maxPinAttempts counter is no obstacle at all on the offline path. → Legally-binding (non-repudiation) signatures could be forged.

Why iOS doesn't have this: iOS additionally wraps the inner PIN-blob with a Secure Enclave key. The SE private key is (a) non-extractable from hardware (even on a rooted/JB device), and (b) requires user-presence (Face ID/Touch ID/passcode) on every use. So the blob cannot be extracted OFF the device, and even inside it, every brute-force attempt needs biometrics → an offline/scripted attack is impossible.

iOS parity source: ios/ios-sdk/Sources/GeregeSmartID/SecureKeyStore.swift - secureWrap(blob) (line 128): ECIES-encrypt with the SE public key → [1|wrapped]. - secureUnwrap(stored, reason) (line 151): if [1|wrapped], decrypt with the SE private key (LAContext = biometric prompt); if the user cancels, signing stops. - secureEnclaveKey() (line 168): access control = [.privateKeyUsage, .userPresence], kSecAttrTokenIDSecureEnclave. - No SE on the simulator → [0|blob] fallback (dev only).


2. Files to change

File Change
eIDMongolia/app/build.gradle.kts Add androidx.biometric:biometric dependency (the SDK's BiometricAuthenticator is plain javax.crypto.Cipher, so the SDK does not need it)
android-sdk/.../BiometricAuthenticator.kt New interface (SDK↔app bridge; UI-framework-independent)
android-sdk/.../AndroidSecureKeyStore.kt Keystore auth-key wrap (secureWrap/secureUnwrap) + versioned blob + async load
android-sdk/.../GeregeSmartIdClient.kt Pass a biometric authenticator to approve()/enroll()/changePIN()/verifyPIN()/pinUnlocksSlot()
eIDMongolia/.../core/BiometricAuth.kt (+ MainActivity = FragmentActivity) ActivityBiometricAuthenticator / rememberBiometricAuthenticator() provider

3. Changes to make

3.1 Dependency (eIDMongolia/app/build.gradle.kts)

implementation("androidx.biometric:biometric:1.1.0")
// BiometricPrompt requires FragmentActivity, so the app's Activity must be FragmentActivity/AppCompatActivity.

3.2 Keystore auth-required wrap key

The closest parity with iOS's ECIES is an asymmetric RSA-OAEP key: encrypt with the public key (NO auth required, so no prompt on save()/enroll), decrypt with the private key (auth REQUIRED → BiometricPrompt). This matches iOS exactly (no biometric ask on enroll, only on load/sign).

// Inside AndroidSecureKeyStore — a separate alias per slot.
private val wrapAlias = "gsid.wrap.$account"   // e.g. gsid.wrap.default / gsid.wrap.default.auth
private val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }

/** Find or create-once the 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 compat — before API 31 the AndroidKeyStore holds
        // MGF1 strictly to SHA-1). So SHA-1 is also allowed in the digests (kept consistent with the oaepSpec below).
        .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
        .setKeySize(2048)
        .setUserAuthenticationRequired(true)                       // ← key: biometrics on every use
        .apply {
            if (Build.VERSION.SDK_INT >= 30) {
                // API 30+: auth validity 0 = fresh auth per operation (via BiometricPrompt CryptoObject).
                setUserAuthenticationParameters(
                    0, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
                )
            } else {
                @Suppress("DEPRECATION")
                setUserAuthenticationValidityDurationSeconds(-1)   // -1 = auth per operation
            }
            // On the SIGNING (PIN2, non-repudiation) slot: invalidate the key if new biometrics are enrolled.
            // On the AUTH (PIN1) slot it may be skipped for UX reasons (see pitfalls below).
            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) {                                        // No StrongBox / SHA-1 unsupported → TEE
        try {
            gen.initialize(spec(false)); gen.generateKeyPair()
        } catch (e2: Exception) {
            // No screen lock/biometrics at all → hard error (fail-closed, iOS parity).
            throw SecureHardwareUnavailableException()
        }
    }
    return kp.public to kp.private
}

3.3 secureWrap / secureUnwrap (parity with iOS)

// On save: RSA-OAEP public-key encrypt (NO auth). If the blob is large, encrypt it with an intermediate AES
// key (hybrid) — RSA-2048-OAEP-SHA256 can only encrypt ~190 bytes, but the identity blob is large.
// ⇒ Hybrid: GCM-encrypt the blob with a random AES-256 key, then wrap that AES key with RSA-OAEP.
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: EXPLICITLY specify hash=SHA-256, MGF1=SHA-1 ("RSA/ECB/OAEPPadding" + OAEPParameterSpec).
    // A mismatch in the MGF1 digest between the encrypt/decrypt sides is a known pitfall that causes OAEP errors.
    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 → no auth needed
    }
    val wrappedDek = rsa.doFinal(dek)
    dek.fill(0)
    // format: [ver=2][len(wrappedDek):2][wrappedDek][iv:12][ct]
    return byteArrayOf(2) + shortLen(wrappedDek.size) + wrappedDek + iv + ct
}

// On unwrap: RSA-OAEP private-key decrypt is auth-required → needs 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)
    // ⚠ Wrap the RSA decrypt Cipher in a CryptoObject and authorize via BiometricPrompt.
    val authed = auth.authenticate(rsa, "Please authenticate to unlock your eID key")
    val dek = authed.doFinal(wrappedDek)                           // only runs after auth succeeds
    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

Make load() suspend and pass in the biometric authenticator. To keep the SDK from depending directly on an Activity, isolate it behind an interface:

/** The app provides the BiometricPrompt (keeps the SDK from binding directly to an Activity). */
interface BiometricAuthenticator {
    /** Authorize the CryptoObject(cipher) via biometrics and return the authorized Cipher.
     *  If the user cancels, throw (→ signing/enroll stops, fail-closed). */
    suspend fun authenticate(cipher: Cipher, reason: String): Cipher
}

New signature of AndroidSecureKeyStore.load:

suspend fun load(pin: CharArray, auth: BiometricAuthenticator, countAttempts: Boolean = true): Identity {
    // countAttempts=false → a read-only probe that checks PIN validity (does NOT touch the counter/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: unwrap the Keystore auth-wrap (biometric prompt)
        } catch (e: KeyPermanentlyInvalidatedException) {
            // Adding new biometrics/removing the screen lock permanently invalidated the wrap key → v2 can no
            // longer be unwrapped. Clear the registration and require re-enroll ("wrong PIN" it is NOT; iOS SE parity).
            deleteIdentity(); throw KeyInvalidatedException()
        }
    } else {
        // v1 (legacy) migration: old format is PBKDF2-only. Later re-save as v2 (below).
        val legacy = prefs.getString(identityKey, null) ?: error("Not registered")
        b64d(legacy)
    }
    // ... from here DOWN the previous PBKDF2(PIN) decrypt logic is UNCHANGED ...
    // after a successful decrypt, if it was v1, re-wrap it as v2:
    // if (storedB64 == null) save(pin, identity, auth)   // migrate-on-load
}

3.5 GeregeSmartIdClient — pass the authenticator

suspend fun approve(
    sessionId: String,
    pin: CharArray,
    auth: BiometricAuthenticator,                                  // ← NEW (required; BEFORE the defaulted parameters)
    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)                           // the biometric prompt appears here
    ...
}

In enroll(...), save() is a public-key encrypt, so no biometric prompt appears — but migrate-on-load and changePIN need the authenticator, so pass it to those as well.

3.6 Example app — BiometricPrompt provider

  • Make MainActivity a FragmentActivity (or AppCompatActivity).
  • Put the adapter in core/BiometricAuth.kt and obtain it from Compose via rememberBiometricAuthenticator().
  • BiometricPrompt MUST be called on the 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("Biometrics failed: $msg"))
                    }
                    // onAuthenticationFailed — retries (the prompt handles it itself), does NOT resume.
                })
            val builder = BiometricPrompt.PromptInfo.Builder()
                .setTitle("eID")
                .setSubtitle(reason)
            // CryptoObject + DEVICE_CREDENTIAL is only supported on API 30+. Below that, BIOMETRIC_STRONG
            // + a negative button (required).
            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 { /* the prompt cannot be cancelled — leave it */ }
        }
      }
}

4. Migration (required — don't strand existing users)

  • v1 (current): prefs["identity.$account"] = base64(salt+iv+ct), no Keystore wrap.
  • v2 (new): prefs["identity.v2.$account"] = base64(secureWrap(salt+iv+ct)).
  • load(): first check identity.v2.$account → if present, take the v2 path. If absent, identity.$account (v1) → PBKDF2-only decrypt → after a successful decrypt, write it as v2 via save() and delete the v1 key (migrate-on-successful-load; since the PIN is already verified, no extra prompt is needed).
  • After migrating to v2, that user gets a biometric prompt on every subsequent load.
  • Note: Create the Keystore auth-key only on the APP's first enroll/update. wrapKeyPair() is lazy — it is created on first call.

5. Security requirements (acceptance checklist)

  • [ ] The wrap key has setUserAuthenticationRequired(true) — verified in the Keystore (KeyInfo.isUserAuthenticationRequired).
  • [ ] The wrap key is non-exportable (AndroidKeyStore key material cannot be exported — default).
  • [ ] On StrongBox-capable devices it is isInsideSecureHardware/StrongBox backed; otherwise TEE.
  • [ ] On the SIGNING (PIN2) slot, setInvalidatedByBiometricEnrollment(true) — enrolling a new fingerprint invalidates the signing key (blocks the attack of the attacker adding their own biometrics). In this case load catches KeyPermanentlyInvalidatedException and throws KeyInvalidatedException (NOT wrong PIN), clearing the registration and steering to re-enroll. See pitfalls for the AUTH slot.
  • [ ] Biometric cancel/failure → load throws (fail-closed): signing/enroll stops, NO silent bypass.
  • [ ] maxPinAttempts logic UNCHANGED (online).
  • [ ] v1→v2 migration does not strand users (test with a v1 blob on a real device).
  • [ ] The golden-vector/serialization FORMAT is unchanged (the inner PBKDF2 blob is the same; only the OUTER wrap is added).

6. Test cases (real device)

  1. Fresh enroll → sign: a biometric prompt appears and the signature succeeds. Cert/threshold sig still correct.
  2. Cancel biometrics: sign stops, an error is shown, the session is intact (can retry).
  3. v1 migration: enroll with the old build → install the new build → the first sign succeeds with the PIN, then it converts to v2 (identity.v2.* created, identity.* deleted), and the 2nd sign asks for biometrics.
  4. Add a new fingerprint (SIGNING): the signing key becomes invalidated → re-enroll is required (or a clear error). The AUTH slot is not stranded.
  5. Device without StrongBox: worked via the TEE fallback.
  6. Rooted/emulator sanity: the attestation side (the earlier PlayIntegrityAttestor fix) is still fail-closed.

7. Pitfalls

  • BiometricPrompt = FragmentActivity. A Compose-only ComponentActivity is not enough; make it an AppCompatActivity. The prompt is invoked on the UI thread.
  • setInvalidatedByBiometricEnrollment(true) has a UX risk on the AUTH slot: if the user enrolls a fingerprint, the login key is destroyed and re-enroll is required. It is CORRECT for SIGNING (non-repudiation) (protection), optional for AUTH — test with false first, then decide the policy.
  • DEVICE_CREDENTIAL fallback: some devices have users without biometrics — allowing PIN/pattern (device credential) keeps them from being stranded. But AUTH_DEVICE_CREDENTIAL combined with setInvalidatedByBiometricEnrollment has API restrictions (may require API 30+) — test it.
  • Double prompt on migration: during migrate-on-load the PIN is already verified, so there MUST be no prompt on save() (public-key encrypt). If a prompt appears, you are using a symmetric rather than an asymmetric key — verify that RSA-OAEP is asymmetric.
  • Backup/allowBackup: AndroidKeyStore keys are never included in backups; a migrated v2 blob unwraps only on that device (device-bound) — this is CORRECT (like iOS ThisDeviceOnly).

Brief API diff (for callers)

- 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         →  all gain an auth: BiometricAuthenticator parameter
+ interface BiometricAuthenticator { suspend fun authenticate(cipher, reason): Cipher }
+ class ActivityBiometricAuthenticator(activity: FragmentActivity) : BiometricAuthenticator   // app side (core/BiometricAuth.kt)
+ fun rememberBiometricAuthenticator(): BiometricAuthenticator                                 // Compose helper
+ KeyInvalidatedException / SecureHardwareUnavailableException — new fail-closed errors

iOS parity source (compare against, required): ios/ios-sdk/Sources/GeregeSmartID/SecureKeyStore.swift (secureWrap/secureUnwrap/secureEnclaveKey).