正在搭物联网设备接入平台的时候我遇到一个此前从没想过的问题一机一密上线之后设备密钥泄露了怎么办第一反应是改密码不就行了但往下想一步就发现没那么简单我的平台库里只存密钥的 SHA-256 哈希不存明文——这是防拖库的正确设计但它带来一个直接的代价明文丢了就是丢了没有任何找回的可能。唯一的办法是作废旧密钥、发一把新的。这就是真实物联网平台都在做的密钥轮换rotation。这篇文章就用我项目里的真实代码不是编的 demo把密钥轮换和设备禁用这两套机制一次讲透最后有实测截图为证。一、先回到设计为什么只存哈希设备注册时平台生成一把随机密钥明文只在注册响应里出现一次库里落的是哈希// 来源src/main/java/com/iothub/device/DeviceService.javapublicRegisterResultregister(RegisterDeviceRequestrequest){if(deviceRepository.existsByProductKeyAndDeviceName(request.getProductKey(),request.getDeviceName())){thrownewBusinessException(409,同名设备已存在);}StringsecretgenerateSecret();DevicedevicenewDevice();device.setDeviceName(request.getDeviceName());device.setProductKey(request.getProductKey());device.setSecretHash(sha256(secret));// 只存哈希DevicesaveddeviceRepository.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){DevicedevicedeviceRepository.findById(id).orElseThrow(()-newBusinessException(404,设备不存在));StringsecretgenerateSecret();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)publicApiResponseDeviceService.RegisterResultresetSecret(PathVariableLongid){returnApiResponse.ok(deviceService.resetSecret(id));}注意返回值复用了注册时的RegisterResult——重置和注册在协议层面是同一件事给设备发一把新钥匙。设备端拿到新密钥后重新刷写或通过 OTA 下发下次连接就用新密钥了。三、禁用 ≠ 删除设备状态的第三种取值光轮换密钥有时不够。如果一台设备确认被盗、被逆向或者根本不确定泄露范围你不能只换钥匙——万一新钥匙也在对方手里呢这时候需要直接把设备拉黑。我的Device实体里状态本来只有online / offline两种由设备连接和遗嘱消息维护禁用机制引入了第三种// 来源src/main/java/com/iothub/device/DeviceService.java/** 设备状态常量online / offline / disabled禁用后认证与数据接入全部拒绝 */publicstaticfinalStringSTATUS_DISABLEDdisabled;/** 禁用设备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节选DevicedevicedeviceRepository.findById(deviceId).orElse(null);if(devicenull||!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 APIDELETE /api/v5/clients/{clientId}可以把指定客户端踢下线被踢的设备重连时才会再次触发认证回调这时禁用状态才真正拦住它。完整的紧急拉黑动作应该是disable掐断入库→ 调 EMQX API 踢下线掐断连接两步一起做。这是我在设计禁用功能时才意识到的盲区也是本文最想让你带走的一句话。七、收个尾面试里怎么聊这套设计这套东西在面试里可以提炼成三层密钥生命周期一机一密 → 只存哈希明文不落库、JsonIgnore 防序列化泄漏→ 丢失不可找回、只能轮换 → 轮换 覆盖哈希旧密钥即刻作废禁用是状态不是删除可逆、保留档案与数据enable 回到 offline 是因为在线要设备自己挣纵深防御认证回调拦新连接、消息入口拦脏数据各层独立生效再加 Broker 踢连接补上已在线的空档。安全类问题面试官最想听的往往不是标准答案而是你在什么场景下意识到需要它。密钥泄露没法实验复现但设备报废了怎么处理固件被逆向了怎么办这类追问答出轮换 禁用 踢下线的完整动作链就比加个黑白名单高出一个段位。作者软件工程在读正在从零搭一个物联网设备接入平台Spring Boot 3 EMQX MySQL把踩过的坑都写成文章。上一篇讲了接口鉴权这一篇补上设备侧密钥的售后环节欢迎关注交流。