Files
Unlucky-Launcher/packaging/docs/signing.md
T
AskaEth a219a21db8 fix(m190): r2 复审——dalek strict 口径照实测写正、补小阶 R 用例、锁里 toml_datetime 退回 1.1.2
三条 P2 都是「文档/注释与事实不符 + 一条上报事实错了」,代码行为没变。

P2-1 dalek 的 strict vs 非 strict 口径改成正测试出来的样子:
* `VerifyingKey::from_bytes` **不**校验编码规范性(`y = p` 的非规范写法返回 Ok,`is_weak() == true`),
  先前写的「要求规范编码」是错的;
* 非 strict 的 `verify` **也**查 `S` 规范性 —— 先前把它写成「不做可锻检查」是半错;
* 于是这批拒绝类用例里,strict 与非 strict 唯一的分歧是**小阶点**:承重的是
  `small_order_public_key_is_rejected`,其余几条(malleable / non_canonical_r / empty)换掉 strict 后
  **仍然绿**,文件头的表与用例注释都改成这个口径,别让人误以为它们也是守卫。
* 新补 `small_order_r_is_rejected_only_by_strict`:R = 单位元 + S = k·a mod L 的伪造,**两边都断言**
  —— dalek 的非 strict `verify` 接受它、我们的 `verify` 拒绝。这是第二条「只由 strict 拒」的判据。
* `non_canonical_r_encoding_is_rejected` 里那条「非规范公钥编码」原来对两种实现都绿(等于没测),
  现在断言 dalek 的**真实**行为(`from_bytes(...).is_ok()` + `is_weak()`),dalek 升级改口径会红。

P2-2 `packaging/sign_manifest.py` 里指向已删除 `ed25519/{field,group}.rs` 的旧注释删掉(那段还写着
「same formulas」,而两套实现现在是**互相独立**才构成交叉验证)。

P2-3 `Cargo.lock`:`toml_datetime` 从 1.1.3 **退回 1.1.2**(`cargo update -p toml_datetime --precise
1.1.2` 实测可降、`cargo metadata --locked` 自洽)。先前报告里「被 `toml` 拒绝所以无法避免」的理由
**不成立**,已在报告里更正。现在相对 `44a0c87` 的锁变更是**纯新增**:16 个包、零既有版本变动。

变异证明(本轮按要求重跑):把 `verify_strict` 换成 `verify`(并补 `Verifier` trait 使其可编译)
⇒ `test result: FAILED. 110 passed; 2 failed`,红的两条正是
`small_order_public_key_is_rejected` 与 `small_order_r_is_rejected_only_by_strict`;其余拒绝类用例全绿
(与修正后的口径一致)。恢复后 `112 passed; 0 failed`。
2026-11-09 02:33:08 +08:00

155 lines
9.9 KiB
Markdown
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.
# 产物签名与更新验签(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-pinned dist/manifest.json # ④ 用钉住的公钥回验产出
```
第 ④ 步复现「客户端会怎么判」:它**不碰私钥**,只拿 `trust.rs` 里那把公钥去验刚落盘的 `.sig`。
因此「签名脚本自己说签好了」不构成证据,证据是「按客户端的判据能验过」。
四条命令任何一条非零退出 ⇒ **这一步红 ⇒ 整个 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. 实现分工(M190 r1 复审后)
| 哪一半 | 在哪 | 用什么 |
|---|---|---|
| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(在非 strict 的基础上额外拒**小阶公钥**与**小阶 `R`**;`S` 规范性是非 strict 的 `verify` 也查的) |
| **签名**(只在发布侧) | `packaging/ed25519_ref.py` + `packaging/sign_manifest.py` | 纯 Python 参考实现,零第三方依赖(CI 不装 pip 包) |
两套实现互相独立,`--self-test` 用 RFC 8032 §7.1 的同一组向量把两边各自钉住。**第一版曾把验签写成
仓内自写**(5x51 素域 + 扩展坐标);r1 复审裁定不安全——信任锚的微妙错误后果是「验签通过但本不该通过」,
因此整段删除,改为官方实现。`crates/core/src/update/tests/ed25519_cases.rs` 现在钉的是
**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`,`small_order_public_key_is_rejected` 与
`small_order_r_is_rejected_only_by_strict` 会红(这是 r2 实测的结论——其余几条拒绝类用例在非 strict
下仍绿,别把它们当 strict 的守卫)。
## 8. 本地自测(不需要私钥)
```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` 的验收构建里另有一把**公开的测试钥**(种子写在测试夹具里):它只用于
「本地目录源 + 真二进制」的端到端验收,**不在**正式信任表里,签不出任何正式版本能装的清单。