OTA2 校验流程
一个 OTA2 固件包从打包到写进设备,一共经过五道校验:构建期一道、App 侧两道、设备侧两道。同一组值被反复检查,不是冗余——每道的信任前提不同。
总览
竖线是参与方,自环箭头是本地校验。
三个关键认识
完整的 32 字节摘要不在包里。包头只带 productIdHash 和 partitionHash 各 4 字节。验签时用的完整 productDigest 与 partitionDigest,由验证方从自己的信任策略表取。所以一个包只在「验证方登记的产品与分区布局」下签名才成立——换个产品或换套 flash 布局,同一把密钥签出来的包也验不过。
OFFER 是未签名的。设备在 OFFER 阶段做的十项预检,依据全是 App 声称的值。所以验签通过之后,设备必须拿已签名的包头把 offer 里声称过的八个字段重新核一遍(headerMatchesOffer),否则预检等于白做。
App 的校验不是安全边界。App 侧那两道的作用是尽早失败、少传一次几百 KB,以及给用户可读的失败原因。真正的安全边界在设备端——设备不信任 App 的任何结论。
一 · 构建期
打包脚本。失败即构建中止,不产出包。
| 校验 | 比对什么 | 失败信息 |
|---|---|---|
| 分区摘要存在 | product.partitionDigestHex 非空 | has no frozen partition digest |
| 分区身份 | sha256(partition.csv) 同时等于产品登记值和 policy.partitionDigest | partition source does not match trust policy |
| 特权 flag | flags & ~policy.allowedPrivilegedFlags == 0 | does not authorize requested privileged flags |
| 签名密钥 | 私钥导出的公钥 == policy.publicKeySec1 | signing key does not match policy |
写进包头的值
| 字段 | 来源 |
|---|---|
productIdHash 4B | sha256(productId) 前 4 字节 |
partitionHash 4B | policy.partitionDigest 前 4 字节 |
keyId 1B | policy.keyId |
signatureScheme 1B | policy.signatureScheme |
payloadHash 32B / payloadSize | 载荷本身 |
hwRevMask 2B | 由目标硬件版本的永久位号算出 |
targetVersion / minFromVersion | 发版参数 |
flags 4B | 命令行请求的特权位,须在策略授权内 |
签名覆盖什么
签名对象是这串字节的 SHA-256,再用 P-256 私钥签:
域分隔符 ‖ productDigest[32] ‖ partitionDigest[32] ‖ 包头 ‖ 载荷
两个 32 字节摘要参与签名但不随包传输。这就是上面说的绑定机制。
二 · App 侧包校验
不需要连接设备,纯离线。任一步失败即抛错,包不进入后续流程。
| 顺序 | 校验 | 比对什么 |
|---|---|---|
| 1 | 最小长度 | 字节数 ≥ 包头 + 签名 + 1 |
| 2 | 包头结构 | flags 无保留位;payloadSize 在 1..上限;payloadHash 32B;productIdHash 4B;partitionHash 4B;hwRevMask 在 1..0xFFFF;signatureScheme 与契约一致;keyId 在 1..255 |
| 3 | 载荷完整性 | sha256(载荷) == 包头的 payloadHash |
| 4 | 产品已登记 | 用 productIdHash 在本地产品目录查得到;查不到即拒 |
| 5 | 取信任策略 | 正式模式只接受 production 策略;产品没有策略即拒 |
| 6 | 策略匹配 | policy.keyId == 包头 keyId,且签名方案一致 |
| 7 | 特权 flag | flags & ~policy.allowedPrivilegedFlags == 0 |
| 8 | 摘要提示一致 | policy.productDigest 前 4 字节 == 包头 productIdHash;policy.partitionDigest 前 4 字节 == 包头 partitionHash |
| 9 | 验签 | P-256 验签,公钥用 policy.publicKeySec1,两个 32 字节摘要取自策略 |
| 10 | 身份指纹 | 算整包 packageHash 与 identity fingerprint,供后续流程引用 |
三 · App 预检
需要设备信息。七项检查放在一张清单里,任一项不过就抛错,不会发起 OFFER。
| 检查项 | 比对什么 |
|---|---|
artifact-signature | 上一步已验,此处恒真,只为在报告里留一行 |
trust-policy | 包头 keyId == policy.keyId |
product | 设备自报的产品哈希 == 包头 productIdHash,且该产品在发版支持列表内 |
partition | 产品目录的 partitionHashHex == 包头 partitionHash |
hardware | 设置了 SKIP_HARDWARE_CHECK,或包头 hwRevMask 含设备硬件版本对应的位 |
minimum-version | minFromVersion 为全零,或设备当前版本 ≥ 它 |
target-version | 设置了 ALLOW_DOWNGRADE,或 targetVersion > 设备当前版本 |
capabilities | 设备能力集满足要求 |
四 · 设备 OFFER 预检
设备收到未签名的 OFFER_V4。十项检查按顺序走,第一个不过就返回错误码与恢复动作。
| 检查 | 比对什么 | 错误码 | 恢复 |
|---|---|---|---|
| 包大小 | packageSize ≤ 设备可暂存上限 | 0x12 PAYLOAD_TOO_LARGE | STOP |
| 产品 | offer.productIdHash == 设备策略 productDigest 前 4 字节 | 0x13 PRODUCT_MISMATCH | STOP |
| 分区 | offer.partitionHash == 设备策略 partitionDigest 前 4 字节 | 0x14 PARTITION_MISMATCH | STOP |
| 签名方案 | 与设备策略一致 | 0x19 SIGNATURE_SCHEME_UNSUPPORTED | STOP |
| 密钥编号 | offer.keyId == policy.keyId | 0x1A KEY_ID_UNKNOWN | STOP |
| 特权 flag | 全部在 allowedPrivilegedFlags 内 | 0x1C PRIVILEGED_FLAG_NOT_AUTHORIZED | STOP |
| 最低来源版本 | minFromVersion 为零,或当前版本 ≥ 它 | 0x15 MIN_FROM_NOT_MET | STOP |
| 目标版本 | 允许降级,或 targetVersion > 当前版本 | 0x10 VERSION_TOO_OLD | STOP |
| 硬件版本 | 跳过硬件检查,或 hwRevMask 含本机位号 | 0x11 HARDWARE_MISMATCH | STOP |
| 数据帧协商 | 能协商出可用的帧长 | 0x1B DATA_FRAME_SIZE_UNSUPPORTED | STOP |
这十项全部返回 STOP——它们描述的是「这个包和这台设备根本不匹配」,重试没有意义。
五 · 传输期
逐帧写入。这一层的错误大多可恢复,恢复动作是查询后续传而不是终止。
| 检查 | 错误码 | 恢复 |
|---|---|---|
| 帧 CRC | 0x21 CRC_FAIL | QUERY_AND_RESUME |
| 序号断档 | 0x26 SEQ_GAP | QUERY_AND_RESUME |
| 序号重复 | 0x25 SEQ_DUPLICATE | QUERY_AND_RESUME |
| 帧格式 / 长度 | 0x23 DATA_FRAME_MALFORMED · 0x24 DATA_LENGTH_INVALID | QUERY_AND_RESUME |
| 流控违规 | 0x29 FLOW_CONTROL_VIOLATION | QUERY_AND_RESUME |
| 收满包头长度时,包头与 offer 不符 | 0x17 PACKAGE_FORMAT_INVALID | STOP |
| 传输期间信任策略变更 | 0x1D TRUST_POLICY_CHANGED | STOP |
| 写 flash 失败 | 0x27 FLASH_WRITE_FAILED | STOP |
注意第 6 行:设备一旦收够包头那么多字节,不等包收完就立刻比对包头与 offer。这样一个声称与实物不符的传输在开头就被砍掉,不用白传几百 KB。
六 · 设备终检
包收全之后。这是唯一真正决定能不能刷的地方,顺序有讲究。
| 顺序 | 校验 | 比对什么 | 失败 |
|---|---|---|---|
| 1 | 整包哈希 | sha256(收到的全部字节) == offer.packageHash | 0x30 HASH_MISMATCH |
| 2 | 载荷哈希 | 载荷部分的哈希与包头声明一致 | 0x30 HASH_MISMATCH |
| 3 | 策略未变 | 当前信任策略仍与传输开始时一致 | 0x1D TRUST_POLICY_CHANGED |
| 4 | 特权 flag | 仍在 allowedPrivilegedFlags 内 | 0x1C PRIVILEGED_FLAG_NOT_AUTHORIZED |
| 5 | 结构解码 | 能解出合法的 OTA2 结构 | 0x31 SIGNATURE_INVALID |
| 6 | 验签 | P-256 验签,公钥与两个 32 字节摘要全部取自设备自己的信任策略 | 0x31 SIGNATURE_INVALID |
| 7 | 复核 offer | 用已签名的包头逐一比对 offer 声称的八个字段 | 0x13 PRODUCT_MISMATCH |
| 8 | 版本与硬件复核 | 用签名包头里的值重算 minFrom / target / hwRevMask | 0x15 · 0x10 · 0x11 |
| 9 | 平台镜像校验 | 芯片平台自己的镜像合法性检查 | 0x17 PACKAGE_FORMAT_INVALID |
第 7 步复核的八个字段
flags · hwRevMask · keyId · minFromVersion · partitionHash · productIdHash · signatureScheme · targetVersion
全部相等才继续。这一步存在的唯一理由是:OFFER 不受签名保护,而第 4 阶段的十项预检全部建立在 OFFER 之上。
为什么同一组值要查这么多遍
以 partitionHash 为例,它一共被比了四次:App 包校验时比策略摘要前缀、App 预检时比产品目录、设备 OFFER 预检时比设备策略、设备终检时比已签名的包头。
四次的信任前提各不相同。前两次信任 App 自己的本地数据,目的是尽早失败;第三次信任的是 App 送来的声称值,目的是在传输前砍掉明显不匹配的请求;只有第四次,比对的两端都在签名保护之下,才构成真正的安全判断。
值从哪来
| 值 | 打包脚本 | App | 设备 |
|---|---|---|---|
| productDigest 32B | 由 productId 算出 | 本地信任策略表 | 编译进固件的策略 |
| partitionDigest 32B | 分区表文件的 sha256 | 本地信任策略表 | 编译进固件的策略 |
| 公钥 | 由签名私钥导出并与策略核对 | 本地信任策略表 | 编译进固件的策略 |
| keyId | 策略登记值,写进包头 | 与策略比对 | 与策略比对 |
| hwRevMask | 由目标硬件版本位号算出 | 与设备自报版本比对 | 与本机位号比对 |
| 当前版本 | — | 从设备读 | 自己持有 |
三方的完整摘要都不来自固件包,而来自各自持有的一份信任策略。三份必须一致,这个校验体系才成立;任何一份漂移,结果都是升级被拒而不是被放行。