正在搭物联网设备接入平台的时候,我遇到一个此前从没想过的问题:一机一密上线之后,设备密钥泄露了怎么办?
第一反应是"改密码不就行了",但往下想一步就发现没那么简单:我的平台库里只存密钥的 SHA-256 哈希,不存明文——这是防拖库的正确设计,但它带来一个直接的代价:明文丢了就是丢了,没有任何"找回"的可能。唯一的办法是作废旧密钥、发一把新的。这就是真实物联网平台都在做的"密钥轮换"(rotation)。
这篇文章就用我项目里的真实代码(不是编的 demo),把密钥轮换和设备禁用这两套机制一次讲透,最后有实测截图为证。
一、先回到设计:为什么只存哈希
设备注册时,平台生成一把随机密钥,明文只在注册响应里出现一次,库里落的是哈希:
// 来源:src/main/java/com/iothub/device/DeviceService.javapublicRegisterResultregister(RegisterDeviceRequestrequest){if(deviceRepository.existsByProductKeyAndDeviceName(request.getProductKey(),request.getDeviceName())){thrownewBusinessException(409,"同名设备已存在");}Stringsecret=generateSecret();Devicedevice=newDevice();device.setDeviceName(request.getDeviceName());device.setProductKey(request.getProductKey());device.setSecretHash(sha256(secret));// 只存哈希Devicesaved=deviceRepository.save(device);returnnewRegisterResult(saved.getId(),saved.getDeviceName(),saved.getProductKey(),secret,// 明文只在这里出现一次"tcp://localhost:1883","device-"+saved.getProductKey()+"-"+saved.getId());}实体类上还有一道保险——secretHash标了@JsonIgnore,任何接口都不可能把它序列化出去:
// 来源:src/main/java/com/iothub/device/Device.java/** 设备密钥的 SHA-256 哈希。@JsonIgnore:任何接口都不允许把它序列化出去 */@JsonIgnoreprivateStringsecretHash;这个设计的安全收益(拖库拿不到明文、单台泄露不殃及全局)在上一篇文章里讲过,这里只强调它的代价:既然明文不落库,"查看设备密钥"这个功能就不存在。设备端没把密钥存好、或者固件被逆向了,唯一的出路就是下面这节——重置。
二、密钥轮换:三行核心逻辑
重置密钥的业务逻辑其实非常短:查设备 → 生成新密钥 → 覆盖哈希。
// 来源:src/main/java/com/iothub/device/DeviceService.java/** * 重置设备密钥:生成新密钥、覆盖哈希,明文只在本次响应出现一次。 * * 为什么需要:一机一密下密钥丢了(没存下来/设备端泄露)没有"找回"可言, * 只能作废重发——这就是真实平台的密钥轮换(rotation)。 */publicRegisterResultresetSecret(Longid){Devicedevice=deviceRepository.findById(id).orElseThrow(()->newBusinessException(404,"设备不存在"));Stringsecret=generateSecret();device.setSecretHash(sha256(secret));// 旧密钥的哈希被直接覆盖 = 旧密钥作废deviceRepository.save(device);returnnewRegisterResult(device.getId(),device.getDeviceName(),device.getProductKey(),secret,"tcp://localhost:1883","device-"+device.getProductKey()+"-"+device.getId());}对应的管理端接口就一个 POST:
// 来源:src/main/java/com/iothub/device/DeviceController.java/** 密钥轮换:生成新密钥,明文只在本次响应出现一次 */@PostMapping("/{id}/reset-secret")publicApiResponse<DeviceService.RegisterResult>resetSecret(@PathVariableLongid){returnApiResponse.ok(deviceService.resetSecret(id));}注意返回值复用了注册时的RegisterResult——重置和注册在协议层面是同一件事:给设备发一把新钥匙。设备端拿到新密钥后重新刷写(或通过 OTA 下发),下次连接就用新密钥了。
三、禁用 ≠ 删除:设备状态的第三种取值
光轮换密钥有时不够。如果一台设备确认被盗、被逆向,或者根本不确定泄露范围,你不能只换钥匙——万一新钥匙也在对方手里呢?这时候需要直接把设备拉黑。
我的Device实体里状态本来只有online / offline两种(由设备连接和遗嘱消息维护),禁用机制引入了第三种:
// 来源:src/main/java/com/iothub/device/DeviceService.java/** 设备状态常量:online / offline / disabled(禁用后认证与数据接入全部拒绝) */publicstaticfinalStringSTATUS_DISABLED="disabled";/** 禁用设备:MQTT 认证直接拒绝、数据接入丢弃,档案保留(区别于删除) */publicDevicedisable(Longid){returnsetStatus(id,STATUS_DISABLED);}/** 重新启用:回到 offline,等设备下次连上来再变 online */publicDeviceenable(Longid){returnsetStatus(id,"offline");}这里有两个设计决策值得展开:
① 为什么禁用而不删除?删除是破坏性操作:历史遥测数据会变成"孤儿"(数据表里 deviceId 指向一台不存在的设备),设备档案、注册时间这些排查线索也全没了。禁用是可逆的——档案还在、数据还在,只是连接和上报被掐断。运维场景里"先禁用观察,确认后再删"是标准节奏。
② 为什么 enable 之后是 offline 而不是 online?因为平台没资格宣称一台设备在线。online只能由设备自己"挣"来——它连上来、上报数据,状态才翻转。enable 只是解除封印,把状态机放回原点。这个细节看着小,面试里聊到状态机设计时很加分。
四、拦截位置:三层各拦各的,不假设上一层生效
禁用状态写进数据库只是第一步,真正起作用的是两处拦截。
第一处在 EMQX 的 HTTP 认证回调里,禁用检查放在哈希比对之前——被禁的设备连"验证密钥"的资格都没有,快速失败:
// 来源:src/main/java/com/iothub/mqtt/MqttAuthController.java(节选)Devicedevice=deviceRepository.findById(deviceId).orElse(null);if(device==null||!productKey.equals(device.getProductKey())){returndeny();}// 禁用的设备直接拒绝,密钥对不对都不行——禁用是档案级开关,比删密钥更快、可逆if(DeviceService.STATUS_DISABLED.equals(device.getStatus())){log.warn("设备[{}]已被禁用,拒绝连接",deviceId);returndeny();}if(!DeviceService.sha256(password).equals(device.getSecretHash())){log.warn("设备[{}]认证失败:密钥不匹配",deviceId);returndeny();}但认证层只拦"新连接"。一台已经连上来的设备,消息是不走认证回调的。所以第二道拦截放在消息处理入口——入库前再查一次状态:
// 来源:src/main/java/com/iothub/mqtt/TelemetryMessageHandler.java(节选)// 已禁用的设备:即使 Broker 层有漏网的(ACL 未生效/缓存),入库前再拦一道。// 安全上的原则:认证、鉴权、业务校验三层各拦各的,不假设上一层一定生效。if(com.iothub.device.DeviceService.STATUS_DISABLED.equals(device.getStatus())){log.warn("设备[{}]已禁用,丢弃其消息: topic={}",deviceId,topic);return;}这就是我在这个项目里反复验证的一个原则:认证、鉴权、业务校验三层各拦各的,永远不假设上一层一定生效。EMQX 可能有配置缓存,回调可能超时降级,任何一层的失效都不应该让脏数据进库。
五、实测:三张截图走完全流程
空口说无凭,我把项目(默认 H2 profile,不需要 EMQX 也能测认证回调)跑起来,用真实 HTTP 请求走了一遍全流程。
第一步:注册设备,拿到明文密钥。响应里deviceSecret是明文,仅此一次:
第二步:管理端重置密钥,然后分别用旧密钥和新密钥连一次。结果一目了然:重置接口返回了新明文密钥;紧接着拿旧密钥去认证回调,得到{"result":"deny"};换新密钥,得到{"result":"allow"}——旧密钥在哈希被覆盖的那一刻就已经作废:
第三步:禁用设备,再拿"当前正确的密钥"去连。设备状态已变成disabled,这次认证返回deny——密钥完全正确也没用,因为拦截发生在哈希比对之前:
六、一个容易踩的盲区:重置密钥不会踢掉在线设备
实测里有个细节必须单独拎出来说:重置密钥、禁用设备,都只对"下一次连接"生效。
MQTT 的认证发生在 CONNECT 报文到达的那一刻,EMQX 验完就放行,之后这条 TCP 连接上的 PUBLISH 不会再走认证回调。也就是说:一台已经连着的问题设备,你在平台这边改了密钥、甚至禁用了,它的连接还活着,还能继续往 Broker 发消息——只是消息会在上面说的第二层拦截(入库前)被丢弃。
所以"立刻断开问题设备"这件事,光改数据库是做不到的,得靠 Broker 侧主动踢人:EMQX 5.x 提供 REST API(DELETE /api/v5/clients/{clientId})可以把指定客户端踢下线,被踢的设备重连时才会再次触发认证回调,这时禁用状态才真正拦住它。完整的"紧急拉黑"动作应该是:disable(掐断入库)→ 调 EMQX API 踢下线(掐断连接)两步一起做。这是我在设计禁用功能时才意识到的盲区,也是本文最想让你带走的一句话。
七、收个尾:面试里怎么聊这套设计
这套东西在面试里可以提炼成三层:
- 密钥生命周期:一机一密 → 只存哈希(明文不落库、@JsonIgnore 防序列化泄漏)→ 丢失不可找回、只能轮换 → 轮换 = 覆盖哈希,旧密钥即刻作废;
- 禁用是状态不是删除:可逆、保留档案与数据,enable 回到 offline 是因为"在线要设备自己挣";
- 纵深防御:认证回调拦新连接、消息入口拦脏数据,各层独立生效,再加 Broker 踢连接补上"已在线"的空档。
安全类问题面试官最想听的往往不是标准答案,而是你在什么场景下意识到需要它。密钥泄露没法实验复现,但"设备报废了怎么处理""固件被逆向了怎么办"这类追问,答出"轮换 + 禁用 + 踢下线"的完整动作链,就比"加个黑白名单"高出一个段位。
作者:软件工程在读,正在从零搭一个物联网设备接入平台(Spring Boot 3 + EMQX + MySQL),把踩过的坑都写成文章。上一篇讲了接口鉴权,这一篇补上设备侧密钥的"售后"环节,欢迎关注交流。