feat: biometric auth and lockscreen password autofill
This commit is contained in:
174
doc/scheme/锁屏密码自动填充实现设计方案初案.md
Normal file
174
doc/scheme/锁屏密码自动填充实现设计方案初案.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 锁屏密码自动填充实现设计方案初案
|
||||
|
||||
## 用户输入
|
||||
|
||||
- Activity `onCreate` 设置 `FLAG_SECURE`
|
||||
- TextField 设置
|
||||
- `visualTransformation = PasswordVisualTransformation()`
|
||||
- `keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, autoCorrect = false)`
|
||||
- `onPause` / `onStop` 时主动清空 TextField
|
||||
- 密码允许字符字母数字混合输入,明确白名单/黑名单,禁止 `\n` 类似的控制字符注入
|
||||
- 不考虑连线式密码,直接无视,不需要写任何提示
|
||||
|
||||
## 密码存取
|
||||
|
||||
- 调用前使用 `BiometricManager` 确认硬件状态。若设备未设置任何锁屏保护,每次进入设置密码的 activity 前都提示风险,且按下“同意”后才允许继续
|
||||
- 使用 `BiometricPrompt` 实现验证 ,开关允许跳过验证直接使用,当用户在设置页面将开关从 ON 切换到 OFF 时,除非此时不存在任何密码,否则立即拉起一次 `BiometricPrompt`
|
||||
- `BiometricPrompt` 的验证器类型包括 `BIOMETRIC_STRONG` 和 `DEVICE_CREDENTIAL`,即同时允许本机密码验证
|
||||
- 验证过程受 Activity 生命周期约束,`onStop` 时自动取消挂起的验证请求
|
||||
- 使用 `EncryptedSharedPreferences` 储存密码
|
||||
- 读取时若捕获到 `KeyStoreException` / `GeneralSecurityException`,静默清除后提示密钥损坏并引导重新设置密码,注意静默清除后同步验证是否可用的状态
|
||||
- 明文密码使用 `CharArray` 类型,密码仅在 BiometricPrompt 回调成功的瞬间提取
|
||||
- 在传递给 Shell 执行层后,必须在 `finally` 块中执行 `fill(0)`,确保即使后续代码异常,内存也能被清理
|
||||
- 严禁将明文密码赋值给长生命周期的成员变量
|
||||
- 严禁任何日志泄露
|
||||
- 每次调用密码自动输入时都判断按需触发验证,不做长期信任
|
||||
- 密码附带一个字段,判断密码的创建环境和使用环境是否匹配,不允许密码在认证的情况下被添加,但是在无认证的时候被使用,因为我允许无验证的情况下使用这个功能
|
||||
- `val createdWithAuth: Boolean`
|
||||
|
||||
```kotlin
|
||||
val globalRequiresAuth: Boolean // 全局开关
|
||||
val canAuthNow: Boolean // 当前设备是否可认证
|
||||
|
||||
if (globalRequiresAuth) {
|
||||
// 只要开关开着,就必须认证
|
||||
BiometricPrompt()
|
||||
} else {
|
||||
// 开关关闭 → 允许不认证,但要防止安全降级
|
||||
if (pw.createdWithAuth && !canAuthNow) {
|
||||
// 降级禁止
|
||||
showError("设备安全状态已变更,请重新设置密码")
|
||||
return
|
||||
}
|
||||
|
||||
// 可以直接使用
|
||||
usePassword()
|
||||
}
|
||||
```
|
||||
|
||||
- 在应用启动时就获取设备安全状态,并遍历一遍密码,如果存在安全添加的密码,但是设备变得不安全,就静默清除那个密码
|
||||
- 只清除密文,但保留这个 item,让用户可以在密码管理界面看到哪个密码变得不可用 (endAction 多一个 `Block` 图标)
|
||||
- 显示在 OverlayListPopup 里时也要是不可用的状态,不可点击
|
||||
|
||||
## 交互设计
|
||||
|
||||
新增入口 `ArrowPreference` `锁屏密码自动输入` 在设置页 `投屏` section 中的最后一个
|
||||
|
||||
点击后跳转上述中带 `FLAG_SECURE` 的新 activity,接下来所有的交互都在这个安全的 activity 内
|
||||
|
||||
首屏页参考目前的 `虚拟按钮排序` 页,使用项目内包装好的 `LazyColumn`
|
||||
|
||||
第一个选项为跳过验证的总开关 `使用密码时需要验证`,在设备无认证条件的时候禁用且(enabled = false),正常默认启用,关闭时提示安全性风险且需要同意,有密码时拉起认证,通过认证成功关闭后遍历一遍当前所有密码,将 `requiresAuth` 都设置为 false
|
||||
|
||||
接下来是密码列表,参考 `虚拟按钮排序` 页,允许排序和命名,命名的交互逻辑参考 `管理配置` 的 BottomSheet,在 endAction 中放一个 `Icons.Rounded.Edit` 图标按钮,右上角三点菜单放 `新增密码` 的入口
|
||||
|
||||
点击新增密码后根据开关判断是否拉起生物验证,通过/跳过后拉起一个 `OverlayBottomSheet`,里面放一个命名 TextField 和一个密码输入 TextField,左上角关闭按钮,右上角确认按钮,参考 `B:\Git\miuix\example\shared\src\commonMain\kotlin\component\BottomSheetSection.kt` 里的 `private fun SuperBottomSheetDemo`,确认前判断密码框输入长度,禁止空密码,确认后主动清空两个 TextField
|
||||
|
||||
使用入口 `填充锁屏密码` 新增在目前虚拟按钮列表 `class VirtualButtonBar` 中,图标用 `Password2`,点击呼出另一个 `OverlayListPopup` (参考 `VirtualButtonBar.ActionPopup`),里面放按用户排序的密码,点击任意一个就判断是否要验证来选择呼出生物验证还是不需要验证直接注入密码到 `Scrcpy.injectText`
|
||||
|
||||
## 代码设计
|
||||
|
||||
```kotlin
|
||||
// 单例
|
||||
// 内部使用 EncryptedSharedPreferences
|
||||
// 不处理认证逻辑
|
||||
object PasswordRepository {
|
||||
fun getAll(): List<PasswordEntry>
|
||||
fun add(entry: PasswordEntry)
|
||||
fun update(entry: PasswordEntry)
|
||||
fun delete(id: String)
|
||||
|
||||
fun markInvalid(id: String) // 清除密文但保留 entry
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// 明文
|
||||
data class PasswordEntry(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val cipherText: CharArray?, // null = 已失效
|
||||
val createdWithAuth: Boolean
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// 封装:
|
||||
// BiometricManager
|
||||
// BiometricPrompt
|
||||
// 内部要处理:
|
||||
// lifecycle
|
||||
// callback 防重复(sessionId)
|
||||
object BiometricGate {
|
||||
fun canAuthenticate(): Boolean
|
||||
suspend fun authenticate(activity: FragmentActivity): Boolean
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
class PasswordUseCase {
|
||||
|
||||
suspend fun preparePassword(
|
||||
activity: FragmentActivity,
|
||||
entry: PasswordEntry,
|
||||
globalRequiresAuth: Boolean
|
||||
): Result<CharArray> {
|
||||
|
||||
val canAuthNow = BiometricGate.canAuthenticate()
|
||||
|
||||
// 1. 需要认证
|
||||
if (globalRequiresAuth) {
|
||||
val ok = BiometricGate.authenticate(activity)
|
||||
if (!ok) {
|
||||
return Result.failure(Exception("认证失败"))
|
||||
}
|
||||
} else {
|
||||
// 2. 不认证,但防降级
|
||||
if (entry.createdWithAuth && !canAuthNow) {
|
||||
return Result.failure(Exception("设备安全状态已变更"))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 取密码
|
||||
val pw = entry.cipherText ?: return Result.failure(Exception("密码已失效"))
|
||||
|
||||
return Result.success(pw.copyOf()) // 一定要 copy
|
||||
}
|
||||
}
|
||||
|
||||
// 用法
|
||||
val result = passwordUseCase.preparePassword(
|
||||
activity,
|
||||
entry,
|
||||
globalRequiresAuth
|
||||
)
|
||||
|
||||
result.onSuccess { pw ->
|
||||
InjectionController.inject(pw)
|
||||
}.onFailure {
|
||||
showError(it.message)
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
object InjectionController {
|
||||
fun inject(password: CharArray) {
|
||||
try {
|
||||
Scrcpy.injectText(String(password))
|
||||
} finally {
|
||||
password.fill(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// 应用启动
|
||||
val canAuthNow = BiometricGate.canAuthenticate()
|
||||
|
||||
PasswordRepository.getAll().forEach {
|
||||
if (it.createdWithAuth && !canAuthNow) {
|
||||
PasswordRepository.markInvalid(it.id)
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user