网关 OTP 身份读取

仓库:https://github.com/ffffxxxjjj/gateway

应用启动时,序列号与设备私钥从芯片的一次性可编程存储区(OTP)读,不再默认走测试串口注入。 本文档是读侧的单份定义:记录格式、有效性判定、启动判定流程、测试注入门控、板上测试法。 写侧不变:写 OTP 仍走固件工具的 PROV 命令(见 docs/provisioning.md);provision.rs 不动。

1. 记录格式

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;应用侧只调接口,不自己算地址。

2. 有效性判定

按序检查:magic → format → 序列号长度/字节/填充 → 校验。结果三态:

结果 含义 连接判定
Provisioned 有效记录 用 OTP 身份(序列号 + 设备私钥),见第 4 节
NotWritten 擦除区(全 0xFF)、全零区、或 magic 不符:从未写入 无 OTP 身份,不以 OTP 身份连;测试注入可用(仅开发板)
Corrupt magic 对但 format、序列号或校验不过:写过但不是有效记录 与 NotWritten 完全一样处理(无 OTP 身份),但日志记“损坏”而非“未写入”

Corrupt 单独成态的原因:OTP 写一次即不可改,损坏的记录不能靠重写修复, 该台须隔离检查(与写侧 ERR VERIFY 的隔离思路一致)。读本身失败(越界/总线错) 是错误,不是状态:启动日志记“读失败”,连接判定上按“无 OTP 身份”处理。

3. 读取接口

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 层不得连)

4. 启动判定流程

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 通道。

5. 测试串口注入门控

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 节), 不是串口注入能解决的。

生产镜像默认不接受串口密钥注入:生产设备都是已写入的,门控天然关闭; 未写入的板子只能是开发板(或待隔离的损坏板),注入日志与文档一律标测试用。

6. 板上测试法

板上测试由用户跑(本批不宣称板上已验)。分三条路径:

路径 A:未写入的开发板(本批可直接跑)

前置:板子按 docs/board-h743.md 带起并确认 LAN/IP;EMQX 测试账号已建。

  1. 上电(或重启 app),串口应出现: gateway-app: board up, USART1 OK, MQTT layer ready otp: not written, mqtt will not connect on otp identity
  2. 串口依次发(见 docs/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(配置齐自动连)。
  3. 此后 OTA 流程按 docs/mqtt.md 第 10 节。

路径 B:已写入的板子(前置:写 OTP 的板上接线是后续批次)

写 OTP 的板上接线(哪个二进制接 PROV 串口、调 provision.rs)是后续批次, 本批只做读;所以“已写入的板子”目前还造不出来,路径 B 的步骤先写清、等写接线落地后再跑:

  1. 用固件工具对板子写过 OTP 后,重启 app,串口应出现: gateway-app: board up, USART1 OK, MQTT layer ready otp: provisioned serial=<写入的序列号> 且不用发 KEY 就有设备私钥(MQTT STATUS 不显示密钥,无法直接看;用第 2 步的连通验证)。
  2. 串口只发: MQTT BROKER <EMQX 局域网 IP> 1883 MQTT IDENTITY <任意序列号> <租户> <测试口令> 预期:IDENTITY 回 OK identity set (serial locked to otp)(序列号被忽略); MQTT STATUS 与云端看到的 client_id/device_id 均为 OTP 序列号,不是命令里的序列号。
  3. 再发 MQTT KEY <64hex> 与 MQTT CLOUDKEY <64hex>,预期双双回 ERR otp written, key injection rejected,且 MQTT STATUS 状态不变。
  4. 此后 OTA 流程按 docs/mqtt.md 第 10 节,但跳过 KEY 步(设备私钥已取自 OTP); 云端密钥仍需生产来源(见第 8 节),否则 OTA 门回 keys not set (key injection disabled ...)。

路径 C:损坏记录的板子(隔离检查,不连)

若串口出现 otp: corrupt, quarantine check needed, ...,该台隔离检查: 不以 OTP 身份连(与未写入同处理,测试注入仍可用、但只用于诊断), 不得重写 OTP(写一次即不可改),损坏原因查写侧记录。

7. 主机测试法

# 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 零警告编过。

8. 仍缺环节(未做,不要宣称端到端已通)