跳转至

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. 测试用例(真机)

  1. 全新 enroll → 签名: 弹出生物识别提示且签名成功。证书与门限签名仍然正确。
  2. 取消生物识别: 签名中止并显示错误,会话完好(可重试)。
  3. v1 迁移: 在旧版本上注册 → 安装新版本 → 首次签名凭 PIN 成功,随后转换为 v2 (创建 identity.v2.*,删除 identity.*),第 2 次签名则会要求生物识别。
  4. 新增指纹(签名槽位): 签名密钥失效 → 需要重新 enroll(或给出清晰错误提示)。 AUTH 槽位不受影响。
  5. 无 StrongBox 的设备: 通过 TEE 回退正常工作。
  6. root/模拟器合理性检查: 认证侧(此前的 PlayIntegrityAttestor 修复)仍保持 fail-closed。

7. 注意事项

  • BiometricPrompt = FragmentActivity。 仅使用 Compose 的 ComponentActivity 不够; 请改为 AppCompatActivity。prompt 需在 UI 线程调用。
  • setInvalidatedByBiometricEnrollment(true) 在 AUTH 槽位存在体验风险: 若用户录入指纹,登录密钥会被销毁并需要重新 enroll。对签名(不可否认)而言这是正确的 (属于防护),对 AUTH 则是可选的 —— 建议先用 false 测试,再决定策略。
  • DEVICE_CREDENTIAL 回退: 部分设备的用户未启用生物识别 —— 允许 PIN/图案 (设备凭据)可避免其无法使用。但 AUTH_DEVICE_CREDENTIALsetInvalidatedByBiometricEnrollment 组合存在 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.swiftsecureWrap/secureUnwrap/secureEnclaveKey)。