feat(packaging): 签名就绪度播报 + 操作手册(M255)
M254 查出 `trust.rs` 的 `PRODUCTION` 表在 PINNED-KEYS 标记之间是空的 ⇒ 任何正式二进制都验不过 任何清单(fail-closed)。工具链早就齐了,缺的是操作步骤与可见性;本条补的就是这两样,不生成钥、 不改 Rust、不碰 release-publish 的判据。 - `packaging/check_signing_ready.py`(只读、零第三方依赖):三行读数 ① 信任表空不空 ② 环境有没有钥 (顺手对账「这把钥在不在表里」,种子一个字节都不打印)③ 最近一次发布的 .sig/清单痕迹(取不到写 「未知」)。退出码 0 就绪 / 1 未就绪(打印 fail-closed 后果 + 一行修复命令)/ 2 用法错。 - `packaging/tests/signing_ready_selftest.py`:判据自测,含**两个方向的变异重放**(退出码永远 0 ⇒ 判据①必须红;永远 1 ⇒ 判据②必须红)与接线判据(没人 uses: 它、没有 error 级注解、就绪度那步恒 exit 0)。 - `packaging/docs/signing-runbook.md`:人类照做的 5 步 + 每步判据 + 11 条已知坑 + 没验的部分(无任何密钥值)。 - `.github/workflows/signing-status.yml`:把三行搬进 job summary 的**播报** workflow。guards.yml 已 399/400,与 gpu-evidence.yml 同先例另开文件;刻意不声明 workflow_call:,未就绪用 ::warning::、 就绪度那步恒 exit 0,明确「不应被设为 required」。 - `packaging/README.md` / `packaging/docs/signing.md`:索引与指针。
This commit is contained in:
1 parent
fbb663c87c
commit
bff48e27fa
6 files changed
+828
No files matched your search
@@ -0,0 +1,105 @@
|
||||
name: Signing status(播报,不是门禁)
|
||||
|
||||
# 第一句就是口径:**这是播报,不是门禁。它红了不代表不能合,它绿了也不代表签名就绪。**
|
||||
# 就绪度的结论只在 job summary 的三行字里(`packaging/check_signing_ready.py --line` 写的),
|
||||
# 不在 job 的颜色里。**本 workflow 不应被设为 required check**(GitHub 侧分支保护里不要勾它)。
|
||||
#
|
||||
# 为什么要有它(M255):M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION`
|
||||
# 表在 `PINNED-KEYS` 标记之间是空的,而空表一律 `KeyringEmpty` 拒装 ⇒ 今天任何正式二进制都验不过任何
|
||||
# 清单(fail-closed,用户端表现为「更新永远失败」)。工具链早就齐了,缺的是**可见性**:这三行读数让
|
||||
# 「今天差什么」在每次运行里都摆在台面上,而不是等发版时才发现。
|
||||
#
|
||||
# 口径三条(由 `packaging/tests/signing_ready_selftest.py` 的接线判据机器校验,不是靠注释自律):
|
||||
# ① 就绪度那一步抓 `rc=$?` 之后**恒 `exit 0`** —— 未就绪用 `::warning::` 播报,不用 error 级注解
|
||||
# (error 会让人以为构建挂了,与播报定位直接冲突)。真正的红留在 `release-publish.yml` 的签名步:
|
||||
# 那里今天必然红是**对的**,因为签不出来的产物本来就不能发。
|
||||
# ② 文件名/名字都不带 guards、check 字样:它不是守卫,是一条状态播报。
|
||||
# ③ 本文件**刻意不声明 `workflow_call:`**:没有这个触发器,别的 workflow 就没法 `uses:` 它 ——
|
||||
# 「不能被接进 dev 线变成必需检查」是一条机制,不是一句嘱咐。接线自检另外扫全部 workflow,
|
||||
# 任何 `uses: …/signing-status.yml` 出现即红(防的是有人先加 workflow_call 再接进来)。
|
||||
#
|
||||
# 判据本体与 0/1/2 的语义:`packaging/check_signing_ready.py` 的文件头;
|
||||
# 人类要做的那几步:`packaging/docs/signing-runbook.md`。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# 尺子自测:它红 = **判据脚本坏了**(不是「签名没就绪」)。两者措辞在 summary 里分开写。
|
||||
# 这一步照常会红,因为「尺子坏了」本来就该有人看见 —— 但它不阻断任何东西:本 workflow 独立、
|
||||
# 非必需,也不在 `dev-guards.yml` 的 `uses:` 里。
|
||||
selftest:
|
||||
name: 判据自测(三行/退出码/变异重放/接线)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 三行读数 + 0/1/2 语义 + 「退出码改绿必须被抓到」+ 接线不是门禁
|
||||
run: python3 packaging/tests/signing_ready_selftest.py
|
||||
- name: 真跑三种情形(今天的状态 / 假 PinnedKey 副本 / 用法错)
|
||||
run: |
|
||||
set -uo pipefail
|
||||
work="$RUNNER_TEMP/signing-ready"
|
||||
mkdir -p "$work"
|
||||
# 结论只打印,不判定:这里要的是「三种情形各自的退出码与三行读数长什么样」,
|
||||
# 判据在上一跑的 selftest 里。所有 `|| true` 只为让这一步本身不变红。
|
||||
run_and_show() { # run_and_show <说明> <命令...>
|
||||
note="$1"; shift
|
||||
echo "── $note"
|
||||
python3 packaging/check_signing_ready.py "$@" 2>&1 || true
|
||||
echo
|
||||
}
|
||||
run_and_show "今天的状态(真实 trust.rs:表空 ⇒ 未就绪)"
|
||||
# 副本夹一条假 PinnedKey(**不碰真实 trust.rs**):造出「就绪」的正路长什么样。
|
||||
python3 - "$work" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
trust = Path("crates/core/src/update/trust.rs").read_text(encoding="utf-8")
|
||||
begin = trust.index(">>> PINNED-KEYS >>>") + len(">>> PINNED-KEYS >>>")
|
||||
end = trust.index("<<< PINNED-KEYS <<<")
|
||||
fake = ' PinnedKey { key_id: "demo-not-a-real-key", public_key_hex: "' + "ab" * 32 + '" },\n'
|
||||
Path(sys.argv[1], "trust-with-fake-key.rs").write_text(
|
||||
trust[:begin] + "\n" + fake + trust[end:], encoding="utf-8"
|
||||
)
|
||||
PY
|
||||
run_and_show "副本夹一条假 PinnedKey(②仍缺钥 ⇒ 仍未就绪,但 ① 会变就绪)" \
|
||||
--trust "$work/trust-with-fake-key.rs"
|
||||
run_and_show "用法错(不存在的 trust.rs ⇒ 2)" --trust "$work/nope/trust.rs" || true
|
||||
|
||||
# 播报:只把三行搬进 job summary,不看颜色。
|
||||
status:
|
||||
name: 签名就绪度(播报)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# ② 要读「环境里有没有钥」,所以把 Secret 传进来(与 release-publish.yml 的做法一致)。
|
||||
# 脚本只回显 key_id 与**公钥**,种子(seed_hex)一个字节都不打印 —— 用例 no_seed_leak 钉着这条。
|
||||
- name: 三行就绪度(脚本给 0/1/2;本步恒 exit 0)
|
||||
env:
|
||||
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
set +e
|
||||
python3 packaging/check_signing_ready.py --line > "$RUNNER_TEMP/signing-ready.txt" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
cat "$RUNNER_TEMP/signing-ready.txt"
|
||||
case "$rc" in
|
||||
0) echo "::notice::签名就绪:信任表非空,且环境里的钥与它同源。这是一条播报,不是门禁。" ;;
|
||||
1) echo "::warning::签名未就绪:今天任何正式二进制都验不过任何清单(fail-closed)。该做什么见 packaging/docs/signing-runbook.md §2 的下单清单。" ;;
|
||||
*) echo "::warning::就绪度脚本用不了(rc=$rc,用法错):这是尺子的问题,不是签名状态 —— 看上面的 stderr。" ;;
|
||||
esac
|
||||
{
|
||||
echo
|
||||
echo "- 本 workflow 是**播报**,不是门禁:**不应被设为 required**,也不阻断任何其它 job。"
|
||||
echo "- 颜色 ≠ 结论:绿色只说明脚本跑完了;结论只看上面三行(尤其 ①「信任表」)。"
|
||||
echo "- fork / Dependabot 的 PR 运行拿不到仓库 Secret ⇒ ② 必然是「未就绪」;以 main 上的运行为准。"
|
||||
echo "- 真门禁在哪:\`release-publish.yml\` 的签名步(\`--check-pinned-keyring\` / \`--sign\` / \`--verify-pinned\`)。"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
@@ -81,6 +81,10 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
||||
| `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 |
|
||||
| `packaging/check_test_key_off.py` | 测试公钥守门(M190 r3):把公开的测试公钥并进信任表的那条 feature 只许出现在 `crates/core/Cargo.toml` 自己的声明与 `crates/presenter/Cargo.toml` 的 `[dev-dependencies]` 段内,其余(workflow / 打包脚本 / 根 `Cargo.toml` / `.cargo/config*.toml` / presenter 的 `[dependencies]`)出现即红;`--self-test` 注入 7 条向量(5 红 / 2 绿)+ 基线;其中一条专治「core 的声明里偷偷带子 feature」(`update-test-key = ["update-test-source"]` ⇒ 红) | ≤400 |
|
||||
| `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 |
|
||||
| `packaging/check_signing_ready.py` | 签名就绪度播报(M255):**只读**、零第三方依赖,三行读数 —— ① `PRODUCTION` 表空不空(解析 `PINNED-KEYS` 标记之间)② 环境里有没有钥(`ULL_MANIFEST_SIGNING_KEY` / `--key`,顺手对账「这把钥在不在表里」,**种子一个字节都不打印**)③ 最近一次发布的 `.sig`/清单痕迹(取不到写「未知」,不写「无」)。退出码 `0` 就绪 / `1` 未就绪(打印「这会让已安装用户永远验不过(fail-closed)」+ 一行修复命令)/ `2` 用法错(含标记丢了 —— 那时形不成判断) | ≤400 |
|
||||
| `packaging/tests/signing_ready_selftest.py` | 上一条的判据自测(M255):表空必 `exit 1` 且三行都打印 / **副本**夹一条假 `PinnedKey` 变 `exit 0`(不碰真实 `trust.rs`)+ 不泄种子 / ② 对账 / ③ 有-无-未知 / 用法错 = 2 / `--line` 写 summary / **两个方向的变异重放**(「退出码永远 0」⇒ 判据①必须红;「永远 1」⇒ 判据②必须红)/ **接线判据**:没有别的 workflow `uses:` 它、文件里没有 error 级注解、就绪度那步 `rc=$?` 后 `exit 0`。不覆盖:整个 workflow 被删后没人跑它(与 `gpu_shot_ci_selftest.py` 同一条边界) | ≤400 |
|
||||
| `.github/workflows/signing-status.yml` | 就绪度**播报**接线(M255):把三行搬进 job summary。**不是门禁**:未就绪用 `::warning::`、就绪度那步**恒 `exit 0`**、**不应被设为 required**、刻意不声明 `workflow_call:`(别人没法 `uses:` 它,接线自检另外扫全仓防「顺手接进 dev 线」)。同 job 另有一步跑上面的自测(那一步会红 = 尺子坏了,与「未就绪」分开写)。新开文件而不是并进 `guards.yml`:那份已 399/400 行 | ≤400 |
|
||||
| `packaging/docs/signing-runbook.md` | **人类照做**的签名手册(M255):现状读数 → 5 步下单清单(生成钥 / Secret 名字逐字 / 贴 `PinnedKey` / 发带 `.sig` 的 Release / `--verify-pinned` 回验)→ 每步判据 → 11 条已知坑 → 发布后回验 → 播报≠门禁 → **没验的部分**(Authenticode、托管签名、真机更新端到端、本手册命令未真跑)。不含任何真实密钥,全是占位符 | ≤400 |
|
||||
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
|
||||
| `packaging/docs/linux-evidence.md` | Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤400 |
|
||||
| `packaging/docs/gpu-evidence.md` | GPU 截图证据账(M249 + §8 CI 接线 M253:无 GPU 显式跳过、跳过 ≠ 通过、21 条接线变异):判据逐条「抓得住什么/抓不住什么」、本机实跑退出码、**能证**(字形栅格与边缘 AA / 面板 α 与玻璃 / 两后端一致性)与**不能证**(首帧与帧率 / 整窗 α 与圆角观感,走 DWM 任何截图都拍不到)、历史 `--diff` 基线永久不可复核 | ≤400 |
|
||||
@@ -215,5 +219,10 @@ CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复
|
||||
的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的
|
||||
**编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装,
|
||||
**不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。
|
||||
- **签名就绪度与操作步骤(M255)**:今天 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的** ⇒ 任何正式
|
||||
二进制都验不过任何清单(fail-closed)。读数一条命令:`python3 packaging/check_signing_ready.py`
|
||||
(三行 + `0/1/2`,未就绪时打印一行修复命令);CI 里同一条读数在 `signing-status.yml` 的 job summary
|
||||
上,**那是播报不是门禁**(不应设为 required)。要动手做的那 5 步在
|
||||
[`docs/signing-runbook.md`](docs/signing-runbook.md)(不含任何真实密钥)。
|
||||
- **不得混淆**:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,**不解决 SmartScreen**
|
||||
——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""签名就绪度播报(M255):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。
|
||||
|
||||
为什么要它:M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在
|
||||
`PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 于是**今天任何正式二进制都验不过
|
||||
任何清单**(fail-closed 是成立的,但在用户机器上表现为「更新永远失败」)。工具链早就齐了
|
||||
(`sign_manifest.py` 的 `--generate-key` / `--check-pinned-keyring` / `--sign` / `--verify-pinned`),
|
||||
缺的是**操作步骤与可见性**。本脚本只负责「现在差什么」,它不生成钥、不写任何文件、不改 `trust.rs`。
|
||||
|
||||
三行就绪度(各一条判据,互不替代):
|
||||
|
||||
| 行 | 判据 | 未就绪意味着 |
|
||||
|---|---|---|
|
||||
| ① | `PRODUCTION` 表非空:解析 `PINNED-KEYS` 标记之间的 `PinnedKey` 条目,0 条即未就绪 | 客户端一把公钥都没有,任何清单都验不过(今天就是这个状态) |
|
||||
| ② | 环境里有签名钥:`ULL_MANIFEST_SIGNING_KEY`(或 `--key <文件>`)能解出 32 字节种子,**且**它的公钥在 ① 的表里 | 没钥签不了;钥与信任表不是同一把 ⇒ 发布时 `--check-pinned-keyring` 当场红 |
|
||||
| ③ | 最近一次发布的 `manifest.json` / `.sig` 痕迹 | **不参与就绪判定**。取不到证据就写「未知」——「本地没留档」不等于「上次没签」 |
|
||||
|
||||
②里的对账(`公钥 ∈ 表`)是刻意加的:只说「有钥」会给出一个**看着就绪、发布却红**的假象,与本仓
|
||||
「别再造假绿的尺子」的血泪账直接冲突。公钥是公开物,回显它是安全的;**种子(`seed_hex`)在本脚本里
|
||||
一次都不打印**。
|
||||
|
||||
退出码是对外接口(改动即行为变化,`packaging/tests/signing_ready_selftest.py` 钉着它):
|
||||
`0` 就绪 / `1` 未就绪 / `2` 用法错(含 trust.rs 读不到、标记丢了 —— 那时脚本形不成判断,不是「未就绪」)。
|
||||
|
||||
**它不是门禁**:CI 里的 `.github/workflows/signing-ready.yml` 只把这三行搬进 job summary,
|
||||
不因「未就绪」变红。理由见 `packaging/docs/signing-runbook.md` §6。
|
||||
|
||||
python3 packaging/check_signing_ready.py
|
||||
python3 packaging/check_signing_ready.py --trust <副本 trust.rs> --key <钥文件> --dist dist
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SIGN = ROOT / "packaging/sign_manifest.py"
|
||||
RUNBOOK = "packaging/docs/signing-runbook.md"
|
||||
#: 未就绪时的固定措辞:判据与「人类一眼看懂后果」绑在一起(用例按字面断言这一句)。
|
||||
FAIL_CLOSED = "这会让已安装用户永远验不过(fail-closed)"
|
||||
|
||||
sys.path.insert(0, str(ROOT / "packaging"))
|
||||
|
||||
# 标记 / 环境变量名 / 信任表路径一律**从 sign_manifest.py 取**,不在这里写第二份字面量:
|
||||
# 两份标记各自漂开时,`--check-pinned-keyring` 与就绪度会给出互相矛盾的读数。
|
||||
from sign_manifest import KEY_ENV, KID_RE, PIN_BEGIN, PIN_END, TRUST_RS, load_key # noqa: E402
|
||||
from ed25519_ref import public_key # noqa: E402
|
||||
|
||||
Line = tuple[bool, str] # (就绪?, 该行文本)
|
||||
|
||||
|
||||
def table_state(path: Path) -> tuple[bool, str, list[tuple[str, str]]]:
|
||||
"""① 判据:`PINNED-KEYS` 标记之间有至少一条 `PinnedKey`。
|
||||
|
||||
标记缺失 / 文件读不到 ⇒ `RuntimeError`(调用方翻成退出码 2:脚本无法形成判断)。
|
||||
"""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
begin, end = text.find(PIN_BEGIN), text.find(PIN_END)
|
||||
if begin < 0 or end < begin:
|
||||
raise RuntimeError(
|
||||
f"{path} 里找不到 {PIN_BEGIN} / {PIN_END} 标记(标记本身不许动 —— sign_manifest.py 也按它解析)"
|
||||
)
|
||||
inside = text[begin:end]
|
||||
keys = KID_RE.findall(inside)
|
||||
line_no = text[:begin].count("\n") + 1
|
||||
if keys:
|
||||
names = "、".join(f"{kid}({pk[:16]}…)" for kid, pk in keys)
|
||||
return True, f"就绪 —— 标记之间 {len(keys)} 条:{names}({path}:{line_no} 起)", keys
|
||||
# 空表就是今天的状态。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。
|
||||
# ⚠ 本仓另有一把 `TEST_ONLY` 测试钥**本来就在标记之外**(那是它的正确位置),所以这里只**点名**,
|
||||
# 不替人下结论 —— 上一版写成「贴错位置,等于没钉」,在真实 trust.rs 上是误报(实测抓出来的)。
|
||||
strays = [kid for kid, _ in KID_RE.findall(text[:begin]) + KID_RE.findall(text[end:])]
|
||||
hint = ""
|
||||
if strays:
|
||||
hint = (
|
||||
f";文件里另有 {len(strays)} 条在标记之外(key_id:{'、'.join(strays[:3])})"
|
||||
"—— `TEST_ONLY` 那条本来就在外面,属正常;**生产钥**贴在外面等于没钉"
|
||||
)
|
||||
return False, f"未就绪 —— 标记之间 0 条 PinnedKey{hint}({path}:{line_no} 起)", []
|
||||
|
||||
|
||||
def key_state(key_file: str | None, pinned: list[tuple[str, str]]) -> tuple[bool, str, str | None]:
|
||||
"""② 判据:环境里有钥、解析得出、且它的公钥在 ① 的表里。
|
||||
|
||||
返回 `(就绪?, 该行文本, 可直接贴进 trust.rs 的那一行 PinnedKey)`。
|
||||
**任何分支都不打印种子**;回显的只有 `key_id` 与公钥十六进制。
|
||||
"""
|
||||
raw = Path(key_file).read_text(encoding="utf-8") if key_file else os.environ.get(KEY_ENV, "")
|
||||
source = f"--key {key_file}" if key_file else f"环境变量 {KEY_ENV}"
|
||||
if not raw.strip():
|
||||
return False, f"未就绪 —— {source} 未设置/为空(没有私钥就签不出任何清单)", None
|
||||
try:
|
||||
key_id, seed = load_key(raw)
|
||||
except SystemExit as error: # load_key 用 SystemExit 报错(它会 sys.exit);这里降级成一行读数
|
||||
return False, f"未就绪 —— {source} 读不出钥:{error}", None
|
||||
pk_hex = public_key(seed).hex()
|
||||
paste = f'PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},'
|
||||
for pinned_id, pinned_hex in pinned:
|
||||
if pinned_hex.lower() == pk_hex:
|
||||
note = "" if pinned_id == key_id else f"(表里叫 {pinned_id},钥文件叫 {key_id};判据是公钥字节,能过)"
|
||||
return True, f"就绪 —— {source} 里有钥 {key_id},公钥 {pk_hex} 已在信任表里{note}", paste
|
||||
if pinned:
|
||||
return False, f"未就绪 —— {source} 里有钥 {key_id}(公钥 {pk_hex}),但**不在**信任表里,发布时会被拦", paste
|
||||
return False, f"未就绪 —— {source} 里有钥 {key_id}(公钥 {pk_hex}),但信任表是空的,没处钉", paste
|
||||
|
||||
|
||||
def release_state(dist: Path | None, assets: Path | None) -> tuple[bool | None, str]:
|
||||
"""③ 判据:最近一次发布有没有 `manifest.json` / `.sig` 痕迹。**只取证,不参与就绪判定。**
|
||||
|
||||
`None` = 未知(既没给 `--dist` 里的留档,也没给 `--release-assets`)。「本地没留档」不是
|
||||
「上次没签」的证据,所以这一档只写「未知」,不写「无」。
|
||||
"""
|
||||
if dist is not None and dist.is_dir():
|
||||
manifest = dist / "manifest.json"
|
||||
signature = dist / f"{manifest.name}.sig"
|
||||
if manifest.is_file() and signature.is_file():
|
||||
return True, f"有 —— 本地留档 {manifest} + {signature}"
|
||||
if manifest.is_file():
|
||||
return False, f"无 —— 本地 {dist} 里有 manifest.json 但**没有** .sig(那份清单客户端会拒收)"
|
||||
if assets is not None and assets.is_file():
|
||||
text = assets.read_text(encoding="utf-8", errors="replace")
|
||||
has_manifest = "manifest.json" in text
|
||||
has_sig = f"manifest.json.sig" in text
|
||||
if has_manifest and has_sig:
|
||||
return True, f"有 —— {assets} 的资产清单里同时有 manifest.json 与 manifest.json.sig"
|
||||
if has_manifest:
|
||||
return False, f"无 —— {assets} 的资产清单里有 manifest.json,但没有 manifest.json.sig(没签名)"
|
||||
return False, f"无 —— {assets} 的资产清单里既没有 manifest.json 也没有 .sig(那次发布没走上签名这条链)"
|
||||
looked = [str(p) for p in (dist, assets) if p is not None]
|
||||
where = f"(看过:{'、'.join(looked)})" if looked else "(没给 --dist / --release-assets,也没本地 dist/ 留档)"
|
||||
return None, f"未知 —— 取不到任何发布产物证据{where}"
|
||||
|
||||
|
||||
def fix_hint(table_ok: bool, key_ok: bool, paste: str | None) -> str:
|
||||
"""未就绪时的**一行**修复命令(不带任何密钥值;钥的事只指向 Secret 名与手册)。"""
|
||||
if not table_ok and paste:
|
||||
return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §3)"
|
||||
if not table_ok:
|
||||
return (
|
||||
"修复:python3 packaging/sign_manifest.py --generate-key --key-id <key-id> "
|
||||
f"→ 私钥贴进 Secret {KEY_ENV},打印的 PinnedKey 那行贴进 trust.rs 标记之间(手册 {RUNBOOK} §1–§3)"
|
||||
)
|
||||
if not key_ok and paste:
|
||||
return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §3)"
|
||||
return f"修复:把密钥贴进仓库 Secret {KEY_ENV}(内容 = --generate-key 打印的那行 JSON;手册 {RUNBOOK} §2)"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="签名就绪度播报:三行读数 + 0/1/2 退出码(M255)")
|
||||
parser.add_argument("--trust", type=Path, default=TRUST_RS, help=f"trust.rs 路径(默认 {TRUST_RS})")
|
||||
parser.add_argument("--key", metavar="FILE", help=f"钥文件(不给则读环境变量 {KEY_ENV})")
|
||||
parser.add_argument("--dist", type=Path, default=Path("dist"), help="本地留档目录(默认 dist/;不存在则不看)")
|
||||
parser.add_argument("--release-assets", type=Path, metavar="FILE", help="某次 Release 的资产清单文本(如 gh release view 的输出)")
|
||||
parser.add_argument("--line", action="store_true", help="CI 用:额外把三行写进 $GITHUB_STEP_SUMMARY")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
table_ok, table_line, pinned = table_state(args.trust)
|
||||
except (OSError, UnicodeDecodeError, RuntimeError) as error:
|
||||
print(f"error: 读不了信任表:{error}(脚本无法形成判断,按用法错退出)", file=sys.stderr)
|
||||
return 2
|
||||
key_ok, key_line, paste = key_state(args.key, pinned)
|
||||
release_ok, release_line = release_state(args.dist, args.release_assets)
|
||||
|
||||
lines: list[Line] = [
|
||||
("① 信任表 PRODUCTION", table_ok, table_line),
|
||||
("② 环境签名钥", key_ok, key_line),
|
||||
("③ 最近一次发布痕迹", release_ok is not False, release_line),
|
||||
]
|
||||
for label, _ok, text in lines:
|
||||
print(f"{label}:{text}")
|
||||
|
||||
ready = table_ok and key_ok
|
||||
if ready:
|
||||
print(f"结论:就绪({args.trust} 里钉了钥,环境里的钥与它同源)—— 可以按 {RUNBOOK} §4 发一次带 .sig 的 Release 并用 --verify-pinned 回验")
|
||||
else:
|
||||
blockers = [label for label, ok, _ in lines[:2] if not ok]
|
||||
print(f"结论:未就绪(差 {'、'.join(blockers)})—— {FAIL_CLOSED}")
|
||||
print(fix_hint(table_ok, key_ok, paste))
|
||||
|
||||
if args.line:
|
||||
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if summary:
|
||||
body = ["## 签名就绪度(M255,播报不是门禁)", ""]
|
||||
body += [f"- **{label}**:{text}" for label, _ok, text in lines]
|
||||
body += [
|
||||
"",
|
||||
f"- 结论:**{'就绪' if ready else '未就绪'}**",
|
||||
f"- 本 job 的绿色**不代表签名就绪**:判据只看上面三行字,退出码恒为 0(见 `{RUNBOOK}` §6)。",
|
||||
]
|
||||
Path(summary).write_text("\n".join(body) + "\n", encoding="utf-8")
|
||||
|
||||
return 0 if ready else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,169 @@
|
||||
# 签名就绪度操作手册(M255)
|
||||
|
||||
本文件是**人类照做**的手册:从「信任表是空的」走到「发出去的 Release 用客户端判据能验过」。
|
||||
背景/威胁模型/轮换纪律在 [`signing.md`](signing.md);本文件只讲**步骤、命令、坑**。
|
||||
|
||||
> ⚠ 本文件**不含**任何真实私钥、种子或公钥。所有示例都是 `<尖括号占位符>`。
|
||||
> `--generate-key` 的输出是**敏感物**:只在你要贴进 Secret 的那一次运行里存在,别存文件、别贴 issue。
|
||||
|
||||
## 0. 现状读一遍(2026-11 的 M254 审计结论)
|
||||
|
||||
`crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的**。空表 =
|
||||
`KeyringEmpty` 一律拒装 ⇒ **今天任何正式二进制都验不过任何清单**(fail-closed 成立,但用户端表现为
|
||||
「更新永远失败」)。已存在的部分(都能用):
|
||||
|
||||
| 环节 | 在哪 | 今天的状态 |
|
||||
|---|---|---|
|
||||
| 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一行 |
|
||||
| 钥 ↔ 信任表对账 | `sign_manifest.py --check-pinned-keyring` | 有;**今天必然红**(表空 ⇒ 「信任表是空的」) |
|
||||
| 签名 | `sign_manifest.py --sign dist/manifest.json` → `.sig` | 有;**今天必然红**(`ULL_MANIFEST_SIGNING_KEY` 没有,前面先红) |
|
||||
| 回验 | `sign_manifest.py --verify-pinned dist/manifest.json` | 有;**今天必然红**(表空 ⇒ 「无从回验」) |
|
||||
| 发布接线 | `release-publish.yml` 的「签更新清单」步 | 有;上面四条任一非零 ⇒ 不出 Release(**这是对的**) |
|
||||
| 资产上传 | `release-publish.yml` 的 `assets=(… manifest.json manifest.json.sig …)` | 有 |
|
||||
| **操作步骤与可见性** | 本文件 + `packaging/check_signing_ready.py` + `signing-status.yml` | **本次补上的就是这两样** |
|
||||
|
||||
一句话:**工具链不缺,缺的是有人把钥钉进表里。** 那件事只有人类能做(塔不会自己生成生产私钥)。
|
||||
|
||||
## 1. 先看读数(不用碰任何钥)
|
||||
|
||||
```bash
|
||||
python3 packaging/check_signing_ready.py # 三行读数 + 退出码
|
||||
python3 packaging/check_signing_ready.py --dist dist # 顺手看一眼本地留档
|
||||
```
|
||||
|
||||
三行分别是 ① `PRODUCTION` 表空不空 ② 环境里有没有签名钥(并顺手对账「这把钥在不在表里」)
|
||||
③ 最近一次发布的 `.sig`/清单痕迹(取不到就写「未知」——本地没留档 ≠ 上次没签)。
|
||||
|
||||
退出码:`0` 就绪 / `1` 未就绪 / `2` 用法错(参数写错、`trust.rs` 读不到、标记丢了)。
|
||||
**未就绪时会打印一行修复命令**,直接照抄即可。CI 里同一条读数在 `Signing status(播报)` workflow 的
|
||||
job summary 里(见 §6 —— 它是播报,不是门禁)。
|
||||
|
||||
## 2. 人类要亲手做的 5 步(下单清单)
|
||||
|
||||
下面每一行都只有命令与占位符,**没有任何密钥值**。做完一步就把该步的验证跑一遍。
|
||||
|
||||
```bash
|
||||
# ① 生成钥(⚠ 输出是敏感物;不要跑在 CI 里,不要重定向到仓库里的文件)
|
||||
python3 packaging/sign_manifest.py --generate-key --key-id <key-id,例 ull-release-2026a>
|
||||
|
||||
# ② 把打印出来的那一行 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 历史里)
|
||||
|
||||
# ③ 把打印出来的那一行 PinnedKey { … } 贴进 trust.rs 的 PINNED-KEYS 标记之间(标记本身别动)
|
||||
grep -n 'PINNED-KEYS' crates/core/src/update/trust.rs
|
||||
# 贴完提交这次改动 —— 公钥进 git 是对的:它是信任锚,本来就该公开可审计
|
||||
|
||||
# ④ 对账(需要一个有钥的环境:本机就先 export,别写进任何文件)
|
||||
python3 packaging/sign_manifest.py --check-pinned-keyring # 期望:ok: 签名钥 <key-id> 已被钉住
|
||||
|
||||
# ⑤ 发一次带 .sig 的 Release,然后用**客户端的判据**回验(这一步不需要私钥)
|
||||
python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # 期望:ok: 用钉住的公钥能验过
|
||||
```
|
||||
|
||||
`--verify-pinned` 才是证据:`--sign` 说「签好了」不算 —— 客户端拿的是 `trust.rs` 里那把公钥。
|
||||
发布后建议再用**下载下来的资产**验一次(§5),因为「CI 上能验过」不等于「上传的那份就是被签的那份」。
|
||||
|
||||
### 每一步的判据(怎么知道这步真的成了)
|
||||
|
||||
| 步 | 成功长什么样 | 失败长什么样 |
|
||||
|---|---|---|
|
||||
| ① | 打印 ① 私钥 JSON、② 一行 `PinnedKey`、③ 公钥十六进制;脚本自带「自己签自己验」自检 | `error: 自己签自己验不过` ⇒ 不要用这把钥,环境有问题 |
|
||||
| ② | `gh secret list` 里能看到 `ULL_MANIFEST_SIGNING_KEY` | 列表里没有 / 名字拼错(大小写、下划线) |
|
||||
| ③ | `check_signing_ready.py` 的 ① 从「未就绪」翻成「就绪」 | ① 仍说 0 条 ⇒ 贴到了标记**外面**(读数里会点名「另有 N 条在标记之外(key_id:…)」) |
|
||||
| ④ | `ok: 签名钥 … 已被钉住` | `签名钥(…)不在信任表里` ⇒ ②③ 不是同一把钥 |
|
||||
| ⑤ | `ok: manifest.json.sig 用钉住的公钥 … 能验过(客户端会接受)` | `拿 trust.rs 里钉住的公钥回验不过 —— 这份 Release 的用户会全部拒装。别发版` |
|
||||
|
||||
## 3. 一次发布里签名是怎么被执行的(不用额外操作)
|
||||
|
||||
`release-publish.yml` 的「签更新清单」步按顺序跑四条,任一非零 ⇒ 本步红 ⇒ 不出 Release:
|
||||
|
||||
```bash
|
||||
sign_manifest.py --self-test # RFC 8032 向量:脚本自己没坏
|
||||
sign_manifest.py --check-pinned-keyring # Secret 里的钥必须已在 trust.rs 钉住
|
||||
sign_manifest.py --sign dist/manifest.json # 产出 dist/manifest.json.sig
|
||||
sign_manifest.py --verify-pinned dist/manifest.json # 用钉住的公钥回验产出
|
||||
```
|
||||
|
||||
这一步已经把「未配置 Secret」「钥没钉」「钥不匹配」都变成**发版前的红**,所以「发了但没签名」这条
|
||||
不会发生 —— 它只会在发布 job 上红,不会静默发出去。
|
||||
|
||||
## 4. 已知坑(按踩到的概率排序)
|
||||
|
||||
1. **Secret 名字拼错**(`ULL_MANIFEST_...` vs `ULL_MANIFEST_KEY`):可复用工作流只拿得到自己**声明过**的
|
||||
secret,名字不对就是空串 ⇒ 签名步红,而错误文案会反过来指控「Secret 没配」。`release-publish.yml`
|
||||
已经声明了这一个(`required: false`,缺省走 fail-closed),照抄名字即可。
|
||||
2. **只配 Secret 没钉表**:`--check-pinned-keyring` 报「信任表是空的(fail-closed)」,并**直接打印**
|
||||
该贴的那一行 `PinnedKey`(只含公钥,安全)。照贴即可。
|
||||
3. **只钉表没配 Secret**:`--check-pinned-keyring` 报「没有签名密钥」。这不影响已有用户(表非空 ⇒
|
||||
他们能验签),但发不了新版。
|
||||
4. **Secret 与 trust.rs 是两把不同的钥**:最常见于「换过钥但只更新了一边」。判据是**公钥字节**,
|
||||
`key_id` 只是标签;`--check-pinned-keyring` 会把两边的公钥都打出来对账。
|
||||
5. **把条目贴到标记外面**(`];` 之后、或贴着别的 `const`):等于没钉。`check_signing_ready.py` 会**点名**
|
||||
标记之外的 `key_id`;注意本仓 `TEST_ONLY` 那条**本来就在标记之外**(那是它的正确位置),所以读数是
|
||||
「另有 N 条在标记之外(key_id:…)」而不是「贴错了」—— 对着 `key_id` 自己认。
|
||||
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` 上的读数为准。
|
||||
9. **`.sig` 没上传到 Release**(手改过 `assets=(…)` 那一行):客户端取 `<产物根>/manifest.json.sig`
|
||||
会 404 ⇒ 所有人验签失败。上传后请在资产列表里**亲眼看到** `manifest.json` 与 `manifest.json.sig`
|
||||
两个名字(§5 的 `gh release view` 那条命令就是干这个的)。
|
||||
10. **删掉旧钥条目太早**:用户机器上装着旧二进制,它只认旧钥;删条目的那个版本必须**只**由新钥签,
|
||||
并且至少一个发布周期的双签名之后(`signing.md` §5)。
|
||||
11. **「昨天验过」不等于「今天验得过」**:`PRODUCTION` 是**编译期**常量,改 `trust.rs` 只影响**下一次构建**。
|
||||
已在用户盘上的旧二进制永远用旧表 —— 这是设计,不是 bug。
|
||||
|
||||
## 5. 发布后回验(发布侧,不需要私钥)
|
||||
|
||||
```bash
|
||||
# 资产里必须两个名字都在(漏一个等于全员验签失败)
|
||||
gh release view <tag> --json assets --jq '.assets[].name' | grep -E 'manifest\.json(\.sig)?$'
|
||||
|
||||
# 拿下载回来的真实资产复现客户端判据:manifest.json 与 .sig 必须成对、且是同一份字节
|
||||
mkdir -p /tmp/verify && gh release download <tag> --pattern 'manifest.json*' --dir /tmp/verify
|
||||
python3 packaging/sign_manifest.py --verify-pinned /tmp/verify/manifest.json
|
||||
```
|
||||
|
||||
退出码 0 = 「客户端会接受这份清单」。非零时**不要**手动 `edit` Release 绕过 —— 先看脚本 stderr
|
||||
(它会说清楚是表空、无 `.sig`、还是签名对不上)。
|
||||
|
||||
## 6. CI 里那条读数:`Signing status(播报)`
|
||||
|
||||
`.github/workflows/signing-status.yml` 每天在 `main` / PR / 手动 dispatch 上跑一次,把 §1 的三行搬进
|
||||
job summary。**它不是门禁**:
|
||||
|
||||
* 它**不应被设为 required check**(分支保护里不要勾它),也不阻断任何其它 job;
|
||||
* 它就绪度那一步**恒 `exit 0`**:未就绪用 `::warning::` 播报,不用 error 级注解(那会让人以为构建挂了);
|
||||
* 它**刻意不声明 `workflow_call:`**,所以别的 workflow 没法 `uses:` 它;接线自检还会扫全部 workflow,
|
||||
任何 `uses: …/signing-status.yml` 出现即红(防的是「顺手接进 dev 线把播报变成门禁」);
|
||||
* 它**绿**只说明脚本跑完了;结论只看 summary 里的三行字。
|
||||
* 同一 workflow 里另有一步跑 `packaging/tests/signing_ready_selftest.py`:那一步**会红**,红 = **尺子坏了**
|
||||
(三行读数/退出码/变异重放/接线判据之一没过),与「签名未就绪」是两件不同的事,措辞在 summary 里分开写。
|
||||
|
||||
为什么不做成门禁:今天已经有四次「守卫假绿」的账(见 `packaging/README.md` §12 里那几条先例)。
|
||||
把一条**按定义今天必然失败**的检查做成 required,只会有两种结局 —— 要么所有人学会忽略红叉,
|
||||
要么有人把它改成永远绿。真正的红留在 `release-publish.yml` 的签名步:那里今天红是**对的**,
|
||||
因为签不出来的产物本来就不能发。
|
||||
|
||||
## 7. 本手册**没**覆盖 / 没验的部分(明账)
|
||||
|
||||
* **Authenticode / SmartScreen**:项目自有签名解决的是「这条自更新链是不是我们发的」,用户双击 exe 看到
|
||||
的「未知发布者」与此无关(`signing.md` §0)。
|
||||
* **托管签名**(如 SignPath):只是后续可替换路径,本手册与判据都不依赖它。
|
||||
* **本文档里的命令没有被真跑过一遍**:`--generate-key` 会产出生产私钥,塔不生成、也不跑它;
|
||||
②③ 两步要动 GitHub Secret 与 Rust 常量,同理。所以本手册是「按现有脚本的接口写的步骤」,
|
||||
首次执行时**以脚本的实际输出为准**,跑完请回来修正本文档。
|
||||
* **「用户机器上真的更新成功」**没有在本条里验:需要一次真 Release + 一台装着旧版本的机器。
|
||||
|
||||
## 8. 相关文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `packaging/check_signing_ready.py` | 三行就绪度 + `0/1/2` 退出码(只读、零第三方依赖) |
|
||||
| `packaging/tests/signing_ready_selftest.py` | 判据自测:三行/退出码/两个方向的变异重放/接线不是门禁 |
|
||||
| `.github/workflows/signing-status.yml` | 把三行搬进 job summary 的**播报** workflow(不是门禁) |
|
||||
| `packaging/docs/signing.md` | 为什么签名、威胁模型、轮换纪律、四条发布侧命令的红法对照表 |
|
||||
| `packaging/sign_manifest.py` | 生成钥 / 签名 / 对账 / 回验的唯一实现(`--self-test` 跑 RFC 8032 向量) |
|
||||
@@ -3,6 +3,12 @@
|
||||
本文件是**发布侧手册**:私钥从哪来、怎么用、怎么轮换、哪一步失败必须让发布变红。
|
||||
用户面的说明(README 里「为什么第一个包要手动下」那类话)不在本文件范围。
|
||||
|
||||
> **M255 补充:要照做的那几步在 [`signing-runbook.md`](signing-runbook.md)** —— 生成钥 → 私钥进 GitHub
|
||||
> Secret(名字逐字是 `ULL_MANIFEST_SIGNING_KEY`)→ `PinnedKey` 贴进 `trust.rs` 标记之间 → 发一次带 `.sig`
|
||||
> 的 Release → 用 `--verify-pinned` 回验 → 已知坑。本文件讲**为什么**,那份讲**怎么做**。
|
||||
> 现状读数用 `python3 packaging/check_signing_ready.py`(三行 + `0/1/2`;未就绪时会打印一行修复命令);
|
||||
> CI 里同一条读数挂在 `.github/workflows/signing-status.yml` 的 job summary 上 —— **那是播报,不是门禁**。
|
||||
|
||||
## 0. 先说清这件事**不**解决什么
|
||||
|
||||
| 有 / 没有 | 状态 |
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""`packaging/check_signing_ready.py` 的判据自测(M255):让「三行读数 + 0/1/2 退出码」变成可执行的事实。
|
||||
|
||||
为什么它必须存在:这个脚本的全部价值是「说清今天差什么」。它一旦退化成「永远 exit 0」,
|
||||
它就从**播报**变成了本仓栽过四次的**假绿尺子** —— 而且没人会当场发现(输出看起来一模一样)。
|
||||
所以这里的第一类用例判的是退出码语义,第二类用例判的是**把退出码改绿之后自测会红**(变异重放)。
|
||||
|
||||
钉住的判据(每一条都对应 mission 的验收项):
|
||||
|
||||
| 判据 | 用例 | 抓什么 |
|
||||
|---|---|---|
|
||||
| ① 表空 ⇒ `exit 1` 且三行都打印 | `empty_table` | 今天的状态(`PRODUCTION` 空表)必须读成「未就绪」,且不许少打印第 ③ 行 |
|
||||
| ② 副本夹一条假 `PinnedKey` ⇒ `exit 0` | `pinned_and_key` | 就绪的正路真的存在(**不碰真实 `trust.rs`**:全部走 `--trust <临时副本>`) |
|
||||
| 变异:把退出码改成永远 0 ⇒ 用例必须红 | `mutation_always_zero` | 上面 ① 的期望值不被「永远绿」满足 ⇒ 自测当场报红 |
|
||||
| 三行之外的读数 | `key_mismatch` / `no_key` / `release_*` | ② 的对账(钥不在表里)、③ 的有/无/未知 |
|
||||
| 不泄密 | `no_seed_leak` | 种子的十六进制在 stdout/stderr 里一次都不出现 |
|
||||
| 用法错 = 2 | `markers_missing` / `usage` | 标记丢了、参数写错 ⇒ 2,不是 1 |
|
||||
| 接线不是门禁 | `wiring` | 没有其它 workflow `uses:` 它;文件里没有 `::error::`;就绪度那步 `exit 0` |
|
||||
|
||||
**不覆盖(如实记)**:本文件自己也是「守门人住在被守对象旁边」——整个 `signing-status.yml` 被删掉后
|
||||
没有任何东西会跑这份自测(与 `gpu_shot_ci_selftest.py` 同一条边界)。它也不判「用户机器上真的能更新」,
|
||||
那是 `cargo test -p unlucky-core --lib update::tests::signature_cases` 与一次真 Release 的事。
|
||||
|
||||
公开的测试种子:`DUMMY_SEED` 是**由一句固定文本哈希出来的**假种子(同 `trust.rs` 里 `TEST_ONLY` 的那把
|
||||
公开测试钥同一路数),只用来造读数,签不出任何正式清单;文档与本文件都不含任何真实私钥。
|
||||
|
||||
python3 packaging/tests/signing_ready_selftest.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
SCRIPT = ROOT / "packaging/check_signing_ready.py"
|
||||
WORKFLOW_DIR = ROOT / ".github/workflows"
|
||||
WORKFLOW = WORKFLOW_DIR / "signing-status.yml"
|
||||
KEY_ENV = "ULL_MANIFEST_SIGNING_KEY"
|
||||
FAIL_CLOSED = "这会让已安装用户永远验不过(fail-closed)"
|
||||
LABELS = ("① 信任表 PRODUCTION", "② 环境签名钥", "③ 最近一次发布痕迹")
|
||||
|
||||
sys.path.insert(0, str(ROOT / "packaging"))
|
||||
from ed25519_ref import public_key # noqa: E402
|
||||
|
||||
DUMMY_SEED = hashlib.sha256(b"ull-m255-signing-ready-selftest-not-a-real-key").hexdigest()
|
||||
DUMMY_PUB = public_key(bytes.fromhex(DUMMY_SEED)).hex()
|
||||
OTHER_SEED = hashlib.sha256(b"ull-m255-selftest-the-other-dummy-key").hexdigest()
|
||||
|
||||
TRUST_TEMPLATE = """\
|
||||
pub(crate) const PRODUCTION: &[PinnedKey] = &[
|
||||
// >>> PINNED-KEYS >>>
|
||||
{body} // <<< PINNED-KEYS <<<
|
||||
];
|
||||
"""
|
||||
|
||||
|
||||
def secret_json(seed: str = DUMMY_SEED, key_id: str = "selftest-key") -> str:
|
||||
return '{"key_id": "%s", "seed_hex": "%s"}' % (key_id, seed)
|
||||
|
||||
|
||||
def write(path: Path, text: str) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8", newline="\n")
|
||||
return path
|
||||
|
||||
|
||||
def run(args: list[str], env_key: str | None, summary: Path | None = None, script: Path = SCRIPT) -> tuple[int, str]:
|
||||
"""跑一次脚本,返回 `(退出码, stdout+stderr)`。`env_key` = 塞进 `KEY_ENV` 的内容(`None` = 不设)。"""
|
||||
env = {k: v for k, v in os.environ.items() if k != KEY_ENV}
|
||||
env.pop("GITHUB_STEP_SUMMARY", None)
|
||||
# 子进程与父进程的编码显式对齐成 UTF-8:本机 Windows 默认 gbk,中文读数会 decode 失败,
|
||||
# 而 CI(ubuntu)本来就是 UTF-8。这条不是洁癖 —— 编码不一致会让整份自测在 Windows 上假红/假绿。
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
if env_key is not None:
|
||||
env[KEY_ENV] = env_key
|
||||
if summary is not None:
|
||||
env["GITHUB_STEP_SUMMARY"] = str(summary)
|
||||
done = subprocess.run(
|
||||
[sys.executable, str(script), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
cwd=str(ROOT),
|
||||
check=False,
|
||||
)
|
||||
return done.returncode, done.stdout + done.stderr
|
||||
|
||||
|
||||
def case_empty_table(work: Path, failures: list[str], script: Path = SCRIPT) -> None:
|
||||
"""① 今天的状态:空表 ⇒ exit 1,三行都要打印,且必须点明 fail-closed 后果与修复命令。
|
||||
|
||||
`script` 可换:变异用例把**同一组断言**原样套在「退出码永远 0」的副本上,断言集合必须报红。
|
||||
"""
|
||||
trust = write(work / "empty/trust.rs", TRUST_TEMPLATE.format(body=""))
|
||||
rc, out = run(["--trust", str(trust)], None, script=script)
|
||||
if rc != 1:
|
||||
failures.append(f"判据①:空表应 exit 1,实际 {rc}\n{out}")
|
||||
missing = [label for label in LABELS if label not in out]
|
||||
if missing:
|
||||
failures.append(f"判据①:空表时没打印这三行中的 {missing}\n{out}")
|
||||
if FAIL_CLOSED not in out:
|
||||
failures.append(f"判据①:未就绪时必须写明「{FAIL_CLOSED}」(后果,不是术语)\n{out}")
|
||||
if "修复:" not in out:
|
||||
failures.append(f"判据①:未就绪时必须给一行修复命令\n{out}")
|
||||
|
||||
|
||||
def case_pinned_and_key(work: Path, failures: list[str], script: Path = SCRIPT) -> None:
|
||||
"""② 副本里夹一条假 PinnedKey + 环境里是同一把钥 ⇒ exit 0(真实 trust.rs 一个字节都不碰)。"""
|
||||
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
|
||||
trust = write(work / "pinned/trust.rs", TRUST_TEMPLATE.format(body=body))
|
||||
rc, out = run(["--trust", str(trust)], secret_json(), script=script)
|
||||
if rc != 0:
|
||||
failures.append(f"判据②:表非空 + 钥同源应 exit 0,实际 {rc}\n{out}")
|
||||
missing = [label for label in LABELS if label not in out]
|
||||
if missing:
|
||||
failures.append(f"判据②:就绪时也要打印三行,缺 {missing}\n{out}")
|
||||
if "就绪" not in out:
|
||||
failures.append(f"判据②:就绪时必须明说「就绪」\n{out}")
|
||||
if FAIL_CLOSED in out:
|
||||
failures.append(f"判据②:就绪时不该出现 fail-closed 那段警告\n{out}")
|
||||
# 不泄密:种子的十六进制在输出里一次都不许出现(公钥可以出现,它是公开物)。
|
||||
if DUMMY_SEED in out:
|
||||
failures.append(f"判据②:输出里出现了**种子**(私钥)—— 这是泄密\n{out}")
|
||||
|
||||
|
||||
def case_key_mismatch(work: Path, failures: list[str]) -> None:
|
||||
"""② 的对账:表里钉的是 A,环境里是 B ⇒ 未就绪(发布时 --check-pinned-keyring 会红)。"""
|
||||
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
|
||||
trust = write(work / "mismatch/trust.rs", TRUST_TEMPLATE.format(body=body))
|
||||
rc, out = run(["--trust", str(trust)], secret_json(OTHER_SEED, "other-key"))
|
||||
if rc != 1:
|
||||
failures.append(f"②对账:钥不在表里应 exit 1,实际 {rc}\n{out}")
|
||||
if "不在" not in out:
|
||||
failures.append(f"②对账:必须明说这把钥不在信任表里\n{out}")
|
||||
if DUMMY_SEED in out or OTHER_SEED in out:
|
||||
failures.append(f"②对账:输出里出现了种子\n{out}")
|
||||
|
||||
|
||||
def case_no_key(work: Path, failures: list[str]) -> None:
|
||||
"""② 没有钥:即便表是好的,也算未就绪(签不出任何清单)。"""
|
||||
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
|
||||
trust = write(work / "nokey/trust.rs", TRUST_TEMPLATE.format(body=body))
|
||||
rc, out = run(["--trust", str(trust)], None)
|
||||
if rc != 1:
|
||||
failures.append(f"②无钥:应 exit 1,实际 {rc}\n{out}")
|
||||
if KEY_ENV not in out:
|
||||
failures.append(f"②无钥:必须点名环境变量 {KEY_ENV}(换个人也知道该配什么)\n{out}")
|
||||
|
||||
|
||||
def case_release_evidence(work: Path, failures: list[str]) -> None:
|
||||
"""③ 只取证:有留档 / 有清单没签名 / 什么都没给 = 未知;且它不影响退出码。"""
|
||||
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
|
||||
trust = write(work / "release/trust.rs", TRUST_TEMPLATE.format(body=body))
|
||||
signed = work / "release/signed"
|
||||
write(signed / "manifest.json", '{"channel": "stable", "releases": []}\n')
|
||||
write(signed / "manifest.json.sig", '{"format": 1, "signatures": []}\n')
|
||||
rc, out = run(["--trust", str(trust), "--dist", str(signed)], secret_json())
|
||||
if rc != 0 or "有 ——" not in out:
|
||||
failures.append(f"③有:留档齐全时应写「有」且退出码仍是 0,实际 rc={rc}\n{out}")
|
||||
|
||||
bare = work / "release/bare"
|
||||
write(bare / "manifest.json", '{"channel": "stable", "releases": []}\n')
|
||||
rc, out = run(["--trust", str(trust), "--dist", str(bare)], secret_json())
|
||||
if "没有" not in out:
|
||||
failures.append(f"③无:只有 manifest.json 时必须点明「没有 .sig」\n{out}")
|
||||
|
||||
nowhere = work / "release/absent"
|
||||
rc, out = run(["--trust", str(trust), "--dist", str(nowhere)], secret_json())
|
||||
if "未知" not in out:
|
||||
failures.append(f"③未知:取不到证据时必须写「未知」,不许写成「无」(本地没留档 ≠ 上次没签)\n{out}")
|
||||
if rc != 0:
|
||||
failures.append(f"③未知:③ 不参与就绪判定,退出码应仍是 0,实际 {rc}\n{out}")
|
||||
|
||||
|
||||
def case_usage(work: Path, failures: list[str]) -> None:
|
||||
"""用法错 = 2:参数写错、标记丢了(脚本形不成判断)。绝不能混进 1(那是「未就绪」的读数)。"""
|
||||
rc, out = run(["--nonsense"], None)
|
||||
if rc != 2:
|
||||
failures.append(f"用法错:未知参数应 exit 2,实际 {rc}\n{out}")
|
||||
broken = write(work / "broken/trust.rs", "pub(crate) const PRODUCTION: &[PinnedKey] = &[];\n")
|
||||
rc, out = run(["--trust", str(broken)], None)
|
||||
if rc != 2:
|
||||
failures.append(f"用法错:标记缺失应 exit 2(不是 1),实际 {rc}\n{out}")
|
||||
rc, out = run(["--trust", str(work / "no-such-dir/trust.rs")], None)
|
||||
if rc != 2:
|
||||
failures.append(f"用法错:trust.rs 不存在应 exit 2,实际 {rc}\n{out}")
|
||||
|
||||
|
||||
def case_summary(work: Path, failures: list[str]) -> None:
|
||||
"""`--line`:CI 用,把三行搬进 $GITHUB_STEP_SUMMARY;且必须写明「不是门禁」。"""
|
||||
trust = write(work / "summary/trust.rs", TRUST_TEMPLATE.format(body=""))
|
||||
summary = work / "summary-step.md"
|
||||
rc, out = run(["--trust", str(trust), "--line"], None, summary=summary)
|
||||
if rc != 1:
|
||||
failures.append(f"summary:空表仍应 exit 1,实际 {rc}\n{out}")
|
||||
if not summary.is_file():
|
||||
failures.append("summary:给了 $GITHUB_STEP_SUMMARY 却没写文件")
|
||||
return
|
||||
text = summary.read_text(encoding="utf-8")
|
||||
missing = [label for label in LABELS if label not in text]
|
||||
if missing:
|
||||
failures.append(f"summary:三行没全部进 summary,缺 {missing}\n{text}")
|
||||
if "不代表签名就绪" not in text:
|
||||
failures.append(f"summary:必须写清「绿色不代表签名就绪」(播报 ≠ 门禁)\n{text}")
|
||||
|
||||
|
||||
TARGET_LINE = " return 0 if ready else 1\n"
|
||||
|
||||
|
||||
def mutant(work: Path, name: str, replacement: str) -> Path | None:
|
||||
"""造一份「只改了退出码那一行」的副本。副本自成一体(模块一起拷):脚本按 `__file__` 上一级找
|
||||
`packaging/`,不靠 PYTHONPATH(实测本机 3.12 根本不把 PYTHONPATH 放进 sys.path)。"""
|
||||
source = SCRIPT.read_text(encoding="utf-8")
|
||||
if source.count(TARGET_LINE) != 1:
|
||||
return None
|
||||
root = work / name
|
||||
path = write(root / "packaging/check_signing_ready_mutated.py", source.replace(TARGET_LINE, replacement))
|
||||
for module in ("sign_manifest.py", "ed25519_ref.py"):
|
||||
write(root / f"packaging/{module}", (ROOT / f"packaging/{module}").read_text(encoding="utf-8"))
|
||||
write(root / "trust.rs", TRUST_TEMPLATE.format(body=""))
|
||||
return path
|
||||
|
||||
|
||||
def case_stray_hint(work: Path, failures: list[str]) -> None:
|
||||
"""① 的「标记之外还有条目」提示:只**点名**,不替人断言「贴错了」。
|
||||
|
||||
实测抓到的误报:真实 `trust.rs` 里 `TEST_ONLY` 那一条**本来就在标记之外**(那是它的正确位置),
|
||||
上一版措辞「另有 N 条写在标记之外(贴错位置,等于没钉)」会对今天的状态喊狼来了。
|
||||
"""
|
||||
outside = 'pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey { key_id: "test-only-key", public_key_hex: "%s" }];\n'
|
||||
trust = write(
|
||||
work / "stray/trust.rs",
|
||||
outside % ("ab" * 32) + TRUST_TEMPLATE.format(body=""),
|
||||
)
|
||||
rc, out = run(["--trust", str(trust)], None)
|
||||
if rc != 1:
|
||||
failures.append(f"①提示:标记外有条目时表仍是空的 ⇒ 应 exit 1,实际 {rc}\n{out}")
|
||||
if "test-only-key" not in out:
|
||||
failures.append(f"①提示:标记之外的条目必须**点名**(否则人不知道贴漏了哪一条)\n{out}")
|
||||
if "TEST_ONLY" not in out:
|
||||
failures.append(f"①提示:必须说清 `TEST_ONLY` 本来就在标记之外(否则这就是一句误报)\n{out}")
|
||||
|
||||
|
||||
def case_mutation(work: Path, failures: list[str]) -> None:
|
||||
"""变异重放:改坏被测对象,**同一组断言原样套上去必须报红**(两个方向都试)。
|
||||
|
||||
这是本 mission 唯一认可的形状 —— 不是「我写了断言」,而是「把对象改坏,断言真的会红」。
|
||||
"""
|
||||
# ── 变异 A:退出码永远 0(「未就绪」伪装成「就绪」)⇒ 判据① 必须红 ──
|
||||
always_zero = mutant(work, "mutant-zero", " return 0\n")
|
||||
if always_zero is None:
|
||||
failures.append(f"变异 A:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`,变异无处下手(脚本形状变了就更新这里)")
|
||||
else:
|
||||
trust = work / "mutant-zero/trust.rs"
|
||||
probe: list[str] = []
|
||||
case_empty_table(work / "mutant-zero", probe, script=always_zero)
|
||||
# 副本必须真的「跑得起来、只有退出码变了」:崩溃(rc=2)也会让 probe 非空,那就变成假绿了。
|
||||
rc, out = run(["--trust", str(trust)], None, script=always_zero)
|
||||
if rc != 0:
|
||||
failures.append(f"变异 A:副本在空表上应返回 0(这就是变异本身),实际 {rc}\n{out}")
|
||||
missing = [label for label in LABELS if label not in out]
|
||||
if missing:
|
||||
failures.append(f"变异 A:副本连三行读数都没打印(缺 {missing}),它不是在测退出码那一条\n{out}")
|
||||
if not probe:
|
||||
failures.append(f"变异 A:「退出码永远 0」的副本竟然让判据①全过 —— 断言抓不住改绿(副本空表返回 {rc})")
|
||||
elif not any("exit 1" in item for item in probe):
|
||||
failures.append(f"变异 A:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据①:{probe}")
|
||||
|
||||
# ── 变异 B:退出码永远 1(「就绪」伪装成「未就绪」)⇒ 判据② 必须红 ──
|
||||
# 反向也要试:只测一个方向的话,「就绪的正路根本不存在」这种回归仍然全绿。
|
||||
always_one = mutant(work, "mutant-one", " return 1\n")
|
||||
if always_one is None:
|
||||
failures.append(f"变异 B:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`")
|
||||
else:
|
||||
probe = []
|
||||
case_pinned_and_key(work / "mutant-one", probe, script=always_one)
|
||||
if not probe:
|
||||
failures.append("变异 B:「退出码永远 1」的副本竟然让判据②全过 —— 就绪的正路没被断言(假绿的反面)")
|
||||
elif not any("exit 0" in item for item in probe):
|
||||
failures.append(f"变异 B:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据②:{probe}")
|
||||
|
||||
|
||||
def case_wiring(failures: list[str]) -> None:
|
||||
"""接线判据:它**只是播报**。别人 `uses:` 它 = 它成了某条线的必需检查 ⇒ 红。"""
|
||||
if not WORKFLOW.is_file():
|
||||
failures.append(f"接线:{WORKFLOW} 不存在")
|
||||
return
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
for phrase in ("播报,不是门禁", "不应被设为 required"):
|
||||
if phrase not in text:
|
||||
failures.append(f"接线:文件头必须写清「{phrase}」(不阻断要是可核的事实,不是靠没接线)")
|
||||
if "::error::" in text:
|
||||
failures.append("接线:播报 workflow 里不许出现 `::error::`(那会让人以为构建挂了,与播报定位冲突)")
|
||||
if "rc=$?" not in text or "exit 0" not in text:
|
||||
failures.append("接线:就绪度那一步必须抓 `rc=$?` 后 `exit 0`(未就绪不许让 job 变红)")
|
||||
wired: list[str] = []
|
||||
for path in sorted(WORKFLOW_DIR.glob("*.y*ml")):
|
||||
if path.resolve() == WORKFLOW.resolve():
|
||||
continue
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
if re.match(r"^\s*uses:\s*", line) and WORKFLOW.name in line:
|
||||
wired.append(f"{path.relative_to(ROOT).as_posix()}:{number}: {line.strip()}")
|
||||
if wired:
|
||||
failures.append(
|
||||
"接线:有 workflow `uses:` 了 " + WORKFLOW.name + " ⇒ 播报变成了别人的必需检查:\n " + "\n ".join(wired)
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures: list[str] = []
|
||||
with tempfile.TemporaryDirectory(prefix="ull-signing-ready-") as temp:
|
||||
work = Path(temp)
|
||||
case_empty_table(work, failures)
|
||||
case_pinned_and_key(work, failures)
|
||||
case_key_mismatch(work, failures)
|
||||
case_no_key(work, failures)
|
||||
case_release_evidence(work, failures)
|
||||
case_stray_hint(work, failures)
|
||||
case_usage(work, failures)
|
||||
case_summary(work, failures)
|
||||
case_mutation(work, failures)
|
||||
case_wiring(failures)
|
||||
|
||||
for line in failures:
|
||||
print(f"FAIL {line}", file=sys.stderr)
|
||||
print("signing-ready selftest:", "ok" if not failures else f"{len(failures)} 处失败")
|
||||
return 0 if not failures else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user