fix(packaging): 播报 workflow 的种子只在 main 注入 + --key 用法错回 2 + 指针修正(M255 r1 P2-1/2/3)

P2-1(安全):`signing-status.yml` 原先把生产签名种子注入到 `pull_request` 与任意 ref 的
`workflow_dispatch` 运行里,而 CI 跑的是那个 ref 自己的代码 ⇒ 任何能推分支的人都能把种子搬走
(这把种子能给全量已装客户端签可信清单)。改成:
  - 去掉 `pull_request` 触发;
  - 注入行加 ref 门:`github.ref == 'refs/heads/main' && secrets.… || ''`;
  - 非 main 用 `ULL_SIGNING_KEY_WITHHELD` 说明原因,于是 ② 读「未判定(未注入)」而不是「没配」。
runbook §2 步② 加硬顺序:先合这笔、再录 Secret。

P2-2:`--key` 读不了(不存在 / 是目录 / 非 UTF-8)曾在 main 的 try 之外抛 traceback + rc=1,
与「未就绪」同码;现在统一 rc=2 + 一行说明,并补用例 `key_unreadable`。

P2-3:4 处文案指针修正(`signing-ready.yml` → `signing-status.yml`;§3 → §2 步③;§1–§3 →
§2 步①–③;§4 → §2 步⑤ / §5)。新增两条机检:`section_refs`(输出的 `§N` 必须是 runbook 真实
章节号)与 `signing_ready_wiring_selftest.py`(文档里的 `packaging/…`/`crates/…`/`.github/…`
路径必须真存在),后者另带三处变异重放:去掉 ref 门 / 加回 pull_request / 改坏文档路径 ⇒ 必须红。

顺带修掉 r1 复审指出的两处小问题:`--release-assets` 里只有 `manifest.json.sig` 时的子串假读数
(先剥 `.sig` 再判清单)、③ 那个从不参与判定的 bool 保持原样(无行为影响)。

selftest 拆成两份(400 行红线):判据本体 + 接线/文案。guards.yml 仍 399 行零改动。
This commit is contained in:
AskaEth committed 2026-11-11 20:17:04 +08:00
1 parent bff48e27fa
commit bc1b09b776
6 files changed
+397 -86

No files matched your search

+30 -9
View File
@@ -42,11 +42,20 @@ job summary 里(见 §6 —— 它是播报,不是门禁)。
下面每一行都只有命令与占位符,**没有任何密钥值**。做完一步就把该步的验证跑一遍。
> 🔴 **顺序硬要求(M255 r1 P2-1):先合并「播报 workflow 的 ref 门」那笔改动,再录 Secret。**
> 播报 workflow(`.github/workflows/signing-status.yml`)会把 `ULL_MANIFEST_SIGNING_KEY` 注入到
> 就绪度那一步的环境里;那个注入**只在 `refs/heads/main` 上发生**,而且该 workflow **没有**
> `pull_request` 触发。这两条是安全边界:仓库内分支的 PR 与任意分支的 `workflow_dispatch` 都拿得到
> 仓库 Secret,而 CI 会在**那个 PR 自己的代码**上执行 checker —— 种子一旦进过那种运行,等于交给
> 任何能推分支的人(这把种子能给**全量已安装客户端**签可信清单,即用户端 RCE)。
> 所以:**ref 门那笔没合进去之前不要录 Secret**;录之前先确认线上版本里有
> `github.ref == 'refs/heads/main'` 这个条件式(§6 也写了这条)。
```bash
# ① 生成钥(⚠ 输出是敏感物;不要跑在 CI 里,不要重定向到仓库里的文件)
python3 packaging/sign_manifest.py --generate-key --key-id <key-id,例 ull-release-2026a>
# ② 把打印出来的那一行 JSON({"key_id": …, "seed_hex": …})贴进仓库 Secret:
# ② 把打印出来的那一行 JSON({"key_id": …, "seed_hex": …})贴进仓库 Secret(⚠ 先读上面的顺序硬要求):
# Settings → Secrets and variables → Actions → New repository secret
# 名字必须**逐字**是:ULL_MANIFEST_SIGNING_KEY
# (等价 CLI:gh secret set ULL_MANIFEST_SIGNING_KEY,然后按提示粘贴,别用 -b 把值留在 shell 历史里)
@@ -106,8 +115,9 @@ sign_manifest.py --verify-pinned dist/manifest.json # 用钉住的公钥回验
6. **`set -x` / `echo $ULL_MANIFEST_SIGNING_KEY`**:一次就够把私钥泄进日志。工作流里那一步显式 `set +x`
且只回显**公钥**;本机调试同理,别开 trace。
7. **`--generate-key` 的输出落进文件/issue/聊天**:当成泄露处理,走 `signing.md` §5 轮换(旧钥签新钥)。
8. **fork / Dependabot 的 PR 拿不到仓库 Secret**:那种运行里 `check_signing_ready.py` 的 ② 必然是
「未就绪」,这是**环境限制**,不是仓库状态。以 `main` 上的读数为准。
8. **以为「非 main 分支的读数」就是仓库状态**:播报 workflow **只在 `refs/heads/main` 上注入** Secret
(也没有 `pull_request` 触发 —— 见 §2 的顺序硬要求与 §6)。非 main ref 的运行里 ② 显示
「**未判定** —— 非 main ref(按设计不注入 Secret)」,这是**策略**,不是「没配」。以 `main` 上的读数为准。
9. **`.sig` 没上传到 Release**(手改过 `assets=(…)` 那一行):客户端取 `<产物根>/manifest.json.sig`
会 404 ⇒ 所有人验签失败。上传后请在资产列表里**亲眼看到** `manifest.json` 与 `manifest.json.sig`
两个名字(§5 的 `gh release view` 那条命令就是干这个的)。
@@ -115,6 +125,9 @@ sign_manifest.py --verify-pinned dist/manifest.json # 用钉住的公钥回验
并且至少一个发布周期的双签名之后(`signing.md` §5)。
11. **「昨天验过」不等于「今天验得过」**:`PRODUCTION` 是**编译期**常量,改 `trust.rs` 只影响**下一次构建**。
已在用户盘上的旧二进制永远用旧表 —— 这是设计,不是 bug。
12. **给播报 workflow 加回 `pull_request` 触发、或去掉注入行的 ref 门**:等于把生产签名种子交给任意推
分支的人的代码(自更新链 = 用户端 RCE)。这两处各有接线用例钉着(去掉任一处都会红),
别为了「PR 上也能看读数」去动它 —— 看读数请对 `main` 跑,或读 §1 在本地跑。
## 5. 发布后回验(发布侧,不需要私钥)
@@ -132,16 +145,21 @@ python3 packaging/sign_manifest.py --verify-pinned /tmp/verify/manifest.json
## 6. CI 里那条读数:`Signing status(播报)`
`.github/workflows/signing-status.yml` 每天在 `main` / PR / 手动 dispatch 上跑一次,把 §1 的三行搬进
job summary。**它不是门禁**:
`.github/workflows/signing-status.yml` 在 `push: branches: [main]` 与**手动 dispatch** 上跑,把 §1 的三行
搬进 job summary。**它不是门禁**:
* 它**不应被设为 required check**(分支保护里不要勾它),也不阻断任何其它 job;
* 它就绪度那一步**恒 `exit 0`**:未就绪用 `::warning::` 播报,不用 error 级注解(那会让人以为构建挂了);
* **生产签名种子只在 `refs/heads/main` 上注入**(`github.ref == 'refs/heads/main' && secrets.… || ''`),
而且**没有 `pull_request` 触发**。理由是安全:仓库内分支的 PR / 任意分支的 dispatch 都拿得到仓库 Secret,
而 CI 跑的是那个 ref 自己的代码 —— 种子进过那种运行就等于交给任何能推分支的人。
非 main ref 的运行里 ② 显示「**未判定** —— 非 main ref(按设计不注入)」,**不写「没配」**;
* 它**刻意不声明 `workflow_call:`**,所以别的 workflow 没法 `uses:` 它;接线自检还会扫全部 workflow,
任何 `uses: …/signing-status.yml` 出现即红(防的是「顺手接进 dev 线把播报变成门禁」);
* 它**绿**只说明脚本跑完了;结论只看 summary 里的三行字。
* 同一 workflow 里另有一步跑 `packaging/tests/signing_ready_selftest.py`:那一步**会红**,红 = **尺子坏了**
(三行读数/退出码/变异重放/接线判据之一没过),与「签名未就绪」是两件不同的事,措辞在 summary 里分开写。
* 同一 workflow 里另有两步跑自测(`packaging/tests/signing_ready_selftest.py` +
`packaging/tests/signing_ready_wiring_selftest.py`):那两步**会红**,红 = **尺子坏了**(三行读数/退出码/
变异重放/接线与 ref 门之一没过),与「签名未就绪」是两件不同的事,措辞在 summary 里分开写。
为什么不做成门禁:今天已经有四次「守卫假绿」的账(见 `packaging/README.md` §12 里那几条先例)。
把一条**按定义今天必然失败**的检查做成 required,只会有两种结局 —— 要么所有人学会忽略红叉,
@@ -157,13 +175,16 @@ job summary。**它不是门禁**:
②③ 两步要动 GitHub Secret 与 Rust 常量,同理。所以本手册是「按现有脚本的接口写的步骤」,
首次执行时**以脚本的实际输出为准**,跑完请回来修正本文档。
* **「用户机器上真的更新成功」**没有在本条里验:需要一次真 Release + 一台装着旧版本的机器。
* **ref 门只在本地验证过**(抽 `run:` 块用 Git Bash 跑 + 接线用例的文本变异),没有在真 Actions 上看过
`secrets` 的求值结果 —— 第一次真跑请确认非 main ref 的 ② 显示「未判定」。
## 8. 相关文件
| 文件 | 作用 |
|---|---|
| `packaging/check_signing_ready.py` | 三行就绪度 + `0/1/2` 退出码(只读、零第三方依赖) |
| `packaging/tests/signing_ready_selftest.py` | 判据自测:三行/退出码/两个方向的变异重放/接线不是门禁 |
| `.github/workflows/signing-status.yml` | 把三行搬进 job summary 的**播报** workflow(不是门禁) |
| `packaging/tests/signing_ready_selftest.py` | 判据自测:三行/退出码/两向变异重放/`--key` 用法错/指针章节 |
| `packaging/tests/signing_ready_wiring_selftest.py` | 接线自测:不是门禁 / 不被 `uses:` / **ref 门** / 文档路径存在性 + 三处变异 |
| `.github/workflows/signing-status.yml` | 把三行搬进 job summary 的**播报** workflow(不是门禁;种子只在 main 注入) |
| `packaging/docs/signing.md` | 为什么签名、威胁模型、轮换纪律、四条发布侧命令的红法对照表 |
| `packaging/sign_manifest.py` | 生成钥 / 签名 / 对账 / 回验的唯一实现(`--self-test` 跑 RFC 8032 向量) |