Files
Unlucky-Launcher/packaging/sign_manifest.py
T
AskaEth 0522a41a52
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
release: v0.2.5
2026-11-12 12:06:59 +08:00

399 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Sign / verify the update manifest, and check the pinned keyring (M190).
Why Ed25519 + a **detached** `.sig`: the manifest's `sha256` only says "the package matches the
manifest"; it says nothing about who wrote the manifest. A signature over the manifest's *exact
bytes* answers that, and a separate file avoids JSON canonicalisation games (any byte change
breaks the signature).
Key discipline: the **private key** lives only in the repository Secret
`ULL_MANIFEST_SIGNING_KEY` (never in git, logs or artifacts); the **public key** is pinned
*inside the binary* — `crates/core/src/update/trust.rs`, between the `>>> PINNED-KEYS >>>` /
`<<< PINNED-KEYS <<<` markers. `--check-pinned-keyring` fails the release when the secret's key
is not pinned (a release signed with a key clients do not trust is worse than no release).
Manual: `packaging/docs/signing.md`.
Usage: `--generate-key [--key-id K]` / `--sign dist/manifest.json`(钥从环境变量
`ULL_MANIFEST_SIGNING_KEY` 取)/ `--check-pinned-keyring`(钥必须已钉住)/ `--verify-pinned
dist/manifest.json`(用 trust.rs 里钉的公钥回验产出,复现客户端判据)/ `--self-test`。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
FORMAT = 1
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 ——
# 两套实现互相独立,`--self-test` 用 RFC 8032 的同一组向量把两边各自钉住。
sys.path.insert(0, str(Path(__file__).resolve().parent))
from ed25519_ref import ALG, sign, verify, public_key # noqa: E402
def load_key(text: str) -> tuple[str, bytes]:
"""Parse the secret JSON (`{"key_id":…,"seed_hex":…}`) or a bare 64-char hex seed."""
text = text.strip()
try:
payload = json.loads(text)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
key_id = str(payload.get("key_id", "")).strip()
seed_hex = str(payload.get("seed_hex", "")).strip()
else:
key_id, seed_hex = "", text
if not key_id:
raise SystemExit("error: 密钥缺少 `key_id`(清单签名要靠它查信任表)")
try:
seed = bytes.fromhex(seed_hex)
except ValueError as error:
raise SystemExit(f"error: `seed_hex` 不是十六进制({error})") from error
if len(seed) != 32:
raise SystemExit(f"error: `seed_hex` 必须 32 字节(实际 {len(seed)})")
return key_id, seed
def key_from_env_or_arg(path: str | None) -> tuple[str, bytes]:
if path:
secret = Path(path).read_text(encoding="utf-8")
else:
secret = os.environ.get(KEY_ENV, "")
if not secret.strip():
raise SystemExit(
f"error: 没有签名密钥:设环境变量 {KEY_ENV}(内容 = --generate-key 打出来的那一行 JSON),"
"或显式给 --key <文件>。**绝不**把私钥写进仓库/日志/产物。"
)
return load_key(secret)
def pinned_keys(trust_text: str) -> list[tuple[str, str]]:
"""Extract `(key_id, public_key_hex)` pairs between the PINNED-KEYS markers."""
begin = trust_text.find(PIN_BEGIN)
end = trust_text.find(PIN_END)
if begin < 0 or end < 0 or end < begin:
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)
pk = public_key(seed)
pk_hex = pk.hex()
if not verify(pk, b"self-check", sign(seed, b"self-check")):
print("error: 自己签自己验不过——不要用这把钥", file=sys.stderr)
return 1
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("# 形状就是 `cargo fmt` 会保持的那一种 —— 贴完直接提交,不用再跑 fmt):")
print(render_pinned_key(key_id, pk_hex))
print(f"# 公钥十六进制:{pk_hex}")
return 0
def do_sign(manifest: Path, key_path: str | None, out: Path | None, key_id_override: str | None) -> int:
if not manifest.is_file():
print(f"error: 没有清单文件 {manifest}", file=sys.stderr)
return 1
raw = manifest.read_bytes()
# 签的必须是**能解析**的清单:签一串垃圾只会把事故推迟到用户机器上。
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
print(f"error: {manifest} 不是合法 JSON({error})", file=sys.stderr)
return 1
if not isinstance(parsed, dict) or "channel" not in parsed or "releases" not in parsed:
print(f"error: {manifest} 不像发布清单(缺 channel/releases)", file=sys.stderr)
return 1
key_id, seed = key_from_env_or_arg(key_path)
if key_id_override:
key_id = key_id_override
signature = sign(seed, raw)
if not verify(public_key(seed), raw, signature):
print("error: 签出来的签名自己验不过(实现有问题),拒绝落盘", file=sys.stderr)
return 1
envelope = {
"format": FORMAT,
"signatures": [
{
"alg": ALG,
"key_id": key_id,
"sig": signature.hex(),
"manifest_sha256": hashlib.sha256(raw).hexdigest(),
}
],
}
out = out or manifest.with_name(manifest.name + SIG_SUFFIX)
out.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
print(f"signed: {manifest} → {out}")
print(f" key_id = {key_id}")
print(f" public_key = {public_key(seed).hex()}")
print(f" manifest_sha256 = {envelope['signatures'][0]['manifest_sha256']}")
return 0
def do_verify(manifest: Path, sig: Path, public_key_hex: str) -> int:
raw = manifest.read_bytes()
envelope = json.loads(sig.read_text(encoding="utf-8"))
pk = bytes.fromhex(public_key_hex)
for item in envelope.get("signatures", []):
if item.get("alg") != ALG:
continue
if verify(pk, raw, bytes.fromhex(item.get("sig", ""))):
print(f"ok: {sig} 里的签名(key_id={item.get('key_id')})通过")
return 0
print("error: 没有任何一条签名能验过", file=sys.stderr)
return 1
def do_check_pinned(key_path: str | None) -> int:
key_id, seed = key_from_env_or_arg(key_path)
pk_hex = public_key(seed).hex()
keys = pinned_keys(TRUST_RS.read_text(encoding="utf-8"))
if not keys:
# 「说人话的红」:因为什么、缺什么、怎么补,一行一行写清楚(不涉密,只回显公钥)。
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)
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:
if pinned_hex.lower() == pk_hex:
print(f"ok: 签名钥 {key_id} 已被钉住(trust.rs 里叫 {pinned_id})")
if pinned_id != key_id:
print(
f"warning: 同一个公钥在 trust.rs 里叫 {pinned_id}、密钥文件里叫 {key_id};"
"清单会带后者,客户端按**公钥字节**验签,所以两种写法都能过(但建议改名对齐)",
file=sys.stderr,
)
return 0
pinned_ids = [k[0] for k in keys]
print(
f"error: 签名钥(key_id={key_id} / 公钥={pk_hex})不在信任表里 —— 签出来的清单没有任何客户端会认,所以拒绝发布。",
file=sys.stderr,
)
print(f"error: trust.rs 现在钉的是:{pinned_ids}", file=sys.stderr)
for pinned_id, pinned_hex in keys:
print(f"error: {pinned_id} = {pinned_hex}", file=sys.stderr)
print("error: 二选一:", file=sys.stderr)
print("error: ① 用已钉住的那把钥签(换回对应的 Secret);或", file=sys.stderr)
print(
"error: ② 换钥:把上面的公钥按「旧钥签新钥」贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 之间,"
"并让本次清单同时带旧钥签名",
file=sys.stderr,
)
print("error: 手册:packaging/docs/signing.md §3/§5", file=sys.stderr)
return 1
def do_verify_pinned(manifest: Path) -> int:
"""用 trust.rs 里钉住的公钥回验 `<manifest>.sig` —— 复现「客户端会怎么判」,不需要私钥。"""
keys = pinned_keys(TRUST_RS.read_text(encoding="utf-8"))
if not keys:
print("error: 信任表是空的,无从回验(先钉公钥,见 --check-pinned-keyring)", file=sys.stderr)
return 1
sig_path = manifest.with_name(manifest.name + SIG_SUFFIX)
if not sig_path.is_file():
print(f"error: 没有签名文件 {sig_path}", file=sys.stderr)
return 1
raw = manifest.read_bytes()
envelope = json.loads(sig_path.read_text(encoding="utf-8"))
for item in envelope.get("signatures", []):
if item.get("alg") != ALG:
continue
try:
signature = bytes.fromhex(item.get("sig", ""))
except ValueError:
continue
for pinned_id, pinned_hex in keys:
if verify(bytes.fromhex(pinned_hex), raw, signature):
print(f"ok: {sig_path.name} 用钉住的公钥 {pinned_id} 能验过(客户端会接受)")
return 0
print(
"error: 拿 trust.rs 里钉住的公钥回验不过 —— 这份 Release 的用户会全部拒装。别发版",
file=sys.stderr,
)
return 1
def do_self_test() -> int:
vectors = [
("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "",
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"),
("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72",
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"),
("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", "af82",
"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:
fail(f"vector {index}: public key mismatch")
if sign(seed, message).hex() != sig_hex:
fail(f"vector {index}: signature mismatch")
if not verify(pk, message, bytes.fromhex(sig_hex)):
fail(f"vector {index}: verify rejected")
tampered = bytearray(message)
tampered.append(0)
if verify(pk, bytes(tampered), bytes.fromhex(sig_hex)):
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)]:
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)]:
fail("pinned_keys() 没解析出多行(rustfmt)形态的条目")
if pinned_keys(f"{PIN_BEGIN}\n{PIN_END}") != []:
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
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="更新清单的 Ed25519 签名/验签与信任表检查(M190)")
parser.add_argument("--generate-key", action="store_true", help="生成一把新钥(打印 Secret 与 PinnedKey 片段)")
parser.add_argument("--key-id", default="ull-release", help="生成/签名时用的 key_id")
parser.add_argument("--sign", metavar="MANIFEST", type=Path, help="签这份清单(产出 <清单>.sig)")
parser.add_argument("--out", metavar="SIG", type=Path, help="签名输出路径(默认 <清单>.sig)")
parser.add_argument("--key", metavar="FILE", help=f"密钥文件(不给则读环境变量 {KEY_ENV})")
parser.add_argument("--verify", nargs=2, metavar=("MANIFEST", "SIG"), help="手工验一份签名")
parser.add_argument("--public-key", metavar="HEX", help="--verify 用的公钥")
parser.add_argument("--check-pinned-keyring", action="store_true", help="检查密钥是否已被钉进 trust.rs")
parser.add_argument("--verify-pinned", metavar="MANIFEST", type=Path, help="用 trust.rs 里钉的公钥回验产出")
parser.add_argument("--self-test", action="store_true", help="跑 RFC 8032 向量与解析器自检")
args = parser.parse_args(argv)
if args.self_test:
return do_self_test()
if args.generate_key:
return do_generate_key(args.key_id)
if args.sign:
return do_sign(args.sign, args.key, args.out, args.key_id if args.key_id != "ull-release" else None)
if args.verify:
if not args.public_key:
print("error: --verify 需要 --public-key", file=sys.stderr)
return 1
return do_verify(Path(args.verify[0]), Path(args.verify[1]), args.public_key)
if args.check_pinned_keyring:
return do_check_pinned(args.key)
if args.verify_pinned:
return do_verify_pinned(args.verify_pinned)
parser.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())