PAYATHON 2026

Android 卸载共享 UID 应用后,如何排查 Keychain 解密失败?

数据小陈

结论

仅凭这条异常,不能认定“卸载 App A 会使共享组中的全部密钥失效”。还需要确认实际运行的 react-native-keychain 版本、两个应用的实际 UID,以及卸载前后使用的密钥和加密实现。

accessGroup 是 iOS 配置;Android Keystore 的应用密钥命名空间与 UID 有关。删除 accessGroup、修改 storage 或换一个 service,都不能直接把现有共享 UID 下的密钥变成 App B 独占的密钥。react-native-keychain v8.1.1 配置说明、Android Keystore 访问控制

accessGroup 与 sharedUserId 有什么区别?

这两个配置作用于不同平台,不能当成同一种共享机制。

配置在当前问题中的含义
accessGroupiOS Keychain 访问组配置,不能用来建立 Android 跨应用密钥共享
accessibleiOS Keychain 可访问性配置,不能据此设置 Android 密钥的解锁条件
android:sharedUserIdAndroid 的共享 Linux UID 配置;是否实际共享,应检查已安装应用的 UID
servicereact-native-keychain 区分凭据条目的名称;在相关 Android 实现中也用于密钥别名
storage保存凭据时选择的加密实现

这些平台限制可以在 v8.1.1 的选项表中确认。

Android Keystore 识别普通应用的密钥时,会结合调用方 UID 和 alias。如果 A、B 确实共享 UID,即使只由 B 调用密钥生成接口,也不能建立按 B 包名单独隔离的命名空间。如果两个应用还使用相同 alias,需要排查是否存在密钥覆盖或删除冲突;相同的 accessGroup 本身不会造成这种共享。Android Keystore domains

共享 UID 也不能直接说明“卸载任意成员必然清空密钥”。例如,Android 14 的一条完整卸载路径在 removedAppId != -1 时安排 Keystore 清理。具体设备是否走到相关清理分支,需要结合卸载方式、用户配置和系统日志判断。Android 14 RemovePackageHelper 源码

为什么文件还在,却无法解密?

加密文件和 Keystore 密钥是两份不同的数据。文件仍在,只能说明密文还在,不能说明原来的解密密钥仍然可用。

Authentication tag verification failed 表示认证标签校验失败,与用户的登录认证是两回事。这条异常也不能单独证明密钥被卸载流程删除。排查时需要考虑密钥不匹配,以及密文或相关加密参数发生变化等可能。

如果原密钥已经丢失,重新生成同名密钥也无法解开旧密文,因为 alias 相同不代表密钥材料相同。

还需要核实文件路径:提供的路径属于 DataStore,而官方 v8.1.1 的 PrefsStorage 使用名为 RN_KEYCHAIN 的 SharedPreferences。这个差异需要通过实际安装包中的原生实现确认,也可能存在历史文件或定制代码,不能直接按版本声明推断运行行为。v8.1.1 PrefsStorage 源码

应该怎样定位原因?

1. 确认版本和实际 UID

在项目中查看依赖解析结果,并核对 lockfile、原生源码与设备上安装的 APK 是否对应:

npm ls react-native-keychain

^8.1.1 是依赖范围,不能据此确认运行的是精确的 8.1.1。还应排查是否存在旧 APK、原生补丁或其他构建产物。

针对给出的 App B 包名,可以查看:

adb shell dumpsys package com.exemple.charge

对 App A 的实际包名执行同样的检查,比较 UID、共享用户信息和当前 Android 用户,并分别记录卸载前后的结果。Manifest 中写过相同字符串,不能作为实际共享 UID 的唯一证据。

2. 保留完整异常链和实际 storage

记录 setGenericPassword 成功返回的 service、storage,并保存失败时的原生堆栈及最底层异常。不要记录 token 或密码。

确认异常发生在哪个 CipherStorage、哪个解密步骤,以及卸载前后实际使用的 alias 是否一致。调试版本可以通过原生代码检查自身 AndroidKeyStore 中的 alias;但 alias 仍然存在,也不能单独证明它还是原来的密钥。

3. 用最小场景区分影响范围

在可清理数据的测试设备上,比较以下情况:

  • A、B 共存时,写入后重启 B。
  • A、B 共存时,写入后卸载 A,再启动 B。
  • B 从全新安装开始就使用独立 UID,再完成同样的 token 导入和卸载流程。

每次记录实际 UID、storage、异常链和文件变化。先保留失败现场,再进行清理,避免把定位依据一起删除。

怎样让迁移后的凭据独立于 App A?

对于尚未安装的新用户,优先让 B 使用独立 UID,通过应用间通信接收迁移凭据,再由 B 在自己的 Keystore 命名空间中加密保存。当前流程已经把 token 返回给 B,因此不需要为了这一步额外共享 UID。

对于已经使用 sharedUserId 安装的用户,不能把删除 Manifest 属性当成可靠的原地迁移方案。Android 文档明确指出,已有应用不支持通过移除该值迁出共享 UID。android:sharedUserMaxSdkVersion="32" 可用于影响较新系统上的全新安装,但不会把存量安装自动转成独立 UID。Android Manifest 文档

迁移方案需要区分新安装与存量安装。如果必须通过重新安装获得独立 UID,应先设计服务端会话恢复或其他可恢复的迁移流程,避免卸载时丢失唯一可用的 token。

在 B 已具备独立 UID、实际库版本确认支持相应 API,且需要保留原有 RSA 生物识别策略的前提下,可以写入一个新的 service:

import * as Keychain from 'react-native-keychain';

const TARGET_CONFIG = {
  service: 'com.exemple.auth.migrated',
  storage: Keychain.STORAGE_TYPE.RSA,
};

export async function saveMigratedToken(
  username: string,
  token: string,
): Promise<void> {
  const saved = await Keychain.setGenericPassword(
    username,
    token,
    TARGET_CONFIG,
  );

  if (!saved) {
    throw new Error('Token storage failed');
  }

  const credentials = await Keychain.getGenericPassword(TARGET_CONFIG);

  if (
    !credentials ||
    credentials.username !== username ||
    credentials.password !== token
  ) {
    throw new Error('Token verification failed');
  }
}

新的 service 用于区分迁移条目,密钥的独立性来自 B 的独立 UID。如果 B 仍在共享 UID 中,这段代码不会改变密钥命名空间。

即时回读成功后,还要验证 B 重启和 A 卸载后的读取结果。RSA 所需的生物识别条件也应满足,读取时可能出现认证交互。v8.1.1 RSA 存储说明

修改 storage 或改用 AES 能修好吗?

单纯修改配置不能恢复已经无法解密的记录。v8.1.1 会随密文保存加密实现名称,读取时依据该记录选择实现。因此,删除 storage: RSA 不会自动把已有 RSA 数据转换成其他格式。v8.1.1 凭据读取实现

转换时,应在旧数据仍可解密的情况下读取明文,再按目标策略重新加密保存,并验证后续读取。如果原密钥已经不可用,就需要重新从 A 获取 token、通过服务端恢复会话,或让用户重新登录。仅有加密文件不足以恢复凭据。

RSA 本身没有“依赖同 UID 下另一个应用仍然安装”的算法要求。它在这个库版本中涉及生物识别保护,需要另行排查认证条件。换成 AES 也不会改变共享 UID 的命名空间关系,不能作为卸载问题的通用修复。

清理旧条目前,应确认新凭据已经可用,并检查共享 UID 下是否有其他数据依赖同一 alias。不要在任何读取异常发生时立即调用 resetGenericPassword:认证取消、暂时无法认证和永久解密失败需要分别处理。

备注:内容仅供参考。