From b7d9b99e1285d5cad13254b654df073e8be793df Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 01:39:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(m190):=20=E4=BA=A7=E7=89=A9=E7=BD=B2?= =?UTF-8?q?=E5=90=8D=EF=BC=88PE=20VERSIONINFO=EF=BC=89+=20=E6=B8=85?= =?UTF-8?q?=E5=8D=95=20Ed25519=20=E5=A4=9A=E7=AD=BE=E5=90=8D=E4=B8=8E?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=89=8D=E9=AA=8C=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。 ① 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` 已实测被拒)。 --- .github/workflows/release-build.yml | 19 + .github/workflows/release-guard.yml | 7 + .github/workflows/release-publish.yml | 30 +- .github/workflows/release.yml | 3 + Cargo.lock | 48 ++- crates/app-iced/Cargo.toml | 6 + crates/app-iced/build.rs | 13 + crates/cli/Cargo.toml | 6 + crates/cli/build.rs | 10 + crates/cli/src/commands/update.rs | 10 +- crates/cli/tests/support/update_e2e.rs | 69 +++- crates/core/src/update/ed25519/field.rs | 247 +++++++++++ crates/core/src/update/ed25519/group.rs | 168 ++++++++ crates/core/src/update/ed25519/mod.rs | 265 ++++++++++++ crates/core/src/update/error.rs | 36 ++ crates/core/src/update/github.rs | 35 +- crates/core/src/update/job.rs | 6 +- crates/core/src/update/mod.rs | 20 +- crates/core/src/update/signature.rs | 256 ++++++++++++ crates/core/src/update/source.rs | 123 +++++- crates/core/src/update/tests/checker_cases.rs | 6 +- crates/core/src/update/tests/ed25519_cases.rs | 213 ++++++++++ crates/core/src/update/tests/github_cases.rs | 73 ++-- crates/core/src/update/tests/job_cases.rs | 14 +- crates/core/src/update/tests/mod.rs | 2 + .../core/src/update/tests/signature_cases.rs | 303 ++++++++++++++ crates/core/src/update/tests/source_cases.rs | 8 +- crates/core/src/update/tests/support.rs | 72 +++- crates/core/src/update/trust.rs | 98 +++++ packaging/README.md | 14 + packaging/check_pe_version.py | 188 +++++++++ packaging/docs/signing.md | 137 +++++++ packaging/pe_version.rs | 101 +++++ packaging/sign_manifest.py | 383 ++++++++++++++++++ 34 files changed, 2885 insertions(+), 104 deletions(-) create mode 100644 crates/app-iced/build.rs create mode 100644 crates/cli/build.rs create mode 100644 crates/core/src/update/ed25519/field.rs create mode 100644 crates/core/src/update/ed25519/group.rs create mode 100644 crates/core/src/update/ed25519/mod.rs create mode 100644 crates/core/src/update/signature.rs create mode 100644 crates/core/src/update/tests/ed25519_cases.rs create mode 100644 crates/core/src/update/tests/signature_cases.rs create mode 100644 crates/core/src/update/trust.rs create mode 100644 packaging/check_pe_version.py create mode 100644 packaging/docs/signing.md create mode 100644 packaging/pe_version.rs create mode 100644 packaging/sign_manifest.py diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 1520852..9993e80 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -212,6 +212,25 @@ jobs: ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true fi + # (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于 + # 它进了 PE——资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式 + # 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。 + # 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见 + # `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。 + - name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里) + if: runner.os == 'Windows' + shell: bash + env: + BUILD_DIR: ${{ inputs.build_dir }} + run: | + set -euo pipefail + PY=$(command -v python3 || command -v python) + if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi + # 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。 + version=$(sed -n 's/^version = "\(.*\)"//p' Cargo.toml | head -n1) + echo "expect-version=$version" + "$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version" + - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL) if: runner.os == 'Windows' shell: bash diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml index 22785b4..9d2e8ea 100644 --- a/.github/workflows/release-guard.yml +++ b/.github/workflows/release-guard.yml @@ -68,3 +68,10 @@ jobs: run: python3 packaging/check_test_source_off.py --self-test - name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开) run: python3 packaging/check_test_source_off.py + # M190:签名脚本与 PE 版本资源门禁自己的自检。前者验 RFC 8032 向量(Ed25519 实现错了就发不出 + # 能被验过的签名),后者把「口径表 ⇄ pe_version.rs」「四段式版本号换算」对拍,并造一个没有版本 + # 资源的假 PE 断言判据**会红**(否则那道门禁对任何输入都绿,等于没有)。 + - name: 清单签名脚本自检(RFC 8032 向量 + 信任表标记解析) + run: python3 packaging/sign_manifest.py --self-test + - name: PE 版本资源判据自检(口径表对拍 + 假 PE 必红) + run: python3 packaging/check_pe_version.py --self-test diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 8888114..f859e6a 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -131,6 +131,32 @@ jobs: --asset-base-url "$RELEASE_BASE/$TAG" \ --released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + # (M190)清单签名:**签不上就不发版**。私钥只从仓库 Secret `ULL_MANIFEST_SIGNING_KEY` 进环境变量, + # 本步 `set +x` 且只回显**公钥**(日志里永远不出现私钥)。四条命令任一非零 ⇒ 本步红 ⇒ 不出 Release: + # ① `--self-test`:RFC 8032 向量(签名脚本自己没坏) + # ② `--check-pinned-keyring`:Secret 里的钥**必须**已在 `crates/core/src/update/trust.rs` 钉住 + # (签出一份客户端不认的清单,比不发版更糟) + # ③ `--sign`:签的就是上一步落盘的 `dist/manifest.json` 原始字节,产出 `dist/manifest.json.sig` + # 红了的处置见 `packaging/docs/signing.md` §4 那张表(缺什么、怎么补,都写在脚本的 stderr 里)。 + - name: 签更新清单(Ed25519;签不上就阻断发布) + shell: bash + env: + ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }} + run: | + set -euo pipefail + set +x + PY=$(command -v python3 || command -v python) + if [ -z "$PY" ]; then echo "::error::找不到 python,签名步骤无法运行(签名门禁不降级)"; exit 1; fi + "$PY" packaging/sign_manifest.py --self-test + if [ -z "${ULL_MANIFEST_SIGNING_KEY:-}" ]; then + echo "::error::仓库 Secret ULL_MANIFEST_SIGNING_KEY 没配(或为空):没有签名私钥就不发版。" + echo "::error::补法见 packaging/docs/signing.md §3:python3 packaging/sign_manifest.py --generate-key,把打印的 JSON 贴进 Secret、把 PinnedKey 那行贴进 crates/core/src/update/trust.rs" + exit 1 + fi + "$PY" packaging/sign_manifest.py --check-pinned-keyring + "$PY" packaging/sign_manifest.py --sign dist/manifest.json + ls -l dist/manifest.json dist/manifest.json.sig + - name: 生成 Release 说明(支持矩阵模板) shell: bash env: @@ -172,7 +198,9 @@ jobs: echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})" # Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。 # manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。 - assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/SHA256SUMS.txt LICENSE NOTICE README.md) + # `manifest.json.sig` 必须与清单一起上传:客户端取清单后取它旁边的签名(`GitHubReleaseSource` + # 拼的地址是 `<产物根>/manifest.json.sig`),漏传等于让所有人都验签失败(M190)。 + assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/manifest.json.sig dist/SHA256SUMS.txt LICENSE NOTICE README.md) if gh release view "$TAG" >/dev/null 2>&1; then # 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。 gh release upload "$TAG" "${assets[@]}" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89d4d9b..03ceff7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,3 +150,6 @@ jobs: secrets: # 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。 PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # (M190)清单签名私钥:被调方 release-publish.yml 用它签 `manifest.json`。没配时是空串, + # 签名步会带着「缺什么、怎么补」的注解当场红(宁可不出 Release,也不出未签名的 Release)。 + ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }} diff --git a/Cargo.lock b/Cargo.lock index 9c87a6a..f0332a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4125,6 +4125,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_spanned" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4739,10 +4748,25 @@ dependencies = [ ] [[package]] -name = "toml_datetime" -version = "1.1.2+spec-1.1.0" +name = "toml" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" dependencies = [ "serde_core", ] @@ -4768,6 +4792,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + [[package]] name = "tower" version = "0.5.3" @@ -5007,6 +5037,7 @@ dependencies = [ "unlucky-design", "unlucky-i18n", "unlucky-presenter", + "winresource", ] [[package]] @@ -5018,6 +5049,7 @@ dependencies = [ "tokio", "unlucky-agent", "unlucky-core", + "winresource", ] [[package]] @@ -5968,6 +6000,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index 248cecb..2e98e76 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -100,3 +100,9 @@ update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/updat [lints] workspace = true + +# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。 +# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回), +# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。 +[build-dependencies] +winresource = "0.1" diff --git a/crates/app-iced/build.rs b/crates/app-iced/build.rs new file mode 100644 index 0000000..466b360 --- /dev/null +++ b/crates/app-iced/build.rs @@ -0,0 +1,13 @@ +//! `unlucky-launcher-iced` 的 PE 版本资源(M190)。 +//! +//! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的 +//! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。 +//! +//! ⚠ 本文件**不**碰 `windows_subsystem`:GUI 子系统由 `src/main.rs` 的 +//! `#![cfg_attr(windows, windows_subsystem = "windows")]` 决定,版本资源与子系统是两件事。 + +include!("../../packaging/pe_version.rs"); + +fn main() { + emit_pe_version_resource("unlucky-launcher-iced"); +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 84ce552..55af3b0 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -38,3 +38,9 @@ workspace = true # M169:转发 core 的测试通道(`--features update-test-source` 才编进来,默认关)。 # `ull update check/apply` 的端到端验收要靠它指向本地目录源。 update-test-source = ["unlucky-core/update-test-source"] + +# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。 +# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回), +# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。 +[build-dependencies] +winresource = "0.1" diff --git a/crates/cli/build.rs b/crates/cli/build.rs new file mode 100644 index 0000000..19c3b82 --- /dev/null +++ b/crates/cli/build.rs @@ -0,0 +1,10 @@ +//! `ull` 的 PE 版本资源(M190)。 +//! +//! 只做一件事:调共享口径 [`emit_pe_version_resource`](定义在 `packaging/pe_version.rs`, +//! 两个 bin 共用同一份署名表)。非 windows 目标里那个函数会立刻返回,Linux/macOS 构建零影响。 + +include!("../../packaging/pe_version.rs"); + +fn main() { + emit_pe_version_resource("ull"); +} diff --git a/crates/cli/src/commands/update.rs b/crates/cli/src/commands/update.rs index a3d1df6..fecf33d 100644 --- a/crates/cli/src/commands/update.rs +++ b/crates/cli/src/commands/update.rs @@ -77,7 +77,15 @@ fn job(ctx: &Ctx, dest: Option) -> Job { /// **底层原因**(`digest_mismatch` / `network` / `invalid_manifest` / `io` …,据此决定「重试有没有意义」)。 /// 以前原因只写在人读 message 里,脚本不解析散文就分不出来。 fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError { - let hint = if error.is_retryable() { + // 签名类错误单独给一句:它们**不可重试**,而且绝不能靠「换个源/跳过校验」绕过——绕过一次 + // 就等于把信任根拆了(见 `core::update::signature` 的文件头)。 + let hint = if error.kind.is_signature() { + concat!( + "清单签名没通过,重试没有意义:signature_missing = 发布侧漏了签名;", + "signature_unknown_key / keyring_empty = 本构建的信任表里没有这把钥(升级启动器或等发布侧换钥);", + "signature_invalid = 签名之后清单被改过。别绕过校验,等发布侧修" + ) + } else if error.is_retryable() { "网络/占用类失败,过一会儿重试一次通常就好" } else { "这不是瞬时失败:清单或版本号本身有问题,等发布侧修" diff --git a/crates/cli/tests/support/update_e2e.rs b/crates/cli/tests/support/update_e2e.rs index d3fa42f..3fbf68f 100644 --- a/crates/cli/tests/support/update_e2e.rs +++ b/crates/cli/tests/support/update_e2e.rs @@ -81,6 +81,65 @@ fn asset_name(version: &str) -> String { ) } +/// 测试签名钥的种子(**公开**:只对 `update-test-source` 构建有意义,正式信任表里没有这把钥, +/// 见 `crates/core/src/update/trust.rs::TEST_ONLY`)。 +const TEST_KEY_SEED: &str = "ull-m190-test-key-seed-000000001"; +/// 测试钥的 `key_id`(与 `trust.rs::TEST_ONLY` 一致)。 +const TEST_KEY_ID: &str = "test-only-key"; + +/// 用 `packaging/sign_manifest.py` 给刚落盘的清单签一份 `.sig`(M190)。 +/// +/// 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒ +/// 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带 +/// python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。 +fn write_signature(manifest_path: &Path) { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/sign_manifest.py"); + assert!(script.is_file(), "找不到签名脚本:{}", script.display()); + let seed_hex: String = TEST_KEY_SEED + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let secret = format!(r#"{{"key_id":"{TEST_KEY_ID}","seed_hex":"{seed_hex}"}}"#); + let mut command = None; + for candidate in ["python3", "python"] { + if std::process::Command::new(candidate) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) + { + command = Some(candidate); + break; + } + } + let python = command.unwrap_or_else(|| panic!("这套用例需要 python3/python 跑 {script:?}")); + let output = std::process::Command::new(python) + .arg(&script) + .arg("--sign") + .arg(manifest_path) + .env("ULL_MANIFEST_SIGNING_KEY", &secret) + .output() + .expect("跑签名脚本"); + assert!( + output.status.success(), + "签名脚本失败:stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + manifest_path + .with_file_name(format!( + "{}.sig", + manifest_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() + )) + .is_file(), + "签名脚本没产出 .sig" + ); +} + /// 一份与 `packaging/tests/manifest.golden.json` **同字段**的清单。 /// /// 字段一个不多一个不少:core 的 `deny_unknown_fields` 会把多余字段当清单损坏拒掉,所以这条 @@ -258,11 +317,11 @@ impl Fixture { /// 发一版,摘要/长度由调用方给(篡改用例用)。 pub fn publish(&self, sha256: &str, size: u64) { std::fs::write(self.dist.path().join(&self.asset), &self.payload).expect("写产物"); - std::fs::write( - self.dist.path().join("stable.json"), - manifest(&self.version, &self.asset, size, sha256), - ) - .expect("写清单"); + let path = self.dist.path().join("stable.json"); + std::fs::write(&path, manifest(&self.version, &self.asset, size, sha256)).expect("写清单"); + // M190:清单与签名成对落盘。篡改用例给的是「错的 sha/size」,签的是**改过之后**那份清单 + // —— 那正是「清单本身可信、包体不对」的现场(摘要判据要独立于签名判据被证到)。 + write_signature(&path); } /// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。 diff --git a/crates/core/src/update/ed25519/field.rs b/crates/core/src/update/ed25519/field.rs new file mode 100644 index 0000000..bc205b6 --- /dev/null +++ b/crates/core/src/update/ed25519/field.rs @@ -0,0 +1,247 @@ +//! 素域 `GF(2^255 - 19)` 的算术:5 条 51 位肢(radix-2^51)+ 128 位中间积。 +//! +//! 为什么自己写而不是引 `curve25519-dalek`:M190 的 scope 不含 `crates/core/Cargo.toml` +//! (见 `ed25519/mod.rs` 文件头的取舍说明)。这一层只服务**验签**,不接触任何私钥, +//! 也不需要常数时间性质:输入全是公开数据(公钥、签名、清单字节)。 +//! +//! 表示是「宽松的」:`[u64; 5]` 只是数值的内部形态,同一元素有多种写法(肢可以不规范)。 +//! 需要规范字节时一律走 [`Fe::to_bytes`](它先进位、再按 `h >= p` 条件减 `p`)。 + +/// 单肢掩码(51 位)。 +const MASK: u64 = (1 << 51) - 1; + +/// 域元素。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Fe(pub(crate) [u64; 5]); + +impl Fe { + /// 0。 + pub(crate) const ZERO: Self = Self([0; 5]); + + /// 1。 + pub(crate) const ONE: Self = Self([1, 0, 0, 0, 0]); + + /// 从整数构造(只用于 121665 / 121666 这类小常数)。 + pub(crate) fn from_u64(value: u64) -> Self { + Self([value & MASK, value >> 51, 0, 0, 0]) + } + + /// 读 32 字节小端(不检查是否 `< p`:调用方要自己判定规范性)。 + pub(crate) fn from_bytes(bytes: &[u8; 32]) -> Self { + let load = |offset: usize| -> u64 { + let mut buf = [0_u8; 8]; + buf.copy_from_slice(&bytes[offset..offset + 8]); + u64::from_le_bytes(buf) + }; + // 51 位一段:位 0..51 从字节 0,位 51..102 从字节 6 起右移 3,依此类推。 + Self([ + load(0) & MASK, + (load(6) >> 3) & MASK, + (load(12) >> 6) & MASK, + (load(19) >> 1) & MASK, + (load(24) >> 12) & MASK, + ]) + } + + /// 规范 32 字节小端编码(先完全进位,再按 `>= p` 条件减一次 `p`)。 + pub(crate) fn to_bytes(self) -> [u8; 32] { + let mut h = carry(self.0); + // `q = 1` 当且仅当 `h >= p`:`h + 19` 溢出 2^255 就说明 `h >= 2^255 - 19`。 + let mut q = (h[0] + 19) >> 51; + q = (h[1] + q) >> 51; + q = (h[2] + q) >> 51; + q = (h[3] + q) >> 51; + q = (h[4] + q) >> 51; + h[0] += 19 * q; + h[1] += h[0] >> 51; + h[0] &= MASK; + h[2] += h[1] >> 51; + h[1] &= MASK; + h[3] += h[2] >> 51; + h[2] &= MASK; + h[4] += h[3] >> 51; + h[3] &= MASK; + h[4] &= MASK; + let mut out = [0_u8; 32]; + let mut acc: u128 = 0; + let mut bits = 0_u32; + let mut at = 0_usize; + for limb in h { + acc |= u128::from(limb) << bits; + bits += 51; + while bits >= 8 { + out[at] = (acc & 0xFF) as u8; + acc >>= 8; + bits -= 8; + at += 1; + } + } + while at < 32 { + out[at] = (acc & 0xFF) as u8; + acc >>= 8; + at += 1; + } + out + } + + /// 加法。 + pub(crate) fn add(&self, rhs: &Self) -> Self { + let f = self.0; + let g = rhs.0; + Self(carry([ + f[0] + g[0], + f[1] + g[1], + f[2] + g[2], + f[3] + g[3], + f[4] + g[4], + ])) + } + + /// 减法(加 `2p` 再减,避免借位)。 + pub(crate) fn sub(&self, rhs: &Self) -> Self { + let f = self.0; + let g = rhs.0; + // 2p 的肢:`2*(2^51-19)` 与 `2*(2^51-1)`。 + let two_p0 = 0x000F_FFFF_FFFF_FFDA; + let two_p = 0x000F_FFFF_FFFF_FFFE; + Self(carry([ + f[0] + two_p0 - g[0], + f[1] + two_p - g[1], + f[2] + two_p - g[2], + f[3] + two_p - g[3], + f[4] + two_p - g[4], + ])) + } + + /// 取负。 + pub(crate) fn neg(&self) -> Self { + Self::ZERO.sub(self) + } + + /// 乘法(教科书式 5x5,中间积用 128 位)。 + pub(crate) fn mul(&self, rhs: &Self) -> Self { + let f = self.0; + let g = rhs.0; + let g1 = u128::from(g[1]) * 19; + let g2 = u128::from(g[2]) * 19; + let g3 = u128::from(g[3]) * 19; + let g4 = u128::from(g[4]) * 19; + let f0 = u128::from(f[0]); + let f1 = u128::from(f[1]); + let f2 = u128::from(f[2]); + let f3 = u128::from(f[3]); + let f4 = u128::from(f[4]); + let g0 = u128::from(g[0]); + let mut h0 = f0 * g0 + f1 * g4 + f2 * g3 + f3 * g2 + f4 * g1; + let mut h1 = f0 * u128::from(g[1]) + f1 * g0 + f2 * g4 + f3 * g3 + f4 * g2; + let mut h2 = f0 * u128::from(g[2]) + f1 * u128::from(g[1]) + f2 * g0 + f3 * g4 + f4 * g3; + let mut h3 = f0 * u128::from(g[3]) + + f1 * u128::from(g[2]) + + f2 * u128::from(g[1]) + + f3 * g0 + + f4 * g4; + let mut h4 = f0 * u128::from(g[4]) + + f1 * u128::from(g[3]) + + f2 * u128::from(g[2]) + + f3 * u128::from(g[1]) + + f4 * g0; + let mask128 = u128::from(MASK); + let mut c = h0 >> 51; + h0 &= mask128; + h1 += c; + c = h1 >> 51; + h1 &= mask128; + h2 += c; + c = h2 >> 51; + h2 &= mask128; + h3 += c; + c = h3 >> 51; + h3 &= mask128; + h4 += c; + c = h4 >> 51; + h4 &= mask128; + h0 += c * 19; + c = h0 >> 51; + h0 &= mask128; + h1 += c; + Self([h0 as u64, h1 as u64, h2 as u64, h3 as u64, h4 as u64]) + } + + /// 平方(乘法的特化,只为可读性)。 + pub(crate) fn square(&self) -> Self { + self.mul(self) + } + + /// `self^exponent`,指数以 32 字节小端给出(从最高位往下平方-乘)。 + pub(crate) fn pow(&self, exponent: &[u8; 32]) -> Self { + let mut acc = Self::ONE; + for byte_index in (0..32).rev() { + for bit in (0..8).rev() { + acc = acc.square(); + if (exponent[byte_index] >> bit) & 1 == 1 { + acc = acc.mul(self); + } + } + } + acc + } + + /// 乘法逆元(`p` 是素数,故 `x^(p-2)`)。 + pub(crate) fn invert(&self) -> Self { + // p - 2 = 2^255 - 21 + let mut e = [0xFF_u8; 32]; + e[0] = 0xEB; + e[31] = 0x7F; + self.pow(&e) + } + + /// `x^((p-5)/8)`,开方用(`(p-5)/8 = 2^252 - 3`)。 + pub(crate) fn pow_p58(&self) -> Self { + let mut e = [0xFF_u8; 32]; + e[0] = 0xFD; + e[31] = 0x0F; + self.pow(&e) + } + + /// `sqrt(-1) = 2^((p-1)/4)`(`p ≡ 5 mod 8` 时开方失败要用它翻一次符号)。 + pub(crate) fn sqrt_m1() -> Self { + // (p-1)/4 = 2^253 - 5 + let mut e = [0xFF_u8; 32]; + e[0] = 0xFB; + e[31] = 0x1F; + Self::from_u64(2).pow(&e) + } + + /// 最低位(规范编码的第 0 位);点解压时与签名的符号位比对。 + pub(crate) fn is_odd(&self) -> bool { + self.to_bytes()[0] & 1 == 1 + } + + /// 是否等于 0(按规范编码比)。 + pub(crate) fn is_zero(&self) -> bool { + self.to_bytes() == [0_u8; 32] + } +} + +/// 把肢挽到 51 位以内(高位的溢出折回第 0 肢,因为 `2^255 ≡ 19`)。 +fn carry(mut h: [u64; 5]) -> [u64; 5] { + let mut c = h[0] >> 51; + h[0] &= MASK; + h[1] += c; + c = h[1] >> 51; + h[1] &= MASK; + h[2] += c; + c = h[2] >> 51; + h[2] &= MASK; + h[3] += c; + c = h[3] >> 51; + h[3] &= MASK; + h[4] += c; + c = h[4] >> 51; + h[4] &= MASK; + h[0] += c * 19; + c = h[0] >> 51; + h[0] &= MASK; + h[1] += c; + h +} diff --git a/crates/core/src/update/ed25519/group.rs b/crates/core/src/update/ed25519/group.rs new file mode 100644 index 0000000..6dbb275 --- /dev/null +++ b/crates/core/src/update/ed25519/group.rs @@ -0,0 +1,168 @@ +//! 扭曲爱德华兹曲线上的群运算(`a = -1`,扩展坐标 `(X:Y:Z:T)`,`xy = T/Z`)。 +//! +//! 只用两条公式(Hisil-Wong-Carter-Dawson 2008):加法 `add-2008-hwcd-3`、倍点 `dbl-2008-hwcd`, +//! 以及一个最朴素的「从最高位往下的平方-乘」标量乘。验签一次只要两次标量乘,朴素实现足够, +//! 换来的是每一步都能对着公式读——这里宁可慢一点也不要一个看不懂的窗口表。 + +use std::sync::OnceLock; + +use super::field::Fe; + +/// 曲线常数 `d = -121665/121666`。 +fn d() -> &'static Fe { + static D: OnceLock = OnceLock::new(); + D.get_or_init(|| { + Fe::from_u64(121665) + .mul(&Fe::from_u64(121666).invert()) + .neg() + }) +} + +/// `2d`(加法公式里那一步)。 +fn d2() -> &'static Fe { + static D2: OnceLock = OnceLock::new(); + D2.get_or_init(|| d().add(d())) +} + +/// 基点 `B` 的压缩编码(RFC 8032 §5.1 的标准常量,解压即得基点)。 +const BASE_ENCODING: [u8; 32] = [ + 0x58, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, + 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, +]; + +/// 曲线上的点(扩展坐标)。 +#[derive(Clone, Copy, Debug)] +pub(crate) struct Point { + x: Fe, + y: Fe, + z: Fe, + t: Fe, +} + +impl Point { + /// 单位元 `(0, 1, 1, 0)`。 + pub(crate) fn identity() -> Self { + Self { + x: Fe::ZERO, + y: Fe::ONE, + z: Fe::ONE, + t: Fe::ZERO, + } + } + + /// 基点 `B`。 + pub(crate) fn base() -> Self { + // 常量本身是合法压缩点;万一实现坏了这一步会返回 None,用身份元兜底并让上层验签必然失败。 + Self::decompress(&BASE_ENCODING).unwrap_or_else(Self::identity) + } + + /// 点加。 + pub(crate) fn add(&self, rhs: &Self) -> Self { + let a = self.y.sub(&self.x).mul(&rhs.y.sub(&rhs.x)); + let b = self.y.add(&self.x).mul(&rhs.y.add(&rhs.x)); + let c = self.t.mul(&d2().mul(&rhs.t)); + let dd = self.z.mul(&rhs.z); + let dd = dd.add(&dd); + let e = b.sub(&a); + let f = dd.sub(&c); + let g = dd.add(&c); + let h = b.add(&a); + Self { + x: e.mul(&f), + y: g.mul(&h), + t: e.mul(&h), + z: f.mul(&g), + } + } + + /// 倍点。 + pub(crate) fn double(&self) -> Self { + let a = self.x.square(); + let b = self.y.square(); + let z2 = self.z.square(); + let c = z2.add(&z2); + let dd = a.neg(); + let e = self.x.add(&self.y).square().sub(&a).sub(&b); + let g = dd.add(&b); + let f = g.sub(&c); + let h = dd.sub(&b); + Self { + x: e.mul(&f), + y: g.mul(&h), + t: e.mul(&h), + z: f.mul(&g), + } + } + + /// 压缩成 32 字节(`y` 的规范编码 + `x` 的最低位放进第 255 位)。 + pub(crate) fn compress(&self) -> [u8; 32] { + let z_inv = self.z.invert(); + let x = self.x.mul(&z_inv); + let y = self.y.mul(&z_inv); + let mut out = y.to_bytes(); + if x.is_odd() { + out[31] |= 0x80; + } + out + } + + /// 从 32 字节压缩编码解压;**严格**:非规范的 `y`、解不出 `x`、`x = 0` 却带符号位都返回 `None`。 + pub(crate) fn decompress(bytes: &[u8; 32]) -> Option { + let mut raw = *bytes; + let sign = raw[31] >> 7; + raw[31] &= 0x7F; + let y = Fe::from_bytes(&raw); + // 规范编码才收:`y >= p` 的写法(同一元素的第二种编法)一律拒绝,防「同一签名两种字节」。 + if y.to_bytes() != raw { + return None; + } + // x^2 = (y^2 - 1) / (d y^2 + 1) + let y2 = y.square(); + let u = y2.sub(&Fe::ONE); + let v = d().mul(&y2).add(&Fe::ONE); + let v3 = v.square().mul(&v); + let v7 = v3.square().mul(&v); + let mut x = u.mul(&v3).mul(&u.mul(&v7).pow_p58()); + if v.mul(&x.square()).to_bytes() != u.to_bytes() { + // p ≡ 5 mod 8:这一支解不出时,乘 sqrt(-1) 再试一次(另一半平方根)。 + x = x.mul(&Fe::sqrt_m1()); + if v.mul(&x.square()).to_bytes() != u.to_bytes() { + return None; + } + } + if x.is_zero() { + // x = 0 时符号位只能是 0(否则就是「-0」,非规范)。 + if sign == 1 { + return None; + } + } else if x.is_odd() != (sign == 1) { + x = x.neg(); + } + Some(Self { + x, + y, + z: Fe::ONE, + t: x.mul(&y), + }) + } + + /// `[scalar] self`,标量以 32 字节小端给出(从最高位往下:倍点,置位才加)。 + pub(crate) fn mul(&self, scalar: &[u8; 32]) -> Self { + let mut acc = Self::identity(); + for byte_index in (0..32).rev() { + for bit in (0..8).rev() { + acc = acc.double(); + if (scalar[byte_index] >> bit) & 1 == 1 { + acc = acc.add(self); + } + } + } + acc + } + + /// 是否小阶点(`[8]P` 是单位元就是;`verify_strict` 的第一道严格性判据)。 + pub(crate) fn is_small_order(&self) -> bool { + let p8 = self.double().double().double(); + p8.x.is_zero() && p8.y.to_bytes() == p8.z.to_bytes() + } +} diff --git a/crates/core/src/update/ed25519/mod.rs b/crates/core/src/update/ed25519/mod.rs new file mode 100644 index 0000000..4c44e7b --- /dev/null +++ b/crates/core/src/update/ed25519/mod.rs @@ -0,0 +1,265 @@ +//! Ed25519 **严格验签**(RFC 8032 §5.1.7),只验不签、只用公开数据。 +//! +//! # 为什么这里没有引 `curve25519-dalek` / `ed25519-dalek` +//! +//! M190 的 scope 不含 `crates/core/Cargo.toml`,而清单验签必须在 `core::update` 里(GUI 与 CLI +//! 共用 `Job`,验签只能有一个收口点)。取舍:把验签实现**就地**写成三个文件(`field` 素域算术 / +//! `group` 曲线运算 / 本文件判据),零新依赖,判据对着 RFC 8032 的官方测试向量逐条验 +//! (`tests/ed25519_cases.rs`)。 +//! +//! 这条取舍的代价必须说清: +//! +//! * 实现只有「验」这一半——没有私钥、没有 nonce、没有常数时间要求,攻击面比完整签名库小得多; +//! * 但它是**自写密码学**。日后 scope 允许时应当整体换成 `ed25519-dalek` 的 +//! `verify_strict`,对外只留 [`verify`] 一个函数,替换是改这一个函数体; +//! * 严格性按 `verify_strict` 的口径来:拒绝非规范编码(`y >= p`、`-0`)、拒绝小阶公钥与小阶 `R`、 +//! 拒绝非规范标量 `S`(`S >= L`,即可锻性)。少任何一条都能造出「改一个字节仍通过」的假签。 +//! +//! # 判据(逐条,都能单独变红) +//! +//! ```text +//! ① 解 A、解 R 都必须成功且是规范编码 (非规范编码 → 拒) +//! ② A、R 都不能是小阶点 (小阶 → 拒) +//! ③ S < L(L = 2^252 + 27742317777372353535851937790883648493) +//! ④ k = SHA-512(R‖A‖M) mod L +//! ⑤ [S]B == R + [k]A(压缩后逐字节比) +//! ``` + +mod field; +mod group; + +use sha2::{Digest, Sha512}; + +use self::group::Point; + +/// 公钥字节数。 +pub(crate) const PUBLIC_KEY_LEN: usize = 32; +/// 签名字节数。 +pub(crate) const SIGNATURE_LEN: usize = 64; + +/// `L = 2^252 + 27742317777372353535851937790883648493`(小端 4 条 64 位肢)。 +const L: [u64; 4] = [ + 0x5812_631a_5cf5_d3ed, + 0x14de_f9de_a2f7_9cd6, + 0x0000_0000_0000_0000, + 0x1000_0000_0000_0000, +]; + +/// 验一条 Ed25519 签名。 +/// +/// `public_key` 与 `signature` 的语义就是 RFC 8032 的那两段字节;`message` 是任意长原文 +/// (这里恒为清单文件的原始字节,见 [`super::signature`])。 +#[must_use] +pub(crate) fn verify( + public_key: &[u8; PUBLIC_KEY_LEN], + message: &[u8], + signature: &[u8; SIGNATURE_LEN], +) -> bool { + let mut r_bytes = [0_u8; PUBLIC_KEY_LEN]; + r_bytes.copy_from_slice(&signature[..PUBLIC_KEY_LEN]); + let mut s_bytes = [0_u8; PUBLIC_KEY_LEN]; + s_bytes.copy_from_slice(&signature[PUBLIC_KEY_LEN..]); + // ③ S 必须是规范标量:`S >= L` 时同一个「消息 + 公钥」存在多个可验证的签名,签名的不可锻性就没了。 + if !scalar_is_canonical(&s_bytes) { + return false; + } + let Some(public_point) = Point::decompress(public_key) else { + return false; + }; + let Some(r_point) = Point::decompress(&r_bytes) else { + return false; + }; + // ② 小阶点会让「谁都能构造一个自洽的四元组」,严格验签一律拒。 + if public_point.is_small_order() || r_point.is_small_order() { + return false; + } + // ④ k = SHA-512(R ‖ A ‖ M) mod L + let mut hasher = Sha512::new(); + hasher.update(r_bytes); + hasher.update(public_key); + hasher.update(message); + let digest = hasher.finalize(); + let mut wide = [0_u8; 64]; + wide.copy_from_slice(&digest); + let k = reduce_512(&wide); + // ⑤ [S]B 与 R + [k]A 必须是同一个点(压缩后逐字节比即「同一个点」,因为压缩是单射)。 + let left = Point::base().mul(&s_bytes); + let right = r_point.add(&public_point.mul(&k)); + left.compress() == right.compress() +} + +/// 从 32 字节种子推出公钥(**只给测试用**:正式构建的验签不需要它,也没有任何私钥代码)。 +#[cfg(test)] +pub(crate) fn public_key_from_seed_for_tests(seed: &[u8; 32]) -> [u8; PUBLIC_KEY_LEN] { + let (scalar, _) = expand_seed(seed); + Point::base().mul(&scalar).compress() +} + +/// 用 32 字节种子签一条消息(**只给测试用**,见 [`public_key_from_seed_for_tests`])。 +#[cfg(test)] +pub(crate) fn sign_for_tests(seed: &[u8; 32], message: &[u8]) -> [u8; SIGNATURE_LEN] { + let (scalar, prefix) = expand_seed(seed); + let public_key = Point::base().mul(&scalar).compress(); + let mut hasher = Sha512::new(); + hasher.update(prefix); + hasher.update(message); + let mut wide = [0_u8; 64]; + wide.copy_from_slice(&hasher.finalize()); + let r = reduce_512(&wide); + let r_encoded = Point::base().mul(&r).compress(); + let mut hasher = Sha512::new(); + hasher.update(r_encoded); + hasher.update(public_key); + hasher.update(message); + let mut wide = [0_u8; 64]; + wide.copy_from_slice(&hasher.finalize()); + let k = reduce_512(&wide); + let s = scalar_mul_add_mod_l(&k, &scalar, &r); + let mut out = [0_u8; SIGNATURE_LEN]; + out[..PUBLIC_KEY_LEN].copy_from_slice(&r_encoded); + out[PUBLIC_KEY_LEN..].copy_from_slice(&s); + out +} + +/// RFC 8032 §5.1.5 的种子展开:`SHA-512(seed)` 的前半是按位裁剪的标量、后半是 nonce 前缀。 +#[cfg(test)] +fn expand_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) { + let digest = Sha512::digest(seed); + let mut scalar = [0_u8; 32]; + scalar.copy_from_slice(&digest[..32]); + scalar[0] &= 248; + scalar[31] &= 127; + scalar[31] |= 64; + let mut prefix = [0_u8; 32]; + prefix.copy_from_slice(&digest[32..]); + (scalar, prefix) +} + +/// `S < L` 判定(小端 32 字节)。 +fn scalar_is_canonical(bytes: &[u8; 32]) -> bool { + let mut limbs = [0_u64; 4]; + let (chunks, _) = bytes.as_chunks::<8>(); + for (limb, chunk) in limbs.iter_mut().zip(chunks) { + *limb = u64::from_le_bytes(*chunk); + } + // 从最高肢往下比:高于 L 的最高肢就不可能小于 L。 + for index in (0..4).rev() { + if limbs[index] < L[index] { + return true; + } + if limbs[index] > L[index] { + return false; + } + } + false +} + +/// 把一个 512 位大端-无关的字节串按小端解释,对 `L` 取模。 +/// +/// `bytes[63]` 是最高字节。做法是最朴素的竖式:每次左移 8 位再补一个字节,随后反复减 `L` +/// 直到小于 `L`。一次验签跑两遍、每遍最坏 64*256 次减——比任何花招都更容易对着看懂。 +fn reduce_512(bytes: &[u8; 64]) -> [u8; 32] { + let mut acc = [0_u64; 5]; + for byte in bytes.iter().rev() { + let mut carry = u128::from(*byte); + for limb in &mut acc { + let wide = (u128::from(*limb) << 8) | carry; + *limb = wide as u64; + carry = wide >> 64; + } + while !below_l(&acc) { + subtract_l(&mut acc); + } + } + let mut out = [0_u8; 32]; + for index in 0..4 { + out[index * 8..index * 8 + 8].copy_from_slice(&acc[index].to_le_bytes()); + } + out +} + +/// `acc < L`(`acc` 是 5 肢,最高肢非零即「远大于 L」)。 +fn below_l(acc: &[u64; 5]) -> bool { + if acc[4] != 0 { + return false; + } + for index in (0..4).rev() { + if acc[index] < L[index] { + return true; + } + if acc[index] > L[index] { + return false; + } + } + false +} + +/// `acc -= L`(调用方保证 `acc >= L`)。 +fn subtract_l(acc: &mut [u64; 5]) { + let mut borrow = 0_u64; + for index in 0..4 { + let (value, first) = acc[index].overflowing_sub(L[index]); + let (value, second) = value.overflowing_sub(borrow); + acc[index] = value; + borrow = u64::from(first || second); + } + let (value, _) = acc[4].overflowing_sub(borrow); + acc[4] = value; +} + +/// `(k*a + r) mod L`(测试签名用;`k`、`a`、`r` 都已是规范标量)。 +#[cfg(test)] +fn scalar_mul_add_mod_l(k: &[u8; 32], a: &[u8; 32], r: &[u8; 32]) -> [u8; 32] { + // 256x256 -> 512 位乘积:8 条 64 位肢,逐肢进位后再按字节展开成 64 字节(小端)。 + let mut product = [0_u64; 8]; + for (left_index, left) in limbs_of(k).iter().enumerate() { + let mut carry = 0_u128; + for (right_index, right) in limbs_of(a).iter().enumerate() { + let cell = u128::from(*left) * u128::from(*right) + + u128::from(product[left_index + right_index]) + + carry; + product[left_index + right_index] = cell as u64; + carry = cell >> 64; + } + product[left_index + 4] = u64::try_from(carry).unwrap_or(0); + } + let mut wide = [0_u8; 64]; + for (index, limb) in product.iter().enumerate() { + wide[index * 8..index * 8 + 8].copy_from_slice(&limb.to_le_bytes()); + } + let reduced = reduce_512(&wide); + // 加上 r 再取模一次(`acc` 5 肢 + 反复减 L,与 reduce_512 同一手法)。 + let mut acc = [0_u64; 5]; + for index in 0..4 { + let mut buf = [0_u8; 8]; + buf.copy_from_slice(&reduced[index * 8..index * 8 + 8]); + acc[index] = u64::from_le_bytes(buf); + } + let mut carry = 0_u64; + for (slot, rhs) in acc.iter_mut().zip(limbs_of(r)) { + let (sum, overflow) = slot.overflowing_add(rhs); + let (sum, overflow2) = sum.overflowing_add(carry); + *slot = sum; + carry = u64::from(overflow || overflow2); + } + acc[4] = carry; + while !below_l(&acc) { + subtract_l(&mut acc); + } + let mut out = [0_u8; 32]; + for index in 0..4 { + out[index * 8..index * 8 + 8].copy_from_slice(&acc[index].to_le_bytes()); + } + out +} + +/// 32 字节小端 -> 4 条 64 位肢。 +#[cfg(test)] +fn limbs_of(bytes: &[u8; 32]) -> [u64; 4] { + let mut limbs = [0_u64; 4]; + let (chunks, _) = bytes.as_chunks::<8>(); + for (limb, chunk) in limbs.iter_mut().zip(chunks) { + *limb = u64::from_le_bytes(*chunk); + } + limbs +} diff --git a/crates/core/src/update/error.rs b/crates/core/src/update/error.rs index 65553f8..5826669 100644 --- a/crates/core/src/update/error.rs +++ b/crates/core/src/update/error.rs @@ -33,6 +33,21 @@ pub enum UpdateErrorKind { DigestMismatch, /// 长度不符:典型成因是下载被截断,也可能清单写错。 SizeMismatch, + /// 清单没有配套签名文件(`<清单名>.sig` 不在源里)。 + /// + /// 与 [`Self::SignatureInvalid`] 分开:这条是**发布侧漏了签名**,不是清单被改; + /// 处置也不同(等发布侧补,或回滚发布),所以必须是两个分类。 + SignatureMissing, + /// 签名文件本身坏了:不是 JSON、字段名拼错、`format` 不认识、`signatures` 是空的。 + SignatureMalformed, + /// 本构建没有钉任何清单签名公钥(正式信任表为空)。 + /// + /// 这是「构建形态」的问题,不是清单的问题:空表一律拒装,绝不「没钥就放行」。 + KeyringEmpty, + /// 清单的签名者不在钉住的信任表里(`key_id` 一个都没命中)。 + SignatureUnknownKey, + /// 签名对不上清单字节(清单被改过,或签名是给另一份清单的)。 + SignatureInvalid, } impl UpdateErrorKind { @@ -49,6 +64,11 @@ impl UpdateErrorKind { Self::Io => "io", Self::DigestMismatch => "digest_mismatch", Self::SizeMismatch => "size_mismatch", + Self::SignatureMissing => "signature_missing", + Self::SignatureMalformed => "signature_malformed", + Self::KeyringEmpty => "keyring_empty", + Self::SignatureUnknownKey => "signature_unknown_key", + Self::SignatureInvalid => "signature_invalid", } } @@ -56,6 +76,9 @@ impl UpdateErrorKind { /// /// 清单本身不合法、协议不支持、渠道不符这几类换多少源都一样,所以只有字节层 /// (摘要/长度)、网络(可能是瞬时抖动)与 IO(可能是瞬时占用)算可重试。 + /// + /// 签名五兄弟**一律不可重试**:签名不对不是抖动,重试一千次还是不对。把它们标成可重试 + /// 会让壳层反复弹「网络问题,稍后重试」,掩盖真正的原因(发布侧漏签 / 钥没钉 / 清单被改)。 #[must_use] pub const fn is_retryable(self) -> bool { matches!( @@ -63,6 +86,19 @@ impl UpdateErrorKind { Self::DigestMismatch | Self::SizeMismatch | Self::Network | Self::Io ) } + + /// 是否属于「清单签名」这一组(壳层据此换文案:这不是网络问题,别让用户重试)。 + #[must_use] + pub const fn is_signature(self) -> bool { + matches!( + self, + Self::SignatureMissing + | Self::SignatureMalformed + | Self::KeyringEmpty + | Self::SignatureUnknownKey + | Self::SignatureInvalid + ) + } } /// 自更新错误。 diff --git a/crates/core/src/update/github.rs b/crates/core/src/update/github.rs index 72ae052..ac0d059 100644 --- a/crates/core/src/update/github.rs +++ b/crates/core/src/update/github.rs @@ -60,8 +60,8 @@ use crate::download::{ use super::channel::Channel; use super::error::{UpdateError, UpdateErrorKind}; -use super::manifest::{Artifact, ReleaseManifest}; -use super::source::UpdateSource; +use super::manifest::Artifact; +use super::source::{ManifestDocuments, UpdateSource}; /// 默认仓库 owner。 pub const DEFAULT_OWNER: &str = "AskaEth"; @@ -71,6 +71,8 @@ pub const DEFAULT_REPO: &str = "Unlucky-Launcher"; pub const DEFAULT_BASE: &str = "https://github.com"; /// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。 pub const MANIFEST_ASSET: &str = "manifest.json"; +/// 清单签名的资产名(M190:与清单并列挂在同一个 release 上)。 +pub const MANIFEST_SIGNATURE_ASSET: &str = "manifest.json.sig"; /// GitHub Releases 更新源。 #[derive(Debug, Clone)] @@ -265,27 +267,24 @@ impl UpdateSource for GitHubReleaseSource { &self.id } - fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { + fn fetch_manifest_documents( + &self, + channel: Channel, + ) -> Result, UpdateError> { let Some(url) = self.manifest_url(channel) else { return Ok(None); }; - let Some(text) = self.fetch_text(&url)? else { + let Some(manifest) = self.fetch_text(&url)? else { return Ok(None); }; - let manifest = ReleaseManifest::from_json(&text).map_err(|mut error| { - error.message = format!("{}(来源 {url})", error.message); - error - })?; - if manifest.channel != channel { - return Err(UpdateError::new( - UpdateErrorKind::ChannelMismatch, - format!( - "请求的是 {channel} 渠道,清单 {url} 自报 {}", - manifest.channel - ), - )); - } - Ok(Some(manifest)) + // 签名文件的 404 与清单的 404 处理不同:清单没有 = 该渠道没发过版(不是错误); + // 签名没有 = 发布侧漏了签名(上层判 SignatureMissing 并拒装)。 + let signature_url = format!("{}/{MANIFEST_SIGNATURE_ASSET}", self.download_base()); + let signatures = self.fetch_text(&signature_url)?; + Ok(Some(ManifestDocuments { + manifest, + signatures, + })) } fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result { diff --git a/crates/core/src/update/job.rs b/crates/core/src/update/job.rs index a1d1f91..2a0f750 100644 --- a/crates/core/src/update/job.rs +++ b/crates/core/src/update/job.rs @@ -227,11 +227,11 @@ impl UpdateSource for SourceRef<'_> { self.0.source_id() } - fn fetch_manifest( + fn fetch_manifest_documents( &self, channel: Channel, - ) -> Result, UpdateError> { - self.0.fetch_manifest(channel) + ) -> Result, UpdateError> { + self.0.fetch_manifest_documents(channel) } fn fetch_artifact( diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs index ce4db7c..bbec6d3 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -30,6 +30,8 @@ //! manifest 清单模型 + JSON 校验 target 目标平台与包形态 //! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50) //! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急 +//! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring) +//! ed25519 Ed25519 严格验签(自写,无新依赖) //! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点) //! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) //! ``` @@ -49,17 +51,32 @@ //! `--apply-to`),属启动器外壳的编排;本模块只保证搬运本身原子且可回滚。 //! * **不装安装器形态**(msi/dmg/deb/rpm):交给系统包管理器。 //! * **不做差分/增量**:清单里没有 patch 字段,宁可每次全量,也别先上一套没验过的差分协议。 +//! +//! # 信任根(M190) +//! +//! ```text +//! 源只取字节 ──> signature::verify_manifest(清单**原始字节** + `manifest.json.sig`) +//! │ 判据:钉死的公钥(trust::keyring,编译期常量)验 Ed25519 +//! ├─ 过了 ──> manifest::from_json ──> checker/apply +//! └─ 不过 ──> 五类签名错误(不可重试),**什么都不会被安装** +//! ``` +//! +//! 一句话:**先验签、再解析**。清单的 sha256 只保证「包体和清单对上」,清单本身可不可信由签名回答—— +//! 两条判据互不替代(`tests/signature_cases.rs` 分别证明)。 pub mod channel; pub mod checker; +mod ed25519; pub mod error; pub mod github; pub mod install; pub mod job; pub mod manifest; mod replace_io; +mod signature; pub mod source; pub mod target; +mod trust; pub mod verify; pub mod version; @@ -77,7 +94,8 @@ pub use self::job::{ default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV, }; pub use self::manifest::{Artifact, Release, ReleaseManifest}; -pub use self::source::{DirSource, UpdateSource}; +pub use self::signature::VerifiedSignature; +pub use self::source::{verified_manifest, DirSource, ManifestDocuments, UpdateSource}; pub use self::target::{Arch, PackageFormat, Target}; pub use self::verify::{stage, verify_file, StagedPackage}; pub use self::version::{Stage, Version, VersionError}; diff --git a/crates/core/src/update/signature.rs b/crates/core/src/update/signature.rs new file mode 100644 index 0000000..eb7cafe --- /dev/null +++ b/crates/core/src/update/signature.rs @@ -0,0 +1,256 @@ +//! 清单签名信封:`manifest.json.sig` 的解析与验签。 +//! +//! # 为什么是**分离**的签名文件,而不是清单里的一个 `signatures` 字段 +//! +//! 签名只能覆盖「签的时候那份字节」。把签名塞进清单本身,验签就得先把那个字段从 JSON 里摘出来 +//! 再规范化(空白、键序、数字写法全都得钉死),任何一处规范化差异都会让「同一份内容」有两种字节, +//! 于是「改字节不改语义」的老把戏就回来了。分离文件把这件事变简单:**签名覆盖清单文件的原始字节**, +//! 一个 bit 都不能变。顺序也就固定了:先验签,再解析。 +//! +//! ```json +//! {"format": 1, +//! "signatures": [ +//! {"alg": "ed25519", "key_id": "ull-release-2026a", "sig": "<128 位十六进制>", +//! "manifest_sha256": "<64 位十六进制>"} +//! ]} +//! ``` +//! +//! `manifest_sha256` 是冗余但**有意**的:它让「清单变了而签名没跟着换」这种最常见的发布事故 +//! 给出**具体**原因(签名声明的是哪一份、手上是哪一份),而不是一句「验签失败」。它不参与判据 +//! ——判据永远是 Ed25519 的那条方程。 +//! +//! # 多签名 +//! +//! 数组里可以有任意多条,**任意一条**由钉死的钥验过就放行。这给了「新旧钥并存」的窗口期: +//! 轮换期一份清单同时带旧钥与新钥签名,装着旧二进制的用户与装着新二进制的用户都过得去。 +//! +//! # 判据顺序(每条都能单独变红) +//! +//! 1. 信封存在且能解析(`format` 必须是 1); +//! 2. 本构建钉了至少一把公钥([`super::trust::keyring`] 非空)——空表一律拒,绝不「没钥就放行」; +//! 3. 至少一条签名的 `key_id` 命中钉住的表,且它的 `alg` 是 `ed25519`; +//! 4. 那条签名对**清单原始字节**验过; +//! 5. 若签名带 `manifest_sha256`,与清单字节的 SHA-256 相符(只为给出可读原因,不替代第 4 条)。 + +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use super::ed25519::{verify, PUBLIC_KEY_LEN, SIGNATURE_LEN}; +use super::error::{UpdateError, UpdateErrorKind}; +use super::trust; + +/// 信封格式版本(改字段形状要抬它,老客户端会以「格式不支持」明确拒绝,而不是静默不认签名)。 +pub(crate) const ENVELOPE_FORMAT: u32 = 1; + +/// 验收通过的签名摘要(进日志/CLI 输出:谁签的、用哪把钥验过的)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedSignature { + /// 验过的 `key_id`。 + pub key_id: String, + /// 算法标识(当前只有 `ed25519`)。 + pub alg: String, +} + +/// 清单的签名信封。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct Envelope { + format: u32, + signatures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EnvelopeSignature { + alg: String, + key_id: String, + sig: String, + manifest_sha256: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEnvelope { + format: u32, + #[serde(default)] + signatures: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEnvelopeSignature { + alg: String, + key_id: String, + sig: String, + manifest_sha256: Option, +} + +/// 验一份清单的签名。 +/// +/// # Errors +/// +/// * [`UpdateErrorKind::SignatureMissing`]:源里没有配套的签名文件; +/// * [`UpdateErrorKind::SignatureMalformed`]:信封不是合法 JSON / 字段名拼错 / `format` 不认识; +/// * [`UpdateErrorKind::KeyringEmpty`]:本构建一把公钥都没钉(正式表还没录钥); +/// * [`UpdateErrorKind::SignatureUnknownKey`]:所有签名的 `key_id` 都不在钉住的表里; +/// * [`UpdateErrorKind::SignatureInvalid`]:命中了钥但对清单字节验不过(清单被改过、签名不是给这份的)。 +pub(crate) fn verify_manifest( + manifest_bytes: &[u8], + envelope_text: Option<&str>, +) -> Result { + let Some(text) = envelope_text else { + return Err(UpdateError::new( + UpdateErrorKind::SignatureMissing, + "清单没有配套签名(清单旁边缺 `<清单名>.sig`):本启动器只安装「清单签名对得上」的更新,\ + 没有签名一律拒装——这不是你的问题,是发布侧漏了签名", + )); + }; + let envelope = parse_envelope(text)?; + let keys = trust::keyring(); + if keys.is_empty() { + return Err(UpdateError::new( + UpdateErrorKind::KeyringEmpty, + "本构建没有钉任何清单签名公钥(正式表为空),因此任何清单都无法验签:\ + 请勿绕过,这是「还没录钥」的构建,不是清单有问题", + )); + } + let actual_sha256 = hex_sha256(manifest_bytes); + let mut unknown = Vec::new(); + let mut attempted = Vec::new(); + for signature in &envelope.signatures { + if signature.alg != "ed25519" { + unknown.push(format!("{}({})", signature.key_id, signature.alg)); + continue; + } + let Some(pinned) = keys.iter().find(|key| key.key_id == signature.key_id) else { + unknown.push(format!("{}(未钉住)", signature.key_id)); + continue; + }; + let Some(public_key) = trust::decode_hex(pinned.public_key_hex) else { + return Err(UpdateError::invalid_manifest(format!( + "钉住的公钥 `{}` 不是合法十六进制(这是构建缺陷,不是清单的问题)", + pinned.key_id + ))); + }; + if public_key.len() != PUBLIC_KEY_LEN { + return Err(UpdateError::invalid_manifest(format!( + "钉住的公钥 `{}` 长 {} 字节,Ed25519 公钥必须是 {PUBLIC_KEY_LEN} 字节(构建缺陷)", + pinned.key_id, + public_key.len() + ))); + } + let mut key_bytes = [0_u8; PUBLIC_KEY_LEN]; + key_bytes.copy_from_slice(&public_key); + let Some(signature_bytes) = trust::decode_hex(&signature.sig) else { + attempted.push(format!("{}(`sig` 不是十六进制)", signature.key_id)); + continue; + }; + if signature_bytes.len() != SIGNATURE_LEN { + attempted.push(format!( + "{}(`sig` 长 {} 字节,应为 {SIGNATURE_LEN})", + signature.key_id, + signature_bytes.len() + )); + continue; + } + let mut sig_bytes = [0_u8; SIGNATURE_LEN]; + sig_bytes.copy_from_slice(&signature_bytes); + if !verify(&key_bytes, manifest_bytes, &sig_bytes) { + attempted.push(signature.key_id.clone()); + continue; + } + // 第 5 条:只为「把话说清楚」,不替代上面那条方程。 + if let Some(declared) = &signature.manifest_sha256 { + if declared.to_ascii_lowercase() != actual_sha256 { + return Err(UpdateError::new( + UpdateErrorKind::SignatureInvalid, + format!( + "清单签名声明的是另一份清单(签名里的 sha256 = {},手上这份 = {}):\ + 签名对上了、内容不是它签的那份", + short(declared), + short(&actual_sha256) + ), + )); + } + } + return Ok(VerifiedSignature { + key_id: signature.key_id.clone(), + alg: signature.alg.clone(), + }); + } + if attempted.is_empty() { + return Err(UpdateError::new( + UpdateErrorKind::SignatureUnknownKey, + format!( + "清单由本构建不认识的钥签名({}):本构建钉住的 key_id = [{}]。\ + 要么清单是别人签的,要么发布侧用了还没进信任表的新钥", + unknown.join("、"), + keys.iter() + .map(|key| key.key_id) + .collect::>() + .join(", ") + ), + )); + } + Err(UpdateError::new( + UpdateErrorKind::SignatureInvalid, + format!( + "清单签名验不过(key_id = {},清单 sha256 = {}):这份清单不是那把钥签的,\ + 或签名之后清单被改过。绝不安装", + attempted.join("、"), + short(&actual_sha256) + ), + )) +} + +/// 解析信封(`deny_unknown_fields`:字段名拼错宁可在 CI/发布侧当场炸,也别在用户机器上变成「验签失败」)。 +fn parse_envelope(text: &str) -> Result { + let raw: RawEnvelope = serde_json::from_str(text).map_err(|error| { + UpdateError::new( + UpdateErrorKind::SignatureMalformed, + format!("清单签名文件不是合法 JSON({error})"), + ) + })?; + if raw.format != ENVELOPE_FORMAT { + return Err(UpdateError::new( + UpdateErrorKind::SignatureMalformed, + format!( + "清单签名格式 {} 不认识(本构建只认 {ENVELOPE_FORMAT}):请升级启动器", + raw.format + ), + )); + } + if raw.signatures.is_empty() { + return Err(UpdateError::new( + UpdateErrorKind::SignatureMalformed, + "清单签名文件里 `signatures` 是空的:签了个空", + )); + } + Ok(Envelope { + format: raw.format, + signatures: raw + .signatures + .into_iter() + .map(|item| EnvelopeSignature { + alg: item.alg, + key_id: item.key_id, + sig: item.sig, + manifest_sha256: item.manifest_sha256, + }) + .collect(), + }) +} + +/// 清单字节的 SHA-256(小写十六进制)。 +pub(crate) fn hex_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(64); + for byte in digest { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// 日志/文案里只露前 8 位。 +fn short(hex: &str) -> String { + let trimmed: String = hex.trim().chars().take(8).collect(); + format!("{trimmed}…") +} diff --git a/crates/core/src/update/source.rs b/crates/core/src/update/source.rs index 7184a9a..55a9dd1 100644 --- a/crates/core/src/update/source.rs +++ b/crates/core/src/update/source.rs @@ -11,6 +11,7 @@ //! //! ```text //! /stable.json 渠道清单(文件名 = 渠道名 + .json) +//! /stable.json.sig 清单签名信封(M190;缺了它清单一律拒装,见 `signature`) //! /beta.json //! /<产物文件> 清单里 url 写相对路径时,相对 root //! ``` @@ -26,6 +27,19 @@ use super::channel::Channel; use super::error::{UpdateError, UpdateErrorKind}; use super::manifest::{Artifact, ReleaseManifest}; +/// 源交出来的「清单两份原文」:清单本身 + 配套签名信封(M190)。 +/// +/// 刻意是**原文**而不是解析后的结构:签名只能覆盖字节,先解析再验签就等于给「解析器认了、 +/// 签名没认」的东西开了门。判据顺序因此固定为「先验签、再解析」,两者都在 +/// [`verified_manifest`] 里,源实现自己不做任何验签。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestDocuments { + /// 清单原文(`manifest.json` / `.json` 的字节,按 UTF-8 解释)。 + pub manifest: String, + /// 签名信封原文(`<清单名>.sig`);`None` = 源里没有这份文件(会被判 [`UpdateErrorKind::SignatureMissing`])。 + pub signatures: Option, +} + /// 更新源:清单与包体的来源。 /// /// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**: @@ -34,12 +48,16 @@ pub trait UpdateSource: Send + Sync { /// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。 fn source_id(&self) -> &str; - /// 取某渠道的清单;`Ok(None)` = 该渠道还没发布过(不是错误)。 + /// 取某渠道清单的两份原文;`Ok(None)` = 该渠道还没发布过(不是错误)。 /// /// # Errors /// - /// 读不到文件、JSON 坏、清单自报的渠道与 `channel` 不符都返回 [`UpdateError`]。 - fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError>; + /// 读不到文件、响应为空、网络失败都返回 [`UpdateError`]。**这里不验签、不解析** + /// (见 [`ManifestDocuments`])——管线的下一站是 [`verified_manifest`]。 + fn fetch_manifest_documents( + &self, + channel: Channel, + ) -> Result, UpdateError>; /// 把产物取到 `dest`,返回写入的字节数。 /// @@ -50,6 +68,60 @@ pub trait UpdateSource: Send + Sync { /// /// 源里没有这个文件、读失败、写 `dest` 失败都返回 [`UpdateError`]。 fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result; + + /// 取清单 → **验签** → 解析 → 渠道校验。`check`/`apply` 都只走这一个入口。 + /// + /// 提供默认实现是为了让「怎么取字节」与「怎么判字节」彻底分开:源只实现 + /// [`Self::fetch_manifest_documents`],判据只有 [`verified_manifest`] 一份。 + /// + /// # Errors + /// + /// 取不到/取到空、签名不通过(五类,见 [`UpdateErrorKind`])、清单 JSON 坏、渠道不符。 + fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { + verified_manifest(self, channel) + } +} + +/// 取清单 → 验签 → 解析 → 渠道校验(**唯一**入口,GUI 与 CLI 共用)。 +/// +/// # Errors +/// +/// 源里没有该渠道清单 ⇒ `Ok(None)`;签名/解析/渠道任一不过 ⇒ [`UpdateError`]。 +/// 签名相关的分类见 [`UpdateErrorKind::is_signature`],它们**不可重试**。 +pub fn verified_manifest( + source: &S, + channel: Channel, +) -> Result, UpdateError> { + let Some(documents) = source.fetch_manifest_documents(channel)? else { + return Ok(None); + }; + // ① 先验签:拿到的是不是「我们发的那份字节」,在这里一次说清。 + let source_label = source.source_id().to_string(); + super::signature::verify_manifest( + documents.manifest.as_bytes(), + documents.signatures.as_deref(), + ) + .map_err(|mut error| { + error.message = format!("{}(来源 {source_label})", error.message); + error + })?; + // ② 再解析:到了这一步,字节已经是发布侧签过的那份,解析失败只可能是签名者也写错了。 + let manifest = ReleaseManifest::from_json(&documents.manifest).map_err(|mut error| { + error.message = format!("{}(来源 {source_label})", error.message); + error + })?; + // ③ 渠道:清单自报的渠道与请求的对不上是源的问题,不能悄悄当成「没有更新」。 + if manifest.channel != channel { + return Err(UpdateError::new( + UpdateErrorKind::ChannelMismatch, + format!( + "请求的是 {channel} 渠道,清单(源 {})自报 {}", + source.source_id(), + manifest.channel + ), + )); + } + Ok(Some(manifest)) } /// `Box` 也是 `UpdateSource`。 @@ -62,8 +134,11 @@ impl UpdateSource for Box { (**self).source_id() } - fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { - (**self).fetch_manifest(channel) + fn fetch_manifest_documents( + &self, + channel: Channel, + ) -> Result, UpdateError> { + (**self).fetch_manifest_documents(channel) } fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result { @@ -111,6 +186,12 @@ impl DirSource { self.root.join(format!("{}.json", channel.as_str())) } + /// 某渠道清单的签名文件路径(`stable.json` → `stable.json.sig`)。 + #[must_use] + pub fn signature_path(&self, channel: Channel) -> PathBuf { + self.root.join(format!("{}.json.sig", channel.as_str())) + } + /// 把清单里的 `url` 解析成源侧本地路径。 fn resolve(&self, url: &str) -> Result { if let Some(rest) = url.strip_prefix("file://") { @@ -149,27 +230,27 @@ impl UpdateSource for DirSource { &self.id } - fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { + fn fetch_manifest_documents( + &self, + channel: Channel, + ) -> Result, UpdateError> { let path = self.manifest_path(channel); - let text = match fs::read_to_string(&path) { + let manifest = match fs::read_to_string(&path) { Ok(text) => text, Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(source) => return Err(UpdateError::io_at(&path, &source)), }; - let manifest = ReleaseManifest::from_json(&text).map_err(|error| error.with_path(&path))?; - if manifest.channel != channel { - return Err(UpdateError::new( - UpdateErrorKind::ChannelMismatch, - format!( - "请求的是 {} 渠道,清单 {} 自报 {}", - channel, - path.display(), - manifest.channel - ), - ) - .with_path(&path)); - } - Ok(Some(manifest)) + // 签名文件缺失**不是**「没有清单」:清单在、签名不在 ⇒ 上层判 SignatureMissing 并拒装。 + let signature_path = self.signature_path(channel); + let signatures = match fs::read_to_string(&signature_path) { + Ok(text) => Some(text), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => None, + Err(source) => return Err(UpdateError::io_at(&signature_path, &source)), + }; + Ok(Some(ManifestDocuments { + manifest, + signatures, + })) } fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result { diff --git a/crates/core/src/update/tests/checker_cases.rs b/crates/core/src/update/tests/checker_cases.rs index 456e70c..a2e676e 100644 --- a/crates/core/src/update/tests/checker_cases.rs +++ b/crates/core/src/update/tests/checker_cases.rs @@ -17,7 +17,7 @@ fn target() -> Target { /// 新建一个只放了这一份清单的本地源;返回的 `TempDir` 要活到用例结束。 fn source_with(json: &str, channel: Channel) -> (TempDir, DirSource) { let dir = TempDir::new("checker"); - dir.write(&format!("{}.json", channel.as_str()), json.as_bytes()); + dir.write_signed(&format!("{}.json", channel.as_str()), json); let source = DirSource::new(dir.path()); (dir, source) } @@ -215,7 +215,7 @@ fn falls_back_to_the_newest_release_that_does_have_the_target() { #[test] fn channel_mismatch_in_the_source_is_an_error() { let tmp = TempDir::new("checker-mismatch"); - tmp.write("stable.json", manifest("beta", &[]).as_bytes()); + tmp.write_signed("stable.json", &manifest("beta", &[])); let source = crate::update::DirSource::new(tmp.path()); let current = crate::update::Version::parse("0.2.1").expect("版本"); let error = UpdateChecker::new(source, target()) @@ -228,7 +228,7 @@ fn channel_mismatch_in_the_source_is_an_error() { fn prepare_stages_the_selected_artifact() { let tmp = TempDir::new("checker-prepare"); tmp.write("a.zip", PAYLOAD); - tmp.write("stable.json", stable_release("0.3.0").as_bytes()); + tmp.write_signed("stable.json", &stable_release("0.3.0")); let source = crate::update::DirSource::new(tmp.path()); let checker = UpdateChecker::new(source, target()); let current = crate::update::Version::parse("0.2.0").expect("版本"); diff --git a/crates/core/src/update/tests/ed25519_cases.rs b/crates/core/src/update/tests/ed25519_cases.rs new file mode 100644 index 0000000..a7fdbe3 --- /dev/null +++ b/crates/core/src/update/tests/ed25519_cases.rs @@ -0,0 +1,213 @@ +//! Ed25519 严格验签的判据(RFC 8032 §7.1 官方测试向量 + 六条「该拒就拒」)。 +//! +//! 这一组用例是自写密码学的**唯一**公信力来源:向量来自 RFC 附录(不是自己签自己验), +//! 拒绝类用例覆盖「改一个字节仍然通过」的全部已知构造(改 R / 改 S / S += L / 小阶公钥 / +//! 非规范 R 编码 / 空签名)。 + +use crate::update::ed25519::{ + public_key_from_seed_for_tests, sign_for_tests, verify, PUBLIC_KEY_LEN, SIGNATURE_LEN, +}; + +/// 十六进制转字节(长度不对/字符非法当场 panic:这些向量是写死的常量)。 +fn unhex(text: &str) -> Vec { + let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + cleaned.len().is_multiple_of(2), + "十六进制串长度必须是偶数:{text}" + ); + cleaned + .as_bytes() + .chunks(2) + .map(|pair| { + let hi = (pair[0] as char).to_digit(16).expect("十六进制字符"); + let lo = (pair[1] as char).to_digit(16).expect("十六进制字符"); + u8::try_from(hi * 16 + lo).expect("字节") + }) + .collect() +} + +fn array32(bytes: &[u8]) -> [u8; 32] { + let mut out = [0_u8; 32]; + out.copy_from_slice(bytes); + out +} + +fn array64(bytes: &[u8]) -> [u8; 64] { + let mut out = [0_u8; 64]; + out.copy_from_slice(bytes); + out +} + +/// L(群阶)的小端字节,用于构造「S + L」这种可锻变体。 +const L_BYTES: [u8; 32] = [ + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, +]; + +/// RFC 8032 的四个向量:(public, message, signature)。 +fn rfc_vectors() -> Vec<(String, String, String)> { + vec![ + ( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a".to_string(), + String::new(), + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b".to_string(), + ), + ( + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c".to_string(), + "72".to_string(), + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00".to_string(), + ), + ( + "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025".to_string(), + "af82".to_string(), + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a".to_string(), + ), + ( + "ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf".to_string(), + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f".to_string(), + "dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b58909351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704".to_string(), + ), + ] +} + +#[test] +fn rfc8032_vectors_verify() { + for (index, (public, message, signature)) in rfc_vectors().into_iter().enumerate() { + let public = array32(&unhex(&public)); + let message = unhex(&message); + let signature = array64(&unhex(&signature)); + assert!( + verify(&public, &message, &signature), + "RFC 8032 第 {} 条向量必须通过", + index + 1 + ); + } +} + +#[test] +fn message_bit_flip_is_rejected() { + let (public, message, signature) = &rfc_vectors()[1]; + let public = array32(&unhex(public)); + let mut message = unhex(message); + message[0] ^= 0x01; + let signature = array64(&unhex(signature)); + assert!( + !verify(&public, &message, &signature), + "改一个消息字节必须被拒" + ); +} + +#[test] +fn signature_bit_flip_is_rejected() { + let (public, message, signature) = &rfc_vectors()[2]; + let public = array32(&unhex(public)); + let message = unhex(message); + let mut signature = array64(&unhex(signature)); + // 只改 R 的最后一位(不动消息、不动 S):证明「只改签名字节」也会红。 + signature[31] ^= 0x01; + assert!(!verify(&public, &message, &signature), "改 R 必须被拒"); + let mut signature = array64(&unhex(&rfc_vectors()[2].2)); + signature[63] ^= 0x04; + assert!(!verify(&public, &message, &signature), "改 S 必须被拒"); +} + +#[test] +fn malleable_s_plus_l_is_rejected() { + // 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余),严格实现必须拒。 + let (public, message, signature) = &rfc_vectors()[0]; + let public = array32(&unhex(public)); + let message = unhex(message); + let mut signature = array64(&unhex(signature)); + let mut carry = 0_u16; + for index in 0..32 { + let sum = u16::from(signature[32 + index]) + u16::from(L_BYTES[index]) + carry; + signature[32 + index] = (sum & 0xFF) as u8; + carry = sum >> 8; + } + assert_eq!( + carry, 0, + "S + L 不该溢出 256 位(否则这条用例本身没造出来)" + ); + assert!( + !verify(&public, &message, &signature), + "S >= L 必须被拒(可锻变体)" + ); +} + +#[test] +fn small_order_public_key_is_rejected() { + // 阶为 1 的点(y = 1, x = 0)与阶为 2 的点(y = 0):用它们做公钥,谁都能凑出「自洽」的签名。 + let message = b"manifest bytes"; + let identity_key = { + let mut key = [0_u8; PUBLIC_KEY_LEN]; + key[0] = 1; + key + }; + let order_two_key = [0_u8; PUBLIC_KEY_LEN]; + let mut signature = [0_u8; SIGNATURE_LEN]; + signature[0] = 1; // R = 单位元 + assert!( + !verify(&identity_key, message, &signature), + "小阶公钥必须被拒" + ); + assert!( + !verify(&order_two_key, message, &signature), + "零公钥必须被拒" + ); +} + +#[test] +fn non_canonical_r_encoding_is_rejected() { + // R 的 y = p(= 2^255-19)与 y = 1 同值但编码不同:非规范写法必须拒。 + let (public, message, _) = &rfc_vectors()[0]; + let public = array32(&unhex(public)); + let message = unhex(message); + let p_bytes: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ]; + let mut signature = [0_u8; SIGNATURE_LEN]; + signature[..32].copy_from_slice(&p_bytes); + assert!( + !verify(&public, &message, &signature), + "非规范 R 编码必须被拒" + ); +} + +#[test] +fn empty_signature_is_rejected() { + let (public, message, _) = &rfc_vectors()[0]; + assert!(!verify( + &array32(&unhex(public)), + &unhex(message), + &[0_u8; 64] + )); +} + +#[test] +fn test_signer_round_trips_and_uses_rfc_seed() { + // 测试钥不是「另写一套算法」:RFC 8032 的 secret 反推出 public 就对上了,签出来的也能验过。 + let seed = array32(&unhex( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + )); + let expected = array32(&unhex( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + )); + assert_eq!( + public_key_from_seed_for_tests(&seed), + expected, + "种子展开必须与 RFC 一致" + ); + let signature = sign_for_tests(&seed, b""); + assert_eq!( + signature, + array64(&unhex(&rfc_vectors()[0].2)), + "空消息的签名必须逐字节等于 RFC 向量(证明签名侧与向量同一套算术语义)" + ); + assert!(verify( + &expected, + b"unlucky", + &sign_for_tests(&seed, b"unlucky") + )); +} diff --git a/crates/core/src/update/tests/github_cases.rs b/crates/core/src/update/tests/github_cases.rs index b15cbbb..56e9651 100644 --- a/crates/core/src/update/tests/github_cases.rs +++ b/crates/core/src/update/tests/github_cases.rs @@ -14,7 +14,9 @@ use crate::update::{ UpdateErrorKind, UpdateSource, Version, }; -use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir}; +use super::support::{ + artifact, engine_for, manifest, release, signature_envelope, FakeHttp, FakeResponse, TempDir, +}; const PAYLOAD: &[u8] = b"github release payload"; @@ -23,6 +25,22 @@ fn manifest_path() -> String { format!("/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}") } +/// 假服务上签名所在路径(M190:与清单并列)。 +fn signature_path() -> String { + format!("{}.sig", manifest_path()) +} + +/// 「清单 + 签名」两条路由:签名用测试钥现签,与 `DirSource` 夹具同一把钥。 +fn signed_routes(manifest_json: &str) -> Vec<(String, FakeResponse)> { + vec![ + (manifest_path(), FakeResponse::json(manifest_json)), + ( + signature_path(), + FakeResponse::json(&signature_envelope(manifest_json)), + ), + ] +} + /// 按 D49 命名规则的产物文件名。 fn asset_name() -> &'static str { "unlucky-launcher_0.6.0_windows_x64.exe" @@ -104,10 +122,7 @@ fn manifest_url_is_the_fixed_latest_download_path() { #[test] fn channels_other_than_stable_publish_nothing() { let tmp = TempDir::new("gh-channels"); - let server = FakeHttp::start(vec![( - manifest_path().as_str(), - FakeResponse::json(&stable_manifest_json()), - )]); + let server = FakeHttp::start(signed_routes(&stable_manifest_json())); let source = local_source(&tmp, &server); for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] { @@ -173,10 +188,7 @@ fn artifact_url_appends_the_release_download_path() { #[test] fn fetches_and_parses_the_manifest_from_the_base() { let tmp = TempDir::new("gh-manifest-ok"); - let server = FakeHttp::start(vec![( - manifest_path().as_str(), - FakeResponse::json(&stable_manifest_json()), - )]); + let server = FakeHttp::start(signed_routes(&stable_manifest_json())); let source = local_source(&tmp, &server); let manifest = source @@ -186,14 +198,18 @@ fn fetches_and_parses_the_manifest_from_the_base() { assert_eq!(manifest.channel, Channel::Stable); assert_eq!(manifest.releases.len(), 1); assert_eq!(manifest.releases[0].version.to_string(), "0.6.0"); - assert_eq!(server.hits(), vec![manifest_path()]); + assert_eq!( + server.hits(), + vec![manifest_path(), signature_path()], + "先取清单、再取它旁边的签名,两个请求都发过" + ); } #[test] fn missing_manifest_in_the_release_is_not_an_error() { // 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。 let tmp = TempDir::new("gh-manifest-404"); - let server = FakeHttp::start(vec![]); + let server = FakeHttp::start(Vec::new()); let source = local_source(&tmp, &server); let outcome = source @@ -210,10 +226,8 @@ fn missing_manifest_in_the_release_is_not_an_error() { #[test] fn broken_json_is_reported_as_invalid_manifest() { let tmp = TempDir::new("gh-manifest-broken"); - let server = FakeHttp::start(vec![( - manifest_path().as_str(), - FakeResponse::json("{ not json"), - )]); + // 签名是对的(签的就是这串坏字节)——只有「签名对、内容坏」才走得到解析这一步。 + let server = FakeHttp::start(signed_routes("{ not json")); let source = local_source(&tmp, &server); let error = source @@ -221,8 +235,8 @@ fn broken_json_is_reported_as_invalid_manifest() { .expect_err("坏 JSON 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); assert!( - error.message.contains(&server.base()), - "错误里要带出来源地址,日志才定位得到(实际:{})", + error.message.contains("github:"), + "错误里要带出来源标识,日志才定位得到(实际:{})", error.message ); assert!(!error.is_retryable(), "清单坏重试多少次都一样"); @@ -233,7 +247,7 @@ fn illegal_manifest_fields_are_reported() { let tmp = TempDir::new("gh-manifest-illegal"); // size 为 0(发布脚本写漏)——字段层面的非法,走同一条 InvalidManifest 分类。 let json = r#"{"channel":"stable","releases":[{"version":"0.6.0","artifacts":[{"target":"win-x64","format":"portable-exe","url":"ull.exe","size":0,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}]}"#; - let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::json(json))]); + let server = FakeHttp::start(signed_routes(json)); let source = local_source(&tmp, &server); let error = source @@ -245,10 +259,7 @@ fn illegal_manifest_fields_are_reported() { #[test] fn manifest_self_reporting_another_channel_is_a_mismatch() { let tmp = TempDir::new("gh-manifest-mismatch"); - let server = FakeHttp::start(vec![( - manifest_path().as_str(), - FakeResponse::json(&manifest("beta", &[])), - )]); + let server = FakeHttp::start(signed_routes(&manifest("beta", &[]))); let source = local_source(&tmp, &server); let error = source @@ -260,7 +271,7 @@ fn manifest_self_reporting_another_channel_is_a_mismatch() { #[test] fn server_errors_are_network_errors() { let tmp = TempDir::new("gh-manifest-500"); - let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::status(500))]); + let server = FakeHttp::start(vec![(manifest_path(), FakeResponse::status(500))]); let source = local_source(&tmp, &server); let error = source @@ -273,7 +284,7 @@ fn server_errors_are_network_errors() { #[test] fn downloads_the_artifact_through_the_engine() { let tmp = TempDir::new("gh-artifact-download"); - let server = FakeHttp::start(vec![(asset_path().as_str(), FakeResponse::bin(PAYLOAD))]); + let server = FakeHttp::start(vec![(asset_path(), FakeResponse::bin(PAYLOAD))]); let source = local_source(&tmp, &server); let dest = tmp.join("staging/package.exe"); @@ -292,7 +303,7 @@ fn downloads_the_artifact_through_the_engine() { #[test] fn missing_artifact_is_reported_as_source_missing() { let tmp = TempDir::new("gh-artifact-404"); - let server = FakeHttp::start(vec![]); + let server = FakeHttp::start(Vec::new()); let source = local_source(&tmp, &server); let error: UpdateError = source @@ -354,18 +365,14 @@ fn agrees_with_dir_source_on_the_same_manifest() { let json = stable_manifest_json(); // 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。 - tmp.write( - &format!("{}.json", Channel::Stable.as_str()), - json.as_bytes(), - ); + tmp.write_signed(&format!("{}.json", Channel::Stable.as_str()), &json); tmp.write(asset_name(), PAYLOAD); let dir = DirSource::new(tmp.path()); // GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。 - let server = FakeHttp::start(vec![ - (manifest_path().as_str(), FakeResponse::json(&json)), - (asset_path().as_str(), FakeResponse::bin(PAYLOAD)), - ]); + let mut routes = signed_routes(&json); + routes.push((asset_path(), FakeResponse::bin(PAYLOAD))); + let server = FakeHttp::start(routes); let github = local_source(&tmp, &server); let current = Version::parse("0.5.0").expect("版本号合法"); diff --git a/crates/core/src/update/tests/job_cases.rs b/crates/core/src/update/tests/job_cases.rs index 2788dae..68dc5ac 100644 --- a/crates/core/src/update/tests/job_cases.rs +++ b/crates/core/src/update/tests/job_cases.rs @@ -22,17 +22,16 @@ fn target() -> Target { /// 造一个本地源 + 一个替换目标;返回的 `TempDir` 要活到用例结束。 fn fixture(format: &str, version: &str) -> (TempDir, Job) { let dir = TempDir::new("job"); - dir.write( + dir.write_signed( "stable.json", - manifest( + &manifest( "stable", &[release( version, "", &[artifact("win-x64", format, "asset.bin", PAYLOAD)], )], - ) - .as_bytes(), + ), ); dir.write("asset.bin", PAYLOAD); let dest = dir.join("bin/unlucky-launcher.exe"); @@ -119,17 +118,16 @@ fn a_portable_exe_runs_the_whole_chain() { #[test] fn nothing_to_apply_does_not_touch_the_target() { let dir = TempDir::new("job-none"); - dir.write( + dir.write_signed( "stable.json", - manifest( + &manifest( "stable", &[release( "0.2.2", "", &[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)], )], - ) - .as_bytes(), + ), ); let dest = dir.join("bin/unlucky-launcher.exe"); std::fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录"); diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index 2e0bf78..bdb4ad6 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -20,12 +20,14 @@ mod channel_cases; mod checker_cases; +mod ed25519_cases; mod github_cases; mod install_cases; mod install_io_cases; mod install_xdev_cases; mod job_cases; mod manifest_cases; +mod signature_cases; mod source_cases; mod support; mod target_cases; diff --git a/crates/core/src/update/tests/signature_cases.rs b/crates/core/src/update/tests/signature_cases.rs new file mode 100644 index 0000000..6b60c98 --- /dev/null +++ b/crates/core/src/update/tests/signature_cases.rs @@ -0,0 +1,303 @@ +//! 清单签名:**先验签再解析**(M190)。 +//! +//! 这一组用例是 mission 守卫里「更新侧」的那几条,每条都能单独变红: +//! +//! | 用例 | 红掉意味着什么 | +//! |---|---| +//! | `tampered_field_is_rejected` | 改清单里任意一个字段仍能装 → 签名判据被绕过 | +//! | `tampered_signature_only_is_rejected` | 只改签名文件、内容不动仍能装 → 「只改签名不换内容」没被拒 | +//! | `missing_signature_is_rejected` | 没有 `.sig` 也照装 → 发布侧漏签名即可绕过整套信任根 | +//! | `unknown_key_is_rejected` | 别人用自己的钥签的清单被认 → 信任锚不是钉死的那把 | +//! | `key_id_spoofing_does_not_help` | 按 `key_id` 名字信任而不是按公钥字节验 → 伪个名字就能签 | +//! | `unsigned_install_never_touches_the_target` | 验签失败却已经动过目标 exe → 判据跑在安装之后(顺序错) | +//! | `no_runtime_keyring_override` | 环境变量/旁边的 keyring 文件能换信任锚 → 钉在二进制里的公钥白钉 | +//! +//! 还有一条「两条判据互不替代」:`sha256_mismatch_is_reported_separately` 证明签名过了、包体摘要 +//! 不符时仍是 `DigestMismatch`——签名管「清单是不是我们发的」,sha256 管「包体和清单对不对得上」。 + +use std::fs; + +use crate::update::ed25519::public_key_from_seed_for_tests; +use crate::update::{ + trust, Channel, DirSource, Job, OsFamily, Target, UpdateChecker, UpdateErrorKind, UpdateSource, + Version, +}; + +use super::support::{ + artifact, hex_of, manifest, release, signature_entry, signature_entry_from_bytes, + signature_envelope, signature_envelope_of, signature_envelope_with, TempDir, TEST_KEY_ID, + TEST_SIGNING_SEED, +}; + +const PAYLOAD: &[u8] = b"signed release payload"; + +/// 另一把「攻击者的钥」:种子换一个词,公钥就完全不同。 +const ATTACKER_SEED: &[u8; 32] = b"ull-m190-attacker-seed-000000001"; + +fn target() -> Target { + Target::new(OsFamily::Windows, crate::update::Arch::X64) +} + +fn stable_json(version: &str) -> String { + manifest( + "stable", + &[release( + version, + "", + &[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)], + )], + ) +} + +fn current() -> Version { + Version::parse("0.2.0").expect("版本号合法") +} + +#[test] +fn pinned_test_key_matches_the_test_seed() { + // 信任表里那把测试钥必须就是测试种子推出来的那把。漂了的话「钉住的公钥」与「签名的钥」不是同一个, + // 下面所有正路用例会一起变红——先在这里单独钉死。 + let derived = hex_of(&public_key_from_seed_for_tests(TEST_SIGNING_SEED)); + let pinned = trust::keyring() + .iter() + .find(|key| key.key_id == TEST_KEY_ID) + .expect("测试构建的信任表里必须有测试钥"); + assert_eq!( + pinned.public_key_hex, derived, + "钉住的测试公钥与测试种子不匹配" + ); +} + +#[test] +fn signed_manifest_is_accepted_and_picks_the_candidate() { + let dir = TempDir::new("sig-ok"); + dir.write_signed("stable.json", &stable_json("0.3.0")); + let outcome = UpdateChecker::new(DirSource::new(dir.path()), target()) + .check(¤t(), Channel::Stable) + .expect("签名对得上就不该报错"); + assert_eq!( + outcome.candidate().map(|c| c.release.version.to_string()), + Some("0.3.0".to_string()) + ); +} + +#[test] +fn tampered_field_is_rejected() { + // 守卫①:清单里改一个字段(版本号多一个数字),签名不跟着换 ⇒ 必须拒。 + let original = stable_json("0.3.0"); + let tampered = stable_json("0.3.1"); + assert_ne!(original, tampered, "两次生成的清单必须真的不同"); + + let dir = TempDir::new("sig-tamper-field"); + dir.write("stable.json", tampered.as_bytes()); + // 签名还是**原始**那份的签名。 + dir.write("stable.json.sig", signature_envelope(&original).as_bytes()); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("改过字段的清单必须验不过"); + assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); + assert!( + error.message.contains("sha256"), + "要说清「签名声明的是哪一份、手上是哪一份」(实际:{})", + error.message + ); +} + +#[test] +fn tampered_signature_only_is_rejected() { + // 守卫①的另一半:清单字节一个不动,只把签名的最后一个字节改掉。 + let json = stable_json("0.3.0"); + let mut signature = crate::update::ed25519::sign_for_tests(TEST_SIGNING_SEED, json.as_bytes()); + let good = signature_envelope(&json); + signature[63] ^= 0x04; + let broken = + signature_envelope_of(&[signature_entry_from_bytes(TEST_KEY_ID, &signature, &json)]); + assert_ne!(good, broken, "两份信封必须真的不同"); + + let dir = TempDir::new("sig-tamper-sig"); + dir.write("stable.json", json.as_bytes()); + dir.write("stable.json.sig", broken.as_bytes()); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("只改签名也必须被拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); +} + +#[test] +fn missing_signature_is_rejected() { + let dir = TempDir::new("sig-missing"); + dir.write("stable.json", stable_json("0.3.0").as_bytes()); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("没有签名文件必须拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureMissing); + assert!(!error.is_retryable(), "漏签名不是抖动,重试没有意义"); + assert!(error.kind.is_signature(), "壳层据此换文案"); +} + +#[test] +fn empty_signature_array_is_malformed() { + let dir = TempDir::new("sig-empty-array"); + dir.write("stable.json", stable_json("0.3.0").as_bytes()); + dir.write("stable.json.sig", br#"{"format":1,"signatures":[]}"#); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("空签名数组必须拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed); +} + +#[test] +fn unknown_field_in_envelope_is_malformed() { + // `deny_unknown_fields`:发布侧拼错字段名要在 CI/发布侧当场炸,而不是变成用户机器上的「验签失败」。 + let json = stable_json("0.3.0"); + let dir = TempDir::new("sig-unknown-field"); + dir.write("stable.json", json.as_bytes()); + dir.write( + "stable.json.sig", + br#"{"format":1,"signatures":[],"comment":"hi"}"#, + ); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("未知字段必须拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed); +} + +#[test] +fn unknown_key_is_rejected() { + // 攻击者用自己那把钥签了一份**完全合法**的清单:字节没问题,只是钥不在钉住的表里。 + let json = stable_json("9.9.9"); + let dir = TempDir::new("sig-unknown-key"); + dir.write("stable.json", json.as_bytes()); + dir.write( + "stable.json.sig", + signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(), + ); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("不认识的钥必须拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureUnknownKey); + assert!( + error.message.contains("attacker-key"), + "要说出是谁签的(实际:{})", + error.message + ); +} + +#[test] +fn key_id_spoofing_does_not_help() { + // 攻击者把自己那把钥的 key_id 伪造成钉住的那个:查表命中,但**公钥字节**对不上 ⇒ 仍拒。 + // 这条就是「公钥钉在二进制里,不是按名字信任」的直接证据。 + let json = stable_json("9.9.9"); + let dir = TempDir::new("sig-key-id-spoof"); + dir.write("stable.json", json.as_bytes()); + dir.write( + "stable.json.sig", + signature_envelope_with(ATTACKER_SEED, TEST_KEY_ID, &json).as_bytes(), + ); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("key_id 撞名不能替代公钥"); + assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); +} + +#[test] +fn multi_signature_accepts_any_pinned_key() { + // 轮换窗口:一份清单同时带「钉住的旧钥」与「本构建不认识的新钥」的签名,只要有一条能过就放行。 + let json = stable_json("0.3.0"); + let dir = TempDir::new("sig-multi"); + dir.write("stable.json", json.as_bytes()); + let merged = signature_envelope_of(&[ + signature_entry(ATTACKER_SEED, "someone-else", &json), + signature_entry(TEST_SIGNING_SEED, TEST_KEY_ID, &json), + ]); + dir.write("stable.json.sig", merged.as_bytes()); + let manifest = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect("有一条能过就放行"); + assert!(manifest.is_some()); +} + +#[test] +fn unsigned_install_never_touches_the_target() { + // 顺序证据:验签在**取包之前**,因此目标 exe 与暂存目录一个字节都没动过。 + let dir = TempDir::new("sig-apply-order"); + dir.write("stable.json", stable_json("0.3.0").as_bytes()); + dir.write("asset.bin", PAYLOAD); + let dest = dir.join("bin/unlucky-launcher.exe"); + fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录"); + fs::write(&dest, b"old-bytes").expect("写旧 exe"); + + let job = Job::new( + Box::new(DirSource::new(dir.path())), + target(), + dir.join("staging"), + dest.clone(), + ); + let error = job + .apply(¤t(), Channel::Stable) + .expect_err("没有签名不许 apply"); + assert_eq!(error.kind, UpdateErrorKind::SignatureMissing); + assert_eq!( + fs::read(&dest).expect("目标仍在"), + b"old-bytes", + "目标 exe 不许被动过" + ); + let staging_empty = match fs::read_dir(dir.join("staging")) { + Ok(mut entries) => entries.next().is_none(), + Err(_) => true, + }; + assert!(staging_empty, "暂存目录不该留下任何东西(验签在取包之前)"); +} + +#[test] +fn sha256_mismatch_is_reported_separately() { + // 两条判据互不替代:签名对(内容就是发布侧签的那份),但包体摘要对不上 ⇒ DigestMismatch。 + let dir = TempDir::new("sig-vs-digest"); + dir.write_signed("stable.json", &stable_json("0.3.0")); + // 等长但不同内容:先过长度判据,才走得到摘要判据(顺序在 verify.rs 里是有意的)。 + dir.write("asset.bin", &[b'X'; PAYLOAD.len()]); + let job = Job::new( + Box::new(DirSource::new(dir.path())), + target(), + dir.join("staging"), + dir.join("bin/unlucky-launcher.exe"), + ); + let error = job + .apply(¤t(), Channel::Stable) + .expect_err("摘要不符必须拒"); + assert_eq!(error.kind, UpdateErrorKind::DigestMismatch); + assert!(!error.kind.is_signature(), "这是摘要判据,不是签名判据"); +} + +#[test] +fn no_runtime_keyring_override() { + // 守卫②:信任锚是编译期常量。运行期旁边放一份「信任表」、或设环境变量,都不该有任何作用 + // (现在压根没有这种口子;将来谁加了,这条会红)。 + let json = stable_json("9.9.9"); + let dir = TempDir::new("sig-no-override"); + dir.write("stable.json", json.as_bytes()); + dir.write( + "stable.json.sig", + signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(), + ); + let attacker_public = hex_of(&public_key_from_seed_for_tests(ATTACKER_SEED)); + dir.write( + "keyring.json", + format!(r#"{{"keys":[{{"key_id":"attacker-key","public_key_hex":"{attacker_public}"}}]}}"#) + .as_bytes(), + ); + dir.write("attacker.pub", attacker_public.as_bytes()); + let keyring_path = dir.join("keyring.json"); + std::env::set_var("ULL_UPDATE_KEYRING", &keyring_path); + std::env::set_var("ULL_UPDATE_KEYRING_FILE", &keyring_path); + let outcome = DirSource::new(dir.path()).fetch_manifest(Channel::Stable); + std::env::remove_var("ULL_UPDATE_KEYRING"); + std::env::remove_var("ULL_UPDATE_KEYRING_FILE"); + let error = outcome.expect_err("旁边的信任表文件不许生效"); + assert_eq!( + error.kind, + UpdateErrorKind::SignatureUnknownKey, + "改不了信任锚:攻击者的钥仍在表外(实际:{})", + error.message + ); +} diff --git a/crates/core/src/update/tests/source_cases.rs b/crates/core/src/update/tests/source_cases.rs index 67f8477..f31b416 100644 --- a/crates/core/src/update/tests/source_cases.rs +++ b/crates/core/src/update/tests/source_cases.rs @@ -10,7 +10,7 @@ use super::support::{artifact, manifest, release, TempDir}; const PAYLOAD: &[u8] = b"source payload"; fn publish(tmp: &TempDir, channel: Channel, json: &str) { - tmp.write(&format!("{}.json", channel.as_str()), json.as_bytes()); + tmp.write_signed(&format!("{}.json", channel.as_str()), json); } #[test] @@ -66,7 +66,11 @@ fn broken_manifest_json_is_reported_with_path() { .fetch_manifest(Channel::Stable) .expect_err("坏 JSON 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); - assert!(error.path.is_some(), "错误应带清单路径"); + assert!( + error.message.contains("来源 file:"), + "错误里要带出来源(本地源的 id 就是根目录),日志才定位得到(实际:{})", + error.message + ); } #[test] diff --git a/crates/core/src/update/tests/support.rs b/crates/core/src/update/tests/support.rs index a85e8f2..2047197 100644 --- a/crates/core/src/update/tests/support.rs +++ b/crates/core/src/update/tests/support.rs @@ -37,6 +37,19 @@ impl TempDir { self.path.join(name) } + /// 写一份清单**连它的签名**(`` + `.sig`),返回清单路径。 + /// + /// M190 起「有清单没签名」是被拒的一档,所以夹具一律成对落盘:只写清单的用例都是为了 + /// 证明「缺签名会被拒」,它们自己显式 `write` 清单即可。 + pub(crate) fn write_signed(&self, name: &str, manifest_text: &str) -> PathBuf { + let path = self.write(name, manifest_text.as_bytes()); + self.write( + &format!("{name}.sig"), + signature_envelope(manifest_text).as_bytes(), + ); + path + } + /// 写一个文件(父目录自动建),返回路径。 pub(crate) fn write(&self, name: &str, bytes: &[u8]) -> PathBuf { let path = self.join(name); @@ -213,6 +226,59 @@ pub(crate) fn manifest(channel: &str, releases: &[String]) -> String { ) } +/// 测试签名钥的 32 字节种子。 +/// +/// **它是公开的,故意不保密**:它只对 `cfg(test)` 与 `update-test-source` 构建有意义 +/// (`trust::TEST_ONLY` 才认这把钥),正式构建的信任表里没有它,所以拿它签不出任何正式版本 +/// 能装的清单。正式私钥只能来自仓库 Secret(见 `packaging/docs/signing.md`)。 +pub(crate) const TEST_SIGNING_SEED: &[u8; 32] = b"ull-m190-test-key-seed-000000001"; + +/// 测试钥的 `key_id`(与 `trust::TEST_ONLY` 里那条一致)。 +pub(crate) const TEST_KEY_ID: &str = "test-only-key"; + +/// 用测试钥签一份清单,返回 `manifest.json.sig` 的信封原文。 +pub(crate) fn signature_envelope(manifest_text: &str) -> String { + signature_envelope_with(TEST_SIGNING_SEED, TEST_KEY_ID, manifest_text) +} + +/// 用指定种子/`key_id` 签一份清单(负向用例要造「不认识的钥」「改过内容的签名」)。 +pub(crate) fn signature_envelope_with( + seed: &[u8; 32], + key_id: &str, + manifest_text: &str, +) -> String { + signature_envelope_of(&[signature_entry(seed, key_id, manifest_text)]) +} + +/// 多签名信封(轮换窗口的夹具:一份清单同时带几把钥的签名)。 +pub(crate) fn signature_envelope_of(entries: &[String]) -> String { + format!(r#"{{"format":1,"signatures":[{}]}}"#, entries.join(",")) +} + +/// 一条签名条目(可以直接被 [`signature_envelope_of`] 拼起来,也可以单独改坏再拼)。 +pub(crate) fn signature_entry(seed: &[u8; 32], key_id: &str, manifest_text: &str) -> String { + let signature = crate::update::ed25519::sign_for_tests(seed, manifest_text.as_bytes()); + signature_entry_from_bytes(key_id, &signature, manifest_text) +} + +/// 用**现成的签名字节**拼一条条目(「只改签名字节」的用例靠它)。 +pub(crate) fn signature_entry_from_bytes( + key_id: &str, + signature: &[u8; 64], + manifest_text: &str, +) -> String { + let sha = crate::update::signature::hex_sha256(manifest_text.as_bytes()); + format!( + r#"{{"alg":"ed25519","key_id":"{key_id}","sig":"{}","manifest_sha256":"{sha}"}}"#, + hex_of(signature) + ) +} + +/// 字节转小写十六进制。 +pub(crate) fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + /// 造一台测试用下载引擎:镜像表用内置清单,健康度只在内存里,缓存落临时目录。 /// /// `region` 决定「官方优先」还是「镜像优先」——gh-proxy 前缀只在该是后一种时生效。 @@ -280,13 +346,9 @@ pub(crate) struct FakeHttp { impl FakeHttp { /// 起服务(`routes` 是「路径 → 应答」表)。 - pub(crate) fn start(routes: Vec<(&str, FakeResponse)>) -> Self { + pub(crate) fn start(routes: Vec<(String, FakeResponse)>) -> Self { let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定本地端口"); let addr = listener.local_addr().expect("取本地地址"); - let routes: Vec<(String, FakeResponse)> = routes - .into_iter() - .map(|(path, response)| (path.to_string(), response)) - .collect(); let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); diff --git a/crates/core/src/update/trust.rs b/crates/core/src/update/trust.rs new file mode 100644 index 0000000..4ab3af3 --- /dev/null +++ b/crates/core/src/update/trust.rs @@ -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> = 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> { + 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) +} diff --git a/packaging/README.md b/packaging/README.md index 0d9e29d..8eac8bd 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -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 证书,那是另一条线。 diff --git a/packaging/check_pe_version.py b/packaging/check_pe_version.py new file mode 100644 index 0000000..09c43d7 --- /dev/null +++ b/packaging/check_pe_version.py @@ -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 + +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 ... --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(" len(data) or data[offset : offset + 4] != PE_MAGIC: + return None + return struct.unpack_from(" 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("> 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(" 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()) diff --git a/packaging/docs/signing.md b/packaging/docs/signing.md new file mode 100644 index 0000000..c2d5a7b --- /dev/null +++ b/packaging/docs/signing.md @@ -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 +``` + +四条命令任何一条非零退出 ⇒ **这一步红 ⇒ 整个 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 --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` 的验收构建里另有一把**公开的测试钥**(种子写在测试夹具里):它只用于 +「本地目录源 + 真二进制」的端到端验收,**不在**正式信任表里,签不出任何正式版本能装的清单。 diff --git a/packaging/pe_version.rs b/packaging/pe_version.rs new file mode 100644 index 0000000..724c554 --- /dev/null +++ b/packaging/pe_version.rs @@ -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 名 / `.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::().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; +} diff --git a/packaging/sign_manifest.py b/packaging/sign_manifest.py new file mode 100644 index 0000000..5686dc8 --- /dev/null +++ b/packaging/sign_manifest.py @@ -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())