仓库:https://github.com/ffffxxxjjj/sign
Rust 签名打包网页服务(GWPK 包格式 v3),服务端做的测试版:
网页上传固件 bin,服务端用服务端持有的云端签名密钥签名打包,
保存包文件与一条固件记录(SQLite),返回 pkg 文件下载;
固件管理页可查看记录、下载包文件、改备注、删除记录与文件。
只管签名打包;机器调用方(Java 后台等)调
它的 POST /pack,用分配的调用凭证签名请求(见下“调用凭证”),
不用管理员登录。
打包逻辑复用本仓 tool/ 的 sign-tool 库(同一 workspace 的 tool 成员,
同一份代码,设备侧已交叉验证),本服务只负责通过网页暴露它 +
服务端持有签名密钥。
私钥处理铁律:云端签名私钥种子只以种子文件(600 权限)或注入的 环境变量进服务进程内存,绝不进数据库、不进日志、不出现在页面, 不上命令行。数据库存公开信息、目标设备加密公钥(解密私钥从不出设备), 外加机器调用凭证的密钥——密钥必须能取回才能验签,所以数据库文件 本身按 600 权限保护,视同种子文件;密钥绝不进日志,列表接口也 绝不返回密钥(只在新建成功时显示一次)。
本地测试自己生成一个新鲜种子,不要用别人给的、不要复用生产种子。
在仓根跑,工具打印测试密钥,取 sign key (seed, private key) 那行
(64 位十六进制,行内代码点一下就复制):
cargo run --release -p sign-tool -- gen-keys
这几行是测试密钥(工具自己标明切勿用于量产),仅本地测试用; 量产种子上服务器时按量产流程在服务器本机生成,现在不用管。
下面 4 条逐行跑,不能整段粘贴:带 read 的行会停住等输入,
整段粘贴时第一个 read 会把下一条当成输入吃掉,写出的文件就是空的。
read -s 是读一行且不回显(种子、密码不显示、不进命令历史)。
行内代码点一下就复制。文件名任意,环境变量指对即可。
read -s seed:这行停住后粘贴种子,按回车。printf '%s' "$seed" > seed.txt && chmod 600 seed.txt && unset seedread -s admin_password:这行停住后粘贴管理员密码,按回车。printf '%s' "$admin_password" > admin-password.txt && chmod 600 admin-password.txt && unset admin_password验一下(种子应是 64,密码是它的长度;跑完上面两条变量已清掉,
再 echo ${#seed} 显示 0 属正常,以文件为准):
wc -c seed.txt admin-password.txt
下面两条不含 read,可以整段粘贴。在仓根跑后端
(-p 指定 workspace 成员,端口 3009):
PORT=3009 SIGN_SERVER_SIGNING_SEED_FILE=seed.txt SIGN_SERVER_ADMIN_PASSWORD_FILE=admin-password.txt cargo run --release -p sign-server
再起前端(端口 3000;先装依赖):
cd server/web && npm install && npm run dev
打开 http://127.0.0.1:3000/(首页),点页脚管理登录(/login)登录后进管理台,上传 bin 点打包。
server/web/next.config.js 的代理写死
127.0.0.1:3009,后端换端口前端就调不通;要换端口先改代理配置。docs/测试流程.html 第一章(sign-tool 本地测试流程),
版本块格式见 docs/firmware-version-block.md。npm run dev 直接跑;STATIC_EXPORT=1 只用于静态导出构建
(出 out/,上服务器用),不要加到开发命令上。docs/本地开发测试.md。生产部署(以后上服务器时):web 端放 OpenResty 里面,直接出静态(见 docs/openresty-deployment.md)。开发用 npm run dev 直接跑;npm run start 只用于本地验证生产构建。
SIGN_SERVER_SIGNING_SEED_FILE(推荐):种子文件路径,文件里放
64 位十六进制种子。种子不出现在命令行、不进历史;Unix 下文件
必须是 600 或更严权限,否则服务拒绝启动。SIGN_SERVER_SIGNING_SEED(备选):种子本身作环境变量,只给
注入密钥的编排系统用。不要直接打在命令行上(VAR=值 命令
整行都会记进历史)。SIGN_SERVER_DATA_DIR(可选,默认 data):数据目录,里面放
SQLite 文件(sign-server.db)与包文件(packages/)。PORT(可选,默认 3000)。BIND(可选,默认 127.0.0.1):监听地址。容器部署时设 BIND=0.0.0.0,让另一个容器里的反向代理能连进来(容器本身不对外发布端口,后端只经代理可达)。SIGN_SERVER_MQTT_USERNAME(必填,缺了则 MQTT 整体关闭:推送
返回 503,不跑订阅)、SIGN_SERVER_MQTT_PASSWORD、
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_PUSH_ACK_TIMEOUT_SECS(默认 300)、
SIGN_SERVER_PUSH_RETENTION_DAYS(默认 30)、
SIGN_SERVER_PUBLIC_BASE_URL(推送命令里 pkg_url 的公开地址前缀;
缺了则推送返回 500)。测试用固定测试密钥见 docs/package-format.md 第 6 节
(云端签名私钥种子、设备加密公钥、解密私钥、云端签名公钥),
仅测试用,绝不用于量产。启动时会打印云端签名公钥,
验包时用它 + 解密私钥跑 sign-tool verify 交叉验证。
管理页与全部接口(除存活检查外)都要先登录。单管理员、只设一个 密码,不设用户名。
SIGN_SERVER_ADMIN_PASSWORD_FILE(推荐):密码文件路径,文件里
放密码原文。Unix 下文件必须是 600 或更严权限,否则服务拒绝启动;
写文件同样不要把密码打在命令行上(read -s 后写文件)。SIGN_SERVER_ADMIN_PASSWORD(备选):密码本身作环境变量,只给
注入密钥的编排系统用,不要直接打在命令行上。/login(首页在 /,点页脚管理登录进登录页)。管理员在管理页“调用凭证”一节给调用方分配凭证:填个名称点新建,
服务生成 key(gwk_ + 16 位十六进制)与 secret(32 字节随机,
十六进制),secret 只在新建成功时弹窗显示一次,复制后保存,列表里
不再显示;列表可查看各凭证的名称、创建时间、最近使用、吊销。
吊销后该凭证立即失效,无法恢复。
机器请求带四个头:X-Gwpack-Key(key)、
X-Gwpack-Timestamp(Unix 秒)、X-Gwpack-Nonce(调用方随机
十六进制)、X-Gwpack-Signature(小写十六进制 HMAC-SHA256)。
POST /pack 签名串是七个字段按 \n 连接、顺序固定:方法、路径、
时间戳、随机数、固件 SHA-256(十六进制)、产品名(gateway 默认
已应用)、备注;HMAC 的密钥是 secret 本身的 UTF-8 字节。打包是
共用的,不再有设备密钥进签名串。GET /key-package 签名串也是
七个字段:方法、路径、时间戳、随机数、版本、序列号、产品名
(gateway 默认已应用)。版本不进 /pack 签名串:版本由服务端
从上传固件的版本块读,调用方根本不发版本;版本仍被签名管着——
签名串里有固件 SHA-256,版本写在固件块里,改版本等于改固件,
哈希一变签名就破。服务端验签外还查:时间戳与服务端时间
相差 300 秒内、随机数未用过(重放直接 401)。任何语言加载本仓
的 .so / .dll 就能调完整流程(库里已封装签名),不用自己写
HTTP 客户端;手写客户端按上面这套签名即可。
管理员在管理页(或直调接口)创建操作员账号:用户名 1–64 位、
密码至少 8 位,密码经 Argon2id 哈希后存库,明文永不落盘;
首个操作员也由此创建,没有单独的引导接口。写入工具用操作员
账号登录(POST /operator/login,JSON 用户名 + 密码),成功发
operator_session 会话 Cookie(HttpOnly、12 小时),与管理员
会话互不通用:操作员会话不能调管理员接口,管理员会话不能调
分发接口;登录失败一律 401 invalid username or password
(未知用户、已禁用、密码错三者同文,不区分)。
分发(POST /devices/issue)只走操作员会话:序列号须在已领出
批次里;首次分发后端现生成一对设备 X25519 密钥,绑定序列号
存库(公钥与私钥都存),返回给写入工具;重复分发须显式传
reissue: true,返回已存的那对密钥(不轮换、不静默换密钥)。
私钥只在分发响应里出现:设备列表、包密钥包、审计里绝不出现。
每次分发、重复分发、操作员创建、操作员禁用都记一条审计
(时间、操作员、动作、序列号、详情)。管理员禁用操作员后,
该操作员的全部在用会话立即失效;本版本没有恢复接口。
除 GET /health 外,管理接口都要先登录(未登录返回 401,前端收到 401 跳登录页)。例外:POST /pack
与 GET /key-package 浏览器管理页用会话 Cookie,机器调用方用上面的调用凭证签名,二者任一通过即可;
POST /devices 只走调用凭证;POST /operator/login 不用登录;
POST /devices/issue 只走操作员会话(不走管理员会话、不走调用凭证);
POST /devices/{序列号}/push 与 POST /devices/push-batch 只走会话,不走调用凭证;
GET /firmwares 与 GET /packages/{文件名} 公开(首页下载用)。
GET /health:存活检查,返回 ok。POST /pack:multipart 表单:bin(bin 文件)、
可选 product(产品名前缀,默认 gateway,一个服务可给多个
产品的固件签名)、可选 remark(备注)。打包是共用的:一份 bin
只出一份包文件,所有设备共用;传 device_pub 字段返回 400
(不静默忽略)。版本、构建时间、型号、固件名不传,由服务端
从上传固件的格式 2 版本块读取;没有有效格式 2 块返回 400。
成功返回 pkg 字节流,响应头带下载文件名,按约定为
{产品}-v{版本}-{打包日期时间}.pkg(如
gateway-v1.2.3-20261029-185633.pkg,重名时加 -2、-3 后缀);
同时包文件存入数据目录、记录与包密钥写入数据库(包密钥按版本
存,仅后端自用);失败返回 400/500 与文字原因。GET /key-package?version=&serial[&product=]:取某设备的包密钥包
(会话或调用凭证):按产品 + 版本取最新打包记录的包密钥,用
该序列号登记的设备公钥包成 80 字节返回;原包密钥绝不返回。
序列号无登记 → 404 no such device;版本无记录 → 404
no such firmware。包密钥包的下发通道(推送还是设备来取)另定,
本接口只提供按需取。GET /package-keys(登录):列出包密钥记录(JSON 数组,新记录在前;
字段:记录编号、产品、版本、密钥指纹、创建时间、状态)。只返回
元数据,绝不含原包密钥值。GET /package-keys/{记录编号}(登录):某条记录的原包密钥
(JSON {"firmware_id", "package_key"})。管理后台点眼睛查看原值时
调,显示后 30 秒自动重新隐藏;记录不存在或无存入的包密钥 → 404
no such package key。GET /packages/{文件名}(公开,首页下载卡直接下载):下载已存的包文件(只有数据库里有
记录的文件名才能下载)。GET /firmwares(公开,首页下载卡读网关型号最新版):列出固件记录(JSON 数组,新记录在前;字段:
编号、产品、版本、型号、固件名、文件名、包大小、包指纹、
目标设备加密公钥(共用包记录为空串)、备注、上传时间)。POST /firmwares/{编号}/remark:改一条记录的备注(表单字段 remark)。POST /firmwares/{编号}/delete:删除一条记录与其包文件。POST /api-keys(登录):新建调用凭证,请求 JSON {"name": "..."},
返回 JSON {"key_id", "secret"}(secret 只返回这一次)。GET /api-keys(登录):列出调用凭证(不含 secret)。POST /api-keys/{key}/revoke(登录,表单):吊销一个凭证。POST /devices/allocate(登录):批量领号,请求 JSON {"count": N}(1–1000),
返回 {"serials": [...]};领出即标记,同一序列号只能登记一次。POST /devices(只走调用凭证,不走会话):写入工具在线登记,请求 JSON
{"serial", "device_pub", "model", "firmware_version"};device_pub 须为
64 位十六进制;序列号须在已领出批次里、未登记过。POST /operator/login(不用登录):操作员登录,请求 JSON
{"username", "password"},成功 200 {"username"} 并以
operator_session Cookie 发会话(HttpOnly、12 小时,与管理员会话
互不通用);失败 401 invalid username or password。POST /operators(登录):管理员创建操作员,请求 JSON
{"username", "password"}(用户名 1–64 位、密码至少 8 位),密码经
Argon2id 哈希存库,返回 {"username", "disabled": false, "created_at"}
(永不含密码或哈希);重名 400。每次创建记审计。POST /operators/{username}/disable(登录):管理员禁用操作员,
返回 {"username", "disabled": true, "created_at"},同时清掉该操作员
的全部在用会话;不存在 404。本版本没有恢复接口。每次禁用记审计。POST /devices/issue(只走操作员会话):后台分发设备密钥,请求 JSON
{"serial", "model", "product"?, "reissue"?}(序列号与型号 1–64 位);
后端现生成一对设备 X25519 密钥绑定序列号存库(公钥与私钥都存),
返回 {"serial", "model", "device_pub", "device_priv", "issued_at", "issued_by"}
(公私钥均为 64 位十六进制)。私钥只在这里返回,列表接口绝不出现。
序列号不在已领出批次里 400;已分发过未传 reissue: true 400;
传 reissue: true 返回已存密钥(不轮换)并记审计;已登记但无后端
存私钥 400。每次分发与重复分发都记审计。GET /devices(登录):列出设备记录(JSON 数组,新登记在前)。
每条含当前版本(firmware_version,由状态上报更新)与最新版本
(latest_version,该型号最新共用包版本,无共用包时为空)。GET /devices/{序列号}(登录):查一条设备记录(同上两列)。POST /devices/{序列号}/push(登录):向一台设备发 firmware_update
升级命令(MQTT QoS 1),请求 JSON {"version": "..."}(可选;
不传取该型号最新共用包);包密钥按该设备公钥包成 80 字节,
命令 params 含 version、pkg_url、key_package;返回含 cmd_id
(与设备回执配对),不含 key_package 本身。命令先记库再发布
(发布失败删记录);请求头 Idempotency-Key(可选)可使重发
返回上次响应、不重复推送。POST /devices/push-batch(登录):按筛选条件圈设备、逐台发升级命令,
请求 JSON {"version"?, "model"?, "online"?, "only_outdated"?}(全部可选);
单次最多 500 台,超限返回 400;逐台结果 pushed / failed / skipped
(only_outdated 为真时,已是目标版本的设备记 skipped),一台失败不中断整批。
响应含 batch_id(本批所有命令共享);请求头 Idempotency-Key(可选)
可使重发返回上次整批响应、不重复推送。GET /devices/push/{cmd_id}(登录):查一条推送命令的回执状态
(pending / acked / timeout,含 ack_ok、ack_message、已脱敏的
ack_data、acked_at);超时窗口默认 300 秒(环境变量
SIGN_SERVER_PUSH_ACK_TIMEOUT_SECS 可改),超时后到的迟回执仍会
纠正为 acked;回执数据不含密钥材料。GET /devices/push-batch/{batch_id}(登录):查一批推送的逐台回执
状态(total / acked / timeout / pending 计数 + 逐台记录)。v1/tenants/+/devices/+/status,更新设备记录的在线状态与当前版本;
未登记的序列号忽略;同时订阅 v1/tenants/+/devices/+/cmd_ack,按
cmd_id 配对回执(首个回执为准,重复回执不覆盖;话题序列号须与
命令发往的序列号一致)。订阅会话是持久的(clean_session=false),
离线期间的回执由 Broker 排队、重连后重发补上。终态回执记录与幂等键
在 SIGN_SERVER_PUSH_RETENTION_DAYS 天(默认 30)后裁剪,存储有界。
推送失败关闭:未配 MQTT 或 Broker 连接未建立时均返回 503。/(公开,大气简约落地页):头部 [Logo] 产品 | 下载 | 支持 | 关于 | 联系
(Logo 点回 /,头部不放登录链接、不做搜索);首屏产品名 + 短标语 +
“了解产品”→#product、“下载最新版”→#downloads(文字排版,无产品照片);
产品区只放注册型号(stm32h743vi / gateway);下载区恰好两张精选卡
(只放最新版,不列目录、不放旧版):固件卡是打包加密后的 pkg 包
(GET /firmwares 取网关型号最新版,GET /packages/{文件名} 直接下载,
免登录,绝不是原始固件),写入工具卡是最新 firmware-tool 发布版
(链到 https://github.com/ffffxxxjjj/firmware-tool/releases,版本、日期、
大小只在发布数据有时显示,校验值没有就省略);另有“旧版与手册 →”链到支持条。
页脚有导航链接 + 管理登录链接(→/login)+ 版权。/login(以前在 /,行为不变);旧 /download 只跳转到
/#downloads,旧链接不断;管理台头部有 Logo(与首页同一文字 Logo),点它跳回首页。POST /published、
GET /published、GET /published/{文件名} 三接口已删(不单独做发布页;
发布走管理台打包,即 POST /pack)。接口全量(参数表、返回表、错误表、调用示例)见 ../docs/sign-server-api.md。
固件记录字段:产品、版本、型号、固件名、文件名、包大小、包指纹(SHA-256)、 目标设备加密公钥(共用包记录为空串)、备注、上传时间。固件记录里没有私钥字段; 包密钥另存在包密钥表里,只给后端包密钥包与管理员点眼睛查看用, 列表接口只给指纹不给原值。
单测:
cd sign
cargo test -p sign-server
覆盖:文件名重名加后缀、记录增删改查往返、产品名前缀校验、 页面转义、种子文件加载(600 文件通过、644 文件拒绝、坏内容拒绝)、 调用凭证新建/列表/吊销往返、请求验签(通过、错密钥、固件被改、 时间戳过期、未知 key、已吊销、重放)。
端到端用 curl 测(单测 cargo test --workspace 全过):
起服务后上传一个 bin,拿回共用包;按版本 + 已登记序列号取包密钥包,
再用 sign-tool unpack(云端签名公钥 + 解密私钥 + 包密钥包文件)
写出明文,以 sign-tool cmp 与原 bin 比对一致;
再测列表页出现记录、包密钥记录出现指纹、下载包文件、改备注、
删除记录与文件(连带删除包密钥)。
回执配对单测(cargo test --workspace 全过:服务端 38 项、工具库 27 项、
命令行 3 项;服务端原 26 项 + 新增 12 项):命令记库往返、重复回执首胜、
超时后迟回执纠正、超时标记与派生状态、回执脱敏与截断、裁剪保待办、
批量顺序、幂等存取与裁剪;回执话题解析、配对与忽略、串号不匹配与重复、
回执脱敏。
回执配对故障注入与 soak(真实服务端二进制 + 迷你 MQTT Broker,5 台假设备,
回执超时测试值 5 秒):单台回执配对正确;静默设备 5 秒窗后标 timeout;
重复回执首个为准(后到的不覆盖);回执数据脱敏后只剩 {"version":"9.9.9"};
批量 5 台全推,汇总 total 5、acked 3、timeout 2、pending 0,逐台与预期一致;
同 Idempotency-Key 重放返回逐字节相同响应、设备只多收 1 条命令;
Broker 挂掉时推送 503 失败关闭,重启后重订阅恢复、推送与回执正常;
服务端重启后离线期回执经持久会话排队补回(catchup 配对成功)。
Soak 300 秒:592 轮推送 + 回执轮询,丢失 0 轮;内存 RSS 11888KB → 6512KB
(增长 -5376KB,无增长趋势);命令记录行 12 → 604,增量 592 与轮数一致、无重复行。