| 项目 | 内容 |
|---|---|
| 适用 | 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 用例。