sign-server 服务检查

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

三、sign-server

在仓库根目录准备两个 600 权限文件(不要把密钥写进 shell 历史):

逐行跑,不能整段粘贴(带 read 的行会停住等输入,整段粘贴时第一个 read 会把下一条当成输入吃掉,写出的文件就是空的;行内代码点一下就复制):

  1. read -s seed:这行停住后粘贴种子,按回车。
  2. printf '%s' "$seed" > seed.txt && chmod 600 seed.txt && unset seed
  3. read -s adminpw:这行停住后粘贴密码,按回车。
  4. 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),不是静默失败。

4.1 管理台流程(Next.js,3000 端口)

管理台在 server/web/(React + Next.js),开发启动见 docs/development-environment.md; 浏览器打开 http://127.0.0.1:3000/,管理台经代理调后端(3009),浏览器同源。

  1. 打开 http://127.0.0.1:3000/,未登录应显示登录页。
  2. 输入管理员密码登录,应进入固件管理页(固件记录、调用凭证、设备列表、包密钥记录四节)。
  3. 上传 bin(填可选产品与备注;不填设备公钥——打包弹窗没有设备公钥输入框;版本、型号、固件名从 bin 版本块读,不手填),应下载包并出现在列表里,文件名形如 gateway-v1.2.3-<服务端打包日期>-<服务端打包时间>.pkg(日期与时间取服务端打包时刻,不是块内构建时间;重名自动加后缀 -2、-3)。
  4. 包密钥包:先按 4.4 节给一台测试设备领号并分发(操作员账号),再用该设备的序列号与版本调 GET /key-package?version=<版本>&serial=<序列号>(会话或机器凭证),应返回 80 字节包密钥包;用 unpack 写出明文(云端公钥与该设备解密私钥都取本轮值),以 sign-tool cmp 与上传的 bin 比对一致;只验证结论用 verify。 另一条路:包密钥记录区点眼睛单独取该版本的包密钥原值(仅管理员会话,30 秒自动隐藏),再用 wrap-key 给测试设备包一个包密钥包,同样 unpack 比对一致。
  5. 改备注、删除记录与文件,应即时生效;重名文件上传应自动加后缀(-2、-3)。
  6. 跨设备拒收:用设备 B 的私钥与设备 A 的包密钥包验证设备 A 的包(verify 与 unpack 均应失败,unpack 不写明文;生产关键:一台设备的包密钥包打不开另一台设备)。
  7. 设备列表应有当前版本与最新版本两列:当前版本来自设备状态上报(没上报过为空),最新版本是该型号最新共用包版本(返回时现算,不存库;该型号无共用包为空)。
  8. 推送现阶段只走接口(见 4.5 节),管理台没有推送按钮,不要找。

4.2 机器凭证调用流程(Java 直调 HTTP 服务)

  1. 管理台“调用凭证”一节新建凭证,记下 key(gwk_ + 16 位十六进制)与 secret(只显示一次)。
  2. 准备 Java 演示(纯 Java,不依赖原生库与 JNA;需 Java 11 以上):

  3. mkdir -p /tmp/javademo

  4. javac -d /tmp/javademo tool/native/java/GwpackServiceDemo.java
  5. java -cp /tmp/javademo GwpackServiceDemo http://127.0.0.1:3009 "<key>" "<secret>"

应打印出包文件名;用 unpack 写出明文,以 sign-tool cmp 比对(先按 4.4 节给测试设备分发并取包密钥包),应通过且明文一致。

  1. Rust 示例同样走一遍(GWPACK_TEST_URL/GWPACK_TEST_KEY/GWPACK_TEST_SECRET + ./target/release/examples/service_pack_demo),结果一致。

4.2b 机器凭证调用流程(curl + Python 标准库签名,不依赖 Java)

沙盒无 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;未登录打开管理页应跳登录页

注意:服务端种子与命令行测试的云端种子须是同一把——服务打出的包只能用该种子对应的云端公钥验;混用两把种子的密钥,验包必失败,不是工具问题。

4.3 鉴权负向用例

用例 操作 预期结果
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.3b /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 发两次。

4.4 操作员分发与包密钥包

分发只走操作员会话(管理员会话与机器凭证均 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

4.5 推送与状态接入

推送与状态接入只在配了 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 与话题不一致跳过;格式错只记日志、不写库

通过标准与失败处理

附录 A:密钥占位符(你手动生成,文档不提供现成值)

占位符 含义 来源
<签名私钥种子> 云端签名私钥种子(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 节)只作格式参考,用不用由你定;是否每轮重新生成,也由你定。量产密钥与测试密钥生成方法相同(操作系统随机数),区别只在保管:量产种子在服务器本机生成、解密私钥每台设备独立生成、一机一密、后台分发先存着。

附录 B:命令速查

服务(仓根):

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):

  1. cd server/web
  2. npm install
  3. npm run dev

附录 C:报错→用例对照表

实现里每条拒绝路径都有对应手动用例;下表即完整性自查。

服务端报错

报错 用例
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

不可手动触发(代码防御,不列用例)