做定制系统的同学应该都碰到过这个需求:MDM 服务要在不知道当前密码的情况下,强制重置或清空锁屏密码。乍一看不难,但真做起来坑不少。本文记录一下我在 Android 16 上用 Escrow Token 机制踩过的路。
先搞清楚密码存在哪
Android 的锁屏凭据不是简单存个字符串,背后有一套 Synthetic Password(合成密码)体系:
graph TD
A[自定义 MDM 服务] -->|LocalService| B[LockSettingsInternal]
A -->|AIDL| C[ILockSettings]
B --> D[LockSettingsService]
C --> D
D --> E[SyntheticPasswordManager]
E --> F[GateKeeper / Weaver]
E --> G[DE 存储 /data/system_de/0/spblob/]
E --> H[CE 存储 /data/system_ce/0/spblob/]
几个关键点:
- LockSettingsService 是锁屏设置的真正实现,管理凭据的增删改查
- SyntheticPasswordManager 管理合成密码,所有凭据操作最终都走到这里
- LockPatternUtils 是对底层接口的封装,同时支持 AIDL 跨进程调用和 LocalService 同进程调用
- LockscreenCredential 是凭据数据类,分 PIN、Password、Pattern、None 几种类型
最重要的一个事实:所有 protector 数据都存在 DE(Device Encrypted)存储里,也就是用户还没解锁的时候就能读写。这是后面一切操作的基础。
为什么不能直接调 setLockCredential
最直觉的想法是:
mLockPatternUtils.setLockCredential(newCred, LockscreenCredential.createNone(), userId);
问题在于 savedCredential 传 NONE 的话,底层会做类型校验:
// LockscreenCredential.java
public boolean checkAgainstStoredType(int storedType) {
return getType() == storedType || getType() == CREDENTIAL_TYPE_NONE;
}
如果设备已经设了 PIN 码,storedType = PIN,你传 NONE,类型不匹配,直接失败。只有设备本来就没有密码的时候才能用 NONE。
所以这条路走不通,除非你能知道当前密码——但 MDM 场景下显然不可能。
Escrow Token 方案
原理
Escrow Token 是 Android 提供的一种"预授权令牌"机制。简单说就是:
- 提前注册一个 Token
- 等用户输入一次密码后,Token 被激活
- 之后拿这个 Token 就可以代替密码来操作凭据,不需要知道当前密码
如果设备本来就没有密码,Token 注册后立即激活,不需要等。
关键问题:重启后还能用吗?
这是我被问得最多的问题。答案是可以,但需要理解为什么。
isEscrowTokenActive 的实现是检查磁盘上的 protector 文件:
// LockSettingsService.java
private boolean isEscrowTokenActive(long handle, int userId) {
return mSpManager.protectorExists(handle, userId); // 读磁盘文件
}
// SyntheticPasswordManager.java
public boolean protectorExists(long protectorId, int userId) {
return hasState(SP_BLOB_NAME, protectorId, userId); // DE 存储
}
Token 激活后,protector 数据会以文件形式写到 /data/system_de/<uid>/spblob/ 下。这个目录属于 DE 存储,开机就能访问,不需要用户先解锁。所以重启后 Token 依然可用。
和其他方案的对比
| 方案 | 需要当前密码 | 重启后 DAR 状态可用 | 局限 |
|---|---|---|---|
setLockCredential(savedCred=NONE) |
❌ 只对无密码设备 | - | 有密码就失败 |
| Escrow Token | 不需要 | 可以 | 首次需用户解锁激活 |
| RebootEscrow HAL | 不需要 | 可以 | 仅限 OTA 场景 |
| 直接删 spblob 文件 | 不需要 | 可以 | 危险,容易搞坏 |
实现
EscrowTokenManager 内部类
把 Token 相关逻辑收进一个内部类,干净也方便移植:
private static class EscrowTokenManager {
private static final String TAG = "EscrowTokenMgr";
private static final String ESCROW_TOKEN_FILE =
Environment.getDataSystemDirectory() + "/mdm_escrow.bin";
private static final int ESCROW_TOKEN_SIZE = 32;
private static final int ESCROW_FILE_SIZE = 8 + ESCROW_TOKEN_SIZE;
private long mHandle = 0;
private byte[] mToken = null;
private LockPatternUtils mLpu;
void init(LockPatternUtils lpu) {
mLpu = lpu;
if (loadFromDisk()) {
if (mLpu.isEscrowTokenActive(mHandle, UserHandle.USER_SYSTEM)) {
Slog.i(TAG, "restored and active, handle=" + mHandle);
return;
}
Slog.w(TAG, "on disk but protector missing, re-registering");
}
try {
mToken = new byte[ESCROW_TOKEN_SIZE];
new java.security.SecureRandom().nextBytes(mToken);
mHandle = mLpu.addEscrowToken(
mToken, UserHandle.USER_SYSTEM,
new LockPatternUtils.EscrowTokenStateChangeCallback() {
@Override
public void onEscrowTokenActivated(long handle, int userId) {
Slog.i(TAG, "activated, handle=" + handle);
saveToDisk();
}
});
Slog.i(TAG, "registered, handle=" + mHandle);
saveToDisk();
} catch (Exception e) {
Slog.e(TAG, "init failed", e);
}
}
boolean isReady() {
if (mHandle == 0 || mToken == null) {
Slog.e(TAG, "not initialized");
return false;
}
if (!mLpu.isEscrowTokenActive(mHandle, UserHandle.USER_SYSTEM)) {
Slog.e(TAG, "not active");
return false;
}
return true;
}
boolean setCredential(LockscreenCredential cred, int userId) {
return mLpu.setLockCredentialWithToken(cred, mHandle, mToken, userId);
}
private boolean loadFromDisk() {
try {
File file = new File(ESCROW_TOKEN_FILE);
if (!file.exists()) return false;
byte[] data = java.nio.file.Files.readAllBytes(file.toPath());
if (data.length < ESCROW_FILE_SIZE) return false;
java.nio.ByteBuffer buf = java.nio.ByteBuffer.wrap(data);
mHandle = buf.getLong();
mToken = new byte[ESCROW_TOKEN_SIZE];
buf.get(mToken);
Slog.i(TAG, "loaded from disk, handle=" + mHandle);
return true;
} catch (Exception e) {
Slog.e(TAG, "loadFromDisk failed", e);
return false;
}
}
private void saveToDisk() {
try {
java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(ESCROW_FILE_SIZE);
buf.putLong(mHandle);
buf.put(mToken);
File file = new File(ESCROW_TOKEN_FILE);
file.getParentFile().mkdirs();
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(file)) {
fos.write(buf.array());
fos.getFD().sync();
}
Slog.i(TAG, "saved to disk");
} catch (Exception e) {
Slog.e(TAG, "saveToDisk failed", e);
}
}
}
几个设计决策说明一下:
- Token 大小 32 字节:AOSP 的
DevicePolicyManagerService.setResetPasswordToken要求至少 32 字节,虽然底层 API 不校验长度,但对齐这个标准更稳 - 哨兵值用 0 而非 -1:
generateProtectorId()返回的是随机 long,可能是负数,用< 0判断"未初始化"会误判。0 是NULL_PROTECTOR_ID的保留值,不会冲突 - 持久化路径
/data/system/:AOSP 把 user 0 的锁屏文件(locksettings.db、reboot.escrow.key)都放这里,不用硬编码 userId sync()确保落盘:Files.write()只 flush 到 OS buffer,掉电会丢。AOSP 的SyntheticPasswordManager写完都会调FileDescriptor.sync()
调用方
private final EscrowTokenManager mEscrowTokenMgr = new EscrowTokenManager();
@Override
public void systemReady(int phase) {
if (phase == SystemService.PHASE_BOOT_COMPLETED) {
// ...
}
mEscrowTokenMgr.init(mLockPatternUtils);
}
public boolean setPasswordNone(ComponentName who, int userHandle) {
long id = Binder.clearCallingIdentity();
try {
if (!mEscrowTokenMgr.isReady()) {
Slog.e(TAG, "setPasswordNone: escrow token not ready");
return false;
}
LockscreenCredential newCred = LockscreenCredential.createNone();
if (!mEscrowTokenMgr.setCredential(newCred, mUserId)) {
Slog.e(TAG, "setPasswordNone: setCredential failed");
return false;
}
mLockPatternUtils.setLockScreenDisabled(false, mUserId);
FingerprintManager fpMgr = (FingerprintManager)
mContext.getSystemService(Context.FINGERPRINT_SERVICE);
if (fpMgr != null && fpMgr.isHardwareDetected()) {
fpMgr.remove(new Fingerprint(null, 0, 0, 0),
UserHandle.myUserId(), null);
}
return true;
} finally {
Binder.restoreCallingIdentity(id);
}
}
public int setPassword(ComponentName who, String pwd, int userHandle) {
if (TextUtils.isEmpty(pwd)) {
return setPasswordNone(who, userHandle) ? 0 : 4;
}
long id = Binder.clearCallingIdentity();
try {
// 参数校验省略...
if (!mEscrowTokenMgr.isReady()) {
Slog.e(TAG, "setPassword: escrow token not ready");
return 4;
}
LockscreenCredential newCred = isNumPwd
? LockscreenCredential.createPin(pwd)
: LockscreenCredential.createPassword(pwd);
if (!mEscrowTokenMgr.setCredential(newCred, mUserId)) {
Slog.e(TAG, "setPassword: setCredential failed");
return 4;
}
lockDevice();
return 0;
} finally {
Binder.restoreCallingIdentity(id);
}
}
注意 LockscreenCredential 不要用 try-with-resources,这个后面坑里会说。
踩过的坑
坑 1:非托管设备上 Escrow 数据被删光
LockSettingsService 里有个方法叫 disableEscrowTokenOnNonManagedDevicesIfNeeded,会在设备不满足以下任一条件时,把 escrow 数据(e0/p1 文件)永久删除:
- 设备有 Device Owner
- 用户是托管 Profile
- 车载设备
- 设备未完成初始化(SUW 阶段)
如果你的 MDM 不走标准 DPMS 设置 Device Owner,isDeviceManaged() 就是 false,escrow 数据会被删得干干净净。然后调 addEscrowToken 直接抛异常:
java.lang.SecurityException: Escrow token is disabled on the current user
解决方法是在 LockSettingsService 里加一个放行条件:
// disableEscrowTokenOnNonManagedDevicesIfNeeded() 中
if (RoSystemFeatures.hasFeatureAutomotive(mContext)) {
return;
}
if (android.os.SystemProperties.getBoolean("ro.mdm.enabled", false)) {
Slog.i(TAG, "MDM device can have escrow token");
return;
}
然后在设备的 system.prop 里加上 ro.mdm.enabled=true。
坑 2:LockscreenCredential 被 try-with-resources 提前 zeroize
setLockCredentialWithToken 内部通过 Handler 异步调用 notifyPasswordChanged(credential, userId)。如果你用了 try-with-resources,方法返回后 credential 就被 close() 清零了,等 Handler 跑起来去读 credential 的时候直接崩:
java.lang.IllegalStateException: Credential is already zeroized
at LockscreenCredential.ensureNotZeroized(...)
at PasswordMetrics.computeForCredential(...)
at LockSettingsService.notifyPasswordChanged(...)
解决办法就是不要 try-with-resources,让 GC 去回收。LockscreenCredential 内部有 Cleaner 机制,最终会 zeroize,不会泄漏。
坑 3:Handle 是负数导致误判
generateProtectorId() 返回随机 long 值,可以是负数。如果初始化用 mHandle = -1 然后用 mHandle < 0 判断"未初始化",负数的合法 handle 就会被误判。
改用 0 作为哨兵值就行了,0 是 NULL_PROTECTOR_ID,正常不会生成出来。
坑 4:持久化文件掉电丢失
Files.write() 内部会 close → flush,但只是到 OS 的 page cache,没有 fsync。写完瞬间掉电数据就没了。AOSP 自己写 spblob 文件都会 sync,我们没理由不 sync。改用 FileOutputStream + getFD().sync() 就行。
工作时序
graph TD
A["首次开机 systemReady()"] --> B["init()"]
B --> C{"磁盘有 Token?"}
C -->|否| D["注册新 Token"]
C -->|是| E["loadFromDisk()"]
E --> F{"Token 还活着?"}
F -->|是| G["就绪"]
F -->|否| D
D --> H{"用户有密码?"}
H -->|否| I["立即激活 + 持久化"]
H -->|是| J["等用户解锁"]
J --> K["激活回调 → 持久化"]
I --> G
K --> G
G --> M["调用 setPassword / setPasswordNone"]
M --> N["setLockCredentialWithToken"]
N --> O["密码变更成功"]
P["设备重启"] --> Q["systemReady()"]
Q --> R["loadFromDisk()"]
R --> S["protector 在 DE 磁盘上 → 仍然可用"]
S --> T["用户未解锁也能重置密码"]
style G fill:#e8f5e9,stroke:#388e3c
style O fill:#e8f5e9,stroke:#388e3c
style T fill:#e8f5e9,stroke:#388e3c
Strong Token vs Weak Token
AOSP 有两种 Escrow Token,差别很大:
| Strong Token | Weak Token | |
|---|---|---|
| 用户改密码后 | 保持有效 | 被自动销毁 |
| 用户清密码后 | 保持有效 | 被自动销毁 |
| 适用设备 | 托管设备 | 仅车载 |
| DPMS 用的是 | 这个 | 不是 |
我们用的是 Strong Token,AOSP 文档原话是"一旦激活,永久有效,即使密码被更改或清除"。密码改了 Token 还在,下次还能用。
总结
整个方案的核心就一句话:在 system_server 里通过 LocalService 注册 Escrow Token,把它持久化到 DE 存储,之后拿 Token 代替密码来操作凭据。
| 需要当前密码吗 | 不需要 |
| 重启后用户未解锁前能用吗 | 能 |
| 无密码设备首次设密码 | Token 立即激活,马上能用 |
| 有密码设备改密码 | 用户解锁一次激活 Token,之后永久可用 |
| 改动范围 | 自定义服务 + LockSettingsService 一处放行 + 系统属性 |