React Native 设备唯一 ID:MediaDrm + Keychain 实战

一套代码搞定 iOS/Android 设备唯一 ID:MediaDrm + Keychain 实战踩坑

业务痛点

项目里有个需求:主设备绑定

场景是这样的------用户 A 在自己的手机上登录了账号,我们把这台设备标记为"主设备"并存储设备 ID。过段时间,用户 B 在同一台手机上登录自己的账号。此时服务端发现:这台手机的设备 ID 不在用户 B 的主设备列表里------说明有人在陌生设备上登录了账号。

这时候应该触发安全校验:发短信验证码,确认是本人操作。验证通过后,把这台设备设为用户 B 的新主设备,同时生成登录日志和风险预警记录。

整条链路的核心依赖只有一个:拿到的设备 ID 必须稳定

硬指标:卸载 App 重装后 ID 不能变,否则用户每次更新 App 都要被短信轰炸一遍。


现状:三套逻辑,各自为战

接手时,项目里设备 ID 的获取逻辑散落在至少三个地方,互不统一:

第一套(用于登录、个人中心等核心页面):iOS 走 Keychain,Android 直接用 getAndroidIdSync() 拿 ANDROID_ID。没有 fallback,ANDROID_ID 拿不到就返回空。

第二套(用于考勤打卡页):iOS 还是 Keychain,Android 走了一条很长的降级链:硬件序列号 → getUniqueIdSync()(SharedPreferences 里的 UUID) → AsyncStorage 缓存 → uuidv4() 兜底。链路虽长,但卸载后 SharedPreferences 和 AsyncStorage 全丢,实际上就是每次重装都生成新 ID。

第三套是一些工具类里的封装,逻辑跟第一套几乎一样,但复制了一份。

问题很明显:同样的需求实现了三次,Android 端没有一个版本能保证"卸载后 ID 不变"。


怎么才算一个"稳定"的设备 ID

动手之前先明确标准。设备 ID 的稳定性可以从这几个维度衡量:

场景 是否应该保持不变 为什么
同一次启动内多次获取 ✅ 必须 基本要求
杀死 App 重新打开 ✅ 必须 否则每次冷启动都触发验证
卸载后重装 ✅ 应该 用户换机才会变,卸载不是换机
系统 OTA 升级 ✅ 应该 设备没变
清除 App 缓存/数据 ✅ 应该 缓存不是设备身份
恢复出厂设置 ⚠️ 可变 等于"像新设备一样",可接受
换了一台手机 ❌ 必须变 这才是真正的"换设备"

其中"卸载重装不变"是最难啃的骨头------因为 Android 刻意设计成卸载时清除所有 App 私有数据,几乎所有本地存储都会消失。


方案调研:谁是真正的"设备指纹"

把所有能拿到设备标识的 API 拉出来,从"卸载后是否还在"这个角度筛一遍:

ANDROID_ID --- 大部分人第一反应,但问题不少

java 复制代码
String androidId = Settings.Secure.getString(
    getContentResolver(), Settings.Secure.ANDROID_ID
);
  • Android 8.0 之前:所有 App 拿到的 ANDROID_ID 一样,恢复出厂会变
  • Android 8.0 之后:按 应用签名 + 用户 分别分配,同签名同用户卸载重装不变
  • :部分定制 ROM 切换多用户/访客模式时返回 null
  • :有些设备永久返回 9774d56d682e549c------这是 Android 模拟器的默认值,部分厂商 ROM 忘了替换

结论:ANDROID_ID 可以当辅助维度,不能当唯一主键。

IMEI / 硬件序列号 --- 曾经的标准答案,已被封杀

java 复制代码
// 需要 READ_PHONE_STATE 权限,Android 10+ 普通 App 拿不到
TelephonyManager.getDeviceId()

// Android 10+ 同样受限
Build.getSerial()

Google 出于隐私保护,从 Android 10 开始逐步收紧,非系统 App 基本无缘。这类 API 不能作为通用方案。

OAID(开放匿名设备标识)--- 国内厂商联盟方案

移动安全联盟推出的标准,小米、华为、OPPO、vivo 都支持。卸载重装不变,用户可以手动重置。

问题是:非国行手机不支持、依赖厂商 ROM 实现质量参差不齐、需要额外集成 SDK。适合纯国内市场的 App,但不够通用。

MediaDrm (Widevine ID) --- 和大厂用同一个 API

这是调研过程中最惊喜的发现。

Android 内置了一套 DRM(数字版权管理)框架,用于保护视频、音乐等版权内容。所有通过 Google CTS 认证的设备都强制实现。Netflix 用它加密视频流,Snapchat 用它做设备封禁。

java 复制代码
// Widevine 的 UUID 是固定的
UUID widevineUuid = new UUID(0xEDEF8BA979D64ACEL, 0xA3C827DCD51D21EDL);
MediaDrm mediaDrm = new MediaDrm(widevineUuid);

// 这个值是硬件绑定的,不依赖任何本地存储
byte[] deviceUniqueId = mediaDrm.getPropertyByteArray(
    MediaDrm.PROPERTY_DEVICE_UNIQUE_ID
);

mediaDrm.close();

// 转成十六进制字符串
String hexId = bytesToHex(deviceUniqueId);

为什么它稳定? 这个 ID 是 DRM 服务提供商(Google Widevine)在设备首次激活时分配并保存在安全区域的,跟 App 沙盒完全无关。卸载 App 不会触碰它,恢复出厂大概率也不影响。

安全公司 Talsec 专门做过研究,结论是 MediaDRM + device model 是 Android 反欺诈场景的最优组合

有什么缺点? 极少数设备(<1%)的 ROM 裁剪了 Widevine 支持,调用 new MediaDrm() 会抛 UnsupportedSchemeException。所以需要 try-catch + 降级策略。

iOS Keychain --- 几乎完美

iOS 侧就没有 Android 那种纠结。Keychain 是苹果官方推荐的敏感数据存储方案,独立于 App 沙盒。

objc 复制代码
// 首次获取时,检查 Keychain 是否已有
NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier];
NSString *uuid = [KeyChainStore load:bundleId];

if (!uuid) {
    // 没有就生成一个,存进去
    uuid = [(__bridge_transfer NSString *)CFUUIDCreateString(NULL, CFUUIDCreate(NULL)) copy];
    [KeyChainStore save:bundleId data:uuid];
}

Keychain 的数据在卸载 App 时不会被删除 ------这是它和 NSUserDefaults、文件沙盒的本质区别。

唯一需要注意的是保护级别。如果不小心用了不带 ThisDeviceOnly 后缀的级别,用户换新 iPhone 从 iCloud 恢复备份后,两台手机会有相同的 Keychain 数据,导致"一机一 ID"被打破:

objc 复制代码
// 不要用 --- iCloud 备份会带到新设备
kSecAttrAccessibleAfterFirstUnlock

// 正确 --- 只存本机,换机不影响
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

为什么没用这些方案

调研过程中有几种看起来"也能用"的方案,但都有硬伤。

react-native-device-info 的 getUniqueId()

javascript 复制代码
import DeviceInfo from 'react-native-device-info';
const id = DeviceInfo.getUniqueIdSync();

很多人以为这是"设备唯一 ID",实际上它的实现是在首次调用时生成一个随机 UUID,存到 SharedPreferences(Android)或 NSUserDefaults(iOS)。

致命问题 :卸载 App 时这两个存储都会被系统清除。iOS 侧勉强能用 Keychain 替代,但 Android 侧 getUniqueId() 就是包装了一层的 uuidv4,跟"设备绑定"毫无关系。

Android Keystore

Android Keystore 是系统级的密钥存储,有人尝试用它来持久化 UUID:

java 复制代码
KeyGenerator keyGenerator = KeyGenerator.getInstance(
    KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");

社区有项目(如 Capacitor Updater)声称用 Keystore 实现了跨卸载的 UUID 持久化。但仔细读代码会发现,它实际的持久化依赖的是 Android Auto Backup------利用 Google 的自动备份机制在重装时恢复 SharedPreferences 里的加密数据,Keystore 本身在卸载时会被清空。

换句话说,如果用户关闭了自动备份,或者备份还没同步就重装了,ID 就丢了。这不是一个可靠的方案。

Firebase Installation ID

java 复制代码
FirebaseInstallations.getInstance().getId()
    .addOnCompleteListener(task -> {
        String id = task.getResult();
    });

Firebase Installation ID 用于标识一个 App 安装实例。和 getUniqueId() 一样,卸载重装后会变。它的设计目标就不是"设备绑定"。

Android Advertising ID (AAID)

java 复制代码
AdvertisingIdClient.getAdvertisingIdInfo(context).getId();

广告 ID 的定位是跨 App 的用户追踪,用户可以随时在系统设置里重置它。拿来做设备绑定,用户重置一次广告 ID 就触发一次短信验证,显然不合适。

自定义组合指纹(Canvas/WebGL/AudioContext 指纹)

javascript 复制代码
// 通过 Canvas 绘制 + hash 生成浏览器指纹的技术
// 在纯 Web 端很成熟,但移动端不适用

这种技术在 Web 端做反欺诈很成熟,但移动端有两个问题:

  1. 不同 App 的 WebView 渲染结果可能不一致,拿到的是 WebView 指纹而非设备指纹
  2. 原生端很难获取足够多的差异化信号

适合"反欺诈"但不是"设备标识"。

OAID 深度分析

OAID 在国内主流机型上表现不错,但最终没选的几个考虑:

  1. 依赖厂商 ROM:需要集成 MSA 的 SDK(约 200KB),再通过各厂商的 OAID 服务获取。华为、小米、OPPO、vivo 各自实现质量参差不齐
  2. 海外设备不支持:Google Play 渠道的设备不走 OAID 通道,需要 ANDROID_ID 兜底
  3. 用户可以重置:在系统设置的"隐私-广告"里可以手动重置,相当于随时能让你的设备绑定失效
  4. SDK 更新慢:MSA 的 SDK 更新频率跟不上 Android 大版本节奏,遇到过新系统上 API 不兼容的情况

如果你的 App 99% 用户在国内用国产手机,OAID 是个可选项,但仍建议加 ANDROID_ID fallback。


方案对比总览

方案 卸载重装 恢复出厂 需要权限 可靠性
MediaDrm (Widevine ID) ✅ 大概率 很高
iOS Keychain ⚠️ 加密备份可恢复 很高
ANDROID_ID ⚠️ 8.0+同签名
OAID ⚠️ 用户可重置 依赖厂商 仅国内
IMEI/Serial 特许权限 已封杀
本地 uuidv4 进程级

结论:iOS 用 Keychain UUID,Android 用 MediaDrm + ANDROID_ID + DeviceModel 的复合指纹。


最终方案设计

整体架构

scss 复制代码
                     调用方
              (Login / Me / 考勤...)
                       │
                       ▼
           ┌───────────────────────┐
           │   DeviceIdService.js  │
           │                       │
           │  · 统一入口            │
           │  · 内存缓存            │
           │  · 平台判断            │
           └───────────┬───────────┘
           ┌───────────┴───────────┐
           │                       │
      ┌────▼────┐           ┌──────▼───────┐
      │  iOS    │           │   Android    │
      │         │           │              │
      │ Keychain│           │ NativeModule │
      │  UUID   │           │              │
      │ (36位)  │           │  Widevine ID │
      └─────────┘           │  + ANDROID_ID│
                            │  + Model     │
                            │      │       │
                            │   MD5 哈希    │
                            │   (32位)     │
                            └──────────────┘

JS 层设计

javascript 复制代码
// DeviceIdService.js
import { Platform, NativeModules } from 'react-native';

const { NativeInteraction, DeviceIdModule } = NativeModules;
let cachedDeviceId = null;

const DeviceIdService = {
  getDeviceId: async () => {
    if (cachedDeviceId) {
      return cachedDeviceId;  // 命中内存缓存, 无需桥接调用
    }

    if (Platform.OS === 'ios') {
      cachedDeviceId = await DeviceIdService._getIOSDeviceId();
    } else {
      cachedDeviceId = await DeviceIdService._getAndroidDeviceId();
    }

    return cachedDeviceId;
  },

  clearCache: () => { cachedDeviceId = null; },

  // iOS: 通过 NativeInteraction 桥接到原生 Keychain
  _getIOSDeviceId: () => new Promise((resolve) => {
    NativeInteraction.RNTransferIOSWithCallBack(data => {
      resolve(data);
    });
  }),

  // Android: 通过 DeviceIdModule 桥接到原生 MediaDrm
  _getAndroidDeviceId: async () => {
    return await DeviceIdModule.getDeviceId();
  },
};

export default DeviceIdService;

不搞单例类、不引入第三方依赖,就是一个带内存缓存的纯函数服务。所有调用方统一 import DeviceIdService from 'DeviceIdService',之前散落的三套逻辑全部去掉。

iOS 原生层设计

iOS 侧分三层:KeyChainStore(读写 Keychain)→ UUID(生成/缓存 UUID)→ NativeInteraction(暴露给 JS 的桥接方法)。

KeyChainStore.m --- 读写 Keychain

objc 复制代码
// 查询条件:class + service + account + 保护级别
+ (NSMutableDictionary *)getKeychainQuery:(NSString *)service {
    return [@{
        (id)kSecClass: (id)kSecClassGenericPassword,
        (id)kSecAttrService: service,
        (id)kSecAttrAccount: service,
        // ThisDeviceOnly: 不随 iCloud 备份同步到其他设备
        (id)kSecAttrAccessible: (id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
    } mutableCopy];
}

+ (void)save:(NSString *)service data:(id)data {
    // 1. 先用裸查询(不带 accessibility)删掉旧数据
    NSMutableDictionary *deleteQuery = [@{
        (id)kSecClass: (id)kSecClassGenericPassword,
        (id)kSecAttrService: service,
        (id)kSecAttrAccount: service,
    } mutableCopy];
    SecItemDelete((CFDictionaryRef)deleteQuery);

    // 2. 再用完整查询做写入
    NSMutableDictionary *keychainQuery = [self getKeychainQuery:service];

    NSError *error = nil;
    NSData *archivedData = [NSKeyedArchiver archivedDataWithRootObject:data
                                                requiringSecureCoding:NO
                                                                error:&error];
    if (!archivedData) return;

    [keychainQuery setObject:archivedData forKey:(id)kSecValueData];
    SecItemAdd((CFDictionaryRef)keychainQuery, NULL);
}

+ (id)load:(NSString *)service {
    NSMutableDictionary *keychainQuery = [self getKeychainQuery:service];
    [keychainQuery setObject:(id)kCFBooleanTrue forKey:(id)kSecReturnData];
    [keychainQuery setObject:(id)kSecMatchLimitOne forKey:(id)kSecMatchLimit];

    CFDataRef keyData = NULL;
    if (SecItemCopyMatching((CFDictionaryRef)keychainQuery, (CFTypeRef *)&keyData) == noErr) {
        NSError *error = nil;
        id result = [NSKeyedUnarchiver unarchivedObjectOfClass:[NSString class]
                                                      fromData:(__bridge NSData *)keyData
                                                         error:&error];
        CFRelease(keyData);
        return result;
    }
    return nil;
}

UUID.m --- 生成和缓存

objc 复制代码
+ (NSString *)getUUID {
    NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier];
    NSString *uuid = [KeyChainStore load:bundleId];

    if (!uuid || uuid.length == 0) {
        // 首次安装,生成 UUID 并存入 Keychain
        uuid = [(__bridge_transfer NSString *)CFUUIDCreateString(NULL, CFUUIDCreate(NULL)) copy];
        [KeyChainStore save:bundleId data:uuid];
    }

    return uuid;
}

NativeInteraction.m --- 暴露给 JS

objc 复制代码
// RN 桥接方法,JS 侧通过 NativeModules.NativeInteraction.RNTransferIOSWithCallBack() 调用
RCT_EXPORT_METHOD(RNTransferIOSWithCallBack:(RCTResponseSenderBlock)callBack) {
    NSString *uuid = [UUID getUUID];
    callBack(@[uuid]);
}

Android 原生层设计

核心思路:不信任任何单一数据源。Widevine ID + ANDROID_ID + 设备型号三者拼成一个字符串,MD5 后得到 32 位设备指纹。

java 复制代码
public void getDeviceId(Promise promise) {
    String widevineId = getWidevineId();
    String androidId = getAndroidId();
    String deviceModel = Build.MODEL;

    // 拼合后 MD5,单个维度失败不影响整体
    String combined = (widevineId != null ? widevineId : "")
            + (androidId != null ? androidId : "")
            + deviceModel;

    promise.resolve(md5(combined));
}

// MediaDrm Widevine ID --- 主力
private String getWidevineId() {
    try {
        MediaDrm mediaDrm = new MediaDrm(WIDEVINE_UUID);
        byte[] id = mediaDrm.getPropertyByteArray(
            MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
        mediaDrm.close();
        return bytesToHex(id);
    } catch (UnsupportedSchemeException e) {
        return null;  // 降级
    } catch (Exception e) {
        return null;
    }
}

// ANDROID_ID --- fallback,同时过滤已知 Bug 值
private String getAndroidId() {
    String id = Settings.Secure.getString(
        getContentResolver(), Settings.Secure.ANDROID_ID);

    // "9774d56d682e549c" 是模拟器默认值,部分厂商 ROM 忘了改
    if (id == null || id.isEmpty()
        || "9774d56d682e549c".equals(id)) {
        return null;
    }
    return id;
}

三个维度的组合保证了:即使 Widevine 不可用(极少数设备),ANDROID_ID 也能兜底;即使 ANDROID_ID 是 bug 值,device model 也能防碰撞。


踩坑实录

坑一:NSKeyedArchiver 废弃导致 Keychain 静默失败

现象 :iOS 侧每次获取设备 ID 都不一样。log 打印 SecItemAdd failed, status=-25299errSecDuplicateItem),但代码里 save 方法先调了 SecItemDelete,理论上不应该有重复条目。

排查

yaml 复制代码
KeyChainStore load: no item found        ← 读不到
KeyChainStore save: SecItemAdd -25299     ← 写失败
→ 下次再来:读不到 → 重新生成 → 又写失败 → 死循环

第 1 步,给所有 Keychain 操作加 OSStatus log。发现 SecItemDelete 执行的删除操作 OSStatus=0(成功),但紧接着的 SecItemAdd-25299(重复条目)。这不符合逻辑------刚删了怎么还有?

第 2 步,怀疑查询字典构建有问题,把 getKeychainQuery 返回的字典逐字段 dump。最后发现问题出在这行:

objc 复制代码
// 旧的保存代码
[keychainQuery setObject:[NSKeyedArchiver archivedDataWithRootObject:data]
                  forKey:(id)kSecValueData];
SecItemAdd((CFDictionaryRef)keychainQuery, NULL);

[NSKeyedArchiver archivedDataWithRootObject:] 在 iOS 12.0 被标记废弃,推荐用新的 archivedDataWithRootObject: requiringSecureCoding: error:。问题是这个旧 API 在某些 iOS 版本上会静默返回 nil 。传 nil 给 kSecValueData 不满足 Keychain 的参数要求,结果是 SecItemDelete 虽然执行了但 SecItemAdd 失败------Keychain 里其实没有任何条目。

根因 :不是"删不掉",是"从来就没写进去过"。每次 load 都返回空 → 生成新 UUID → save 又失败 → 下一次 load 继续空。

教训:Keychain API 对无效参数不会 crash,只返回一个 OSStatus。如果不检查返回值,静默失败的 bug 可能潜伏很久才暴露。

坑二:改 Keychain 保护级别引发的连锁反应

背景 :之前用的是 kSecAttrAccessibleAfterFirstUnlock,这个级别允许 iCloud 备份包含该条目。为了做到"一机一 ID",改成 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

现象 :改成 ThisDeviceOnly 后重新 build 安装,设备 ID 又开始每次变化。

排查

这次 SecItemDeleteSecItemAdd 都有正常的 OSStatus 返回,不再报 -25299。但 load 依然每次返回空。

在 Keychain Access 工具里检查(macOS 上模拟器可以查看),发现:

ini 复制代码
旧条目:kSecAttrAccessible = AfterFirstUnlock      ← 上次存的
新查询:kSecAttrAccessible = AfterFirstUnlockThisDeviceOnly  ← 现在查的

kSecAttrAccessible 不仅控制写入时的保护级别,它还参与 Keychain 查询的匹配 。旧数据用 AfterFirstUnlock 存,新查询字典带 ThisDeviceOnlySecItemCopyMatching 匹配不到旧条目。同理,SecItemDelete 也匹配不到,所以旧数据一直残留,新数据虽然能写进去(与旧数据 accessibility 不同所以不算 duplicate),但读的时候按新 accessibility 查,只能查到自己写的。

那为什么 ID 还是变?因为每次都是 load 返回空 → 生成新 ID → save 成功(覆盖上次自己写的) → 下次 load 查到的是最后一次写入的值------但这个值每次都是新生成的,因为你永远读不到第一次写的那个。

修法

objc 复制代码
// save 时,先用不加 kSecAttrAccessible 的裸查询删除旧数据
// 确保无论旧的保护级别是什么,都能清掉
NSMutableDictionary *deleteQuery = [@{
    (id)kSecClass: (id)kSecClassGenericPassword,
    (id)kSecAttrService: service,
    (id)kSecAttrAccount: service,
} mutableCopy];
SecItemDelete((CFDictionaryRef)deleteQuery);

// 然后再用带 ThisDeviceOnly 的完整查询做写入

启示 :Keychain 的 kSecAttrAccessible 是查询维度之一。当你改变它的值时,你实际上是在 Keychain 里创建了一个"新命名空间"------旧数据对你不可见。如果要迁移线上用户的数据,要么做兼容查询(先按旧 accessibility 查,查不到再用新的),要么在首次写入时暴力清理所有残留。


验证方案

自建了功能测试页,核心验证三个场景:

场景一:基础稳定性

arduino 复制代码
点击"获取设备ID" 10 次,每次清除内存缓存
iOS  → D60C69A5-7103-4F12-8E85-24B4AD9D27C1(10 次不变)
Android → a38b1227d1c43dcd9ed2f11c165b7051(10 次不变)

场景二:缓存机制

bash 复制代码
不点"清除缓存",连续获取 5 次
每次 log 显示:"命中内存缓存,直接返回"
耗时从 30-50ms 降到 <1ms

场景三:卸载重装

复制代码
删除 App → 重新安装 → 获取设备 ID
iOS → 同上,不变(Keychain 保留)
Android → 同上,不变(MediaDrm 硬件绑定)

选型总结

你的场景 推荐方案
设备绑定/主设备识别 iOS Keychain + Android MediaDrm(本文方案)
反作弊/风控 MediaDrm + ANDROID_ID + 设备型号 + Build 信息组合
纯国内市场 OAID 也可以,但建议加 ANDROID_ID fallback
只需要本次会话唯一 uuidv4() 存内存,五行代码

三条最重要的经验

  1. 不要信任单一数据源。Android 碎片化决定了任何单点 API 都可能在某些设备上失效,永远做多级 fallback。
  2. 检查每一个系统 API 的返回值 。Keychain 的 SecItemAddSecItemCopyMatching 永远不会 crash,只会返回一个 OSStatus。不检查就是埋雷。
  3. 改存储策略前,先想好迁移路径。Keychain 保护级别、SharedPreferences key 名------这些看起来是"实现细节"的东西,一旦上线就是你的数据契约。
相关推荐
光影少年5 小时前
RN中的StyleSheet.create 好处、内联样式弊端
react native·react.js·掘金·金石计划
大龄秃头程序员2 天前
RN 0.86 新架构实战:TurboModule + Fabric 组件 + iOS 原生容器,完整代码开源
前端·react native
光影少年2 天前
RN 样式特点:Flex 布局默认、没有像素单位、样式不能级联
react native·react.js·掘金·金石计划
青枣八神2 天前
Expo如何只通过数据线连接手机的Expo go
react native·智能手机·安卓
米开朗积德3 天前
React native开发iOS与Android兼容适配常见问题
react native·react.js
光影少年5 天前
RN 渲染原理:不依赖WebView,原生控件渲染
前端·react native·react.js·前端框架
牛仔只喝牛奶7 天前
多端跨端技术选型:小程序 + H5 + App 的两栈拆分落地
react native·react.js
熊猫钓鱼>_>8 天前
2026 前端的三场底层革命:当 Vue、React Native 和 Three.js 同时去掉中间层
前端·javascript·vue.js·react native·架构·webgl·three.js