两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。
① PE 版本资源(两个 bin):`packaging/pe_version.rs` 是唯一口径(CompanyName
「Unlucky Launcher Project」/ ProductName、FileDescription「Unlucky Launcher」/
LegalCopyright「© 2026 … · Apache-2.0」/ 四段式版本号与 Cargo.toml 同源),被
`crates/{cli,app-iced}/build.rs` 各 include 一次;非 windows 目标整段不执行。
判据是读产物字节的 `packaging/check_pe_version.py`(CI 的 Windows 槽位)。
② 清单签名:`manifest.json` 旁边多一份 `manifest.json.sig`(分离签名,签的是清单**原始
字节**),信封是 `{format, signatures:[{alg,key_id,sig,manifest_sha256}]}` —— 多签名数组,
任意一条命中钉住的钥即放行(换钥窗口)。信任锚是 `crates/core/src/update/trust.rs`
里的编译期公钥表(正式表为空 = fail-closed,测试钥只在 cfg(test)/update-test-source 里)。
管线顺序改成「先验签、再解析」:`UpdateSource` 只交原文 + `.sig`,`verified_manifest`
是唯一收口点,`Job::apply` 因此在取包**之前**就拒。新增五类错误分类
(signature_missing / malformed / unknown_key / keyring_empty / invalid ),全部不可重试,
CLI 的 hint 单独说人话。
③ Ed25519 验签自带实现(`update/ed25519/*`,零新依赖):scope 不含 core 的 Cargo.toml,
用 RFC 8032 §7.1 向量 + 可锻/小阶/非规范编码的拒绝用例钉住;签名侧是
`packaging/sign_manifest.py`(纯 Python 参考实现,零 pip 依赖,`--self-test` 同向量)。
④ 守门:`release-publish.yml` 加签名步(`set +x`,只回显公钥;没 Secret / 钥没钉 / 签不上
一律红 ⇒ 不出 Release);`release-build.yml` 加 PE 资源读回;`release-guard.yml` 跑两个
脚本的 `--self-test`。密钥纪律与轮换(旧钥签新钥)写在 `packaging/docs/signing.md`。
Cargo.lock:新增 winresource(build-dependency)与 toml 系;toml_datetime 1.1.2→1.1.3 是
toml 1.2.1 的硬要求(`cargo update --precise 1.1.2` 已实测被拒)。
384 lines
17 KiB
Python
384 lines
17 KiB
Python
#!/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:
|
||
python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a
|
||
ULL_MANIFEST_SIGNING_KEY='{"key_id":"…","seed_hex":"…"}' python3 packaging/sign_manifest.py --sign dist/manifest.json
|
||
python3 packaging/sign_manifest.py --check-pinned-keyring | --self-test
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
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"
|
||
PIN_BEGIN = ">>> PINNED-KEYS >>>"
|
||
PIN_END = "<<< PINNED-KEYS <<<"
|
||
KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{64})"')
|
||
# Ed25519 (RFC 8032), reference-shaped implementation (same formulas as
|
||
# `crates/core/src/update/ed25519/*`). Validated against the RFC 8032 §7.1 vectors in
|
||
# `--self-test`; the Rust verifier is validated against the *same* vectors, so the two
|
||
# implementations cross-check each other. No third-party dependency: CI needs no pip install.
|
||
Q = 2**255 - 19
|
||
L = 2**252 + 27742317777372353535851937790883648493
|
||
|
||
def _inv(x: int) -> int:
|
||
return pow(x, Q - 2, Q)
|
||
|
||
D = -121665 * _inv(121666) % Q
|
||
_D2 = 2 * D % Q
|
||
_SQRT_M1 = pow(2, (Q - 1) // 4, Q)
|
||
|
||
def _recover_x(y: int, sign: int) -> int | None:
|
||
u = (y * y - 1) % Q
|
||
v = (D * y * y + 1) % Q
|
||
v3 = pow(v, 3, Q)
|
||
v7 = pow(v, 7, Q)
|
||
x = u * v3 % Q * pow(u * v7 % Q, (Q - 5) // 8, Q) % Q
|
||
if (v * x * x - u) % Q != 0:
|
||
x = x * _SQRT_M1 % Q
|
||
if (v * x * x - u) % Q != 0:
|
||
return None
|
||
if x % 2 != sign:
|
||
x = (Q - x) % Q
|
||
return x
|
||
|
||
def _decompress(data: bytes) -> tuple[int, int, int, int] | None:
|
||
if len(data) != 32:
|
||
return None
|
||
y = int.from_bytes(data, "little")
|
||
sign = (y >> 255) & 1
|
||
y &= (1 << 255) - 1
|
||
if y >= Q:
|
||
return None
|
||
x = _recover_x(y, sign)
|
||
if x is None:
|
||
return None
|
||
return (x, y, 1, x * y % Q)
|
||
|
||
def _add(p: tuple[int, int, int, int], r: tuple[int, int, int, int]):
|
||
x1, y1, z1, t1 = p
|
||
x2, y2, z2, t2 = r
|
||
a = (y1 - x1) * (y2 - x2) % Q
|
||
b = (y1 + x1) * (y2 + x2) % Q
|
||
c = t1 * _D2 * t2 % Q
|
||
d = 2 * z1 * z2 % Q
|
||
e, f, g, h = (b - a) % Q, (d - c) % Q, (d + c) % Q, (b + a) % Q
|
||
return (e * f % Q, g * h % Q, f * g % Q, e * h % Q)
|
||
|
||
def _double(p: tuple[int, int, int, int]):
|
||
x1, y1, z1, _ = p
|
||
a = x1 * x1 % Q
|
||
b = y1 * y1 % Q
|
||
c = 2 * z1 * z1 % Q
|
||
d = -a % Q
|
||
e = ((x1 + y1) * (x1 + y1) - a - b) % Q
|
||
g = (d + b) % Q
|
||
f = (g - c) % Q
|
||
h = (d - b) % Q
|
||
return (e * f % Q, g * h % Q, f * g % Q, e * h % Q)
|
||
|
||
def _scalarmult(p, e: int):
|
||
acc = (0, 1, 1, 0)
|
||
for bit in bin(e)[2:] if e else "":
|
||
acc = _double(acc)
|
||
if bit == "1":
|
||
acc = _add(acc, p)
|
||
return acc
|
||
|
||
def _encode(p) -> bytes:
|
||
x, y, z, _ = p
|
||
zi = _inv(z)
|
||
x, y = x * zi % Q, y * zi % Q
|
||
return (y | ((x & 1) << 255)).to_bytes(32, "little")
|
||
|
||
def _base():
|
||
encoded = (4 * _inv(5) % Q).to_bytes(32, "little")
|
||
return _decompress(encoded)
|
||
|
||
def _hint(data: bytes) -> int:
|
||
return int.from_bytes(hashlib.sha512(data).digest(), "little") % L
|
||
|
||
def public_key(seed: bytes) -> bytes:
|
||
h = hashlib.sha512(seed).digest()
|
||
a = int.from_bytes(_clamp(bytearray(h[:32])), "little")
|
||
return _encode(_scalarmult(_base(), a))
|
||
|
||
def _clamp(scalar: bytearray) -> bytes:
|
||
scalar[0] &= 248
|
||
scalar[31] &= 127
|
||
scalar[31] |= 64
|
||
return bytes(scalar)
|
||
|
||
def sign(seed: bytes, message: bytes) -> bytes:
|
||
if len(seed) != 32:
|
||
raise SystemExit("error: Ed25519 种子必须 32 字节")
|
||
h = hashlib.sha512(seed).digest()
|
||
a = int.from_bytes(_clamp(bytearray(h[:32])), "little")
|
||
prefix = h[32:]
|
||
r = _hint(prefix + message)
|
||
r_encoded = _encode(_scalarmult(_base(), r))
|
||
pk = _encode(_scalarmult(_base(), a))
|
||
k = _hint(r_encoded + pk + message)
|
||
s = (r + k * a) % L
|
||
return r_encoded + s.to_bytes(32, "little")
|
||
|
||
def verify(pk: bytes, message: bytes, signature: bytes) -> bool:
|
||
if len(pk) != 32 or len(signature) != 64:
|
||
return False
|
||
r_bytes, s_bytes = signature[:32], signature[32:]
|
||
if int.from_bytes(s_bytes, "little") >= L:
|
||
return False
|
||
a = _decompress(pk)
|
||
r_point = _decompress(r_bytes)
|
||
if a is None or r_point is None:
|
||
return False
|
||
k = _hint(r_bytes + pk + message)
|
||
left = _scalarmult(_base(), int.from_bytes(s_bytes, "little"))
|
||
return _encode(left) == _encode(_add(r_point, _scalarmult(a, k)))
|
||
|
||
|
||
|
||
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 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(f' PinnedKey {{ key_id: "{key_id}", public_key_hex: "{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)
|
||
print(f'error: PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},', 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_self_test() -> int:
|
||
vectors = [
|
||
("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "",
|
||
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"),
|
||
("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72",
|
||
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"),
|
||
("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", "af82",
|
||
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"),
|
||
]
|
||
failures = 0
|
||
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
|
||
if sign(seed, message).hex() != sig_hex:
|
||
print(f"FAIL vector {index}: signature mismatch", file=sys.stderr)
|
||
failures += 1
|
||
if not verify(pk, message, bytes.fromhex(sig_hex)):
|
||
print(f"FAIL vector {index}: verify rejected", file=sys.stderr)
|
||
failures += 1
|
||
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
|
||
# 标记解析:信任表解析器必须在示例上命中一对,且在空表上返回空。
|
||
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
|
||
if pinned_keys(f"{PIN_BEGIN}\n{PIN_END}") != []:
|
||
print("FAIL: pinned_keys() 在空表上应返回空", file=sys.stderr)
|
||
failures += 1
|
||
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("--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)
|
||
parser.print_help()
|
||
return 2
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|