Files
Unlucky-Launcher/crates/core/src/update/trust.rs
T
AskaEth af82658d08 fix(core,app-iced): M308 Linux/macOS 的 .tar.gz 真的能自替换 + 重试按 retryable 分流 + 三处文档纠偏
M307 盘查抓到的一条真缺陷 + 两处交互/文档不一致,本条只修它们,不重做通道:

① Linux/macOS 自更新的真缺陷(选 A:真做)
   `job::self_replaceable` 只认裸 exe,而发布矩阵里那两平台的启动器是 `.tar.gz`(清单记作
   `portable-zip`)⇒ 查得到更新、按钮画得出来、点下去必失败。新增 `core::update::unpack`:
   **归档字节过完长度/摘要双检之后**,从归档里取出启动器本体(tar 头 mode 有可执行位 /
   `.exe` 名字的唯一普通文件),再交给原有的 `install_staged`(原子替换 + 回滚一字未改)。
   零新依赖(flate2/tar 早就是 core 的依赖,`core::game::java` 解 Temurin 用同一套)。
   三条具名错误:`LauncherMissing` / `LauncherNotExecutable`(魔数与 target 不符)/
   `LauncherSizeMismatch`(0 字节 / 超上限 / 与头声明不符);zip 容器**明确拒绝**(没有可靠的
   「哪个条目可执行」判据,不猜)。`target.rs` 的 `PackageFormat` 文档改成与判据逐字一致。

② 重试按钮按 `retryable` 分流
   验签类(`is_retryable == false`)不画「重试」,正文换成新 key `UpdateBannerFailedFinal`
   (「这次更新已被拒绝(重试没用):{reason}」)。文案抽到 `pages/update/failure.rs`
   (`pages/update.rs` 的红线),判据落在**真 Widget 树**上(探针 tag + 递归计数)。

③ 三处文档纠偏 + 同一条谎话的其它落点(都在 scope 内)
   `trust.rs`(正式表已有 `ull-release`,commit 68362e5)、`check_signing_ready.py` 自述、
   `target.rs`;顺带 `packaging/README.md`、`docs/{signing,signing-runbook}.md`、
   `check_test_key_off.py`;新文档 `docs/update-trust-anchor.md` 记「M254 审计的『正式表为空』
   已作废」。

判据:core 新增 `unpack_cases`(Linux tar.gz / macOS tar.gz 走完整条链 + 五种拒绝)、
`job_cases` 的形态表改成两种;app-iced 两条真 Widget 树用例(按钮在 / 不在)+ 一条取词入口用例
+ 一条源码守卫。i18n 加一条 key 并**重出金表**(`Key::ALL` 853 行,新行落在 index 834)。
2026-11-13 23:38:17 +08:00

160 lines
9.8 KiB
Rust
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.
//! 钉进二进制的清单签名信任锚(keyring):**多把**公钥,`key_id` 只是查表用的标签。
//!
//! # 为什么是一张编译期常量表
//!
//! 自更新的信任根必须不可被「用户机器上的一枚文件」替换:清单与签名都能被能改 Release 的人一起
//! 换掉,唯一挡得住的是「客户端**编进二进制**的那几把公钥」。所以这张表是 `const`:
//!
//! * 改仓库里的本文件只会改变**下一次构建**的行为,不会改变任何**已发布二进制**的验签结果;
//! * 没有环境变量、没有设置键、没有「旁边的 keyring.json」这类运行期覆盖口子
//! (`tests/signature_cases.rs::no_runtime_keyring_override` 守这条)。
//!
//! # 为什么是「一组」而不是「一把」
//!
//! 换签名方(例如日后接 SignPath 的托管签名,或旧钥泄露后轮换)时,用户机器上装着的是**旧**二进制,
//! 它只认旧钥。做成数组才有两条路同时成立:新签出的清单可以同时带新旧两把钥的签名(旧二进制认旧的、
//! 新二进制认新的),以及新二进制可以一把一把地收紧。步骤见 `packaging/docs/signing.md`。
//!
//! # 唯一的加钥匙纪律:**旧钥签新钥**
//!
//! 往本表里加一把公钥,必须同时有「旧钥对新钥的背书」——即那次发布的清单同时带旧钥签名(见上),
//! 以及 changelog 里点名新钥的 `key_id` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表:
//! 那等于让「能改仓库的人」变成「能改信任根的人」。
//!
//! # 正式构建的表里有**一把**钥(`ull-release`,commit `68362e5` 起)
//!
//! ⚠ 早先这里写的是「正式表当前是空的(fail-closed)」——**那句话已作废**(M254 审计留下的旧口径,
//! M308 纠偏;作废声明另见 `docs/update-trust-anchor.md`)。现在 `PRODUCTION` 里钉着一条
//! `PinnedKey { key_id: "ull-release", … }`:正式构建拿到**由这把私钥签过**的清单会照常验签通过。
//!
//! 仍然 fail-closed 的是「表里一把都没匹配上」的那些情况——签名缺失 / 坏掉 / 钥不认得 / 签名对不上
//! ([`super::error::UpdateErrorKind`] 的签名五兄弟),它们一律拒装,绝不「没钥就放行」。
//! 而「Secret 没配 / 公钥没钉」这类**发布侧**的漏项由
//! `packaging/sign_manifest.py --check-pinned-keyring` 在流水线上**当场红**
//! (宁可不出 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` / `update-test-key` 三档里存在的
//! 测试钥(三档的口径见下面 `keyring()`):它的私钥种子就写在测试夹具里、公开可见,因此它可以签出
//! 「本仓库自己认得」的清单,但对任何正式构建毫无意义(正式表里没有它,`keyring()` 也不会把它并进
//! 信任表,而且那条目根本不进编译 —— 三档都不开的产物里连它的字节都没有,读数见 `keyring()`)。
//! 后两档(那两个 feature)各由 `packaging/check_test_source_off.py` /
//! `check_test_key_off.py` 守着 ——正式发布流水线与打包脚本都不许打开它们。
//! (`cfg(test)` 那档不需要守门:发布构建里根本没有 `test` cfg。)
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 >>>
PinnedKey {
key_id: "ull-release",
public_key_hex: "1c3687ecd656a29fda87a7afb56e07d2b9e223b62cf3a6f1a901fb499a97b122",
},
// <<< PINNED-KEYS <<<
];
/// 测试钥:**只**在 `cfg(test)` / `update-test-source` / `update-test-key` 三档构建里存在(正式表里没有它;
/// 三档的口径见下面 `keyring()`)。
///
/// 公钥由 `tests/support.rs` 里那串公开可见的 32 字节种子推出来(种子 = `ull-m190-test-key-seed-000000001`),
/// 两者一旦漂开,`tests/signature_cases.rs::pinned_test_key_matches_the_test_seed` 会当场红。
///
/// ⚠ 这个 `#[cfg]` 是**编译期剔除**,不是 `cfg!()` 那种「运行期值为假的分支」:不在这三档里时,
/// 下面这条条目根本不进编译(只剩再下面那条同名空表),所以那串十六进制与它的 `key_id`
/// 在产物里连字节都没有 —— 读数是实测的,见 `keyring()` 文档末尾那段。
#[cfg(any(test, feature = "update-test-source", feature = "update-test-key"))]
pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey {
key_id: "test-only-key",
public_key_hex: "f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b",
}];
/// 三档之外的 `TEST_ONLY`:空表。
///
/// 它存在的唯一目的,是让 [`keyring()`] 里那段「把测试钥并进来」的代码在**两档里写法一致**
/// (`keys` 的 `mut` 在两档里都有用处,不至于为 `unused_mut` 单独开 lint 口子)。空表本身就是
/// 「这段字节不在产物里」的全部内容 —— 真条目在上一档里。
#[cfg(not(any(test, feature = "update-test-source", feature = "update-test-key")))]
pub(crate) const TEST_ONLY: &[PinnedKey] = &[];
/// 本构建实际的信任锚(正式表 + 仅测试构建才有的那把测试钥)。
///
/// 三档「仅测试」的口径,逐档说明为什么需要它:
///
/// | 档 | 谁在用 | 为什么不能只靠 `cfg(test)` |
/// |---|---|---|
/// | `cfg(test)` | core 自己的单测 | 够用 |
/// | `update-test-source` | CLI 的端到端验收(`--features update-test-source`) | 那是**另一个 target**,`cfg(test)` 不生效 |
/// | `update-test-key` | `crates/presenter` 的 `[dev-dependencies]` | presenter 的更新链路用例编译 core 时既无 `cfg(test)`,也不该打开 `update-test-source`(那条会同时给出「本地目录当更新源」这条路)。这一档**只**加一把公开的测试公钥,不开任何数据源开关 |
///
/// 三档都不开的构建里,这把测试钥**既不被并进信任表,也不会被编进产物**(官方构建三个 feature 都不开,
/// 且 `check_test_source_off.py` / `check_test_key_off.py` 守着)。
///
/// 这是**编译期**结论,不是运行期判据:门是 `#[cfg]`(三档之外 `TEST_ONLY` 只剩上面那条空表,
/// 真条目根本不进编译),而不是 `cfg!()` 那种「运行期值为假的分支」—— 后者会把那段字节照编进产物。
///
/// 实测读数(本机 Windows + Git Bash,`grep -ac` 数的是命中行数,`grep` 退出码 1 = 零命中;
/// `$HEX` = 上文 `TEST_ONLY` 里的 `public_key_hex`,`$KID` = 那条的 `key_id`):
///
/// ```text
/// $ CARGO_TARGET_DIR=... cargo build -p unlucky-core --lib # exit 0
/// $ grep -ac "$HEX" .../debug/libunlucky_core.rlib # 0
/// $ grep -ac "$KID" .../debug/libunlucky_core.rlib # 0
/// $ CARGO_TARGET_DIR=... cargo build -p unlucky-core --lib --release # exit 0
/// $ grep -ac "$HEX" .../release/libunlucky_core.rlib # 0
/// $ grep -ac "$KID" .../release/libunlucky_core.rlib # 0
/// $ CARGO_TARGET_DIR=...-key cargo build -p unlucky-core --lib --features update-test-key # exit 0
/// $ grep -ac "$HEX" ...-key/debug/libunlucky_core.rlib # 2
/// $ grep -ac "$KID" ...-key/debug/libunlucky_core.rlib # 2
/// ```
///
/// (最后两行是对照臂:真打开那一档时这两串**都在**,所以前六行的 0 不是「grep 坏了」。)
///
/// ⚠ 这份读数只到 **rlib 为止**:core 的 `--lib` 产物里没有那两串字节。**未验**的是最终链接出来的
/// exe 在 `lto = "fat"` + `strip = "symbols"` 之后是否还有那段字节 —— 那是链接期与 strip 的事,
/// 本条的判据(编译期剔除)管不到,别把上面六行读成「exe 里也不可能有」。另:无论有没有那段字节,
/// 验签都 fail-closed(信任表里没有这把钥)。
pub(crate) fn keyring() -> &'static [PinnedKey] {
static ALL: OnceLock<Vec<PinnedKey>> = OnceLock::new();
ALL.get_or_init(|| {
let mut keys = PRODUCTION.to_vec();
// 三档之外 `TEST_ONLY` 是上面那条空表,这一段等于什么都没并;三档之内才是真条目。
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)
}