一套代码搞定 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 端做反欺诈很成熟,但移动端有两个问题:
- 不同 App 的 WebView 渲染结果可能不一致,拿到的是 WebView 指纹而非设备指纹
- 原生端很难获取足够多的差异化信号
适合"反欺诈"但不是"设备标识"。
OAID 深度分析
OAID 在国内主流机型上表现不错,但最终没选的几个考虑:
- 依赖厂商 ROM:需要集成 MSA 的 SDK(约 200KB),再通过各厂商的 OAID 服务获取。华为、小米、OPPO、vivo 各自实现质量参差不齐
- 海外设备不支持:Google Play 渠道的设备不走 OAID 通道,需要 ANDROID_ID 兜底
- 用户可以重置:在系统设置的"隐私-广告"里可以手动重置,相当于随时能让你的设备绑定失效
- 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=-25299(errSecDuplicateItem),但代码里 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 又开始每次变化。
排查:
这次 SecItemDelete 和 SecItemAdd 都有正常的 OSStatus 返回,不再报 -25299。但 load 依然每次返回空。
在 Keychain Access 工具里检查(macOS 上模拟器可以查看),发现:
ini
旧条目:kSecAttrAccessible = AfterFirstUnlock ← 上次存的
新查询:kSecAttrAccessible = AfterFirstUnlockThisDeviceOnly ← 现在查的
kSecAttrAccessible 不仅控制写入时的保护级别,它还参与 Keychain 查询的匹配 。旧数据用 AfterFirstUnlock 存,新查询字典带 ThisDeviceOnly,SecItemCopyMatching 匹配不到旧条目。同理,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() 存内存,五行代码 |
三条最重要的经验:
- 不要信任单一数据源。Android 碎片化决定了任何单点 API 都可能在某些设备上失效,永远做多级 fallback。
- 检查每一个系统 API 的返回值 。Keychain 的
SecItemAdd、SecItemCopyMatching永远不会 crash,只会返回一个 OSStatus。不检查就是埋雷。 - 改存储策略前,先想好迁移路径。Keychain 保护级别、SharedPreferences key 名------这些看起来是"实现细节"的东西,一旦上线就是你的数据契约。