仓库:https://github.com/ffffxxxjjj/gateway
应用启动时,序列号与设备私钥从芯片的一次性可编程存储区(OTP)读,不再默认走测试串口注入。
本文档是读侧的单份定义:记录格式、有效性判定、启动判定流程、测试注入门控、板上测试法。
写侧不变:写 OTP 仍走固件工具的 PROV 命令(见 docs/provisioning.md);provision.rs 不动。
OTP 区:STM32H743,基址 0x1FF0F000,1024 字节,内存映射可读。身份记录在偏移 0 处,80 字节:
| 偏移 | 长度 | 内容 |
|---|---|---|
| 0 | 4 | magic,ASCII OTPI |
| 4 | 4 | format,u32 小端,= 1 |
| 8 | 4 | serial_len,u32 小端,1..=32 |
| 12 | 32 | 序列号字节,serial_len 之后零填充 |
| 44 | 32 | 设备私钥(X25519,一机一对,后台签发) |
| 76 | 4 | 校验:SHA-256(记录[0..76]) 的前 4 字节 |
序列号规则(与 provision.rs 的写侧规则一致):1..=32 字节,可打印 ASCII、无空格(0x21..=0x7E);
serial_len 之后的填充必须全零,否则不是本格式写出的记录。
布局原则与 Flash 布局同一条:单份定义,不许两处各写一套地址。
地址与格式只定义在 bootloader/src/otp.rs;应用侧只调接口,不自己算地址。
按序检查:magic → format → 序列号长度/字节/填充 → 校验。结果三态:
| 结果 | 含义 | 连接判定 |
|---|---|---|
Provisioned |
有效记录 | 用 OTP 身份(序列号 + 设备私钥),见第 4 节 |
NotWritten |
擦除区(全 0xFF)、全零区、或 magic 不符:从未写入 |
无 OTP 身份,不以 OTP 身份连;测试注入可用(仅开发板) |
Corrupt |
magic 对但 format、序列号或校验不过:写过但不是有效记录 | 与 NotWritten 完全一样处理(无 OTP 身份),但日志记“损坏”而非“未写入” |
Corrupt 单独成态的原因:OTP 写一次即不可改,损坏的记录不能靠重写修复,
该台须隔离检查(与写侧 ERR VERIFY 的隔离思路一致)。读本身失败(越界/总线错)
是错误,不是状态:启动日志记“读失败”,连接判定上按“无 OTP 身份”处理。
bootloader/src/otp.rs(板级库共用,纯数据 + 纯函数 + 一个读 trait,无外设、无堆):
| 项 | 说明 |
|---|---|
OtpRead trait |
read(offset, buf):读 OTP 偏移处字节;越界报 OtpReadError::Io。刻意无写方法:写是 provisioning 批次的工作 |
load_identity(reader) |
在偏移 0 读 80 字节并解析;读失败返回 Err,缺/坏记录返回 OtpStatus(不是错误:未写入是未写入板子的正常启动态) |
parse_record(bytes) |
纯解析(第 2 节判定);短于 80 字节的输入报 Corrupt(调用方 bug,不是擦除证据——擦除区也能读出 80 字节 0xFF) |
encode_record(serial, key) |
纯格式构造(返回有效记录字节,非法序列号返回 None);编不出硬件写:未来写后端调它取字节、再一次性编程 |
OtpIdentity |
序列号 + 设备私钥;私钥包在 Zeroizing 里,丢弃即清零;无 Clone(不复制秘密);Debug 手写,密钥位直接写 <redacted>,任何格式化路径都漏不出密钥 |
OtpStatus / OtpReadError |
三态结果 / 仅 Io 一种读失败 |
芯片读器在 app/src/main.rs(ChipOtpRead):对内存映射 OTP 区做易失读(read_volatile),
只读、无解锁、无编程。主机测试用 RAM 模拟走同一 trait(见第 7 节)。
决策逻辑在 app/src/identity.rs(纯逻辑、无硬件、可主机测试):
| 函数 | 规则 |
|---|---|
otp_written(status) |
仅有效记录算“已写入”;Corrupt 算未写入(门控跟它走,不是跟原始枚举走) |
key_injection_allowed(otp_written) |
仅未写入时允许 KEY/CLOUDKEY |
serial_locked(otp_written) |
已写入时序列号锁定 |
effective_serial(otp, injected) |
已写入时取 OTP 序列号(注入的序列号忽略),否则取注入的序列号,无则 None(无身份:MQTT 层不得连) |
app/src/main.rs 的 main 在启动时读一次 OTP(纯内存映射读,不依赖串口/网络),
结果一路传进两个任务(启动快照,运行中不变;OTP 写是一次性的,运行中不会从未写入变已写入;
写走固件工具 PROV,需重启生效):
读 OTP(一次)
├─ Provisioned → 串口打 "otp: provisioned serial=<序列号>"
│ serial_task(otp_written=true)
│ mqtt_task 播种:序列号 + 设备私钥 + otp_locked=true
│ (不置 has_identity:租户/口令不在 OTP 里,仍待串口 IDENTITY 供给;
│ 没租户/口令不自动连)
├─ NotWritten → 串口打 "otp: not written, mqtt will not connect on otp identity"
│ serial_task(otp_written=false),mqtt_task 不播种
├─ Corrupt → 串口打 "otp: corrupt, quarantine check needed, ..."
│ (同上不播种;日志记损坏,须隔离检查)
└─ 读失败 → 串口打 "otp: read failed, ..."
(同上不播种;日志与“未写入”分开)
播种细节:设备私钥从 OTP 记录移入 MQTT 配置(MqttConfig.device_key,Zeroizing 包裹),
播种后 OTP 记录本身被丢弃(其 Zeroizing 密钥随之清零),内存里只剩配置这一份。
序列号不是秘密,可进日志;密钥绝不进日志、回执、状态上报(状态上报本就不含密钥字段,
MqttStatus 无密钥字段,format_status_json 只含设备号/在线/版本)。
无 OTP 身份时:应用报“未写入”、不以 OTP 身份连,绝不拿测试口令悄悄连上。 要连,必须走测试串口注入(第 5 节,仅开发板)或等后续批次的客户端下发存 Flash 通道。
KEY/CLOUDKEY 保留,但只在未写入时可用,且两处门控(缺一不可):
| 门控点 | 已写入时 | 未写入时 |
|---|---|---|
串口侧(handle_serial_cmd,用户可见) |
KEY/CLOUDKEY 回 ERR otp written, key injection rejected,命令不进通道 |
原样接受,回 OK ... (TEST ONLY) |
| MQTT 任务侧(两处通道排空点,纵深防御) | KEY/CLOUDKEY 忽略并记 warn ... rejected (otp locked),万一有命令绕过串口门配置也不接受 |
原样应用,记 (TEST ONLY) |
IDENTITY 命令:已写入时序列号锁定——MQTT 任务只应用租户/口令,忽略命令里的序列号;
串口回执明说 OK identity set (serial locked to otp),避免用户以为序列号改成功了。
未写入时 IDENTITY 原样应用。MQTT HELP 文本已注明上述门控。
OTA 密钥门(firmware_update 前置检查)也随锁变:已写入且缺密钥时,
回执原因不再让人去用 KEY(那是条死路),而是明说
keys not set (key injection disabled on provisioned device);
未写入时仍提示测试注入。已写入的板子缺云端密钥是生产来源问题(见第 8 节),
不是串口注入能解决的。
生产镜像默认不接受串口密钥注入:生产设备都是已写入的,门控天然关闭; 未写入的板子只能是开发板(或待隔离的损坏板),注入日志与文档一律标测试用。
板上测试由用户跑(本批不宣称板上已验)。分三条路径:
前置:板子按 docs/board-h743.md 带起并确认 LAN/IP;EMQX 测试账号已建。
gateway-app: board up, USART1 OK, MQTT layer ready
otp: not written, mqtt will not connect on otp identitydocs/mqtt.md 第 8 节):
MQTT BROKER <EMQX 局域网 IP> 1883
MQTT IDENTITY <序列号> <租户> <测试口令>
MQTT KEY <设备私钥 64hex> # 仅测试
MQTT CLOUDKEY <云端公钥 64hex> # 仅测试
预期:每条回 OK(KEY/CLOUDKEY 带 (TEST ONLY));MQTT STATUS 显示 state=connected(配置齐自动连)。docs/mqtt.md 第 10 节。写 OTP 的板上接线(哪个二进制接 PROV 串口、调 provision.rs)是后续批次,
本批只做读;所以“已写入的板子”目前还造不出来,路径 B 的步骤先写清、等写接线落地后再跑:
gateway-app: board up, USART1 OK, MQTT layer ready
otp: provisioned serial=<写入的序列号>
且不用发 KEY 就有设备私钥(MQTT STATUS 不显示密钥,无法直接看;用第 2 步的连通验证)。MQTT BROKER <EMQX 局域网 IP> 1883
MQTT IDENTITY <任意序列号> <租户> <测试口令>
预期:IDENTITY 回 OK identity set (serial locked to otp)(序列号被忽略);
MQTT STATUS 与云端看到的 client_id/device_id 均为 OTP 序列号,不是命令里的序列号。MQTT KEY <64hex> 与 MQTT CLOUDKEY <64hex>,预期双双回
ERR otp written, key injection rejected,且 MQTT STATUS 状态不变。docs/mqtt.md 第 10 节,但跳过 KEY 步(设备私钥已取自 OTP);
云端密钥仍需生产来源(见第 8 节),否则 OTA 门回 keys not set (key injection disabled ...)。若串口出现 otp: corrupt, quarantine check needed, ...,该台隔离检查:
不以 OTP 身份连(与未写入同处理,测试注入仍可用、但只用于诊断),
不得重写 OTP(写一次即不可改),损坏原因查写侧记录。
# bootloader(含 OTP 记录格式/有效性/清零,RAM 模拟 OTP)
cd bootloader && cargo test
# app lib(含启动身份决策:已写入判定、注入门控、序列号锁定、密钥不进 Debug)
cd app && cargo test --lib --no-default-features --target x86_64-unknown-linux-gnu
覆盖(缺一不可):记录布局常量(80 字节、字段偏移无缝衔接、装得进 OTP 区);
编/解往返;擦除区/全零区/错 magic → NotWritten;错 format、序列号长度 0/33、
非打印序列号、非零填充、密钥字节篡改、校验字节篡改 → Corrupt;短输入 → Corrupt
(不是 NotWritten);读失败 → Err(不是状态);非法序列号编不出记录;
Debug 输出不含密钥十六进制与密钥字节十进制、但含序列号;Zeroizing 显式清零归零;
决策层:仅有效记录算已写入、Corrupt 算未写入、注入仅未写入时允许、序列号锁跟已写入走、
OTP 序列号优先于注入、无身份时 None、决策不碰密钥材料。
当前结果:bootloader 87 项(原 78 + OTP 记录 9)、app lib 49 项(原 42 + 启动身份决策 7),全绿;芯片 debug/release 零警告编过。
provision.rs;本批只做读,路径 B 暂不可跑)。CLOUDKEY 被拒收,生产云端密钥须另有来源(编译进/信任区,待定);
不影响 OTP 身份建连(建连不需云端密钥),只影响 OTA(OTA 门会拦)。docs/mqtt.md 第 12 节(与本批无关的共享缺口,不在此重复)。