NFC 无源锁开发实录:NTAG213 协议读写与国密加密通讯
NFC 无源锁项目是我做过技术含量最高、最靠近底层的项目之一。无源锁本身没有电池,完全由手机 NFC 感应线圈供电,通讯窗口只有几百毫秒。如何在这极短的时间内完成供电→认证→加密指令下发→解锁确认的完整流程,是整个项目最大的工程挑战。
1. 无源锁的工作原理
与普通 NFC 刷卡不同,无源锁的交互链路更长:
手机 NFC 线圈
→ 为锁内芯片供电(能量收集)
→ 建立 ISO 14443-3A 通讯链路
→ 读取 NTAG213 芯片的 UID 及用户数据区
→ 下发加密解锁指令
→ 锁验证指令合法性后执行机械开锁
→ 手机收到应答,UI 反馈整个流程对时序要求极严苛:手机离开感应区则立即断电,通讯中断。
2. Android NFC 层的 Kotlin 实现
React Native 官方没有 NTAG213 的现成库,必须自己写 Kotlin Native Module 直接调用 Android 的 NfcAdapter。
2.1 前台调度(Foreground Dispatch)
要让 App 在前台优先接管 NFC 事件(而不是被系统其他 App 抢走),需要注册前台调度:
class NFCModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
private var nfcAdapter: NfcAdapter? = null
private var pendingIntent: PendingIntent? = null
override fun getName() = "NFCModule"
@ReactMethod
fun startScan(promise: Promise) {
val activity = currentActivity ?: return promise.reject("NO_ACTIVITY", "")
nfcAdapter = NfcAdapter.getDefaultAdapter(activity)
if (nfcAdapter == null || !nfcAdapter!!.isEnabled) {
return promise.reject("NFC_UNAVAILABLE", "NFC not available or disabled")
}
pendingIntent = PendingIntent.getActivity(
activity, 0,
Intent(activity, activity.javaClass).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
},
PendingIntent.FLAG_MUTABLE
)
// 只过滤 NfcA 技术(NTAG213 基于 ISO 14443-3A)
val techFilters = arrayOf(arrayOf(NfcA::class.java.name))
nfcAdapter!!.enableForegroundDispatch(activity, pendingIntent, null, techFilters)
// 存储 promise,在 onNewIntent 中 resolve
pendingScanPromise = promise
}
}2.2 NTAG213 低级命令交互
NTAG213 的读写不能直接用高层 API,需要通过 NfcA.transceive() 发送 ISO 14443-3A 原始字节指令:
private fun communicateWithTag(tag: Tag): Map<String, Any> {
val nfcA = NfcA.get(tag)
nfcA.connect()
nfcA.timeout = 2000 // 2秒超时,超时即断链
try {
// Step 1: 读取 UID(7字节),作为设备唯一标识
val uid = tag.id.toHexString()
// Step 2: 读取用户数据区(Page 4 开始,每 Page 4 字节)
// READ 命令格式:[0x30, pageNumber]
val userDataBytes = nfcA.transceive(byteArrayOf(0x30, 0x04))
// 返回 16 字节(4 页),取前 8 字节作为锁的序列号
val lockSerialNumber = userDataBytes.slice(0..7).toByteArray()
// Step 3: 生成解锁指令(含时间戳防重放攻击)
val command = buildUnlockCommand(uid, lockSerialNumber)
// Step 4: 发送加密解锁指令(写入 Page 8,自定义命令区)
// WRITE 命令格式:[0xA2, pageNumber, 4字节数据]
val writeCmd = byteArrayOf(0xA2.toByte(), 0x08) + command.take(4).toByteArray()
val writeResponse = nfcA.transceive(writeCmd)
if (!writeResponse.contentEquals(byteArrayOf(0x0A.toByte()))) {
throw IOException("Write ACK failed: ${writeResponse.toHexString()}")
}
// Step 5: 读取锁的应答(锁执行开锁后会更新 Page 9)
val responseBytes = nfcA.transceive(byteArrayOf(0x30, 0x09))
val unlockStatus = responseBytes[0].toInt() and 0xFF
return mapOf(
"uid" to uid,
"success" to (unlockStatus == 0xAA),
"timestamp" to System.currentTimeMillis()
)
} finally {
nfcA.close()
}
}3. 国密 SM4 加密:保障通讯链路安全
解锁指令明文传输是绝对不允许的,因为 NFC 信号在近场范围内可被嗅探。项目要求使用国密 SM4 分组加密算法对指令进行加密。
3.1 密钥派生
每把锁有唯一的设备密钥,由后端根据 UID 和主密钥派生:
// 前端通过接口获取设备会话密钥(已加密传输,后端负责派生)
const sessionKey = await api.getLockSessionKey({ uid, timestamp: Date.now() })后端实现(Node.js 伪代码):
// 基于 HMAC-SM3 从主密钥派生设备密钥
const deviceKey = hmacSM3(masterKey, uid + timestamp.toString())3.2 React Native 侧的 SM4 加密
JS 侧使用 sm-crypto 库(支持 SM2/SM3/SM4):
import { sm4 } from 'sm-crypto'
interface UnlockCommand {
lockUid: string
timestamp: number
nonce: string // 防重放的随机数
userId: string
}
function encryptUnlockCommand(command: UnlockCommand, sessionKey: string): string {
const plaintext = JSON.stringify(command)
// SM4-CBC 模式加密,IV 使用时间戳派生确保每次不同
const iv = generateIV(command.timestamp)
const encrypted = sm4.encrypt(plaintext, sessionKey, {
mode: 'cbc',
iv,
padding: 'pkcs#7'
})
return encrypted
}
function generateIV(timestamp: number): string {
// 取时间戳的 MD5(前 16 字节)作为 IV
return sm3(timestamp.toString()).slice(0, 32)
}3.3 防重放攻击机制
NFC 通讯的一个安全隐患是重放攻击:攻击者录制一次合法的解锁信号,之后重放。防御方案:
- 时间戳校验:指令中包含当前时间戳,锁的芯片检查时间戳是否在 30 秒内
- Nonce 机制:每次解锁生成一个随机数,服务端记录已用 nonce,拒绝重复
- 计数器递增:锁内维护一个解锁计数器,指令中包含期望的下一个计数值
async function buildSecureUnlockPayload(lockInfo: LockInfo): Promise<string> {
// 从服务端获取当前锁的计数器值
const { counter, sessionKey } = await api.getLockState(lockInfo.uid)
const command: UnlockCommand = {
lockUid: lockInfo.uid,
timestamp: Date.now(),
nonce: crypto.randomUUID(),
userId: currentUser.id,
// counter + 1 表示这是一条新指令
expectedCounter: counter + 1,
}
return encryptUnlockCommand(command, sessionKey)
}4. 通讯超时与用户体验的平衡
NFC 感应需要用户将手机贴近锁,时间窗口极短(约 500ms 内完成全部交互)。为了给用户清晰的反馈:
function NFCUnlockButton({ lockId }: { lockId: string }) {
const [status, setStatus] = useState<'idle' | 'scanning' | 'success' | 'error'>('idle')
const handleUnlock = async () => {
setStatus('scanning')
// 震动反馈,提示用户已准备好
Vibration.vibrate(50)
try {
const payload = await buildSecureUnlockPayload(lockId)
const result = await NFCModule.writeAndRead(payload, {
timeout: 3000, // 3秒内必须完成,否则提示用户重新靠近
retryOnTimeout: true,
maxRetries: 2,
})
if (result.success) {
setStatus('success')
Vibration.vibrate([0, 100, 50, 100]) // 双震动 = 成功
// 记录解锁日志
await api.logUnlockEvent({ lockId, timestamp: result.timestamp })
}
} catch (e) {
setStatus('error')
Vibration.vibrate(500) // 长震动 = 失败
} finally {
setTimeout(() => setStatus('idle'), 2000)
}
}
return (
<TouchableOpacity onPress={handleUnlock} style={styles.button}>
<NFCStatusIcon status={status} />
<Text>{STATUS_TEXT[status]}</Text>
</TouchableOpacity>
)
}5. 踩坑记录
坑 1:NTAG213 的内存布局必须精确
Page 0-2 是只读的 UID 和锁定字节,Page 3 是 CC 容量容器,用户数据从 Page 4 开始,Page 41-44 是配置区。曾经误写了 Page 3 导致整个芯片被锁死,只能找厂商换芯片,教训极深。
坑 2:前台调度在 Activity 切换时失效
锁屏、来电等情况会导致 Activity 进入 onPause,前台调度自动停止。必须在 onResume 中重新注册,onPause 中调用 disableForegroundDispatch。
坑 3:部分国产机的 NFC 驱动延迟
小米/OPPO 部分机型的 NFC 驱动在第一次 transceive 后有 50-80ms 的额外延迟,导致复杂指令序列超时。解决方案是将多条小指令合并为更少的大指令,减少往返次数。
总结
这个项目让我对”IoT 安全”有了完全不同的理解:软件安全与硬件时序约束的结合,比纯软件系统要复杂得多。国密算法的引入不仅是合规要求,更是在极短通讯窗口内保障安全的工程必要性。
最后更新:2026年4月
标签:NFC、NTAG213、React Native、Kotlin、国密、SM4、IoT安全