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
.userPresencemodel. 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 state — android-sdk/.../AndroidSecureKeyStore.kt:
- The identity (
xClient+ Paillier private key + cert) is encrypted assalt + iv + AES-GCM(PBKDF2(PIN, salt, 210k), serialize(id))and stored inEncryptedSharedPreferences. maxPinAttempts = 10— 10 wrong PINs → the blob is deleted. But this counter only works for ONLINE (in-app) attempts that go throughload().
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
MainActivityaFragmentActivity(orAppCompatActivity). - Put the adapter in
core/BiometricAuth.ktand obtain it from Compose viarememberBiometricAuthenticator(). - 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 checkidentity.v2.$account→ if present, take the v2 path. If absent,identity.$account(v1) → PBKDF2-only decrypt → after a successful decrypt, write it as v2 viasave()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 caseloadcatchesKeyPermanentlyInvalidatedExceptionand throwsKeyInvalidatedException(NOT wrong PIN), clearing the registration and steering to re-enroll. See pitfalls for the AUTH slot. - [ ] Biometric cancel/failure →
loadthrows (fail-closed): signing/enroll stops, NO silent bypass. - [ ]
maxPinAttemptslogic 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)¶
- Fresh enroll → sign: a biometric prompt appears and the signature succeeds. Cert/threshold sig still correct.
- Cancel biometrics: sign stops, an error is shown, the session is intact (can retry).
- 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. - Add a new fingerprint (SIGNING): the signing key becomes invalidated → re-enroll is required (or a clear error). The AUTH slot is not stranded.
- Device without StrongBox: worked via the TEE fallback.
- Rooted/emulator sanity: the attestation side (the earlier
PlayIntegrityAttestorfix) is still fail-closed.
7. Pitfalls¶
- BiometricPrompt = FragmentActivity. A Compose-only
ComponentActivityis not enough; make it anAppCompatActivity. 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 withfalsefirst, 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_CREDENTIALcombined withsetInvalidatedByBiometricEnrollmenthas 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 iOSThisDeviceOnly).
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).