feat(m190): 产物署名(PE VERSIONINFO)+ 清单 Ed25519 多签名与更新前验签
两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。
① 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` 已实测被拒)。
This commit is contained in:
1 parent
44a0c87a11
commit
b7d9b99e12
34 files changed
+2885
-104
No files matched your search
@@ -146,3 +146,17 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
||||
`ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` +
|
||||
`portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。
|
||||
**不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。
|
||||
|
||||
## 14. 产物署名与清单签名(M190)
|
||||
|
||||
这一节只放「去哪看」的三句话,手册本体在 **`packaging/docs/signing.md`**:
|
||||
|
||||
- **PE 版本资源**(Windows「属性 → 详细信息」里的发布者/产品/版权/四段式版本):口径唯一来源
|
||||
`packaging/pe_version.rs`,被 `crates/cli/build.rs` 与 `crates/app-iced/build.rs` 各 `include!` 一次;
|
||||
判据是**读产物字节**的 `packaging/check_pe_version.py`(CI:`release-build.yml` 的 Windows 槽位)。
|
||||
- **清单签名**:`manifest.json.sig` 与 `manifest.json` 并列上传,是分离的 Ed25519 签名文件(签的是清单
|
||||
的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的
|
||||
**编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装,
|
||||
**不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。
|
||||
- **不得混淆**:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,**不解决 SmartScreen**
|
||||
——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assert the PE **version resource is really inside the artifact** (M190).
|
||||
|
||||
Writing `CompanyName` into `build.rs` proves nothing — this reads the produced `.exe` bytes and
|
||||
looks for the `VS_VERSIONINFO` structure (`FIXEDFILEINFO.dwSignature = 0xFEEF04BD`, key strings in
|
||||
UTF-16LE) plus the exact values Windows "Properties → Details" must show:
|
||||
|
||||
CompanyName Unlucky Launcher Project
|
||||
ProductName Unlucky Launcher
|
||||
FileDescription Unlucky Launcher
|
||||
LegalCopyright © 2026 Unlucky Launcher Project · Apache-2.0
|
||||
FileVersion <Cargo.toml version, four parts>
|
||||
|
||||
It is deliberately a *byte* check, not a "did the build script run" check: a resource that got lost
|
||||
in linking still fails here. `--expect-version` also compares `dwFileVersionMS/LS` with the crate
|
||||
version, so "version drifted from Cargo.toml" is caught.
|
||||
|
||||
python3 packaging/check_pe_version.py <exe>... --expect-version 0.2.3
|
||||
python3 packaging/check_pe_version.py --self-test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PE_MAGIC = bytes([0x50, 0x45, 0, 0]) # "PE" + two NUL
|
||||
FIXED_SIGNATURE = b"\xbd\x04\xef\xfe" # FIXEDFILEINFO.dwSignature = 0xFEEF04BD
|
||||
PE_VERSION_SOURCE = Path(__file__).resolve().parent / "pe_version.rs"
|
||||
|
||||
# 承诺的署名(与 packaging/pe_version.rs 同一份口径;`--self-test` 解析那个文件对拍)。
|
||||
EXPECTED_STRINGS = {
|
||||
"CompanyName": "Unlucky Launcher Project",
|
||||
"ProductName": "Unlucky Launcher",
|
||||
"FileDescription": "Unlucky Launcher",
|
||||
"LegalCopyright": "© 2026 Unlucky Launcher Project · Apache-2.0",
|
||||
}
|
||||
|
||||
|
||||
def utf16(text: str) -> bytes:
|
||||
return text.encode("utf-16-le")
|
||||
|
||||
|
||||
def four_part(version: str) -> tuple[int, int, int, int]:
|
||||
"""`v0.2.3` / `0.2.3-beta.1` → `(0, 2, 3, 0)`(与 `pe_version.rs::four_part` 同规则)。"""
|
||||
numeric = version.split("-")[0].split("+")[0].strip().lstrip("v")
|
||||
parts = [0, 0, 0, 0]
|
||||
for index, piece in enumerate(numeric.split(".")[:4]):
|
||||
try:
|
||||
parts[index] = min(int(piece), 0xFFFF)
|
||||
except ValueError:
|
||||
parts[index] = 0
|
||||
return (parts[0], parts[1], parts[2], parts[3])
|
||||
|
||||
|
||||
def pe_subsystem(data: bytes) -> int | None:
|
||||
"""可选头里的 `Subsystem`(2 = GUI,3 = 控制台);PE 头不对时返回 None。"""
|
||||
if len(data) < 0x40 or data[:2] != b"MZ":
|
||||
return None
|
||||
offset = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
if offset + 0x5C > len(data) or data[offset : offset + 4] != PE_MAGIC:
|
||||
return None
|
||||
return struct.unpack_from("<H", data, offset + 24 + 0x44)[0]
|
||||
|
||||
|
||||
def check(path: Path, expect_version: str | None) -> list[str]:
|
||||
"""返回没过的判据(空 = 通过)。"""
|
||||
failures: list[str] = []
|
||||
data = path.read_bytes()
|
||||
if len(data) < 0x100:
|
||||
return [f"{path}: 文件太小({len(data)} 字节),不像 PE"]
|
||||
|
||||
subsystem = pe_subsystem(data)
|
||||
if subsystem is None:
|
||||
return [f"{path}: 不是可解析的 PE(MZ / PE 头不对)"]
|
||||
|
||||
has_resource = FIXED_SIGNATURE in data
|
||||
if not has_resource:
|
||||
failures.append(f"{path}: 找不到 FIXEDFILEINFO 签名 0xFEEF04BD —— 这个 exe 里**没有版本资源**")
|
||||
|
||||
for key in ("VS_VERSION_INFO", "StringFileInfo", "VarFileInfo", "Translation"):
|
||||
if utf16(key) not in data:
|
||||
failures.append(f"{path}: 版本资源里缺结构键 `{key}`(UTF-16LE 未命中)")
|
||||
|
||||
for field, value in EXPECTED_STRINGS.items():
|
||||
if utf16(field) not in data:
|
||||
failures.append(f"{path}: 缺字段名 `{field}`")
|
||||
if utf16(value) not in data:
|
||||
failures.append(f"{path}: 字段 `{field}` 的值不是 `{value}`")
|
||||
|
||||
if utf16(f"{path.stem}.exe") not in data:
|
||||
failures.append(f"{path}: `OriginalFilename` 不是 `{path.stem}.exe`")
|
||||
|
||||
if expect_version:
|
||||
parts = four_part(expect_version)
|
||||
dotted = ".".join(str(part) for part in parts)
|
||||
if utf16(dotted) not in data:
|
||||
failures.append(f"{path}: 版本字符串 `{dotted}` 不在资源里(与 Cargo.toml 不同源?)")
|
||||
index = data.find(FIXED_SIGNATURE)
|
||||
if index >= 0 and index + 16 <= len(data):
|
||||
ms, ls = struct.unpack_from("<II", data, index + 8)
|
||||
actual = (ms >> 16, ms & 0xFFFF, ls >> 16, ls & 0xFFFF)
|
||||
if actual != parts:
|
||||
failures.append(f"{path}: 数值版本 {actual} 与期望 {parts} 不一致(dwFileVersion)")
|
||||
else:
|
||||
failures.append(f"{path}: FIXEDFILEINFO 之后读不到 dwFileVersion")
|
||||
|
||||
print(
|
||||
f"{path.name}: {len(data)} 字节 / subsystem={subsystem}(2=GUI 3=控制台)"
|
||||
f" / 版本资源={'有' if has_resource else '无'}"
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def self_test() -> int:
|
||||
"""口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」。"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
failures = 0
|
||||
source = PE_VERSION_SOURCE.read_text(encoding="utf-8")
|
||||
for field, value in (
|
||||
("COMPANY_NAME", EXPECTED_STRINGS["CompanyName"]),
|
||||
("PRODUCT_NAME", EXPECTED_STRINGS["ProductName"]),
|
||||
("COPYRIGHT", EXPECTED_STRINGS["LegalCopyright"]),
|
||||
):
|
||||
if f'pub const {field}: &str = "{value}";' not in source:
|
||||
print(f"FAIL: pe_version.rs 里找不到 {field} = {value!r}", file=sys.stderr)
|
||||
failures += 1
|
||||
|
||||
for case, expect in (("0.2.3", (0, 2, 3, 0)), ("v1.2.3-beta.4", (1, 2, 3, 0)), ("1.2.3.4", (1, 2, 3, 4))):
|
||||
if four_part(case) != expect:
|
||||
print(f"FAIL: four_part({case!r}) = {four_part(case)},期望 {expect}", file=sys.stderr)
|
||||
failures += 1
|
||||
|
||||
# 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错
|
||||
# (否则这个脚本对任何输入都会「绿」,门禁等于没有)。
|
||||
fake = bytearray(0x200)
|
||||
fake[0:2] = b"MZ"
|
||||
struct.pack_into("<I", fake, 0x3C, 0x40)
|
||||
fake[0x40:0x44] = PE_MAGIC
|
||||
struct.pack_into("<H", fake, 0x40 + 24 + 0x44, 2)
|
||||
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as handle:
|
||||
handle.write(bytes(fake))
|
||||
name = handle.name
|
||||
problems = check(Path(name), "0.2.3")
|
||||
os.unlink(name)
|
||||
if not problems:
|
||||
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", 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="读产物字节断言 PE 版本资源(M190)")
|
||||
parser.add_argument("artifacts", nargs="*", type=Path, help="要检查的 exe")
|
||||
parser.add_argument("--expect-version", help="与 Cargo.toml 同源的版本号(如 0.2.3)")
|
||||
parser.add_argument("--self-test", action="store_true", help="自检(口径表 + 版本换算 + 假 PE 必红)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.self_test:
|
||||
return self_test()
|
||||
if not args.artifacts:
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
failures: list[str] = []
|
||||
for artifact in args.artifacts:
|
||||
if not artifact.is_file():
|
||||
failures.append(f"{artifact}: 文件不存在")
|
||||
continue
|
||||
failures.extend(check(artifact, args.expect_version))
|
||||
|
||||
for line in failures:
|
||||
print(f"error: {line}", file=sys.stderr)
|
||||
if failures:
|
||||
print(f"FAIL:{len(failures)} 条判据没过(版本资源没进产物就是没进,构建日志不算证据)", file=sys.stderr)
|
||||
return 1
|
||||
print(f"ok: {len(args.artifacts)} 个产物的版本资源齐全")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
# 产物签名与更新验签(M190)
|
||||
|
||||
本文件是**发布侧手册**:私钥从哪来、怎么用、怎么轮换、哪一步失败必须让发布变红。
|
||||
用户面的说明(README 里「为什么第一个包要手动下」那类话)不在本文件范围。
|
||||
|
||||
## 0. 先说清这件事**不**解决什么
|
||||
|
||||
| 有 / 没有 | 状态 |
|
||||
|---|---|
|
||||
| 更新器验「清单是不是我们发的」 | **有**(Ed25519 清单签名 + 二进制里钉住的公钥) |
|
||||
| Windows 上「未知发布者」提示消失 | **没有**。项目自有签名与 Authenticode 是两件事 |
|
||||
| SmartScreen 信誉 | **没有**。只有买了 OV/EV 证书并真签名才可能逐步积累 |
|
||||
|
||||
> 一句话:**项目自有签名保护的是自更新这条链,不是 Windows 的信任判断。** 用户双击 exe 时看到的
|
||||
> 「未知发布者」来自 SmartScreen,与本文件的方案无关;买证书是另一条线(见 M190 报告里的
|
||||
> 「后续要用户拍板的两件事」)。
|
||||
|
||||
## 1. 威胁模型(为什么要签名)
|
||||
|
||||
清单里的 `sha256` 只保证「包体与清单对得上」。**能改 Release 的人可以同时改包和摘要**,所以摘要
|
||||
本身不是信任根。签名的对象因此是**清单文件的原始字节**:改了任意一个字段(哪怕只改版本号里一个数字),
|
||||
签名立刻对不上。
|
||||
|
||||
```
|
||||
发布侧 用户机器
|
||||
------ --------
|
||||
manifest.json(字节)──签名──> manifest.json.sig
|
||||
│ │
|
||||
└── 挂到同一个 Release ─────────────┤
|
||||
├─ 取清单 + .sig
|
||||
├─ 用**编译进二进制**的公钥验签(trust.rs)
|
||||
├─ 过了 → 再解析、再比 sha256、再装
|
||||
└─ 不过 → 五类签名错误,**什么都不会被安装**
|
||||
```
|
||||
|
||||
## 2. 信任根在哪:`crates/core/src/update/trust.rs`
|
||||
|
||||
* 公钥是**编译期常量数组**(`PRODUCTION`),不是用户机器上的文件 —— 没有环境变量、没有设置键、
|
||||
没有「清单旁边的 keyring.json」这类运行期口子(`tests/signature_cases.rs::no_runtime_keyring_override`
|
||||
守着这条)。
|
||||
* 是**数组**而不是一把:换签名方时新旧钥可以并存(见 §5)。
|
||||
* `key_id` 只是查表标签,**判据永远是公钥字节**:把 `key_id` 伪造成钉住的那个也过不了
|
||||
(`key_id_spoofing_does_not_help`)。
|
||||
|
||||
### 空表 = fail-closed
|
||||
|
||||
正式表当前**是空的**:私钥只能来自仓库 Secret,任何代码仓库都不该自己生成并落盘私钥。
|
||||
空表的效果是「任何正式构建都拒装任何清单」,同时 `--check-pinned-keyring` 让发布流水线当场红。
|
||||
**这是有意的**:宁可不出 Release,也不出未签名的 Release。
|
||||
|
||||
## 3. 一次性录入(只做一次)
|
||||
|
||||
```bash
|
||||
# ① 生成:打印一段 Secret JSON + 一行可以直接贴进 trust.rs 的 PinnedKey
|
||||
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` 的
|
||||
`>>> PINNED-KEYS >>>` 与 `<<< PINNED-KEYS <<<` 标记之间(标记本身不要动,脚本按它解析)。
|
||||
* 提交这次改动(公钥进 git 是对的:它是信任锚,本来就该公开可审计)。
|
||||
|
||||
### 私钥纪律(违反任何一条都算事故)
|
||||
|
||||
1. **只**存在于仓库 Secret `ULL_MANIFEST_SIGNING_KEY`。本地需要签测试产物时,走 `--key <文件>`
|
||||
并把那个文件放在**仓库外**(gitignore 不保险,别放仓库里)。
|
||||
2. **不得**出现在日志、产物、提交、issue、聊天记录里。CI 里任何 `echo`/`cat`/`set -x` 都不许碰到它
|
||||
(工作流里的签名步骤显式 `set +x` 且只回显**公钥**)。
|
||||
3. 一旦怀疑泄露:立即按 §5 轮换,并在下一个 Release 的说明里点名旧 `key_id` 作废。
|
||||
4. 私钥文件/Secret 的备份不归仓库管;**丢了 = 旧钥作废**,用户机器上的旧二进制只能靠
|
||||
「先手动装一次新版本」恢复(`trust.rs` 里的旧条目那时才可以删)。
|
||||
|
||||
## 4. 发布时发生什么(`.github/workflows/release-publish.yml`)
|
||||
|
||||
在 `manifest.json` 生成之后、`gh release create/upload` 之前,多了一步:
|
||||
|
||||
```bash
|
||||
python3 packaging/sign_manifest.py --self-test # RFC 8032 向量自检
|
||||
python3 packaging/sign_manifest.py --check-pinned-keyring # 密钥 ↔ trust.rs 必须对得上
|
||||
python3 packaging/sign_manifest.py --sign dist/manifest.json # 产出 dist/manifest.json.sig
|
||||
python3 packaging/sign_manifest.py --verify dist/manifest.json dist/manifest.json.sig --public-key <keyring 里的那把>
|
||||
```
|
||||
|
||||
四条命令任何一条非零退出 ⇒ **这一步红 ⇒ 整个 Release job 红 ⇒ 本次不出 Release**。常见红法:
|
||||
|
||||
| 现象 | 原因 | 怎么补 |
|
||||
|---|---|---|
|
||||
| `没有签名密钥` | Secret `ULL_MANIFEST_SIGNING_KEY` 没配 / 名字拼错 | 按 §3 录入 |
|
||||
| `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那行贴进标记之间 |
|
||||
| `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 |
|
||||
| `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue |
|
||||
|
||||
`dist/manifest.json.sig` 与 `dist/manifest.json` 一起作为 Release 资产上传(`assets=(...)` 那一行)。
|
||||
|
||||
## 5. 轮换(换钥 / 旧钥泄露 / 接托管签名)
|
||||
|
||||
**纪律:旧钥签新钥。** 不允许在没有旧钥签名的情况下替换信任表 —— 那等于让「能改仓库的人」变成
|
||||
「能改信任根的人」。
|
||||
|
||||
1. 生成新钥(§3),得到 `key_id = ull-release-2027a`。
|
||||
2. `trust.rs` 里**保留**旧条目、**追加**新条目(数组顺序无关;`key_id` 不能重名)。
|
||||
3. 在一次发布里,让清单**同时带两把钥的签名**(两条 `signatures`,客户端「任意一条通过即放行」):
|
||||
用旧钥签一次、新钥签一次,把两个信封的 `signatures` 数组拼起来放进 `manifest.json.sig`。
|
||||
这一步让「装着旧二进制的用户」和「装着新二进制的用户」都过得去。
|
||||
4. 之后至少**一个完整发布周期**保持双签名,再观察下载/更新失败率。
|
||||
5. 确认新钥已被广泛接受后,用新钥单独签发下一次清单,并从 `trust.rs` 删掉旧条目
|
||||
(删条目的那个版本必须**只**由新钥签)。
|
||||
6. changelog 里点名:新增/作废的 `key_id` 与公钥指纹。
|
||||
|
||||
托管签名(例如 SignPath Foundation)作为**后续可替换路径**:它的产物签名由 CI 侧调用其服务完成,
|
||||
`trust.rs` 的数组形状不用变(多一条 `PinnedKey` 即可);清单签名这一层保持本项目自有钥不动。
|
||||
**不允许**为了接托管签名把判据改成「只认证书」。
|
||||
|
||||
## 6. 三条守门(都能单独变红)
|
||||
|
||||
| 守门 | 脚本 / 用例 | 变红意味着 |
|
||||
|---|---|---|
|
||||
| 版本资源真的在产物里 | `packaging/check_pe_version.py <exe> --expect-version <版本>` | 读回产物字节找不到 `VS_VERSIONINFO` / 字段值不对(构建日志绿也没用) |
|
||||
| 篡改清单 ⇒ 验签失败且不安装 | `cargo test -p unlucky-core --lib update::tests::signature_cases` | 改字段/只改签名/漏签名/陌生钥 任一仍能装 |
|
||||
| 签名失败阻断发布 | `release-publish.yml` 的签名步 + `--check-pinned-keyring` | 没钥/钥没钉/钥不匹配时仍发出 Release |
|
||||
|
||||
另外 CI 的构建槽位会跑 `check_pe_version.py`(`release-build.yml`):Windows 槽位上直接读
|
||||
`target/release/unlucky-launcher-iced.exe` 与 `ull.exe` 的字节,断言 `CompanyName` 等字符串在。
|
||||
|
||||
## 7. 本地自测(不需要私钥)
|
||||
|
||||
```bash
|
||||
python3 packaging/sign_manifest.py --self-test # RFC 8032 向量 + 标记解析
|
||||
python3 packaging/check_pe_version.py --self-test # 口径表与 pe_version.rs 对拍
|
||||
cargo test -p unlucky-core --lib update::tests::signature_cases
|
||||
cargo test -p unlucky-core --lib update::tests::ed25519_cases
|
||||
```
|
||||
|
||||
带 `update-test-source` 的验收构建里另有一把**公开的测试钥**(种子写在测试夹具里):它只用于
|
||||
「本地目录源 + 真二进制」的端到端验收,**不在**正式信任表里,签不出任何正式版本能装的清单。
|
||||
@@ -0,0 +1,101 @@
|
||||
// PE 版本资源(VERSIONINFO)的唯一口径:发布者/产品/版权/版本号。
|
||||
//
|
||||
// 这个文件被 `crates/cli/build.rs` 与 `crates/app-iced/build.rs` 各 `include!` 一次——两个产物
|
||||
// (`ull` 与 `unlucky-launcher-iced`)的署名必须逐字相同,复制两份表迟早会漂。
|
||||
//
|
||||
// # 字段口径(M190,塔已拍板)
|
||||
//
|
||||
// | 字段 | 值 | 为什么 |
|
||||
// |---|---|---|
|
||||
// | `CompanyName` | `Unlucky Launcher Project` | 发布者。**不得出现个人真实姓名**:这个名字随每个下载的 exe 传播,将来买 Authenticode 证书时 SmartScreen 显示的是证书主体,二者要对得上 |
|
||||
// | `ProductName` / `FileDescription` | `Unlucky Launcher` | Windows「属性 → 详细信息」里最显眼的两行 |
|
||||
// | `LegalCopyright` | `© 2026 Unlucky Launcher Project · Apache-2.0` | 版权 + 许可(仓库是 Apache-2.0) |
|
||||
// | `FileVersion` / `ProductVersion` | `CARGO_PKG_VERSION` 的四段式 | 与 `Cargo.toml` **同源**:不另写一份版本号 |
|
||||
// | `InternalName` / `OriginalFilename` | bin 名 / `<bin>.exe` | 供「这个 exe 到底叫什么」的取证 |
|
||||
// | `Comments` | 仓库地址 | 项目署名 `AskaEth` 的可读落点(GitHub 命名空间,不是个人姓名) |
|
||||
//
|
||||
// # 只在 windows 目标上真跑
|
||||
//
|
||||
// 版本资源是 PE 概念:`build.rs` 先看 `CARGO_CFG_TARGET_OS`,不是 `windows` 就整段返回,
|
||||
// Linux/macOS 构建一个字节都不多编译(它们的发布产物本来也没有 PE 头)。
|
||||
//
|
||||
// # 为什么不用 `windows_subsystem` 之外的资源
|
||||
//
|
||||
// 只要 VERSIONINFO 一块:图标、清单(DPI/权限)另有归属,本 mission 不碰(加了会改变运行时行为)。
|
||||
//
|
||||
// # 守门
|
||||
//
|
||||
// `packaging/check_pe_version.py` 是「写进源码不算,读回产物才算」的那道判据:它直接读 exe 字节,
|
||||
// 断言 `VS_VERSION_INFO` 结构在、上述字符串(UTF-16LE)在。CI 的构建槽位跑它。
|
||||
|
||||
/// 发布者(CompanyName)。**改它等于改署名**:同时要改 Authenticode 证书主体与 README 的署名口径。
|
||||
pub const COMPANY_NAME: &str = "Unlucky Launcher Project";
|
||||
|
||||
/// 产品名(`ProductName` 与 `FileDescription`)。
|
||||
pub const PRODUCT_NAME: &str = "Unlucky Launcher";
|
||||
|
||||
/// 版权行(`LegalCopyright`)。
|
||||
pub const COPYRIGHT: &str = "© 2026 Unlucky Launcher Project · Apache-2.0";
|
||||
|
||||
/// 项目署名(GitHub 命名空间;`Comments` 里随仓库地址一起出现)。
|
||||
pub const PROJECT_HANDLE: &str = "AskaEth";
|
||||
|
||||
/// 仓库地址(`Comments`)。
|
||||
pub const REPOSITORY: &str = "https://github.com/AskaEth/Unlucky-Launcher";
|
||||
|
||||
/// 把版本号补成四段式的数值(`0.2.3` → `0.2.3.0`;预发布段丢掉,因为 PE 的数值字段只认数字)。
|
||||
///
|
||||
/// 返回 `(四段元组, 打包成 FILEVERSION 用的 u64)`;任一数字大于 65535 时按 65535 截断
|
||||
/// (PE 的每个分量只有 16 位,真实版本号远小于它)。
|
||||
#[must_use]
|
||||
pub fn four_part(version: &str) -> ([u64; 4], u64) {
|
||||
// `0.2.3-beta.1` 的数值部分只有前三段;`+build` 元数据同理不计入版本资源。
|
||||
let numeric = version.split(['-', '+']).next().unwrap_or(version).trim();
|
||||
let mut parts = [0_u64; 4];
|
||||
for (index, piece) in numeric.split('.').take(4).enumerate() {
|
||||
parts[index] = piece.parse::<u64>().unwrap_or(0).min(0xFFFF);
|
||||
}
|
||||
let packed = (parts[0] << 48) | (parts[1] << 32) | (parts[2] << 16) | parts[3];
|
||||
(parts, packed)
|
||||
}
|
||||
|
||||
/// 给一个 bin 名(`ull` / `unlucky-launcher-iced`)写出版本资源。
|
||||
///
|
||||
/// 由两个 crate 的 `build.rs` 调用;非 windows 目标直接返回。
|
||||
pub fn emit_pe_version_resource(binary_name: &str) {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
|
||||
// 共享口径文件改了也要重编(`include!` 不被 cargo 追踪)。
|
||||
println!("cargo:rerun-if-changed=../../packaging/pe_version.rs");
|
||||
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
// Linux/macOS:没有 PE 头,整段不跑(发布产物的形状不变)。
|
||||
return;
|
||||
}
|
||||
|
||||
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
|
||||
let (parts, packed) = four_part(&version);
|
||||
let dotted = format!("{}.{}.{}.{}", parts[0], parts[1], parts[2], parts[3]);
|
||||
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
resource.set("CompanyName", COMPANY_NAME);
|
||||
resource.set("ProductName", PRODUCT_NAME);
|
||||
resource.set("FileDescription", PRODUCT_NAME);
|
||||
resource.set("LegalCopyright", COPYRIGHT);
|
||||
resource.set("InternalName", binary_name);
|
||||
resource.set("OriginalFilename", &format!("{binary_name}.exe"));
|
||||
resource.set("Comments", REPOSITORY);
|
||||
// 字符串形态(「属性」页显示的)与数值形态(资源管理器/安装器排序用的)都要给。
|
||||
resource.set("FileVersion", &dotted);
|
||||
resource.set("ProductVersion", &dotted);
|
||||
resource.set_version_info(winresource::VersionInfo::FILEVERSION, packed);
|
||||
resource.set_version_info(winresource::VersionInfo::PRODUCTVERSION, packed);
|
||||
|
||||
if let Err(error) = resource.compile() {
|
||||
// 资源编不出来就**中断构建**:宁可不出产物,也不出一个「属性里什么都没有」的 exe
|
||||
// (那正是 M190 要修的病)。
|
||||
panic!("写 PE 版本资源失败({binary_name} {dotted}):{error}");
|
||||
}
|
||||
println!("cargo:warning=PE VERSIONINFO: {binary_name} {dotted} / {COMPANY_NAME}");
|
||||
let _ = PROJECT_HANDLE;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user