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:
AskaEth committed 2026-11-09 01:59:05 +08:00
1 parent 44a0c87a11
commit b7d9b99e12
34 files changed
+2885 -104

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
//! 钉进二进制的清单签名信任锚(keyring):**多把**公钥,`key_id` 只是查表用的标签。
//!
//! # 为什么是一张编译期常量表
//!
//! 自更新的信任根必须不可被「用户机器上的一枚文件」替换:清单与签名都能被能改 Release 的人一起
//! 换掉,唯一挡得住的是「客户端**编进二进制**的那几把公钥」。所以这张表是 `const`:
//!
//! * 改仓库里的本文件只会改变**下一次构建**的行为,不会改变任何**已发布二进制**的验签结果;
//! * 没有环境变量、没有设置键、没有「旁边的 keyring.json」这类运行期覆盖口子
//! (`tests/signature_cases.rs::no_runtime_keyring_override` 守这条)。
//!
//! # 为什么是「一组」而不是「一把」
//!
//! 换签名方(例如日后接 SignPath 的托管签名,或旧钥泄露后轮换)时,用户机器上装着的是**旧**二进制,
//! 它只认旧钥。做成数组才有两条路同时成立:新签出的清单可以同时带新旧两把钥的签名(旧二进制认旧的、
//! 新二进制认新的),以及新二进制可以一把一把地收紧。步骤见 `packaging/docs/signing.md`。
//!
//! # 唯一的加钥匙纪律:**旧钥签新钥**
//!
//! 往本表里加一把公钥,必须同时有「旧钥对新钥的背书」——即那次发布的清单同时带旧钥签名(见上),
//! 以及 changelog 里点名新钥的 `key_id` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表:
//! 那等于让「能改仓库的人」变成「能改信任根的人」。
//!
//! # 正式构建的表当前是**空的**(fail-closed)
//!
//! 私钥只能来自仓库 Secret,本轮没有(也不该由代码仓库产生)私钥,所以正式表里一个条目都没有:
//! 任何正式构建拿到清单都会以 [`super::error::UpdateErrorKind::SignatureMissing`] 之类的分类拒绝更新,
//! 而 `packaging/sign_manifest.py --check-pinned-keyring` 会让发布流水线在「Secret 没配 / 公钥没钉」
//! 时**当场红**(宁可不出 Release,也不出未签名的 Release)。
//!
//! 录一把真钥的两步(细节见 `packaging/docs/signing.md`):
//!
//! ```text
//! python packaging/sign_manifest.py --generate-key --out-key release.key # ① 生成,私钥写进 Secret
//! # ② 把打印出来的 `PinnedKey { … }` 一行贴到下面 PINNED-KEYS 标记之间
//! ```
//!
//! 测试/验收构建另有一把**只在** `cfg(test)` 与 `update-test-source` feature 里存在的测试钥:
//! 它的私钥种子就写在测试夹具里、公开可见,因此它可以签出「本仓库自己认得」的清单,但对任何正式
//! 构建毫无意义(正式表里没有它)。这条也由 `packaging/check_test_source_off.py` 的同一道闸守着
//! ——正式发布流水线与打包脚本都不许打开 `update-test-source`。
use std::sync::OnceLock;
/// 一把钉死的公钥。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PinnedKey {
/// 标签(清单签名里的 `key_id` 靠它查表;**改名就是换钥**)。
pub(crate) key_id: &'static str,
/// Ed25519 公钥的 64 位十六进制小写形态(32 字节)。
pub(crate) public_key_hex: &'static str,
}
/// 正式构建的信任锚(发布二进制里就是这一份)。
///
/// ── 加钥匙只改下面 `PINNED-KEYS` 标记之间的内容,别改标记本身 ──
/// (`packaging/sign_manifest.py --check-pinned-keyring` 按标记解析这一段,标记变了它就找不到表)。
pub(crate) const PRODUCTION: &[PinnedKey] = &[
// >>> PINNED-KEYS >>>
// <<< PINNED-KEYS <<<
];
/// 测试钥:**只**在 `cfg(test)` 与 `update-test-source` 构建里存在(正式表里没有它)。
///
/// 公钥由 `tests/support.rs` 里那串公开可见的 32 字节种子推出来(种子 = `ull-m190-test-key-seed-000000001`),
/// 两者一旦漂开,`tests/signature_cases.rs::pinned_test_key_matches_the_test_seed` 会当场红。
pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey {
key_id: "test-only-key",
public_key_hex: "f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b",
}];
/// 本构建实际的信任锚(正式表 + 仅测试构建才有的那把测试钥)。
pub(crate) fn keyring() -> &'static [PinnedKey] {
static ALL: OnceLock<Vec<PinnedKey>> = OnceLock::new();
ALL.get_or_init(|| {
let mut keys = PRODUCTION.to_vec();
if cfg!(any(test, feature = "update-test-source")) {
keys.extend_from_slice(TEST_ONLY);
}
keys
})
}
/// 十六进制解码(只收小写/大写十六进制,长度必须是偶数)。
pub(crate) fn decode_hex(text: &str) -> Option<Vec<u8>> {
if !text.len().is_multiple_of(2) {
return None;
}
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len() / 2);
let (pairs, _) = bytes.as_chunks::<2>();
for pair in pairs {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out.push(u8::try_from(hi * 16 + lo).ok()?);
}
Some(out)
}