蓝牙智能锁实战:安全认证、开锁指令、日志记录、离线模式

智能锁这个项目,是我个人觉得最能体现 BLE 开发综合能力的一个场景。你想想看,一把锁要同时搞定安全、实时响应、日志追溯,还得在没网的时候正常工作——这几乎把蓝牙开发的所有痛点都踩了一遍。

今天我们就来拆解这个实战案例。我会把我在智能锁项目里踩过的坑、总结的经验,都揉进这四个核心模块里。

一、安全认证:别让锁变成“开门器”

智能锁最怕什么?怕被中间人攻击,怕重放攻击。说白了,就是怕别人捡到你的蓝牙包,然后照着发一遍就把门打开了。

我在第一个智能锁原型里就吃过这个亏。当时图省事,直接明文传输开锁指令。结果测试同事用抓包工具一抓,复制粘贴就开了锁……嗯,那个版本连夜重写了。

1.1 动态密钥 + 时间戳

正确的做法是:每次连接都生成一个动态密钥。手机和锁之间通过 ECDH 密钥交换协议协商出一个会话密钥。开锁指令必须带上当前时间戳,锁端校验时间差在 5 秒内才放行。

核心思路: 密钥一次一换,指令一次有效。就算被截获,也无法重放。
// 手机端:生成开锁指令
fun generateUnlockCommand(sessionKey: ByteArray, timestamp: Long): ByteArray {
    val payload = "UNLOCK:$timestamp".toByteArray()
    val mac = HmacSHA256(sessionKey, payload).take(4) // 取前4字节做签名
    return payload + mac
}

// 锁端:验证指令
fun verifyCommand(data: ByteArray, sessionKey: ByteArray): Boolean {
    val timestamp = data.sliceArray(0..12).toString(Charsets.UTF_8).split(":")[1].toLong()
    if (abs(System.currentTimeMillis() - timestamp) > 5000) return false
    val mac = HmacSHA256(sessionKey, data.sliceArray(0..12)).take(4)
    return mac.contentEquals(data.sliceArray(13..16))
}
我的习惯: 签名只取前4字节,不是为了省流量,而是为了降低锁端 MCU 的计算压力。4字节碰撞概率在智能锁场景下完全可接受。

1.2 绑定流程:谁才是主人?

第一次绑定必须走带外认证。我一般用两种方式:

  • 扫码绑定: 锁体上印二维码,里面包含锁的蓝牙 MAC 和初始密钥。手机扫码后通过密钥加密通信。
  • 按键确认: 手机发起绑定请求,锁体上物理按键按下确认。这种方式适合没有屏幕的廉价锁。

我曾经见过一个产品,绑定流程只校验了蓝牙名称……结果隔壁老王用手机一搜,也能绑定。这哪是智能锁,分明是“共享锁”。

二、开锁指令:快、准、稳

开锁这个动作,用户体验要求极高。你想想,人站在门口,掏出手机,等个三五秒才开门——这体验还不如掏钥匙呢。

2.1 指令结构设计

我习惯把指令设计成固定长度,16字节。这样锁端解析起来不用动态分配内存,对 MCU 非常友好。

字节偏移 长度 内容 说明
0 1 0xA5 帧头
1 1 0x01 指令类型(开锁)
2-7 6 时间戳 精确到秒
8-11 4 签名 HMAC 前4字节
12-15 4 0x5A5A5A5A 帧尾
注意: 帧尾不要用 0x00 或 0xFF,MCU 串口缓冲区容易误判。0x5A5A5A5A 是个不错的选择,不容易跟数据冲突。

2.2 响应速度优化

开锁指令从手机发出到锁动作,我要求控制在 200ms 以内。怎么做到?

  • MTU 协商: 连接后立即协商到最大 MTU(一般是 512 字节),减少分包。
  • 写操作使用 Write Without Response: 开锁指令不需要锁端确认收到,只要锁执行了就行。
  • 锁端中断处理: 收到指令后直接在 BLE 中断回调里解析,不要进 RTOS 任务调度。

我记得有一次,锁端用了 FreeRTOS 的消息队列来传递指令,结果从收到指令到电机转动花了 800ms。后来改成直接在回调里处理,直接降到 120ms。嗯,实时性就是这么抠出来的。

三、日志记录:出了事得有据可查

智能锁的日志,说白了就是“谁、什么时候、干了什么”。这个功能平时没人看,但一旦出了纠纷,它就是铁证。

3.1 日志存储策略

锁端的存储空间有限,我一般用循环覆盖的方式:

  • 分配 4KB 的 Flash 空间给日志
  • 每条日志固定 32 字节
  • 最多存 128 条,满了就覆盖最旧的
// 日志结构
data class LockLog(
    val timestamp: Long,      // 8字节
    val action: Byte,         // 1字节:0x01开锁 0x02关锁 0x03异常
    val userId: Short,        // 2字节:用户ID
    val reserved: ByteArray   // 21字节:预留
)
我的经验: 预留 21 字节不是浪费。后来客户要求加指纹开锁记录,直接塞进预留字段,不用改存储结构。

3.2 日志同步到手机

手机连接锁后,自动拉取增量日志。我用的方法是:

  1. 手机记录上次同步的时间戳
  2. 锁端只返回时间戳大于该值的日志
  3. 手机收到后存入本地 SQLite 数据库

这样做的好处是,即使锁端日志被覆盖了,手机端还有完整的历史记录。

四、离线模式:没网也得干活

智能锁最尴尬的场景是什么?就是用户手机没信号,或者家里 WiFi 断了。这时候如果锁不能工作,那就真成“智障锁”了。

4.1 离线密钥缓存

我的方案是:手机在最后一次联网时,从服务器预取接下来 7 天的离线密钥。每个密钥对应一天,用日期做索引。

// 离线密钥结构
data class OfflineKey(
    val date: String,          // "2024-01-15"
    val key: ByteArray,        // 16字节密钥
    val unlockCount: Int       // 当日剩余开锁次数
)

开锁时,手机用当天的密钥生成指令。锁端也存有同样的密钥列表,验证通过就开门。

注意: 离线密钥必须限制使用次数。我一般设置每天 10 次,防止密钥泄露后被滥用。次数用完了,用户只能等第二天或者去连网更新。

4.2 离线模式下的日志

离线开锁的记录会暂存在手机本地。等网络恢复后,手机自动把日志上传到服务器。锁端本身不关心网络状态,它只负责验证指令和执行动作。

我曾经遇到一个坑:手机离线开锁后,日志里记录的时间是手机本地时间。结果用户手机时间设置错了,日志里显示凌晨 3 点开门,用户非说不是他开的……后来我改成用锁端时间戳来记录日志,手机只负责上传,不负责生成时间。

五、整体架构图

下面这张图,是我画给团队新人的。它把整个智能锁的通信链路和数据流向都串起来了。

手机 App 密钥协商 / 指令生成 / 日志同步 BLE 加密通道 蓝牙智能锁 指令验证 / 电机控制 / 日志存储 云端服务器 密钥分发 / 日志归档 HTTP/HTTPS(在线时) 离线模式核心模块 离线密钥缓存 本地日志暂存 次数限制校验 时间戳校验 安全认证层 ECDH 密钥交换 HMAC 签名验证 扫码/按键绑定 防重放

这张图里,我把整个系统分成了三层:手机 App、蓝牙智能锁、云端服务器。离线模式和安全认证作为两个独立模块,贯穿在手机和锁的通信过程中。

说实话,智能锁这个项目让我学到最多的不是蓝牙技术本身,而是怎么在资源受限的 MCU 上做安全、做存储、做容错。你想想看,一把锁可能要在零下 20 度的北方冬天工作,也可能在 40 度的南方夏天暴晒——硬件扛得住,软件也得扛得住。

嗯,今天就聊到这里。代码示例里的 HMAC 和 ECDH 实现,我建议你直接用 Android 的 javax.crypto 包,别自己造轮子。锁端的话,找个成熟的 MCU 加密库,比如 mbed TLS 的裁剪版,够用就行。


公众号:蓝海资料掘金营,微信 deep3321