一到五全部通过才算本地通过。任一步失败即停,贴整段命令输出与日志,不要只贴结论。
| 项目 | 内容 |
|---|---|
| 适用 | sign 仓,本地(Mac)测试,不依赖持续集成、不上板 |
| sign 提交 | main @ 13843ad3 |
| gateway 提交 | main @ 1b5226bf(bootloader 主机测试,见第四节) |
| 格式 | v3:共用包,包头 312 字节、格式 3,格式 2 直接拒绝;签名范围包头前 248 字节 + 固件明文;身份字段:型号、固件名、版本、构建日期、构建时间,日期/时间 ASCII YYYYMMDD/HHMMSS,UTC;版本块仍是格式 2(偏移 0x1000,228 字节),与包头格式不混淆 |
| 包模型 | 一次打包出共用包(所有设备共用);包密钥每版本随机新生成,包密钥包每设备单独包(80 字节:临时公钥 32 + 包装后包密钥 48),设备用私钥解出包密钥再解固件、验签 |
| 命令行 | pack --bin --sign-key(只签不分发设备密钥);wrap-key --package-key --encrypt-key --out(出 80 字节包密钥包,--out 必填);verify/unpack/info --verify-key --decrypt-key --key-package(三者齐全);unpack 默认输出为包头生成名加 -out.bin,verify 纯验证、不写文件 |
| 参数名 | --sign-key(-s)签名私钥种子,pack 签名用;--encrypt-key(-e)接收方设备加密公钥,只给 wrap-key 用,pack 传了直接拒绝;--package-key 包密钥(32 字节),wrap-key 用;--key-package 包密钥包文件(80 字节),verify/unpack/info 用;--decrypt-key(-d)接收方解密私钥,verify/unpack/info 用;--verify-key(-v)签名公钥,verify/unpack/info 用 |
| gen-keys 标签 | sign key (seed, private key)、verify key (public key)、decrypt key (private key)、encrypt key (public key),四行值对齐到同一列 |
| pack 包名 | {固件名}-{型号}-release-v{版本}-{构建日期}-{构建时间}.pkg(文件名里的型号是完整订货型号,如 stm32h743vit6,由块里家族前缀 stm32h743vi 映射;日期与时间取块内构建日期/时间;包落在输入 bin 旁边,不取输入文件名;块里的型号字段为家族前缀 stm32h743vi,设备比对以块为准) |
| 最新实测 | 2026-11-02:工具库单测 27 项+服务端单测 26 项+命令行单测 3 项全过;网关 bootloader 主机测试 25 项全过;本地端到端 21 项全过;CI 全绿后已改回私有 |
| 测试密钥 | 密钥由测试人手动生成(gen-keys),本文档不提供现成的密钥值;量产种子必须在用的那台机器上本地生成,绝不经聊天发送 |
每一步都有明确的通过标准,任一步失败即停,先定位再往下走。测试一律从源码编:无 release,没有可下载的包。
测试原则:全程按生产环境走——密钥由你手动生成(gen-keys),本文档不提供现成的密钥值;固件用随机字节;预期结果是前后一致性(推导对得上、验证后逐字节一致、错密钥拒收),不是固定值。用固定参考向量还是自己生成的密钥,由你定。
相关仓库:sign https://github.com/ffffxxxjjj/sign ;gateway https://github.com/ffffxxxjjj/gateway ;firmware-tool https://github.com/ffffxxxjjj/firmware-tool 。包格式权威定义见 sign 仓 docs/package-format.md;服务端端点见 sign 仓 docs/sign-server-api.md;固件 provisioning 见 gateway 仓 docs/provisioning.md。
逐行跑(行内代码点一下就复制):
git clone https://github.com/ffffxxxjjj/signcd signcargo build --release --workspace注意:仓已改回私有,克隆需有权限;不要在 /tmp 里编——/tmp 会被自动清理,编好的二进制可能丢,验证工作目录放持久盘。
产物都在仓根 target/release/:sign-tool(命令行)、sign-server(服务)、libsign_tool.dylib(Mac 原生库)。虚拟根下裸编 cargo build --release 也会编出两个成员,--workspace 写法最稳。开发环境(工具与版本)见 docs/development-environment.md。
先在仓库根目录编出工具(见“准备”),再跑下面命令。密钥由你手动生成(第 0 步),本文档不提供现成值。
# 第 0 步:手动生成本轮密钥(你自己跑 gen-keys,生成一次)
target/release/sign-tool gen-keys
# 记下:云端种子、云端公钥、设备 A 解密私钥、设备 A 加密公钥
# 再跑一次 gen-keys,只取其中的设备对作设备 B(另一台设备)
target/release/sign-tool gen-keys
# 记下:设备 B 私钥、设备 B 公钥
# 版本号只在这里填一次,造 bin 时填进版本块;pack 从 bin 块读,不用再传;
# 预期输出里的 1.2.3 就是 VERSION 的值
VERSION=1.2.3
# 造带格式 2 版本块的测试固件(4324 字节,版本块在 0x1000,型号 stm32h743vi、固件名 gateway、版本取 $VERSION、构建日期与构建时间取当前 UTC 时间)
python3 - "$VERSION" <<'EOF'
import os, struct, sys
from datetime import datetime, timezone
major, minor, patch = (int(x) for x in sys.argv[1].split('.'))
now = datetime.now(timezone.utc)
body = bytearray(os.urandom(0x1000 + 228))
blk = bytearray(228)
blk[0:4] = b'FWVR'
blk[4] = 2
blk[5:5+11] = b'stm32h743vi'
blk[105:105+7] = b'gateway'
struct.pack_into('<HHH', blk, 205, major, minor, patch)
blk[211:219] = now.strftime('%Y%m%d').encode()
blk[219:225] = now.strftime('%H%M%S').encode()
body[0x1000:0x1000+228] = blk
open('test-fw.bin', 'wb').write(body)
EOF
下面用占位符表示本轮生成的值:<签名私钥种子>、<签名公钥>、<解密私钥A>、<设备加密公钥A>、<解密私钥B>、<包密钥>(pack 输出的包密钥行)、<包密钥包A>(wrap-key 输出的 80 字节文件)。
# 打包:签名 + 加密一次,出共用包;pack 只传 --sign-key,不传设备密钥(传 --encrypt-key 直接拒绝,见 T1-5)
# 包名永远从块信息生成(版本从 bin 版本块读,块里就是上面的 $VERSION;包落在输入 bin 旁边)
target/release/sign-tool pack --bin test-fw.bin \
--sign-key <签名私钥种子>
# 生成的包名记下来,后面各步都用它(日期与时间是造 bin 的当时 UTC 日期/时间)
PKG=$(ls gateway-stm32h743vit6-release-v*.pkg)
# pack 输出三行:packed 4324 bytes of firmware (v1.2.3, model stm32h743vi, firmware gateway) into 4652 bytes: <包名>
# header 312 bytes + body 4340 bytes (ciphertext + 16-byte tag),即包头 312 +固件 4324 +标签 16
# package key (keep secret; wrap it per device with wrap-key): <64 位十六进制包密钥>
# 注意括号里打印的型号是块里的家族前缀 stm32h743vi,文件名里拼的是完整订货型号 stm32h743vit6,两者是同一颗芯片
# 给设备 A 包包密钥:包密钥包 80 字节(临时公钥 32 +包装后包密钥 48),--out 必填(包密钥包没有可推导名)
target/release/sign-tool wrap-key --package-key <包密钥> \
--encrypt-key <设备加密公钥A> --out keypkg-a.bin
# 应打印 wrapped package key (80 bytes) written to keypkg-a.bin
# 解包:先解包密钥、再解密、再验签,全过才写出明文(默认输出为包头生成名加 -out.bin,即 ${PKG%.pkg}-out.bin,在包旁边)
target/release/sign-tool unpack --pkg "$PKG" \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 应打印 unpacked OK: firmware v1.2.3 (4324 bytes) written to gateway-stm32h743vit6-release-v1.2.3-<造 bin 的当时 UTC 日期>-<当时 UTC 时间>-out.bin
# sign-tool cmp:逐字节比对原固件与解包明文是否一致;一致打印
# PASS: identical(字节数)并退出 0,不一致报第一个不同字节并退出 1。内建命令,
# 不依赖系统 cmp,Windows 上也一样跑。
target/release/sign-tool cmp test-fw.bin "${PKG%.pkg}-out.bin"
# 应打印 PASS: identical (4324 bytes)
只验证不写文件用 verify(见 T1-5)。
分两种位置,报错不同,都必须退出码 1 且 unpack 不写文件。verify/unpack 都带 --key-package keypkg-a.bin(T1-1 的包密钥包):
# 包头区(第 200 字节落在固件名字段):身份校验先挂
python3 -c "
d = bytearray(open('$PKG','rb').read()); d[200] ^= 0xFF; open('test-fw-bad.pkg','wb').write(d)"
target/release/sign-tool verify --pkg test-fw-bad.pkg \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 先清掉 T1-1 的解包输出,避免旧文件干扰“不生成”的判断
rm -f "${PKG%.pkg}-out.bin"
target/release/sign-tool unpack --pkg test-fw-bad.pkg \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 预期:退出码均为 1,报错 error: firmware name has non-NUL bytes after the terminator,且不生成 ${PKG%.pkg}-out.bin
# 包体区(第 1000 字节在包体):解密校验挂
python3 -c "
d = bytearray(open('$PKG','rb').read()); d[1000] ^= 0xFF; open('test-fw-bad2.pkg','wb').write(d)"
target/release/sign-tool verify --pkg test-fw-bad2.pkg \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
target/release/sign-tool unpack --pkg test-fw-bad2.pkg \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 预期:退出码均为 1,报错 error: body decryption failed: body tampered, or wrong package key,且不生成 ${PKG%.pkg}-out.bin
生产关键:设备 A 的包密钥包,设备 B 的私钥解不开。
target/release/sign-tool verify --pkg "$PKG" --verify-key <签名公钥> --decrypt-key <解密私钥B> --key-package keypkg-a.bintarget/release/sign-tool unpack --pkg "$PKG" --verify-key <签名公钥> --decrypt-key <解密私钥B> --key-package keypkg-a.bin预期:两条命令退出码均为 1,报错 error: failed to unwrap package key: wrong decrypt key, or key package tampered,且不生成 ${PKG%.pkg}-out.bin。
注意:不要用“改末字节”当错密钥测试——X25519 钳制位会让末字节改动无效,测不出失败;要用另一台设备的解密私钥,或改中间字节。
target/release/sign-tool derive-pub --kind ed25519 --input <签名私钥种子>target/release/sign-tool derive-pub --kind x25519 --input <解密私钥A>预期:两次输出的 hex 值分别与 gen-keys 给出的签名公钥、设备 A 加密公钥逐字一致;注意输出带标签前缀(verify key: <hex>、encrypt key: <hex>),比对时比 hex 值,不要拿整行比;命令不回显私钥。
包名永远从块信息生成,格式 {固件名}-{型号}-release-v{版本}-{构建日期}-{构建时间}.pkg(文件名里的型号是完整订货型号,如 stm32h743vit6,由块里家族前缀映射;日期与时间取块内构建日期/时间,不是打包时刻;包落在输入 bin 旁边,不取输入文件名)。
# 用一份拷贝测包名生成,避免覆盖原固件;注意 t15.bin 与 test-fw.bin 是同一块(同一构建日期/时间),
# 所以包名与 T1-1 的相同、会覆盖同名包文件(每包包密钥现生,包字节不同,但解包明文相同,属正常现象)
cp test-fw.bin t15.bin
target/release/sign-tool pack --bin t15.bin \
--sign-key <签名私钥种子>
# 应生成 gateway-stm32h743vit6-release-v1.2.3-<造 bin 的当时 UTC 日期>-<当时 UTC 时间>.pkg(在 t15.bin 旁边),不是 t15.pkg
# 默认输出为包头生成名加 -out.bin(在包旁边),即 ${PKG15%.pkg}-out.bin
PKG15=$(ls gateway-stm32h743vit6-release-v*.pkg)
target/release/sign-tool unpack --pkg "$PKG15" \
--verify-key <签名公钥> --decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 应生成 ${PKG15%.pkg}-out.bin,且与 test-fw.bin 逐字节一致
target/release/sign-tool cmp test-fw.bin "${PKG15%.pkg}-out.bin"
# verify 只给结论,不写文件
target/release/sign-tool verify --pkg "$PKG" \
--verify-key <签名公钥> --decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 应打印 verified OK: firmware v1.2.3 (4324 bytes),不生成任何文件
# pack 传设备密钥直接拒绝(不是静默忽略)
target/release/sign-tool pack --bin test-fw.bin \
--sign-key <签名私钥种子> --encrypt-key <设备加密公钥A>
# 应退出码 1,报错 pack takes no --encrypt-key: pack produces a shared package for all devices; wrap its package key per device with wrap-key
# pack 传 --out 同样拒绝(包名永远从块生成)
target/release/sign-tool pack --bin test-fw.bin \
--sign-key <签名私钥种子> --out custom.pkg
# 应退出码 1,报错 pack takes no --out: the package filename always comes from the firmware version block
# verify 传 --out 同样拒绝(verify 不写文件)
target/release/sign-tool verify --pkg "$PKG" \
--verify-key <签名公钥> --decrypt-key <解密私钥A> \
--key-package keypkg-a.bin --out custom.bin
# 应退出码 1,报错 verify writes no file; use unpack to write the decrypted firmware
# unpack 传 --out 与 --expect 同样拒绝(输出名永远从包头生成;比对用 cmp)
target/release/sign-tool unpack --pkg "$PKG" \
--verify-key <签名公钥> --decrypt-key <解密私钥A> \
--key-package keypkg-a.bin --out custom.bin
# 应退出码 1,报错 unpack takes no --out: the output filename always comes from the package header
target/release/sign-tool unpack --pkg "$PKG" --verify-key <签名公钥> --decrypt-key <解密私钥A> --key-package keypkg-a.bin:应通过,写出 ${PKG%.pkg}-out.bin。target/release/sign-tool cmp "${PKG%.pkg}-out.bin" test-fw.bin:应打印 PASS: identical (4324 bytes)。# bin 信息:型号、固件名、版本、构建日期、构建时间来自版本块(日期与时间是造 bin 时写进块里的 UTC 日期/时间,YYYYMMDD/HHMMSS)
target/release/sign-tool info --bin test-fw.bin
# 应打印:size: 4324 bytes、model: stm32h743vi、firmware name: gateway、version: v1.2.3、
# version block: offset 0x1000 (228 bytes)、build date: 造 bin 的当时 UTC 日期、build time: 造 bin 的当时 UTC 时间
# pkg 信息(不用密钥):型号、固件名、版本、构建日期、构建时间、长度、标志都来自 v3 包头,不用解密
target/release/sign-tool info --pkg "$PKG"
# 应打印:format: GWPK v3、model: stm32h743vi、firmware name: gateway、firmware version: v1.2.3、
# build date / build time(造 bin 的当时 UTC 日期/时间)、firmware length: 4324 bytes、
# flags: 0x3 (encrypted, signed)、header: 312 bytes、body: 4340 bytes (ciphertext + 16-byte tag)、total: 4652 bytes
# pkg 信息(给齐三个密钥参数):解密后读内嵌版本块,型号、固件名、版本应与包头一致
target/release/sign-tool info --pkg "$PKG" \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 应在上面各项后追加 embedded firmware version block 一节:model / firmware name / version 三项带 (matches header),
# build date / build time 与包头相同(不带 matches header 字样)
包头解析按固定顺序校验:长度 → 魔数 → 格式版本 → 标志 → 型号 → 固件名 → 构建日期/时间。以下全部用正确的云端公钥与设备 A 解密私钥和 T1-1 的包密钥包,只改包、不改密钥,这样报错只可能来自篡改本身。每条从 $PKG 出发写出同一个 test-fw-t8.pkg,逐行覆盖重跑即可;每条跑完都跑一次 verify;预期均为退出码 1;unpack 同文件同报错且不写文件。8a/8b 直接跑 shell 命令,不套模板;8c 起把表里的改动填进模板。
# 8a 文件过短(100 字节,够读魔数与格式、但不够一个包头):跑完跑一次 verify(下面模板块里那条),预期 package too short: 100 bytes, v3 header alone is 312 bytes
head -c 100 "$PKG" > test-fw-t8.pkg
# 8b 空文件(不够 8 字节,连格式都读不出):同上,预期 package too short: 0 bytes, header alone is 312 bytes
: > test-fw-t8.pkg
# 篡改模板:<改动> 由下表给出
python3 -c "import struct; d = bytearray(open('$PKG','rb').read()); <改动>; open('test-fw-t8.pkg','wb').write(d)"
target/release/sign-tool verify --pkg test-fw-t8.pkg \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
| 用例 | 改动 | 预期报错 |
|---|---|---|
| T1-8c 坏魔数 | d[0:4] = b'XXXX' |
bad magic: package does not start with GWPK |
| T1-8d 格式版本 1 | struct.pack_into('<I', d, 4, 1) |
unsupported format version: 1 (expected 3) |
| T1-8d2 格式版本 2 | struct.pack_into('<I', d, 4, 2) |
unsupported format version: 2 (expected 3)(格式 2 直接拒绝,没有兼容路径) |
| T1-8e 标志缺加密 | struct.pack_into('<I', d, 232, 2) |
unsupported flags: 0x00000002 (packages require encrypted + signed, no other bits) |
| T1-8f 标志缺签名 | struct.pack_into('<I', d, 232, 1) |
unsupported flags: 0x00000001 (packages require encrypted + signed, no other bits) |
| T1-8g 标志多余位 | struct.pack_into('<I', d, 232, 7) |
unsupported flags: 0x00000007 (packages require encrypted + signed, no other bits) |
| T1-8h 型号填充坏 | d[50] ^= 0xFF(型号字段 [8..108] 的填充区) |
model has non-NUL bytes after the terminator |
| T1-8i 型号全零 | d[8:108] = b'\x00'*100 |
model is empty (field is all zeros) |
| T1-8j 型号非 ASCII | d[8] = 0xFF |
model is not ASCII |
| T1-8k 固件名全零 | d[108:208] = b'\x00'*100 |
firmware name is empty (field is all zeros) |
| T1-8l 固件名非 ASCII | d[108] = 0xFF |
firmware name is not ASCII |
| T1-8m 日期非数字 | d[214] = ord('A') |
build date/time must be ASCII digits |
| T1-8n 月份越界 | d[218:220] = b'13' |
build date month out of range: 13 |
| T1-8o 日期越界 | d[220:222] = b'32' |
build date day out of range: 32 |
| T1-8p 小时越界 | d[222:224] = b'24' |
build time hour out of range: 24 |
| T1-8q 分钟越界 | d[224:226] = b'60' |
build time minute out of range: 60 |
| T1-8r 秒越界 | d[226:228] = b'60' |
build time second out of range: 60 |
固件名填充坏已在 T1-2(第 200 字节)覆盖,不重复。注意校验顺序:同一包改两处时,先列出的检查先报;本节每行只改一处,报错唯一。
verify 的链路顺序:包头解析 → 体长 → 解包密钥 → 解密包体 → 验签名。前面挂了就看不到后面的错;T1-9 每行只改一处,报错唯一。篡改模板与 T1-8 相同,输出 test-fw-t9.pkg,密钥用正确的云端公钥与设备 A 解密私钥和 T1-1 的包密钥包。9a~9i 把表里的改动填进模板;9j/9k/9l 不套模板,见表后三条;每条跑完都跑一次 verify;预期均为退出码 1。
# 篡改模板:<改动> 由下表给出
python3 -c "import struct; d = bytearray(open('$PKG','rb').read()); <改动>; open('test-fw-t9.pkg','wb').write(d)"
target/release/sign-tool verify --pkg test-fw-t9.pkg \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
| 用例 | 改动 | 预期报错 |
|---|---|---|
| T1-9a 长度字段改大 | struct.pack_into('<I', d, 228, 4325) |
body length mismatch: body is 4340 bytes, expected plaintext 4325 + tag 16 |
| T1-9b 包密钥包临时公钥改 | 改 keypkg-a.bin 第 10 字节(临时公钥 [0..32]) | failed to unwrap package key: wrong decrypt key, or key package tampered |
| T1-9c 包密钥包包装密钥改 | 改 keypkg-a.bin 第 40 字节(包装后包密钥 [32..80]) | failed to unwrap package key: wrong decrypt key, or key package tampered |
| T1-9d 体随机数改 | d[240] ^= 0xFF(体随机数 [236..248]) |
body decryption failed: body tampered, or wrong package key |
| T1-9e 签名区改 | d[260] ^= 0xFF(签名 [248..312]) |
signature verification failed: wrong verify key, or header/firmware tampered |
| T1-9f 版本改值 | struct.pack_into('<H', d, 208, 2)(主版本 1→2,格式仍合法) |
signature verification failed: wrong verify key, or header/firmware tampered |
| T1-9g 型号改值 | d[8] = ord('t')(stm32h743vi→tm32h743vi,格式仍合法) |
signature verification failed: wrong verify key, or header/firmware tampered |
| T1-9h 固件名改值 | d[108] = ord('h')(gateway→hateway,格式仍合法) |
signature verification failed: wrong verify key, or header/firmware tampered |
| T1-9i 日期改值 | d[220:222] = b'02'(日期合法数字,仍在范围内) |
signature verification failed: wrong verify key, or header/firmware tampered |
# 9j 错云端公钥:不改包,verify 传第二次 gen-keys 的云端公钥
target/release/sign-tool verify --pkg "$PKG" \
--verify-key <第二次 gen-keys 的云端公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 应退出码 1,报错 signature verification failed: wrong verify key, or header/firmware tampered
# 9k 包体截断:不套模板
head -c 1000 "$PKG" > test-fw-t9.pkg
target/release/sign-tool verify --pkg test-fw-t9.pkg \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 应退出码 1,报错 body length mismatch: body is 688 bytes, expected plaintext 4324 + tag 16
# 9l 包体后追加:不套模板,整条 python 命令直接跑
python3 -c "open('test-fw-t9.pkg','wb').write(open('$PKG','rb').read() + __import__('os').urandom(16))"
target/release/sign-tool verify --pkg test-fw-t9.pkg \
--verify-key <签名公钥> \
--decrypt-key <解密私钥A> \
--key-package keypkg-a.bin
# 应退出码 1,报错 body length mismatch: body is 4356 bytes, expected plaintext 4324 + tag 16
T1-9b/9c 与 T1-3 同一句报错,触发不同:T1-3 是解密私钥不对,T1-9b/9c 是包密钥包里的密钥材料被改。T1-9d 与 T1-2 包体区同一句,触发不同:T1-2 改的是密文本身,T1-9d 改的是体随机数。
版本块在 bin 的 0x1000 处:魔数 FWVR(0x1000..0x1004)、格式字节(0x1004)、型号(0x1005..0x1069)、固件名(0x1069..0x10CD)、版本(0x10CD..0x10D3)、日期(0x10D3..0x10DB)、时间(0x10DB..0x10E1)、保留(0x10E1..0x10E4)。
# 10a:bin 无版本块(全零 bin 不可能撞上 FWVR)
head -c 4324 /dev/zero > noblock.bin
target/release/sign-tool pack --bin noblock.bin \
--sign-key <签名私钥种子>
# 应退出码 1,报错 firmware has no version block at bin offset 0x1000: build firmware with the version block,不生成包
# 10b:版本块格式不是 2
python3 -c "d = bytearray(open('test-fw.bin','rb').read()); d[0x1004] = 1; open('badfmt.bin','wb').write(d)"
target/release/sign-tool pack --bin badfmt.bin \
--sign-key <签名私钥种子>
# 应退出码 1,报错 unsupported version block format: 1 (expected 2),不生成包
# 10c:版本块截断(只有魔数与格式,缺后半段)
head -c 4196 test-fw.bin > shortblk.bin
target/release/sign-tool pack --bin shortblk.bin \
--sign-key <签名私钥种子>
# 应退出码 1,报错 version block truncated: 4196 bytes, need 4324 bytes,不生成包
# 10d:只有魔数、缺格式字节
head -c 4100 test-fw.bin > magicblk.bin
target/release/sign-tool pack --bin magicblk.bin \
--sign-key <签名私钥种子>
# 应退出码 1,报错 version block truncated: magic present but format byte missing,不生成包
# 10e:版本块保留字节非零
python3 -c "d = bytearray(open('test-fw.bin','rb').read()); d[0x10E1] = 1; open('resvblk.bin','wb').write(d)"
target/release/sign-tool pack --bin resvblk.bin \
--sign-key <签名私钥种子>
# 应退出码 1,报错 version block reserved bytes must be zero,不生成包
# 10f:版本块固件名填充坏(走 pack 路径,文案与 T1-2 相同)
python3 -c "d = bytearray(open('test-fw.bin','rb').read()); d[0x1000+150] ^= 0xFF; open('badnameblk.bin','wb').write(d)"
target/release/sign-tool pack --bin badnameblk.bin \
--sign-key <签名私钥种子>
# 应退出码 1,报错 firmware name has non-NUL bytes after the terminator,不生成包
# 10g:版本块日期月份越界(走 pack 路径)
python3 -c "d = bytearray(open('test-fw.bin','rb').read()); d[0x10D7:0x10D9] = b'13'; open('badmonblk.bin','wb').write(d)"
target/release/sign-tool pack --bin badmonblk.bin \
--sign-key <签名私钥种子>
# 应退出码 1,报错 build date month out of range: 13,不生成包
# 10h:info --bin 无版本块(文案比 pack 的短)
target/release/sign-tool info --bin noblock.bin
# 应退出码 1,报错 firmware has no version block at bin offset 0x1000
# 10i:info --pkg 只给一个密钥(三个密钥参数必须齐全或全不给)
target/release/sign-tool info --pkg "$PKG" --verify-key <签名公钥>
# 应退出码 1,报错 info --pkg needs --verify-key, --decrypt-key, and --key-package together to decrypt
# 10j:info 既不给 --bin 也不给 --pkg(两者都给同样报错)
target/release/sign-tool info
# 应退出码 1,报错 info takes exactly one of --bin <bin> or --pkg <pkg>
版本块内型号/固件名/日期的其余格式错(全零、非 ASCII、分钟秒越界等)与 T1-8 同文案,走 pack 路径触发,不逐条重复。
verify/unpack 命令里的 --key-package keypkg-a.bin 不可省略(缺了先报缺参数,不是验包失败):
# 11a:缺必需参数(缺 --decrypt-key)
target/release/sign-tool verify --pkg "$PKG" --verify-key <签名公钥> --key-package keypkg-a.bin
# 应退出码 1,报错 missing required flag --decrypt-key
# 11a2:缺 --key-package
target/release/sign-tool verify --pkg "$PKG" --verify-key <签名公钥> --decrypt-key <解密私钥A>
# 应退出码 1,报错 missing required flag --key-package
# 11b:参数无值(下一个 token 又是参数)
target/release/sign-tool verify --pkg --verify-key <签名公钥> --decrypt-key <解密私钥A> --key-package keypkg-a.bin
# 应退出码 1,报错 missing value for --pkg
# 11c:裸参数(不是 --flag 形式)
target/release/sign-tool verify foo --pkg "$PKG" --verify-key <签名公钥> --decrypt-key <解密私钥A> --key-package keypkg-a.bin
# 应退出码 1,报错 unexpected argument 'foo': expected --flag value
# 11d:重复参数
target/release/sign-tool verify --pkg "$PKG" --pkg "$PKG" --verify-key <签名公钥> --decrypt-key <解密私钥A> --key-package keypkg-a.bin
# 应退出码 1,报错 duplicate flag --pkg
# 11e:未知短参数
target/release/sign-tool verify -x --pkg "$PKG" --verify-key <签名公钥> --decrypt-key <解密私钥A> --key-package keypkg-a.bin
# 应退出码 1,报错 unexpected argument '-x': expected --flag value
# 11f:未知子命令(裸跑 sign-tool 同样报错)
target/release/sign-tool frobnicate
# 应退出码 1,报错 missing or unknown subcommand
# 11g:密钥非十六进制
target/release/sign-tool verify --pkg "$PKG" --verify-key zz --decrypt-key <解密私钥A> --key-package keypkg-a.bin
# 应退出码 1,报错 bad hex string 'zz': expected 64 hex characters
# 11h:密钥长度不对(62 位十六进制=31 字节)
target/release/sign-tool verify --pkg "$PKG" --verify-key aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa --decrypt-key <解密私钥A> --key-package keypkg-a.bin
# 应退出码 1,报错 bad key length: 31 bytes, expected 32 bytes (64 hex characters)
# 11i:验证公钥无法解析(y=2 没有对应曲线点;包用好包、解密私钥用设备 A 的)
target/release/sign-tool verify --pkg "$PKG" --verify-key 0200000000000000000000000000000000000000000000000000000000000000 --decrypt-key <解密私钥A> --key-package keypkg-a.bin
# 应退出码 1,报错 invalid verify key
# 注意:全 f 的 32 字节能解析(y 取模后是 18,有对应点),不触发本条,错钥匙时报 signature verification failed
# 11j:文件不存在(三处同 pattern,后缀是系统文案)
target/release/sign-tool verify --pkg 不存在.pkg --verify-key <签名公钥> --decrypt-key <解密私钥A> --key-package keypkg-a.bin
# 应退出码 1,报错 cannot read package file '不存在.pkg': No such file or directory (os error 2)
target/release/sign-tool pack --bin 不存在.bin --sign-key <签名私钥种子>
# 应退出码 1,报错 cannot read firmware file '不存在.bin': No such file or directory (os error 2)
target/release/sign-tool cmp 不存在1 不存在2
# 应退出码 1,报错 cannot read file '不存在1': No such file or directory (os error 2)
# 11k:cmp 参数个数不对(一个或三个都一样报错)
target/release/sign-tool cmp test-fw.bin
# 应退出码 1,报错 cmp takes exactly two file arguments: sign-tool cmp <file1> <file2>
# 11l:cmp 长度不同(other.bin 是 256 字节随机文件)
head -c 256 /dev/urandom > other.bin
target/release/sign-tool cmp test-fw.bin other.bin
# 应退出码 1,报错 files differ in length: 4324 vs 256 bytes
# 11m:cmp 同长首个不同字节(位置从 1 数;hex 值随随机固件而变,只认位置)
cp test-fw.bin same.bin
python3 -c "d = bytearray(open('same.bin','rb').read()); d[100] ^= 0xFF; open('same.bin','wb').write(d)"
target/release/sign-tool cmp test-fw.bin same.bin
# 应退出码 1,报错 files differ: byte 101: 0x.. vs 0x..(101 是改动位置 100 从 1 数)
# 11n:derive-pub 坏 kind(--input 须给合法 64 位十六进制,否则先报密钥错)
target/release/sign-tool derive-pub --kind foo --input <64 位十六进制>
# 应退出码 1,报错 unknown --kind 'foo': expected ed25519 or x25519
# 11o:derive-pub 缺 --input
target/release/sign-tool derive-pub --kind ed25519
# 应退出码 1,报错 missing required flag --input
# 11p:cmp 第二个文件不存在(第一个存在,走到第二个读文件分支)
target/release/sign-tool cmp test-fw.bin 不存在2
# 应退出码 1,报错 cannot read file '不存在2': No such file or directory (os error 2)
# 12a:1MB 固件往返(先造一个带版本块的 big-fw.bin,构建时间取当前 UTC,与 test-fw.bin 不同,包名不撞;再追加随机字节到 1048576)
python3 - "1.2.3" <<'EOF'
import os, struct, sys
from datetime import datetime, timezone
major, minor, patch = (int(x) for x in sys.argv[1].split('.'))
now = datetime.now(timezone.utc)
body = bytearray(os.urandom(0x1000 + 228))
blk = bytearray(228)
blk[0:4] = b'FWVR'
blk[4] = 2
blk[5:5+11] = b'stm32h743vi'
blk[105:105+7] = b'gateway'
struct.pack_into('<HHH', blk, 205, major, minor, patch)
blk[211:219] = now.strftime('%Y%m%d').encode()
blk[219:225] = now.strftime('%H%M%S').encode()
body[0x1000:0x1000+228] = blk
open('big-fw.bin', 'wb').write(body)
EOF
python3 -c "open('big-fw.bin','ab').write(__import__('os').urandom(1048576-4324))"
target/release/sign-tool pack --bin big-fw.bin \
--sign-key <签名私钥种子>
# 应生成 gateway-stm32h743vit6-release-v1.2.3-<big-fw 构建日期>-<big-fw 构建时间>.pkg,输出 packed 1048576 bytes of firmware (v1.2.3, model stm32h743vi, firmware gateway) into 1048904 bytes
# header 312 bytes + body 1048592 bytes (ciphertext + 16-byte tag),外加 package key 一行
BIGPKG=$(ls -t gateway-stm32h743vit6-release-v1.2.3-*.pkg | head -1)
# 给设备 A 包 big 包的包密钥(pack 输出的 package key 行,记下来填进占位符)
target/release/sign-tool wrap-key --package-key <big 包的包密钥> \
--encrypt-key <设备加密公钥A> --out keypkg-big-a.bin
target/release/sign-tool verify --pkg "$BIGPKG" \
--verify-key <签名公钥> --decrypt-key <解密私钥A> \
--key-package keypkg-big-a.bin
# 应打印 verified OK: firmware v1.2.3 (1048576 bytes)
target/release/sign-tool unpack --pkg "$BIGPKG" \
--verify-key <签名公钥> --decrypt-key <解密私钥A> \
--key-package keypkg-big-a.bin
target/release/sign-tool cmp big-fw.bin "${BIGPKG%.pkg}-out.bin"
# 应打印 PASS: identical (1048576 bytes)
# 12b:gen-keys 输出形状(值每次随机,只认形状)
target/release/sign-tool gen-keys
# 应打印 1 行表头 === TEST KEYS - 测试密钥,切勿用于量产 ===,再加 4 行:sign key (seed, private key)、verify key (public key)、decrypt key (private key)、encrypt key (public key),每行值 64 位十六进制(private key 是私钥、public key 是公钥)
# 13a:缺 --out(包密钥包没有可推导名,--out 必填)
target/release/sign-tool wrap-key --package-key <包密钥> --encrypt-key <设备加密公钥A>
# 应退出码 1,报错 missing required flag --out
# 13b:包密钥长度不对(62 位十六进制=31 字节)
target/release/sign-tool wrap-key --package-key aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa --encrypt-key <设备加密公钥A> --out keypkg-bad.bin
# 应退出码 1,报错 bad key length: 31 bytes, expected 32 bytes (64 hex characters)
# 13c:同一包密钥包两次,结果不同、但都解出同一包密钥(临时密钥对每次现生,包装密钥永不重复)
target/release/sign-tool wrap-key --package-key <包密钥> --encrypt-key <设备加密公钥A> --out keypkg-a2.bin
# keypkg-a2.bin 与 keypkg-a.bin 字节不同(临时公钥不同)
target/release/sign-tool cmp keypkg-a.bin keypkg-a2.bin
# 应退出码 1(两文件不同,属正常现象,不是不通过)
target/release/sign-tool verify --pkg "$PKG" \
--verify-key <签名公钥> --decrypt-key <解密私钥A> \
--key-package keypkg-a2.bin
# 应打印 verified OK: firmware v1.2.3 (4324 bytes)(第二个包密钥包同样解出同一包密钥)
# 13d:给设备 B 包同一包密钥,设备 B 也能解(同一共用包、不同包密钥包)
target/release/sign-tool wrap-key --package-key <包密钥> --encrypt-key <设备加密公钥B> --out keypkg-b.bin
target/release/sign-tool verify --pkg "$PKG" \
--verify-key <签名公钥> --decrypt-key <解密私钥B> \
--key-package keypkg-b.bin
# 应打印 verified OK: firmware v1.2.3 (4324 bytes)
cargo test -p sign-tool:库 27 项。cargo test --workspace:连服务一起:库 27 + 服务 26 + 命令行单测 3 项。覆盖(库 27 项):共用包打包再验证与原固件逐字节一致、空固件往返、包体改一字节失败、错解密私钥失败、错云端公钥失败、头部被改失败、错误魔数与格式版本拒绝(格式 2 直接拒绝)、包密钥包包装与解包、推导公钥与生成值一致、包名按块生成(型号完整型号、release 段)与默认路径(打包默认名、解包默认名加 -out.bin)、wrap-key 输出 80 字节、服务接口(未配置拒收、非法配置拒收、版本号非空)。
覆盖(命令行单测 3 项):pack 默认名含完整型号与 release 段;unpack 默认输出为包头生成名加 -out.bin;wrap-key 相关(包名与输出规则)。
覆盖(服务 26 项):文件名重名加后缀、记录增删改查往返、产品名前缀校验、页面转义、种子文件加载(600 权限通过、644 拒绝、坏内容拒绝)、MQTT 话题解析/命令 JSON/状态入库、推送解析与最新版本、批量筛选与命令编号、操作员与分发(分发/重发/禁用规则)、包密钥记录掩码等。
预期:全部通过,0 失败。
| 项目 | 内容 |
|---|---|
| 适用 | sign 仓,本地服务检查,不依赖持续集成 |
| sign 提交 | main @ 13843ad3 |
| 服务 | multipart /pack 字段为 bin(+可选 product、remark;不收 device_pub);服务只绑本机 127.0.0.1,不直接暴露公网;管理台在 server/web(Next.js,3000 端口),经代理调本服务 |
| 包模型 | 服务端签名+加密一次出共用包(v3,包头 312 字节、格式 3);包密钥按版本存库;包密钥包按设备单独包(80 字节),经 GET /key-package 或推送取 |
| 测试密钥 | 密钥由测试人手动生成(gen-keys),本文档不提供现成的密钥值;量产种子必须在用的那台机器上本地生成,绝不经聊天发送 |
服务检查:起 sign-server,走管理台与机器凭证两条路,跑鉴权负向用例,再走操作员分发、包密钥包、推送与状态接入。前置:命令行与单元测试已通过(见命令行文档)。密钥由你手动生成(gen-keys),本文档不提供现成的密钥值。
测试原则:全程按生产环境走——密钥由你手动生成(gen-keys),本文档不提供现成的密钥值;预期结果是前后一致性(验包通过、明文逐字节一致、错凭证拒收),不是固定值。
相关仓库:sign https://github.com/ffffxxxjjj/sign ;gateway https://github.com/ffffxxxjjj/gateway ;firmware-tool https://github.com/ffffxxxjjj/firmware-tool 。端点全量见 sign 仓 docs/sign-server-api.md;固件分发全流程见 sign 仓 docs/固件分发全流程.md。
在仓库根目录准备两个 600 权限文件(不要把密钥写进 shell 历史):
逐行跑,不能整段粘贴(带 read 的行会停住等输入,整段粘贴时第一个 read 会把下一条当成输入吃掉,写出的文件就是空的;行内代码点一下就复制):
read -s seed:这行停住后粘贴种子,按回车。printf '%s' "$seed" > seed.txt && chmod 600 seed.txt && unset seedread -s adminpw:这行停住后粘贴密码,按回车。printf '%s' "$adminpw" > admin.txt && chmod 600 admin.txt && unset adminpw云端种子取本轮 gen-keys 的输出(新鲜随机);管理员密码测试自定。seed.txt 与 admin.txt 放在仓库根目录,起服务时从仓根直接引用。
启动(服务只绑本机 127.0.0.1,不直接暴露公网):
PORT=3009 SIGN_SERVER_DATA_DIR=/tmp/ota-test-data SIGN_SERVER_SIGNING_SEED_FILE=seed.txt SIGN_SERVER_ADMIN_PASSWORD_FILE=admin.txt ./target/release/sign-server
健康检查:curl http://127.0.0.1:3009/health 应返回 ok。
推送与状态接入要另外配 MQTT 环境变量(见 4.5 节);不配时推送直接 503(fail-closed),不是静默失败。
管理台在 server/web/(React + Next.js),开发启动见 docs/development-environment.md;
浏览器打开 http://127.0.0.1:3000/,管理台经代理调后端(3009),浏览器同源。
http://127.0.0.1:3000/,未登录应显示登录页。gateway-v1.2.3-<服务端打包日期>-<服务端打包时间>.pkg(日期与时间取服务端打包时刻,不是块内构建时间;重名自动加后缀 -2、-3)。GET /key-package?version=<版本>&serial=<序列号>(会话或机器凭证),应返回 80 字节包密钥包;用 unpack 写出明文(云端公钥与该设备解密私钥都取本轮值),以 sign-tool cmp 与上传的 bin 比对一致;只验证结论用 verify。
另一条路:包密钥记录区点眼睛单独取该版本的包密钥原值(仅管理员会话,30 秒自动隐藏),再用 wrap-key 给测试设备包一个包密钥包,同样 unpack 比对一致。gwk_ + 16 位十六进制)与 secret(只显示一次)。准备 Java 演示(纯 Java,不依赖原生库与 JNA;需 Java 11 以上):
mkdir -p /tmp/javademo
javac -d /tmp/javademo tool/native/java/GwpackServiceDemo.javajava -cp /tmp/javademo GwpackServiceDemo http://127.0.0.1:3009 "<key>" "<secret>"应打印出包文件名;用 unpack 写出明文,以 sign-tool cmp 比对(先按 4.4 节给测试设备分发并取包密钥包),应通过且明文一致。
GWPACK_TEST_URL/GWPACK_TEST_KEY/GWPACK_TEST_SECRET + ./target/release/examples/service_pack_demo),结果一致。沙盒无 Java 时走这条路;签名串 7 个字段按固定顺序换行连接:方法、路径、时间戳、随机数、固件 SHA-256、产品、备注,HMAC-SHA256 后 hex 编码。随机数用 Python 生成(沙盒无 xxd)。
# 1. 登录拿会话(表单字段 password),再建凭证(只显示一次 secret,记下来)
curl -c jar -b jar -X POST -d 'password=<管理员密码>' http://127.0.0.1:3009/login
curl -b jar -X POST -H 'Content-Type: application/json' -d '{"name":"srvtest"}' \
http://127.0.0.1:3009/api-keys
# 应返回 {"key_id":"gwk_...","secret":"..."};再查列表应只见 key、不见 secret
# 2. 机器调 /pack:multipart bin + product + remark(不带 device_pub),带 4 个签名头
TS=$(date +%s)
NONCE=$(python3 -c "import os; print(os.urandom(16).hex())")
SIG=$(python3 - "$TS" "$NONCE" <<'PY'
import sys, hashlib, hmac
ts, nonce = sys.argv[1:3]
fw = open('test-fw.bin','rb').read()
canon = '\n'.join(['POST','/pack',ts,nonce,hashlib.sha256(fw).hexdigest(),
'gateway','srvtest'])
print(hmac.new('<secret>'.encode(), canon.encode(), hashlib.sha256).hexdigest())
PY
)
curl -o srv.pkg -X POST \
-H "X-Gwpack-Key: <key>" -H "X-Gwpack-Timestamp: $TS" \
-H "X-Gwpack-Nonce: $NONCE" -H "X-Gwpack-Signature: $SIG" \
-F "bin=@test-fw.bin" \
-F "product=gateway" -F "remark=srvtest" \
http://127.0.0.1:3009/pack
# 应直接返回包文件(attachment),大小 4652 字节(312 头+4324 固件+16 标签)
# 验包与解包:先按 4.4 节给测试设备分发并取包密钥包(keypkg-srv.bin),再验
target/release/sign-tool verify --pkg srv.pkg --verify-key <签名公钥> --decrypt-key <解密私钥A> --key-package keypkg-srv.bin
target/release/sign-tool unpack --pkg srv.pkg --verify-key <签名公钥> --decrypt-key <解密私钥A> --key-package keypkg-srv.bin
# 应通过,写出包头生成名加 -out.bin(在 srv.pkg 旁边)
target/release/sign-tool cmp "$(ls -t gateway-*-out.bin | head -1)" test-fw.bin
# 应打印 PASS: identical (4324 bytes)
# 两条都应通过,解出的明文与上传的 bin 一致
# 3. 负向:签名改一位应 401;未登录打开管理页应跳登录页
注意:服务端种子与命令行测试的云端种子须是同一把——服务打出的包只能用该种子对应的云端公钥验;混用两把种子的密钥,验包必失败,不是工具问题。
| 用例 | 操作 | 预期结果 |
|---|---|---|
| T2-1 错 secret | secret 改一位后调 /pack | 401 unauthorized: bad signature,不生成包、不存记录 |
| T2-2 重放 | 同一签名请求发两次 | 第一次 200,第二次 401(unauthorized: nonce replay),不多存记录 |
| T2-3 吊销 | 管理页吊销凭证后用原 key 调 /pack | 401 unauthorized: api key revoked |
| T2-4 未登录 | 未登录打开管理台、程序直接调管理接口 | 管理台跳登录页;程序直接调接口一律返回 401 unauthorized: login required |
| T2-5 会话打包 | 登录后的浏览器会话直接上传打包 | 照常成功(/pack 接受会话或凭证二者任一) |
机器调用类按 4.2b 的签名方法组装,只改对应字段;T2-11 签名值任意(查 key 在验签之前),T2-12/T2-13 不需要有效签名(时间戳检查在验签之前)。解析类(T2-6 到 T2-8)发生在鉴权之前,无须签名头。
| 用例 | 操作 | 预期结果 |
|---|---|---|
| T2-6 缺 bin | /pack 不带 bin 字段 | 400 missing bin file |
| T2-7 空 bin | /pack 带 0 字节 bin | 400 bin file is empty |
| T2-8 带 device_pub | /pack 带 device_pub 字段 | 400 pack takes no device_pub: pack produces a shared package for all devices(共用包不收设备密钥) |
| T2-9 坏产品名 | product 传带空格或超 32 字符 | 400 bad product: use up to 32 letters, digits, '-' or '_'(空产品不报错,默认 gateway;机器签名调用按生效后的产品名签名,空产品按 gateway 签) |
| T2-10 无签名头 | 无会话、无 4 个签名头调 /pack | 401 unauthorized: login or api key signature required |
| T2-11 未知 key | key 传不存在的 gwk_ 值 | 401 unauthorized: unknown api key |
| T2-12 过期时间戳 | 时间戳取当前时间减 600 秒 | 401 unauthorized: timestamp outside window(窗口 300 秒) |
| T2-13 非数字时间戳 | 时间戳传非数字串 | 401 unauthorized: bad timestamp |
| T2-14 无版本块的 bin | 带有效签名上传 noblock.bin(命令行文档 T1-10a 的全零 bin) | 400 firmware has no version block at bin offset 0x1000: build firmware with the version block(块格式/截断/保留字节错同样走 400,文案与 T1-10 相同) |
| T2-15 下载不存在 | 登录后 GET /packages/不存在.pkg | 404 no such firmware(未登录下载返回 401;文件名含 /、\ 或 .. 同样 404) |
| T2-16 登录错密码 | 登录页输错管理员密码 | 401,登录页重显并提示密码错误(文案通用) |
负向用例的操作说明:T2-1 与 T2-3 直接用 Java 演示传改过的 secret 或吊销后的原 key;T2-2 的重放无法用演示触发(客户端每次调用生成新随机数),须按 sign 仓 docs/usage.md 里的 Python 标准库签名参考实现手工组装同一请求、用 curl 发两次。
分发只走操作员会话(管理员会话与机器凭证均 401);私钥后台先存着,列表与日志绝不出现。
# 1. 管理员建操作员(管理员会话),再用操作员账号登录(操作员会话 Cookie,HttpOnly,12 小时)
curl -b jar -X POST -H 'Content-Type: application/json' -d '{"username":"<操作员>","password":"<操作员密码>"}' \
http://127.0.0.1:3009/operators
curl -c ojar -b ojar -X POST -H 'Content-Type: application/json' -d '{"username":"<操作员>","password":"<操作员密码>"}' \
http://127.0.0.1:3009/operator/login
# 错用户名或错密码一律 401 invalid username or password(不区分是哪项错)
# 2. 管理员批量领号(管理员会话,数量 1-1000;序列号 GW{YYYYMMDD}{序号:04},按天计数、不重复)
curl -b jar -X POST -H 'Content-Type: application/json' -d '{"count":3}' \
http://127.0.0.1:3009/devices/allocate
# 应返回 {"serials":["GW<日期>0001","GW<日期>0002","GW<日期>0003"]};数量超限 400 bad count: use 1-1000
# 3. 操作员分发(操作员会话):序列号须在已领出批次里,否则 400 serial not allocated
curl -b ojar -X POST -H 'Content-Type: application/json' -d '{"serial":"<序列号>","model":"stm32h743vi"}' \
http://127.0.0.1:3009/devices/issue
# 应返回 {"serial","model","device_pub","device_priv","issued_at","issued_by"};私钥只在这次响应里给,记下来给写入工具用
# 重发不带 reissue:400 serial already issued: pass reissue=true to re-deliver stored keys
# 重发带 reissue=true:返回存量密钥,不轮换(同一对密钥原样返回)
# 没分发过就带 reissue=true:400 serial not issued: issue without reissue first
# 已用写入工具上报登记过的序列号(无后台存钥)再分发:400 serial already registered
# 已分发过的序列号用写入工具上报登记:400 serial already registered(分发即登记,不重复登记)
# 4. 取包密钥包(会话或机器凭证):版本+序列号,返回 80 字节包密钥包原值(不是 JSON)
curl -b ojar "http://127.0.0.1:3009/key-package?version=<版本>&serial=<序列号>" -o keypkg-srv.bin
# 未登记的序列号 404 no such device;无该版本的共用包 404 no such firmware
# 包密钥记录区:列表只给元数据(版本、指纹 SHA-256、创建时间、状态);点眼睛单独取原值(仅管理员会话),30 秒自动隐藏并清状态
# 5. 禁用操作员(管理员会话,幂等;禁用后该操作员会话失效;本版无恢复接口)
curl -b jar -X POST http://127.0.0.1:3009/operators/<操作员>/disable
# 禁用后用原操作员会话调 /devices/issue 应 401
推送与状态接入只在配了 MQTT 时启用(SIGN_SERVER_MQTT_USERNAME 设了才启用;没设时推送 503、状态不订阅)。本地检查用一个测试 Broker(本机 1883 即可),不要连生产 Broker。
启动时加(测试 Broker 在本机 1883、租户 default;公开下载地址用来拼 pkg_url):
PORT=3009 SIGN_SERVER_DATA_DIR=/tmp/ota-test-data SIGN_SERVER_SIGNING_SEED_FILE=seed.txt SIGN_SERVER_ADMIN_PASSWORD_FILE=admin.txt SIGN_SERVER_MQTT_USERNAME=<测试账号> SIGN_SERVER_MQTT_PASSWORD=<测试密码> SIGN_SERVER_MQTT_BROKER=127.0.0.1 SIGN_SERVER_MQTT_PORT=1883 SIGN_SERVER_MQTT_TENANT=default SIGN_SERVER_PUBLIC_BASE_URL=http://127.0.0.1:3009 ./target/release/sign-server
环境变量:SIGN_SERVER_MQTT_BROKER(默认 127.0.0.1)、SIGN_SERVER_MQTT_PORT(默认 1883)、SIGN_SERVER_MQTT_CLIENT_ID(默认 sign-server)、SIGN_SERVER_MQTT_TENANT(默认 default)、SIGN_SERVER_PUBLIC_BASE_URL(拼 pkg_url 用;没配时推送 500)。
# 1. 单个推送(管理员会话):版本不传取该型号最新共用包;总是发送(即使设备已是目标版本)
curl -b jar -X POST http://127.0.0.1:3009/devices/<序列号>/push
# 应返回 {"cmd_id","topic","pkg_url"}(不含 key_package 本身);订阅者应在 v1/tenants/default/devices/<序列号>/cmd 收到
# firmware_update(QoS 1):{"cmd_id","action":"firmware_update","params":{"version","pkg_url","key_package"}}
# 其中 key_package 是 80 字节包密钥包的 160 位十六进制;cmd_id 与设备回执配对用
# 未配 MQTT 时 503(fail-closed);未配公开地址时 500 push requires SIGN_SERVER_PUBLIC_BASE_URL to be configured;
# 未登记的序列号 404 no such device;未指定版本且该型号无共用包 404 no firmware for this model
# 2. 批量推送(管理员会话):筛选 version / model(精确)/ online / only_outdated 全部可选;单次上限 500 台
curl -b jar -X POST -H 'Content-Type: application/json' -d '{"model":"stm32h743vi","only_outdated":true}' \
http://127.0.0.1:3009/devices/push-batch
# 应返回每台结果 pushed / failed / skipped(only_outdated 跳过已是目标版本的台,记 skipped 不是失败);
# 一台失败不中断整批;超 500 台 400 batch too large: <匹配数> devices match, narrow filters (max 500);空批量返回空结果,不报错
# 3. 状态接入:服务端订阅 v1/tenants/+/devices/+/status(QoS 1,每次连接重订阅);
# 设备(或测试订阅者)向 v1/tenants/default/devices/<序列号>/status 发 {"device_id":"<序列号>","online":true,"version":"<版本>"}
# 设备列表里该台的 online 与当前版本应随之更新;未登记的序列号忽略;device_id 与话题不一致跳过;格式错只记日志、不写库
| 占位符 | 含义 | 来源 |
|---|---|---|
<签名私钥种子> |
云端签名私钥种子(Ed25519) | 本轮 gen-keys 输出 |
<签名公钥> |
云端签名公钥 | 本轮 gen-keys 输出(或命令行文档 T1-4 推导,应一致) |
<解密私钥A> / <设备加密公钥A> |
设备 A 的密钥对(X25519) | 本轮 gen-keys 输出(或 4.4 节分发返回) |
<包密钥> |
本版本共用包的包密钥 | 服务端按版本存库;管理台包密钥记录区点眼睛取原值(仅管理员会话) |
<包密钥包> |
给一台设备的包密钥包(80 字节) | GET /key-package 返回,或 wrap-key 本地包装 |
密钥由你手动生成:测试前自己跑 gen-keys,记下各值填进占位符;本文档不提供现成的密钥值。固定参考向量(sign 仓 docs/package-format.md 第 6 节)只作格式参考,用不用由你定;是否每轮重新生成,也由你定。量产密钥与测试密钥生成方法相同(操作系统随机数),区别只在保管:量产种子在服务器本机生成、解密私钥每台设备独立生成、一机一密、后台分发先存着。
服务(仓根):
PORT=3009 SIGN_SERVER_DATA_DIR=/tmp/ota-test-data SIGN_SERVER_SIGNING_SEED_FILE=seed.txt SIGN_SERVER_ADMIN_PASSWORD_FILE=admin.txt ./target/release/sign-server
推送与状态(加 MQTT 环境变量):
SIGN_SERVER_MQTT_USERNAME=<账号> SIGN_SERVER_MQTT_PASSWORD=<密码> SIGN_SERVER_MQTT_BROKER=127.0.0.1 SIGN_SERVER_MQTT_PORT=1883 SIGN_SERVER_MQTT_TENANT=default SIGN_SERVER_PUBLIC_BASE_URL=http://127.0.0.1:3009
管理台(server/web):
cd server/webnpm installnpm run dev实现里每条拒绝路径都有对应手动用例;下表即完整性自查。
| 报错 | 用例 |
|---|---|
missing bin file/bin file is empty |
T2-6/T2-7 |
pack takes no device_pub: pack produces a shared package for all devices |
T2-8 |
bad product |
T2-9 |
unauthorized: login or api key signature required |
T2-10 |
unauthorized: unknown api key |
T2-11 |
unauthorized: bad timestamp/timestamp outside window |
T2-13/T2-12 |
unauthorized: bad signature |
T2-1 |
unauthorized: nonce replay |
T2-2 |
unauthorized: api key revoked |
T2-3 |
unauthorized: login required |
T2-4(程序调管理接口) |
unauthorized: api key signature required(POST /devices 机器凭证缺失) |
4.4 节写入工具上报路径 |
invalid username or password(操作员登录失败,通用文案) |
4.4 节 |
serial not allocated |
4.4 节(分发与登记) |
serial already issued: pass reissue=true to re-deliver stored keys |
4.4 节 |
serial not issued: issue without reissue first |
4.4 节 |
serial already registered |
4.4 节 |
serial registered without backend-issued keys: no stored private key to re-deliver |
4.4 节 |
bad count: use 1-1000 |
4.4 节(领号超限) |
no such device(取包密钥包、推送) |
4.4 节、4.5 节 |
no such firmware(取包密钥包) |
4.4 节 |
no firmware for this model(推送无版本且型号无包) |
4.5 节 |
push requires SIGN_SERVER_PUBLIC_BASE_URL to be configured(500) |
4.5 节 |
| 推送未配 MQTT(503,fail-closed) | 4.5 节 |
batch too large: <匹配数> devices match, narrow filters (max 500) |
4.5 节 |
no such firmware(下载) |
T2-15 |
| 无版本块/块格式/截断/保留字节错(400,文案同 T1-10) | T2-14 |
bad multipart form/cannot read bin file / product / remark:传输层读取异常,不可稳定复现。database lock failed 及 500 类:服务端内部错,不列入手动用例。no such api key:管理页删除不存在的凭证时触发,走页面操作,不单列 curl 用例。这一步验证 sign-tool 打出的共用包能被设备侧库验证通过,是打包—验包链路的另一端。
cd gateway/bootloadercargo test
预期:25 项全过(验证 12 项+provisioning 13 项)。主机测试与 sign-tool 验证同一套 v3 算法。
docs/provisioning.md;写入工具设计见 firmware-tool 仓 docs/固件写入工具设计.md。| 占位符 | 含义 | 来源 |
|---|---|---|
<签名私钥种子> |
云端签名私钥种子(Ed25519) | 本轮 gen-keys 输出 |
<签名公钥> |
云端签名公钥 | 本轮 gen-keys 输出(或 T1-4 推导,应一致) |
<解密私钥A> / <设备加密公钥A> |
设备 A 的密钥对(X25519) | 本轮 gen-keys 输出 |
<解密私钥B> / <设备加密公钥B> |
设备 B 的密钥对(另一台设备) | 第二次 gen-keys 输出(只取设备对) |
<包密钥> |
本版本共用包的包密钥(32 字节,64 位十六进制) | 本轮 pack 输出的 package key 行(只进 wrap-key,不进包) |
<包密钥包A> |
给设备 A 的包密钥包文件(80 字节) | 本轮 wrap-key 输出(keypkg-a.bin) |
密钥由你手动生成:测试前自己跑 gen-keys,记下各值填进占位符;本文档不提供现成的密钥值。固定参考向量(sign 仓 docs/package-format.md 第 6 节)只作格式参考,用不用由你定;是否每轮重新生成,也由你定。量产密钥与测试密钥生成方法相同(操作系统随机数),区别只在保管:量产种子在服务器本机生成、解密私钥每台设备独立生成、一机一密、后台分发(见固件分发全流程)。
# 编译
cargo build --release --workspace
# 手动生成密钥(测试前自己跑)
sign-tool gen-keys
# 命令行
sign-tool derive-pub --kind ed25519|x25519 --input <hex 32 字节>
sign-tool pack --bin <bin> --sign-key <hex>
sign-tool wrap-key --package-key <hex> --encrypt-key <hex> --out <key-package-file>
sign-tool verify --pkg <pkg> --verify-key <hex> --decrypt-key <hex> --key-package <key-package-file>
sign-tool unpack --pkg <pkg> --verify-key <hex> --decrypt-key <hex> --key-package <key-package-file>
sign-tool cmp <file1> <file2>
sign-tool info --bin <bin>
sign-tool info --pkg <pkg> [--verify-key <hex> --decrypt-key <hex> --key-package <key-package-file>]
# 单测
cargo test -p sign-tool
cargo test --workspace
# 设备验证(gateway 仓 bootloader 目录)
cargo test
实现里每条拒绝路径都有对应手动用例;下表即完整性自查。
| 报错 | 用例 |
|---|---|
package too short(两种:短于 8 字节/短于 312 字节) |
T1-8b/T1-8a |
bad magic: package does not start with GWPK |
T1-8c |
unsupported format version(1 与 2 均拒绝,只认 3) |
T1-8d/T1-8d2 |
unsupported flags |
T1-8e/T1-8f/T1-8g |
model is empty / is not ASCII / has non-NUL bytes after the terminator |
T1-8i/T1-8j/T1-8h |
firmware name is empty / is not ASCII / has non-NUL bytes after the terminator |
T1-8k/T1-8l/T1-2 |
build date/time must be ASCII digits |
T1-8m |
build date month / day out of range |
T1-8n/T1-8o |
build time hour / minute / second out of range |
T1-8p/T1-8q/T1-8r |
body length mismatch |
T1-9a/T1-9k/T1-9l |
failed to unwrap package key |
T1-3(解密私钥不对)、T1-9b/T1-9c(包密钥包材料被改) |
body decryption failed |
T1-2 包体区、T1-9d(体随机数改) |
signature verification failed |
T1-9e/T1-9f/T1-9g/T1-9h/T1-9i/T1-9j |
invalid verify key |
T1-11i |
firmware has no version block at bin offset 0x1000(pack 长文案/info 短文案) |
T1-10a/T1-10h |
unsupported version block format |
T1-10b |
version block truncated(缺格式字节/缺后半段) |
T1-10d/T1-10c |
version block reserved bytes must be zero |
T1-10e |
missing required flag |
T1-11a/T1-11a2(缺 --key-package)/T1-11o |
missing value for |
T1-11b |
unexpected argument |
T1-11c(裸参数)、T1-11e(未知短参数) |
duplicate flag |
T1-11d |
missing or unknown subcommand |
T1-11f |
bad hex string/bad key length |
T1-11g/T1-11h/T1-13b |
unknown --kind |
T1-11n |
cannot read firmware / package / key package / file |
T1-11j/T1-11p |
cmp takes exactly two file arguments |
T1-11k |
files differ in length/files differ: byte |
T1-11l/T1-11m |
info takes exactly one of --bin / --pkg |
T1-10j |
info --pkg needs --verify-key, --decrypt-key, and --key-package together to decrypt |
T1-10i |
pack takes no --encrypt-key |
T1-5 |
pack takes no --out |
T1-5 |
verify writes no file; use unpack to write the decrypted firmware |
T1-5 |
unpack takes no --out/unpack takes no --expect |
T1-5 |
missing required flag --out(wrap-key) |
T1-13a |
PASS: identical(正向) |
T1-1、T1-5、T1-6、T1-12a、T1-13c/T1-13d |
build date/time has the wrong length:日期/时间字段定长 8/6 字节,长度恒对,篡改不出长度错。plaintext length mismatch:体长校验先行,解密输出长度恒等于包头长度,不可达。firmware too large:需大于 4GB 的固件,手动不测。failed to init * cipher/failed to encrypt / wrap/internal error: unwrapped key is not 32 bytes:密钥与数据合法时不触发。| 项目 | 内容 |
|---|---|
| 适用 | sign 仓,原生库(C 服务接口)测试,不依赖持续集成 |
| sign 提交 | main @ 13843ad3 |
| 接口 | tool/native/gwpack.h:配置、打包、取文件名、取错、版本号,共 5 个函数 |
| 测试方式 | C 动态加载服务检查 + Java 示例(用 sign-server 已起好的服务,最后测) |
原生库最后测:验证 C 服务接口本身(不是命令行、不是本地打包)。原生库只对接 sign-server 的服务接口:它不持有签名种子,不做本地打包、验证、推导。本地打包与验证走 Rust 库与命令行(见命令行文档)。前置:单测与服务检查已通过,服务正跑着(不要另起服务,直接用同一个服务和凭证)。编一次,三个平台产物都在仓根 target/release/:libsign_tool.dylib(Mac)、libsign_tool.so(Linux)、sign_tool.dll(Windows)。
相关仓库:sign https://github.com/ffffxxxjjj/sign ;gateway https://github.com/ffffxxxjjj/gateway ;firmware-tool https://github.com/ffffxxxjjj/firmware-tool 。
tool/native/gwpack.h,共 5 个函数)函数名是裸名,C 符号与 Java 方法同名。
通用约定:
| 状态码 | 值 | 含义 |
|---|---|---|
| OK | 0 | 成功 |
| ERR_NULL | -1 | 必填指针为空 |
| ERR_SMALL_BUFFER | -2 | 缓冲区太小;所需长度写进长度参数,不写字节 |
| ERR_FAILED | -3 | 失败;原因用 last_error 取 |
| ERR_PANIC | -4 | 内部 panic 已拦下;原因用 last_error 取 |
int configure(
const char *base_url,
const char *api_key,
const char *api_secret);
参数:
| 参数 | 说明 |
|---|---|
| base_url | 服务地址,可空:空指针或空串用默认 http://127.0.0.1:3000;尾斜杠剥除;非 UTF-8 拒绝 |
| api_key | 管理页分配的调用凭证 key,必填:空指针或空串都拒绝;非 UTF-8 拒绝 |
| api_secret | 管理页分配的调用凭证 secret,必填:同上 |
返回:
| 返回 | 含义 |
|---|---|
| OK | 配置成功;重复配置覆盖上一次 |
| ERR_NULL | api_key 或 api_secret 为空指针 |
| ERR_FAILED | key 或 secret 为空串,或任一字符串非 UTF-8;原因用 last_error 取 |
备注:这是机器调用凭证,不是管理员登录;必须在 pack 之前调。
int pack(
const unsigned char *firmware, size_t firmware_len,
const char *product,
const char *remark,
unsigned char *out, size_t *out_len);
参数:
| 参数 | 说明 |
|---|---|
| firmware / firmware_len | 固件字节与长度;空指针仅当长度为 0 时合法(空固件会被服务端拒绝);版本、型号、固件名由服务端从固件版本块(格式 2,偏移 0x1000)读,无版本块服务端拒绝 |
| product | 产品名,可空:空指针或空串服务端默认 gateway |
| remark | 备注,可空:空指针视为空串;非 UTF-8 拒绝 |
| out / out_len | 输出缓冲区与长度;out_len 为空指针拒绝 |
返回:
| 返回 | 含义 |
|---|---|
| OK | 成功;out_len 为包长(头 312 + 固件 + 标签 16);服务给的文件名用 last_filename 取 |
| OK(out 为空指针时) | 问长度:out_len 为所需长度,不发请求、不写字节(长度直接由包格式算出:312 +固件长+16,所以一次逻辑打包只发一次请求) |
| ERR_NULL | out_len 为空指针,或 firmware(长度非 0 时)为空指针 |
| ERR_SMALL_BUFFER | 缓冲区太小;out_len 为所需长度、不写字节;正确用法是先空指针问长度,按长度分配(问长度不发请求,不会多存记录) |
| ERR_FAILED | 未配置、服务端拒绝(无版本块、空固件、凭证错、时间戳超窗、nonce 重放)、网络失败;原因用 last_error 取 |
| ERR_PANIC | 内部 panic 已拦下;原因用 last_error 取 |
备注:共用包不收设备密钥,pack 没有设备密钥参数;一台设备的包密钥包经服务端 GET /key-package 取,不走原生库。
int last_filename(char *buf, size_t buf_len);
参数:
| 参数 | 说明 |
|---|---|
| buf / buf_len | 调用方缓冲区与长度;buf 为空指针或 buf_len 为 0 时是问长度,不写字节 |
返回:文件名的完整长度(不含 NUL),所有情况下都返回完整长度;缓冲区太小则写截断后的 NUL 结尾副本,调用方拿返回值与 buf_len 比较可发现截断;本线程没成功打包过返回 0(空串)。
int last_error(char *buf, size_t buf_len);
参数:同上(缓冲区与长度;空指针或长度 0 问长度)。
返回:同上协议;长度 0 表示本线程上次调用没失败,或还没调过。
const char *lib_version(void);
参数:无。
返回:库版本号字符串指针(当前 0.1.0);指针永久有效,不用释放、不要 free;保证非空。
服务接口的三项测试(pack_without_configure_fails、configure_rejects_bad_inputs、lib_version_is_non_empty:未配置拒收、非法配置拒收、版本号非空)已随库单测跑过(不用联网、不用起服务),这里不再重跑。
tool/native/ffi-test/c_dlopen_test.c:不链接库,运行时 dlopen 加载,依次验:版本号非空、未配置打包拒收、空 key 配置拒收、配好后对真服务打包(8192 字节测试固件,包 8520 字节、包头 GWPK + 格式 3)、服务给的文件名非空且以 .pkg 结尾。证明导出的 C 服务接口经真实动态加载可用。完整验证(签名与解密往返)不在这里,由 Rust 服务示例与命令行验包步骤覆盖。
在持续集成里最后跑(同款);本地跑时用 sign-server 已起好的服务和凭证,不要另起服务:
逐行跑(行内代码点一下就复制):
cargo build --release --workspacegcc -o /tmp/c_dlopen_test tool/native/ffi-test/c_dlopen_test.c -ldl/tmp/c_dlopen_test ./target/release/libsign_tool.so http://127.0.0.1:3009 "<key>" "<secret>"预期:打印库版本、打包 8520 字节、服务给的文件名,末行 ALL C DLOPEN CHECKS PASSED,退出 0。
tool/native/java/Gwpack.java 是 JNA 映射,方法与 C 函数一一对应(size_t 用 LongByReference 映射,64 位平台):
int configure(String baseUrl, String apiKey, String apiSecret);
int pack(byte[] firmware, long firmwareLen,
String product, String remark, byte[] out, LongByReference outLen);
int last_filename(byte[] buf, long bufLen);
int last_error(byte[] buf, long bufLen);
String lib_version();
另带三个辅助(失败抛 IllegalStateException,错误信息里含 lastError;没成功过、没失败过返回空串):
static byte[] servicePack(Gwpack lib, byte[] firmware, String product, String remark);
static String serviceLastFilename(Gwpack lib);
static String lastError(Gwpack lib);
加载:Gwpack lib = Native.load("sign_tool", Gwpack.class);(JNA 自动加平台前后缀)。
tool/native/java/GwpackExample.java(完整程序,签名种子不出服务;本程序只持有分配的 key + secret,后者是公开的打包参数之外的凭证):
import com.sun.jna.Native;
import java.nio.file.Files;
import java.nio.file.Path;
public class GwpackExample {
public static void main(String[] args) throws Exception {
if (args.length != 4) {
System.err.println("usage: GwpackExample <base-url> <api-key> <api-secret> <firmware-file>");
System.exit(2);
}
Gwpack lib = Native.load("sign_tool", Gwpack.class);
System.out.println("lib version: " + lib.lib_version());
int rc = lib.configure(args[0], args[1], args[2]);
if (rc != Gwpack.OK) {
throw new IllegalStateException("configure failed: " + Gwpack.lastError(lib));
}
byte[] firmware = Files.readAllBytes(Path.of(args[3]));
byte[] pkg = Gwpack.servicePack(lib, firmware, "gateway", "java example");
String filename = Gwpack.serviceLastFilename(lib);
Files.write(Path.of(filename), pkg);
System.out.println("service pack: " + pkg.length + " bytes, filename=" + filename);
}
}
编与跑(在 tool/native/java/ 下;先下 JNA 5.14.0,库路径指到编出的原生库):
curl -fsSL -o jna-5.14.0.jar https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jarjavac -cp jna-5.14.0.jar:. Gwpack.java GwpackExample.javajava -Djna.library.path=../../../target/release -cp jna-5.14.0.jar:. GwpackExample http://127.0.0.1:3009 <api-key> <api-secret> <firmware-file>预期:打印库版本、打包字节数与服务给的文件名,包写到当前目录、文件名与打印的一致。服务演示(GwpackServiceDemo.java)是另一条路:纯 Java 直调 HTTP,不用原生库、不用 JNA(签名串 7 字段,不带设备密钥,见服务检查文档 4.2 节)。
# 编译(原生库随 workspace 编出)
cargo build --release --workspace
# C 动态加载服务检查(用 sign-server 已起好的服务和凭证)
gcc -o /tmp/c_dlopen_test tool/native/ffi-test/c_dlopen_test.c -ldl
/tmp/c_dlopen_test ./target/release/libsign_tool.so http://127.0.0.1:3009 "<key>" "<secret>"
# Java 示例(在 tool/native/java/ 下,先下 JNA,库路径指到编出的原生库)
curl -fsSL -o jna-5.14.0.jar https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar
javac -cp jna-5.14.0.jar:. Gwpack.java GwpackExample.java
java -Djna.library.path=../../../target/release -cp jna-5.14.0.jar:. GwpackExample http://127.0.0.1:3009 <api-key> <api-secret> <firmware-file>