0%

Android 16 实现任意时刻重置锁屏密码

做定制系统的同学应该都碰到过这个需求: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);

问题在于 savedCredentialNONE 的话,底层会做类型校验:

// 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 提供的一种"预授权令牌"机制。简单说就是:

  1. 提前注册一个 Token
  2. 等用户输入一次密码后,Token 被激活
  3. 之后拿这个 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 而非 -1generateProtectorId() 返回的是随机 long,可能是负数,用 < 0 判断"未初始化"会误判。0 是 NULL_PROTECTOR_ID 的保留值,不会冲突
  • 持久化路径 /data/system/:AOSP 把 user 0 的锁屏文件(locksettings.dbreboot.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 一处放行 + 系统属性
  • 本文作者: 6x
  • 本文链接: https://6xyun.cn/article/238
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-ND 许可协议。转载请注明出处!