Android 生物识别门控密钥 —— 实现指南(H-1 / finding #6)¶
面向对象: Android SDK 开发者(可在真机上构建与测试)。 要做什么: 额外使用 Android Keystore 中要求用户认证(生物识别)的密钥再封装一层 手机上的签名 / 认证密钥,以阻断离线 PIN 暴力破解 —— 与 iOS Secure Enclave
.userPresence模型保持一致。 为何需要指南: 该变更会使签名 API 变为异步(BiometricPrompt → FragmentActivity), 并需要迁移既有用户的注册数据,因此必须在真机上完成构建与测试后才可合入。
1. 问题(缺口)¶
现状 —— android-sdk/.../AndroidSecureKeyStore.kt:
- 身份数据(
xClient+ Paillier 私钥 + 证书)以salt + iv + AES-GCM(PBKDF2(PIN, salt, 210k), serialize(id))加密,保存在EncryptedSharedPreferences中。 maxPinAttempts = 10—— PIN 输错 10 次后删除数据块。但该计数器只对经由load()的 在线(应用内)尝试有效。
漏洞(已 root 的设备): 具备 root 权限的攻击者可提取应用的
EncryptedSharedPreferences(应用自身可读,因此以应用身份运行的 root 同样可读),
并取出内层 PIN 数据块。内层 AES 密钥完全由 PBKDF2(PIN) 派生 ——
可在设备之外(GPU 集群上)进行暴力破解。PIN 空间仅有 4–6 位(10⁴–10⁶)。
PBKDF2 的 210k 迭代会拖慢速度,但在 GPU 上也只需数分钟至数小时。
在离线路径上,maxPinAttempts 计数器完全构不成障碍。
→ 具备法律效力(不可否认)的签名可能被伪造。
iOS 为何不存在该问题: iOS 会额外用 Secure Enclave 密钥封装内层 PIN 数据块。 SE 私钥 (a) 从硬件中不可导出(即便设备已 root/越狱),且 (b) 每次使用都要求 用户在场(Face ID/Touch ID/密码)。因此数据块无法被带离设备, 即便在设备内部,每次暴力尝试也需要生物识别 → 离线 / 脚本化攻击不可行。
iOS 对照实现: ios/ios-sdk/Sources/GeregeSmartID/SecureKeyStore.swift
- secureWrap(blob)(第 128 行):用 SE 公钥做 ECIES 加密 → [1|wrapped]。
- secureUnwrap(stored, reason)(第 151 行):若为 [1|wrapped],用 SE 私钥解密
(LAContext = 生物识别提示);若用户取消,则签名中止。
- secureEnclaveKey()(第 168 行):访问控制为 [.privateKeyUsage, .userPresence],
kSecAttrTokenIDSecureEnclave。
- 模拟器上没有 SE → 回退为 [0|blob](仅供开发)。
2. 需要修改的文件¶
| 文件 | 修改内容 |
|---|---|
eIDMongolia/app/build.gradle.kts |
添加 androidx.biometric:biometric 依赖(SDK 的 BiometricAuthenticator 只使用原生 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 封装密钥¶
与 iOS 的 ECIES 最接近的方案是非对称 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 → 回退到 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)。因此 digests 中也允许 SHA-1(与下方 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)槽位出于体验考虑可跳过(见下文注意事项)。
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 字节,而身份数据块较大。
// ⇒ 混合方案:用随机 AES-256 密钥以 GCM 加密数据块,再用 RSA-OAEP 封装该 AES 密钥。
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)。
// 加解密两端 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)
// ⚠ 将 RSA 解密 Cipher 包装进 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 解密 → 解密成功后通过save()写为 v2 并删除 v1 键 (migrate-on-successful-load;由于 PIN 已通过校验,无需额外提示)。- 迁移到 v2 之后,该用户此后每次 load 都会弹出生物识别提示。
- 注意: Keystore 认证密钥应仅在应用首次 enroll/升级时创建。
wrapKeyPair()是惰性的 —— 首次调用时才创建。
5. 安全要求(验收清单)¶
- [ ] 封装密钥设置了
setUserAuthenticationRequired(true)—— 已在 Keystore 中验证 (KeyInfo.isUserAuthenticationRequired)。 - [ ] 封装密钥不可导出(AndroidKeyStore 的密钥材料默认无法导出)。
- [ ] 在支持 StrongBox 的设备上为
isInsideSecureHardware/StrongBox 承载;否则为 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.*),第 2 次签名则会要求生物识别。 - 新增指纹(签名槽位): 签名密钥失效 → 需要重新 enroll(或给出清晰错误提示)。 AUTH 槽位不受影响。
- 无 StrongBox 的设备: 通过 TEE 回退正常工作。
- root/模拟器合理性检查: 认证侧(此前的
PlayIntegrityAttestor修复)仍保持 fail-closed。
7. 注意事项¶
- BiometricPrompt = FragmentActivity。 仅使用 Compose 的
ComponentActivity不够; 请改为AppCompatActivity。prompt 需在 UI 线程调用。 setInvalidatedByBiometricEnrollment(true)在 AUTH 槽位存在体验风险: 若用户录入指纹,登录密钥会被销毁并需要重新 enroll。对签名(不可否认)而言这是正确的 (属于防护),对 AUTH 则是可选的 —— 建议先用false测试,再决定策略。- DEVICE_CREDENTIAL 回退: 部分设备的用户未启用生物识别 —— 允许 PIN/图案
(设备凭据)可避免其无法使用。但
AUTH_DEVICE_CREDENTIAL与setInvalidatedByBiometricEnrollment组合存在 API 限制(可能需要 API 30+)—— 请实测。 - 迁移时出现两次提示: migrate-on-load 时 PIN 已通过校验,因此
save()不应弹出提示(公钥加密)。若出现提示,说明使用的是对称密钥而非非对称密钥 —— 请确认使用的确实是非对称的 RSA-OAEP。 - 备份 /
allowBackup: AndroidKeyStore 密钥永远不会进入备份;迁移后的 v2 数据块 只能在该设备上解封(设备绑定)—— 这是正确的(等同于 iOS 的ThisDeviceOnly)。
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)。