From 66eb912df0bc4ecc3398cb0db4bc568ca4fbcc87 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 22:16:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(packaging):=20--generate-key=20=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E7=9A=84=20PinnedKey=20=E6=94=B9=E6=88=90=20rustfmt?= =?UTF-8?q?=20=E4=BF=9D=E6=8C=81=E7=9A=84=E5=A4=9A=E8=A1=8C=E5=BD=A2?= =?UTF-8?q?=E6=80=81=EF=BC=88M265=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--generate-key` 与「信任表是空的」报错里给人粘贴的 `PinnedKey` 原来是单行: PinnedKey { key_id: "…", public_key_hex: "<64 hex>" }, 这一行有 113+ 字符(即使 key_id 为空),超出 .rustfmt.toml 的 max_width = 100, 所以 `cargo fmt` **必然**把它拆成 4 行 —— 人贴单行 → 下一轮 fmt 改动 → 谁忘了跑 fmt 就把 fmt 红带进 main(T30 那次就是这条路径)。 改的是「打印模板」与文档,不是解析器(KID_RE 的 \s* 本来就吃两种形态): - 新增 render_pinned_key(),字段各占一行、元素缩进 4/字段缩进 8、结尾逗号, 与 trust.rs 里 cargo fmt 生成的形状逐字一致;两条打印路径都走它。 - 提示文案不再暗示「贴完还要 fmt」,改成「贴完直接提交,不用再跑 fmt」。 - docs:signing.md §3 与 signing-runbook.md §2/§4/§8 的「那一行」→「那一段(4 行)」, 并在 runbook §7 明写这条断言在没装 rustfmt 时的退化边界。 - --self-test 新增形态断言:① 渲染结果 == 4 行期望字面量 ② 形状与 trust.rs 现存条目 (字段值抹平后逐行)一致 ③ 单行必然超 max_width、多行每行都不超 ④ 贴进标记之间能解析 ⑤ 有 rustfmt 时在临时副本上真跑一遍,要求粘贴块字节不变(没有则打 note 跳过,不装成验过)。 --- packaging/docs/signing-runbook.md | 15 ++-- packaging/docs/signing.md | 9 ++- packaging/sign_manifest.py | 130 +++++++++++++++++++++++++----- 3 files changed, 128 insertions(+), 26 deletions(-) diff --git a/packaging/docs/signing-runbook.md b/packaging/docs/signing-runbook.md index 7203c2a..798728a 100644 --- a/packaging/docs/signing-runbook.md +++ b/packaging/docs/signing-runbook.md @@ -14,7 +14,7 @@ | 环节 | 在哪 | 今天的状态 | |---|---|---| -| 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一行 | +| 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一段(多行,fmt 保持的形态) | | 钥 ↔ 信任表对账 | `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` | 有;**今天必然红**(表空 ⇒ 「无从回验」) | @@ -60,7 +60,8 @@ python3 packaging/sign_manifest.py --generate-key --key-id manifest.json.sig ## 3. 一次性录入(只做一次) ```bash -# ① 生成:打印一段 Secret JSON + 一行可以直接贴进 trust.rs 的 PinnedKey +# ① 生成:打印一段 Secret JSON + 一段可以直接贴进 trust.rs 的 PinnedKey(多行,4 行) python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a ``` * 打印出来的 **第一行 JSON**(含 `seed_hex`)= 私钥,**只**贴进仓库 Secret: Settings → Secrets and variables → Actions → New repository secret,名字 **`ULL_MANIFEST_SIGNING_KEY`**。 -* 打印出来的 **`PinnedKey { … }` 那一行**贴进 `crates/core/src/update/trust.rs` 的 +* 打印出来的 **`PinnedKey { … }` 那一段(4 行,含结尾逗号)**贴进 `crates/core/src/update/trust.rs` 的 `>>> PINNED-KEYS >>>` 与 `<<< PINNED-KEYS <<<` 标记之间(标记本身不要动,脚本按它解析)。 +* 那个形状就是 `cargo fmt`(`.rustfmt.toml` 的 `max_width = 100`)**会保持**的形态:**贴完不用再跑 + `cargo fmt`**。反过来,把它压成一行反而会被 fmt 拆开 —— 那种「粘贴 → 下一轮 fmt 改动 → 谁忘了跑 fmt + 就把红带进 main」的复发路径由 `sign_manifest.py --self-test` 钉住(`render_pinned_key` 的形态断言)。 * 提交这次改动(公钥进 git 是对的:它是信任锚,本来就该公开可审计)。 ### 私钥纪律(违反任何一条都算事故) @@ -97,7 +100,7 @@ python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # ④ 用 | 现象 | 原因 | 怎么补 | |---|---|---| | `没有签名密钥` | Secret `ULL_MANIFEST_SIGNING_KEY` 没配 / 名字拼错 | 按 §3 录入 | -| `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那行贴进标记之间 | +| `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那段(4 行)贴进标记之间 | | `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 | | `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue | diff --git a/packaging/sign_manifest.py b/packaging/sign_manifest.py index 45fddea..16db1be 100644 --- a/packaging/sign_manifest.py +++ b/packaging/sign_manifest.py @@ -25,7 +25,10 @@ import hashlib import json import os import re +import shutil +import subprocess import sys +import tempfile from pathlib import Path FORMAT = 1 @@ -33,8 +36,11 @@ ALG = "ed25519" KEY_ENV = "ULL_MANIFEST_SIGNING_KEY" SIG_SUFFIX = ".sig" TRUST_RS = Path(__file__).resolve().parent.parent / "crates/core/src/update/trust.rs" +RUSTFMT_TOML = Path(__file__).resolve().parent.parent / ".rustfmt.toml" PIN_BEGIN = ">>> PINNED-KEYS >>>" PIN_END = "<<< PINNED-KEYS <<<" +# 粘贴块排版:`trust.rs` 里数组元素缩进 4 空格、结构体字段再缩进 4(= 元素缩进 + 4)。 +PASTE_INDENT = " " KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{64})"') # Ed25519 的参考实现搬到了 `packaging/ed25519_ref.py`(M190 r1:本文件贴着 400 行红线)。 # 目的与路径都写在那边的文件头:发布侧签名必须零第三方依赖,而仓里验签用的是 ed25519-dalek —— @@ -85,7 +91,66 @@ def pinned_keys(trust_text: str) -> list[tuple[str, str]]: raise SystemExit(f"error: {TRUST_RS} 里找不到 {PIN_BEGIN} / {PIN_END} 标记") return KID_RE.findall(trust_text[begin:end]) +def render_pinned_key(key_id: str, public_key_hex: str) -> str: + """渲染一条**粘贴即可、`cargo fmt` 不再改形**的 `PinnedKey`(M265)。 + 为什么必须多行:单行形态即使 `key_id` 为空也有 113 字符,超出 `.rustfmt.toml` 的 `max_width = 100`, + 所以 `cargo fmt` **必然**把它拆成下面这个形状 —— T30 的 fmt 红就是这么进的 main(人贴单行 → 下一轮 + fmt 改动 → 谁忘了跑 fmt 就带红进主干)。反过来说这个形状过一遍 rustfmt 不变,`--self-test` 会验。 + """ + pad = PASTE_INDENT + return ( + f"{pad}PinnedKey {{\n" + f'{pad} key_id: "{key_id}",\n' + f'{pad} public_key_hex: "{public_key_hex}",\n' + f"{pad}}}," + ) + +def rustfmt_max_width() -> int: + """`.rustfmt.toml` 里的 `max_width`(读不到就用 rustfmt 的默认 100)。""" + try: + for line in RUSTFMT_TOML.read_text(encoding="utf-8").splitlines(): + key, _, value = line.partition("=") + if key.strip() == "max_width": + return int(value.strip()) + except (OSError, ValueError): + pass + return 100 + +def pinned_entry_blocks(trust_text: str) -> list[str]: + """标记之间每条 `PinnedKey { … }` 的**原样文本**(含缩进与结尾逗号)。 + + 与 `pinned_keys()` 互补:那个只看字段值(跨行跨不跨行都吃),这个看**排版**。 + """ + begin, end = trust_text.find(PIN_BEGIN), trust_text.find(PIN_END) + if begin < 0 or end < 0 or end < begin: + return [] + return re.findall(r"^[ \t]*PinnedKey \{.*?^[ \t]*\},", trust_text[begin:end], re.MULTILINE | re.DOTALL) + +def rustfmt_keeps_shape(snippet: str) -> bool | None: + """把 `snippet` 按 `trust.rs` 的上下文贴进临时副本、真跑一遍 `rustfmt`:形态被保持 ⇒ True。 + + `rustfmt` 不在 PATH 上 ⇒ None(自测退化成结构断言,并把「跳过」说出来,不装成验过)。 + """ + rustfmt = shutil.which("rustfmt") + if not rustfmt: + return None + args = [rustfmt, "--edition", "2021"] + if RUSTFMT_TOML.is_file(): + args += ["--config-path", str(RUSTFMT_TOML)] + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "pasted.rs" + src.write_text( + "struct PinnedKey {\n key_id: &'static str,\n public_key_hex: &'static str,\n}\n\n" + f"const PRODUCTION: &[PinnedKey] = &[\n // {PIN_BEGIN}\n{snippet}\n // {PIN_END}\n];\n", + encoding="utf-8", + newline="\n", + ) + result = subprocess.run([*args, str(src)], capture_output=True, text=True, timeout=120) + if result.returncode != 0: + raise RuntimeError(f"rustfmt 跑挂了:{result.stderr.strip()}") + # 比 `snippet in text` 更硬:fmt 之后标记之间那一条必须**逐字节**还是原样(含缩进与结尾逗号)。 + return pinned_entry_blocks(src.read_text(encoding="utf-8")) == [snippet] def do_generate_key(key_id: str) -> int: seed = os.urandom(32) @@ -97,8 +162,9 @@ def do_generate_key(key_id: str) -> int: secret = json.dumps({"key_id": key_id, "seed_hex": seed.hex()}, ensure_ascii=False) print("# ① 私钥(**只**贴进仓库 Secret " + KEY_ENV + ",别写进任何文件/日志/提交):") print(secret) - print("# ② 公钥(贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间):") - print(f' PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},') + print("# ② 公钥(整段贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间;") + print("# 形状就是 `cargo fmt` 会保持的那一种 —— 贴完直接提交,不用再跑 fmt):") + print(render_pinned_key(key_id, pk_hex)) print(f"# 公钥十六进制:{pk_hex}") return 0 @@ -163,8 +229,9 @@ def do_check_pinned(key_path: str | None) -> int: # 「说人话的红」:因为什么、缺什么、怎么补,一行一行写清楚(不涉密,只回显公钥)。 print("error: 信任表是空的(fail-closed):客户端一把公钥都没有,任何清单都验不过,所以拒绝发布。", file=sys.stderr) print(f"error: 本次签名钥 = key_id {key_id} / 公钥 {pk_hex}", file=sys.stderr) - print("error: 补法:把这行贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间 ——", file=sys.stderr) - print(f'error: PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},', file=sys.stderr) + print("error: 补法:把下面这段贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间 ——", file=sys.stderr) + for line in render_pinned_key(key_id, pk_hex).splitlines(): + print(f"error: {line}", file=sys.stderr) print("error: 手册:packaging/docs/signing.md §3", file=sys.stderr) return 1 for pinned_id, pinned_hex in keys: @@ -235,36 +302,63 @@ def do_self_test() -> int: "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"), ] failures = 0 + + def fail(message: str) -> None: + nonlocal failures + print(f"FAIL: {message}", file=sys.stderr) + failures += 1 + for index, (seed_hex, pk_hex, message_hex, sig_hex) in enumerate(vectors, 1): seed, pk, message = bytes.fromhex(seed_hex), bytes.fromhex(pk_hex), bytes.fromhex(message_hex) if public_key(seed).hex() != pk_hex: - print(f"FAIL vector {index}: public key mismatch", file=sys.stderr) - failures += 1 + fail(f"vector {index}: public key mismatch") if sign(seed, message).hex() != sig_hex: - print(f"FAIL vector {index}: signature mismatch", file=sys.stderr) - failures += 1 + fail(f"vector {index}: signature mismatch") if not verify(pk, message, bytes.fromhex(sig_hex)): - print(f"FAIL vector {index}: verify rejected", file=sys.stderr) - failures += 1 + fail(f"vector {index}: verify rejected") tampered = bytearray(message) tampered.append(0) if verify(pk, bytes(tampered), bytes.fromhex(sig_hex)): - print(f"FAIL vector {index}: tampered message still verifies", file=sys.stderr) - failures += 1 + fail(f"vector {index}: tampered message still verifies") # 标记解析:信任表解析器必须在示例上命中一对,且在空表上返回空。 sample = f'{PIN_BEGIN}\n PinnedKey {{ key_id: "k1", public_key_hex: "{"ab" * 32}" }},\n{PIN_END}' if pinned_keys(sample) != [("k1", "ab" * 32)]: - print("FAIL: pinned_keys() 没解析出示例条目", file=sys.stderr) - failures += 1 + fail("pinned_keys() 没解析出示例条目") # 多行形态(M261):`trust.rs` 里那一条是 `cargo fmt` 生成的,字段一行一个 —— 靠 `KID_RE` 的 `\s*` # 跨行命中。若解析器退化成按行匹配,一次 fmt 就会把整张信任表**静默**读成空表(= 全量客户端验签失败)。 multiline = f'{PIN_BEGIN}\n PinnedKey {{\n key_id: "k1",\n public_key_hex: "{"ab" * 32}",\n }},\n{PIN_END}' if pinned_keys(multiline) != [("k1", "ab" * 32)]: - print("FAIL: pinned_keys() 没解析出多行(rustfmt)形态的条目", file=sys.stderr) - failures += 1 + fail("pinned_keys() 没解析出多行(rustfmt)形态的条目") if pinned_keys(f"{PIN_BEGIN}\n{PIN_END}") != []: - print("FAIL: pinned_keys() 在空表上应返回空", file=sys.stderr) - failures += 1 + fail("pinned_keys() 在空表上应返回空") + # 粘贴块形态(M265):`--generate-key` 与「信任表是空的」报错里给人贴的那段 `PinnedKey` 必须是 + # rustfmt **不会改形**的多行形态。单行形态是合法 Rust、也照样能解析,所以这条路一旦回退成单行, + # 除了「谁忘了跑 fmt」之外没有任何东西会红 —— T30 的 fmt 红就是这么进的 main。 + hex64 = "ab" * 32 + rendered = render_pinned_key("k1", hex64) + want = f' PinnedKey {{\n key_id: "k1",\n public_key_hex: "{hex64}",\n }},' + if rendered != want: + fail(f"粘贴块不是多行形态:{rendered!r}") + # 为什么 rustfmt 必然把它排成这样:单行(哪怕 `key_id` 为空)就超出 `max_width`,而多行每行都不超。 + width = rustfmt_max_width() + one_line = f' PinnedKey {{ key_id: "", public_key_hex: "{hex64}" }},' + if len(one_line) <= width or any(len(line) > width for line in rendered.splitlines()): + fail(f"粘贴块与 max_width={width} 的关系变了(单行 {len(one_line)} 字符应当超宽)") + # 判据的核心:形状 = `trust.rs` 里**现存**条目的排班(字段值抹平后逐行比对)。 + shape = lambda text: [re.sub(r'"[^"]*"', '""', line) for line in text.splitlines()] + blocks = pinned_entry_blocks(TRUST_RS.read_text(encoding="utf-8") if TRUST_RS.is_file() else "") + if not blocks: + print("note: trust.rs 标记之间没有条目,跳过「与现存排版一致」那条", file=sys.stderr) + for block in blocks: + if shape(block) != shape(rendered): + fail(f"粘贴块排版与 trust.rs 现存条目不一致:{block!r}") + if pinned_keys(f"{PIN_BEGIN}\n{rendered}\n{PIN_END}") != [("k1", hex64)]: + fail("粘贴块贴进标记之间后解析不出来(贴了等于没贴)") + keeps = rustfmt_keeps_shape(rendered) + if keeps is None: + print("note: PATH 里没有 rustfmt,跳过「真跑一遍 fmt 不改形」那条", file=sys.stderr) + elif not keeps: + fail("真跑一遍 rustfmt 改了粘贴块的形态") print("self-test:", "ok" if failures == 0 else f"{failures} 处失败") return 0 if failures == 0 else 1