From 918d8266454a2041a65cb72bc8cc61ee70f549be Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 02:14:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(m190):=20r1=20=E5=A4=8D=E5=AE=A1=E2=80=94?= =?UTF-8?q?=E2=80=940x01=20=E4=BF=AE=E6=88=90=20\1=20+=20YAML=20=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E3=80=81=E8=A2=AB=E8=B0=83=E6=96=B9=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=20secret=E3=80=81=E6=8B=86=E4=B8=A4=E4=B8=AA=E8=B6=85=E9=99=90?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6=E3=80=81=E9=AA=8C=E7=AD=BE?= =?UTF-8?q?=E6=8D=A2=20ed25519-dalek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四条 P1 + 四条 P2 一并处置(不变的是判据,变的是「谁在承重」)。 P1-1 `.github/workflows/release-build.yml` 的 sed 替换串是裸 0x01(本意 `\1`):GitHub 解析该文件会 失败 ⇒ 整个 run 起不来;即便容忍,版本号也会被求值成单字节,PE 判据报「与 Cargo.toml 不同源?」把 排障引向错方向。修成字面 `\1`,并新增 `packaging/check_workflows_yaml.py`(真 YAML 解析器 parse 全部 workflow + 裸 C0 控制字符字节判据;带 --self-test 注入 0x01/坏结构断言必红),接进 `release-guard.yml` 两步。文本扫描型守卫看不见这类字节,这道门禁专门挡它。 P1-2 `release-publish.yml` 没在 `on.workflow_call.secrets` 里声明 `ULL_MANIFEST_SIGNING_KEY`,调用方 却传了:可复用工作流只拿得到自己声明过的 secret ⇒ 用户配好钥也会在签名步红,而错误文案反过来指控 Secret 没配。补声明(required: false,fail-closed 由步内 ::error:: + exit 1 承担,同 ULL_MSA_CLIENT_ID 的模式)。 P1-3 两个测试支撑文件越过 400 行守卫:签名夹具拆到 `tests/support_sign.rs`(support.rs 458→392), CLI e2e 的签名部分拆到 `support/update_e2e_sign.rs`(update_e2e.rs 406→348)。纯搬家。 P2-2 「唯一收口点」改成结构事实:从 `UpdateSource` 删掉 `fetch_manifest` 默认实现,调用方只能拿 `verified_manifest(&source, channel)`(源只交原文,想绕过验签就得自己重写解析与渠道判据)。 P2-3 文档写的第 ④ 条命令补进流水线:`sign_manifest.py --verify-pinned` 用 trust.rs 里钉住的公钥 回验刚落盘的 .sig(不碰私钥),发布侧因此验证的是「客户端的判据」而不是「签名脚本的自我声明」。 P2-4 随自写实现一起删除(`Fe` 的错误 PartialEq 不再存在)。 P2-5 app-iced/build.rs 注释里那个 windows_subsystem 属性本分支不存在(M196 才有),改为说明 「本文件只写版本资源,子系统由 rustc 属性决定,check_pe_version.py 会把 subsystem 打出来」。 P2-6 坏清单不再带 error.path 是有意降级(来源 id 在 message 里),保持。 裁定项:自写 Ed25519 换成 `ed25519-dalek::verify_strict`(信任锚不许自写密码学;此刻信任表还是空表, 零兼容负担)。删除 `update/ed25519/{field,group,mod}.rs`(含只服务它们的单测),新 `update/ed25519.rs` 只剩一个薄封装;拒绝类用例(可锻 S+L / 小阶公钥 / 非规范 R / 空签名 / 未知 key)**一条没删**, 改成钉 dalek 的 strict 语义——实测 dalek 对全部五类的结论与旧实现一致(非规范 R 的拒绝理由不同: dalek 走「小阶点」而非「编码非规范」,用例注释里写明)。签名侧留在 `packaging/ed25519_ref.py` (纯 Python、零 pip 依赖),`--self-test` 用 RFC 8032 同一组向量钉两边。 --- .github/workflows/release-build.yml | 2 +- .github/workflows/release-guard.yml | 9 + .github/workflows/release-publish.yml | 11 + Cargo.lock | 118 ++++++++ Cargo.toml | 7 + crates/app-iced/build.rs | 5 +- crates/cli/tests/support/mod.rs | 1 + crates/cli/tests/support/update_e2e.rs | 60 +--- crates/cli/tests/support/update_e2e_sign.rs | 69 +++++ crates/core/Cargo.toml | 2 + crates/core/src/update/checker.rs | 2 +- crates/core/src/update/ed25519.rs | 50 ++++ 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/mod.rs | 2 +- crates/core/src/update/source.rs | 16 +- crates/core/src/update/tests/ed25519_cases.rs | 181 +++++++----- crates/core/src/update/tests/github_cases.rs | 34 +-- crates/core/src/update/tests/mod.rs | 1 + .../core/src/update/tests/signature_cases.rs | 47 ++-- crates/core/src/update/tests/source_cases.rs | 15 +- crates/core/src/update/tests/support.rs | 66 ----- crates/core/src/update/tests/support_sign.rs | 94 +++++++ packaging/check_workflows_yaml.py | 143 ++++++++++ packaging/docs/signing.md | 25 +- packaging/ed25519_ref.py | 142 ++++++++++ packaging/sign_manifest.py | 163 +++-------- 28 files changed, 860 insertions(+), 1085 deletions(-) create mode 100644 crates/cli/tests/support/update_e2e_sign.rs create mode 100644 crates/core/src/update/ed25519.rs delete mode 100644 crates/core/src/update/ed25519/field.rs delete mode 100644 crates/core/src/update/ed25519/group.rs delete mode 100644 crates/core/src/update/ed25519/mod.rs create mode 100644 crates/core/src/update/tests/support_sign.rs create mode 100644 packaging/check_workflows_yaml.py create mode 100644 packaging/ed25519_ref.py diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 9993e80..aba0c3f 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -227,7 +227,7 @@ jobs: 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) + version=$(sed -n 's/^version = "\(.*\)"/\1/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" diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml index 9d2e8ea..af53ab6 100644 --- a/.github/workflows/release-guard.yml +++ b/.github/workflows/release-guard.yml @@ -75,3 +75,12 @@ jobs: run: python3 packaging/sign_manifest.py --self-test - name: PE 版本资源判据自检(口径表对拍 + 假 PE 必红) run: python3 packaging/check_pe_version.py --self-test + # M190 r1 复审 P1-1:`.github/workflows/release-build.yml` 的 sed 替换串里混进过一枚裸 0x01 + # (本意是 `\1`)。GitHub 解析这种文件会直接失败 ⇒ **整个 run 起不来**,而所有文本扫描型守卫 + # 都看不见它(它们把 workflow 读成文本)。这条用**真 YAML 解析器** parse 全部 workflow, + # 外加一条「C0 控制字符」字节判据(两种失败模式各自独立报 `::error file=…,line=…`)。 + # PyYAML 在 GitHub runner 上自带;万一缺失,脚本自己装一次,仍失败则红(门禁不降级)。 + - name: workflow YAML 自检(注入 0x01 / 坏结构必须红) + run: python3 packaging/check_workflows_yaml.py --self-test + - name: workflow 全部可被真 YAML 解析器 parse(无裸控制字符) + run: python3 packaging/check_workflows_yaml.py diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index f859e6a..7585c2f 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -27,6 +27,14 @@ on: PUBLISH_TOKEN: description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)" required: true + # (M190 r1 复审 P1-2)**必须在这里声明**:可复用工作流只拿得到自己声明过的 secret,调用方 + # `release.yml` 按名传了这一个,若被调方不声明,它要么在调用处报错、要么静默求值为空串—— + # 两种结局都是「用户已把钥录进 Secret,发布仍在签名步红,而错误文案反过来指控 Secret 没配」。 + # `required: false` 是刻意的:缺省为空串,由签名步的 `::error::` + `exit 1` 做 fail-closed + # (与 `release-build.yml` 的 `ULL_MSA_CLIENT_ID` 同一模式),失败信息才和事实一致。 + ULL_MANIFEST_SIGNING_KEY: + description: "清单签名私钥(JSON:key_id + seed_hex);未配置时签名步 fail-closed、不出 Release" + required: false # 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。 permissions: @@ -155,6 +163,9 @@ jobs: fi "$PY" packaging/sign_manifest.py --check-pinned-keyring "$PY" packaging/sign_manifest.py --sign dist/manifest.json + # ④ 产出后**用 trust.rs 里钉住的公钥回验一遍**:复现「客户端会怎么判」,而不是信签名脚本 + # 自己那句「签好了」。私钥不需要参与,所以这一步跑在同一个环境里也不多一个泄露面。 + "$PY" packaging/sign_manifest.py --verify-pinned dist/manifest.json ls -l dist/manifest.json dist/manifest.json.sig - name: 生成 Release 说明(支持矩阵模板) diff --git a/Cargo.lock b/Cargo.lock index f0332a1..a16ef25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -660,6 +666,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.11.0" @@ -846,6 +858,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.24.1" @@ -892,6 +931,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -977,6 +1026,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1094,6 +1167,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -3087,6 +3166,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3332,6 +3421,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -4197,6 +4295,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4397,6 +4504,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sse-stream" version = "0.2.6" @@ -5056,6 +5173,7 @@ dependencies = [ name = "unlucky-core" version = "0.2.3" dependencies = [ + "ed25519-dalek", "flate2", "futures-util", "md-5", diff --git a/Cargo.toml b/Cargo.toml index c85314a..a7f49be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,13 @@ md-5 = "0.10" # 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。 zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] } +# M190(core::update::ed25519):清单签名**验签**用官方实现,不再自写曲线算术。 +# M190 r1 复审的裁定:信任锚不许用自写密码学(非规范编码/小阶点/批量验证差异的后果是 +# 「验签通过但本不该通过」)。BSD-3-Clause,在 deny.toml 白名单内。 +# 只开 `std`(不用 `rand_core`/`batch`/`fast` 这类可选面):本仓的签名侧在 +# `packaging/sign_manifest.py`,二进制里只有「验」这一半。 +ed25519-dalek = { version = "2", default-features = false, features = ["std"] } + # M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`, # 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。 flate2 = "1" diff --git a/crates/app-iced/build.rs b/crates/app-iced/build.rs index 466b360..30cd908 100644 --- a/crates/app-iced/build.rs +++ b/crates/app-iced/build.rs @@ -3,8 +3,9 @@ //! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的 //! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。 //! -//! ⚠ 本文件**不**碰 `windows_subsystem`:GUI 子系统由 `src/main.rs` 的 -//! `#![cfg_attr(windows, windows_subsystem = "windows")]` 决定,版本资源与子系统是两件事。 +//! ⚠ 本文件**只**写版本资源,不碰子系统:PE 的 `Subsystem` 由 rustc 的 +//! `windows_subsystem` 属性决定(本分支上还没有那个属性,是 M196 的事),版本资源与子系统是两件事。 +//! 判据:`packaging/check_pe_version.py` 会把它读到的 `subsystem` 打出来,加资源前后必须一致。 include!("../../packaging/pe_version.rs"); diff --git a/crates/cli/tests/support/mod.rs b/crates/cli/tests/support/mod.rs index f2596da..aa0befa 100644 --- a/crates/cli/tests/support/mod.rs +++ b/crates/cli/tests/support/mod.rs @@ -4,6 +4,7 @@ #![allow(dead_code)] pub mod update_e2e; +pub mod update_e2e_sign; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/crates/cli/tests/support/update_e2e.rs b/crates/cli/tests/support/update_e2e.rs index 3fbf68f..f46af21 100644 --- a/crates/cli/tests/support/update_e2e.rs +++ b/crates/cli/tests/support/update_e2e.rs @@ -27,6 +27,7 @@ use std::path::{Path, PathBuf}; +use super::update_e2e_sign::write_signature; use unlucky_core::update::{HashAlgo, OsFamily, Target, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV}; /// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。 @@ -81,65 +82,6 @@ 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` 会把多余字段当清单损坏拒掉,所以这条 diff --git a/crates/cli/tests/support/update_e2e_sign.rs b/crates/cli/tests/support/update_e2e_sign.rs new file mode 100644 index 0000000..5b899fa --- /dev/null +++ b/crates/cli/tests/support/update_e2e_sign.rs @@ -0,0 +1,69 @@ +//! 更新 e2e 夹具的**签名**部分(M190):用 `packaging/sign_manifest.py` 给清单签 `.sig`。 +//! +//! 为什么单独一个文件:`support/update_e2e.rs` 已经贴着全仓 400 行红线(M190 r1 复审 P1-3), +//! 而签名这一摊只被 `Fixture::publish` 用到。搬出来是纯搬家,语义未改。 +//! +//! 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒ +//! 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带 +//! python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。 + +use std::path::Path; + +/// 测试签名钥的种子(**公开**:只对 `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 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。 +pub 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" + ); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 98838d4..d11d56a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -26,6 +26,8 @@ futures-util.workspace = true sha1.workspace = true sha2.workspace = true zip.workspace = true +# M190(core::update::ed25519):清单签名验签(官方实现,见 workspace 依赖处的取舍说明)。 +ed25519-dalek.workspace = true # M31(core::auth):离线 UUID = MD5 v3(与 HMCL / 服务端 nameUUIDFromBytes 对拍)。 md-5.workspace = true # M33(core::game::java)预接线:tar.gz 解压(Temurin 的 Linux/macOS 产物)。 diff --git a/crates/core/src/update/checker.rs b/crates/core/src/update/checker.rs index 56f0d01..a3186de 100644 --- a/crates/core/src/update/checker.rs +++ b/crates/core/src/update/checker.rs @@ -132,7 +132,7 @@ impl UpdateChecker { /// 清单取不到、清单不合法、渠道不符都返回 [`UpdateError`]。 /// 「没有更新」不是错误,走 [`CheckOutcome::UpToDate`]。 pub fn check(&self, current: &Version, channel: Channel) -> Result { - let Some(manifest) = self.source.fetch_manifest(channel)? else { + let Some(manifest) = super::source::verified_manifest(&self.source, channel)? else { return Ok(CheckOutcome::UpToDate { current: current.clone(), latest: None, diff --git a/crates/core/src/update/ed25519.rs b/crates/core/src/update/ed25519.rs new file mode 100644 index 0000000..7758146 --- /dev/null +++ b/crates/core/src/update/ed25519.rs @@ -0,0 +1,50 @@ +//! Ed25519 **严格验签**(`ed25519-dalek::verify_strict`),只验不签、只用公开数据。 +//! +//! # 为什么是官方实现,而不是本仓自写 +//! +//! M190 第一版为了「scope 里不含 `crates/core/Cargo.toml`」把曲线算术写在了仓里 +//! (5x51 素域 + 扩展坐标 + 平方-乘)。r1 复审的裁定是**不允许**:这是**信任锚**, +//! 非规范编码、小阶点、批量验证差异这类微妙错误的后果是「验签通过但本不该通过」, +//! 自写的向量覆盖再全也证明不了「严格性判据选对了」。当时的时机也最好:正式信任表 +//! (`trust::PRODUCTION`)还是空表,换实现**零兼容负担**。 +//! +//! 于是这里只剩一层薄封装,判据全部交给 dalek: +//! +//! * `VerifyingKey::from_bytes` —— 公钥必须是**规范编码的合法点**(非规范 `y` / 不在曲线上都拒); +//! * `VerifyingKey::verify_strict` —— 比 RFC 8032 的最低要求更严:拒绝**小阶**公钥与小阶 `R` +//! (阻止「谁都能构造自洽四元组」的伪造族),并拒绝**非规范标量 `S`**(`S >= L` 的可锻变体); +//! * `Signature::from_bytes` —— 只搬运 64 字节;真正的判据在 `verify_strict` 里,**不能**换成 +//! `verify`(后者不做小阶/可锻检查)。 +//! +//! 这批行为由 `tests/ed25519_cases.rs` 逐条钉住(RFC 8032 四条向量 + 六条「该拒就拒」): +//! 那些用例原来用来测自写实现,现在改成**钉 dalek 的 strict 语义**——将来谁把 +//! `verify_strict` 换成 `verify`(或换回自写实现),它们会当场红。 +//! +//! # 签名侧不在二进制里 +//! +//! 本仓没有任何私钥代码:清单由 `packaging/sign_manifest.py`(纯 Python 参考实现,零第三方依赖) +//! 在发布侧签。测试里的签名夹具走 dalek 的 `SigningKey`,只在 `cfg(test)` 里编译。 + +use ed25519_dalek::{Signature, VerifyingKey}; + +/// 公钥字节数。 +pub(crate) const PUBLIC_KEY_LEN: usize = 32; +/// 签名字节数。 +pub(crate) const SIGNATURE_LEN: usize = 64; + +/// 验一条 Ed25519 签名(**严格**语义,见模块文档)。 +/// +/// `message` 是任意长原文——这里恒为清单文件的**原始字节**(见 [`super::signature`])。 +#[must_use] +pub(crate) fn verify( + public_key: &[u8; PUBLIC_KEY_LEN], + message: &[u8], + signature: &[u8; SIGNATURE_LEN], +) -> bool { + let Ok(key) = VerifyingKey::from_bytes(public_key) else { + // 公钥都不是规范编码的合法点:直接拒(`from_bytes` 已经做了曲线方程与规范编码检查)。 + return false; + }; + key.verify_strict(message, &Signature::from_bytes(signature)) + .is_ok() +} diff --git a/crates/core/src/update/ed25519/field.rs b/crates/core/src/update/ed25519/field.rs deleted file mode 100644 index bc205b6..0000000 --- a/crates/core/src/update/ed25519/field.rs +++ /dev/null @@ -1,247 +0,0 @@ -//! 素域 `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 deleted file mode 100644 index 6dbb275..0000000 --- a/crates/core/src/update/ed25519/group.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! 扭曲爱德华兹曲线上的群运算(`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 deleted file mode 100644 index 4c44e7b..0000000 --- a/crates/core/src/update/ed25519/mod.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! 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/mod.rs b/crates/core/src/update/mod.rs index bbec6d3..18117c2 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -31,7 +31,7 @@ //! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50) //! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急 //! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring) -//! ed25519 Ed25519 严格验签(自写,无新依赖) +//! ed25519 Ed25519 严格验签(ed25519-dalek::verify_strict 的薄封装) //! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点) //! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) //! ``` diff --git a/crates/core/src/update/source.rs b/crates/core/src/update/source.rs index 55a9dd1..eea1610 100644 --- a/crates/core/src/update/source.rs +++ b/crates/core/src/update/source.rs @@ -68,22 +68,14 @@ 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 共用)。 /// +/// 它是个**自由函数**而不是 trait 的默认方法:trait 方法可被任何实现方覆写,那样「信任根的收口点」 +/// 就只剩纪律(M190 r1 复审 P2-2)。删掉那条默认实现之后,调用方手里只有源交出来的**原文** +/// ([`ManifestDocuments`]),想绕过验签就必须自己重写一遍解析 + 渠道判据 —— 不再是「一行覆写」。 +/// /// # Errors /// /// 源里没有该渠道清单 ⇒ `Ok(None)`;签名/解析/渠道任一不过 ⇒ [`UpdateError`]。 diff --git a/crates/core/src/update/tests/ed25519_cases.rs b/crates/core/src/update/tests/ed25519_cases.rs index a7fdbe3..4b5008c 100644 --- a/crates/core/src/update/tests/ed25519_cases.rs +++ b/crates/core/src/update/tests/ed25519_cases.rs @@ -1,12 +1,23 @@ -//! Ed25519 严格验签的判据(RFC 8032 §7.1 官方测试向量 + 六条「该拒就拒」)。 +//! Ed25519 验签判据:**钉住 `ed25519-dalek::verify_strict` 的行为**(RFC 8032 §7.1 向量 + 六条拒绝类)。 //! -//! 这一组用例是自写密码学的**唯一**公信力来源:向量来自 RFC 附录(不是自己签自己验), -//! 拒绝类用例覆盖「改一个字节仍然通过」的全部已知构造(改 R / 改 S / S += L / 小阶公钥 / -//! 非规范 R 编码 / 空签名)。 +//! M190 r1 复审把自写曲线算术整体换成了官方实现(理由见 `crate::update::ed25519` 的模块文档)。 +//! 这批用例的定位因此从「测自己的实现」变成「**钉 dalek 的严格语义**」: +//! +//! | 用例 | 它挡住什么 | +//! |---|---| +//! | `rfc8032_vectors_verify` | 实现换成任何一个错的版本(向量是 RFC 附录的,不是自签自验) | +//! | `malleable_s_plus_l_is_rejected` | 把 `verify_strict` 换成 `verify`(后者不检查 `S` 规范性) | +//! | `small_order_public_key_is_rejected` | 同上:小阶公钥/小阶 `R` 在 `verify` 下能「自洽通过」 | +//! | `non_canonical_r_encoding_is_rejected` | 非规范编码的点(`y >= p` 的第二种写法) | +//! | `empty_signature_is_rejected` / `message_bit_flip` / `signature_bit_flip` | 判据本身失效(恒真) | +//! | `signing_key_round_trips` | 测试夹具(dalek `SigningKey`)与验签侧是同一套语义 | +//! +//! 每条都实测过 dalek 2.2 的**实际**行为;与「RFC 最低要求」不同之处在用例注释里写明(见 +//! `non_canonical_r_encoding_is_rejected`:dalek 拒它的理由是**小阶**,不是「非规范编码」单独一条)。 -use crate::update::ed25519::{ - public_key_from_seed_for_tests, sign_for_tests, verify, PUBLIC_KEY_LEN, SIGNATURE_LEN, -}; +use crate::update::ed25519::{verify, PUBLIC_KEY_LEN, SIGNATURE_LEN}; + +use super::support_sign::{hex_of, public_key_hex, sign_bytes}; /// 十六进制转字节(长度不对/字符非法当场 panic:这些向量是写死的常量)。 fn unhex(text: &str) -> Vec { @@ -15,15 +26,15 @@ fn unhex(text: &str) -> Vec { 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() + let bytes = cleaned.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).expect("十六进制字符"); + let lo = (pair[1] as char).to_digit(16).expect("十六进制字符"); + out.push(u8::try_from(hi * 16 + lo).expect("字节")); + } + out } fn array32(bytes: &[u8]) -> [u8; 32] { @@ -44,28 +55,28 @@ const L_BYTES: [u8; 32] = [ 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)> { +/// RFC 8032 §7.1 的四条向量:(public, message, signature)。 +fn rfc_vectors() -> Vec<(&'static str, &'static str, &'static str)> { vec![ ( - "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a".to_string(), - String::new(), - "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b".to_string(), + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", ), ( - "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c".to_string(), - "72".to_string(), - "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00".to_string(), + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", + "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00", ), ( - "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025".to_string(), - "af82".to_string(), - "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a".to_string(), + "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "af82", + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a", ), ( - "ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf".to_string(), - "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f".to_string(), - "dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b58909351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704".to_string(), + "ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf", + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f", + "dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b58909351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704", ), ] } @@ -73,11 +84,12 @@ fn rfc_vectors() -> Vec<(String, String, 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), + verify( + &array32(&unhex(public)), + &unhex(message), + &array64(&unhex(signature)) + ), "RFC 8032 第 {} 条向量必须通过", index + 1 ); @@ -86,37 +98,41 @@ fn rfc8032_vectors_verify() { #[test] fn message_bit_flip_is_rejected() { - let (public, message, signature) = &rfc_vectors()[1]; - let public = array32(&unhex(public)); + let (public, message, signature) = rfc_vectors()[1]; let mut message = unhex(message); message[0] ^= 0x01; - let signature = array64(&unhex(signature)); assert!( - !verify(&public, &message, &signature), + !verify( + &array32(&unhex(public)), + &message, + &array64(&unhex(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 必须被拒"); + let (public, message, signature) = rfc_vectors()[2]; + let mut r_flipped = array64(&unhex(signature)); + r_flipped[31] ^= 0x01; + assert!( + !verify(&array32(&unhex(public)), &unhex(message), &r_flipped), + "改 R 必须被拒" + ); + let mut s_flipped = array64(&unhex(signature)); + s_flipped[63] ^= 0x04; + assert!( + !verify(&array32(&unhex(public)), &unhex(message), &s_flipped), + "改 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); + // 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余)。RFC 8032 的最低要求允许它, + // `verify_strict` 必须拒 —— 这条用例就是「别把 verify_strict 换成 verify」的判据。 + let (public, message, signature) = rfc_vectors()[0]; let mut signature = array64(&unhex(signature)); let mut carry = 0_u16; for index in 0..32 { @@ -129,21 +145,20 @@ fn malleable_s_plus_l_is_rejected() { "S + L 不该溢出 256 位(否则这条用例本身没造出来)" ); assert!( - !verify(&public, &message, &signature), + !verify(&array32(&unhex(public)), &unhex(message), &signature), "S >= L 必须被拒(可锻变体)" ); } #[test] fn small_order_public_key_is_rejected() { - // 阶为 1 的点(y = 1, x = 0)与阶为 2 的点(y = 0):用它们做公钥,谁都能凑出「自洽」的签名。 + // 阶为 1 的点(y = 1, x = 0)与零字节公钥:用它们做公钥,`verify`(非 strict)会接受一批伪造。 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!( @@ -151,17 +166,19 @@ fn small_order_public_key_is_rejected() { "小阶公钥必须被拒" ); assert!( - !verify(&order_two_key, message, &signature), + !verify(&[0_u8; PUBLIC_KEY_LEN], 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); + // R = p(= 2^255-19)的字节:它与 y = 0 同值但编码不同。 + // **dalek 的实际行为(M190 r1 实测)**:`CompressedEdwardsY::decompress` 不单独校验「规范编码」, + // 这个点解压出来是阶为 4 的点 ⇒ 被 `verify_strict` 的**小阶**判据拒。结论(拒)与自写实现的 + // 「非规范编码」理由不同,但对这份输入是同一条出路;下面把「公钥非规范编码」也一并钉住, + // 因为那条走的是 `VerifyingKey::from_bytes` 的编码校验。 + let (public, message, _) = rfc_vectors()[0]; 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, @@ -170,14 +187,23 @@ fn non_canonical_r_encoding_is_rejected() { let mut signature = [0_u8; SIGNATURE_LEN]; signature[..32].copy_from_slice(&p_bytes); assert!( - !verify(&public, &message, &signature), + !verify(&array32(&unhex(public)), &unhex(message), &signature), "非规范 R 编码必须被拒" ); + // 公钥换成同一串非规范编码:`VerifyingKey::from_bytes` 直接拒(连验签都不进)。 + assert!( + !verify( + &p_bytes, + &unhex(message), + &array64(&unhex(rfc_vectors()[0].2)) + ), + "非规范公钥编码必须被拒" + ); } #[test] fn empty_signature_is_rejected() { - let (public, message, _) = &rfc_vectors()[0]; + let (public, message, _) = rfc_vectors()[0]; assert!(!verify( &array32(&unhex(public)), &unhex(message), @@ -186,28 +212,33 @@ fn empty_signature_is_rejected() { } #[test] -fn test_signer_round_trips_and_uses_rfc_seed() { - // 测试钥不是「另写一套算法」:RFC 8032 的 secret 反推出 public 就对上了,签出来的也能验过。 +fn signing_key_round_trips() { + // 测试夹具(dalek `SigningKey`)与验签侧同一套语义:RFC 的 secret 反推出的公钥要对得上, + // 签出来的能验过,改一个字节就验不过。 let seed = array32(&unhex( "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", )); - let expected = array32(&unhex( - "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", - )); assert_eq!( - public_key_from_seed_for_tests(&seed), - expected, + public_key_hex(&seed), + rfc_vectors()[0].0, "种子展开必须与 RFC 一致" ); - let signature = sign_for_tests(&seed, b""); assert_eq!( - signature, - array64(&unhex(&rfc_vectors()[0].2)), - "空消息的签名必须逐字节等于 RFC 向量(证明签名侧与向量同一套算术语义)" + hex_of(&sign_bytes(&seed, b"")), + rfc_vectors()[0].2, + "空消息的签名必须逐字节等于 RFC 向量(测试夹具与向量同一套语义)" ); + let signature = sign_bytes(&seed, b"unlucky"); assert!(verify( - &expected, + &array32(&unhex(rfc_vectors()[0].0)), b"unlucky", - &sign_for_tests(&seed, b"unlucky") + &signature + )); + let mut tampered = signature; + tampered[0] ^= 0x80; + assert!(!verify( + &array32(&unhex(rfc_vectors()[0].0)), + b"unlucky", + &tampered )); } diff --git a/crates/core/src/update/tests/github_cases.rs b/crates/core/src/update/tests/github_cases.rs index 56e9651..97ba278 100644 --- a/crates/core/src/update/tests/github_cases.rs +++ b/crates/core/src/update/tests/github_cases.rs @@ -10,13 +10,12 @@ use crate::download::mirrors::SOURCE_GH_PROXY; use crate::download::{DownloadEngine, RegionProfile, SOURCE_OFFICIAL}; use crate::update::github::{DEFAULT_BASE, DEFAULT_OWNER, DEFAULT_REPO, MANIFEST_ASSET}; use crate::update::{ - Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, UpdateChecker, UpdateError, - UpdateErrorKind, UpdateSource, Version, + verified_manifest, Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, + UpdateChecker, UpdateError, UpdateErrorKind, UpdateSource, Version, }; -use super::support::{ - artifact, engine_for, manifest, release, signature_envelope, FakeHttp, FakeResponse, TempDir, -}; +use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir}; +use super::support_sign::signature_envelope; const PAYLOAD: &[u8] = b"github release payload"; @@ -130,7 +129,7 @@ fn channels_other_than_stable_publish_nothing() { source.manifest_url(channel).is_none(), "{channel} 不该有清单地址" ); - let manifest = source.fetch_manifest(channel).expect("没开通道不是错误"); + let manifest = verified_manifest(&source, channel).expect("没开通道不是错误"); assert!( manifest.is_none(), "{channel} 应返回 Ok(None)(不发布即无更新)" @@ -191,8 +190,7 @@ fn fetches_and_parses_the_manifest_from_the_base() { let server = FakeHttp::start(signed_routes(&stable_manifest_json())); let source = local_source(&tmp, &server); - let manifest = source - .fetch_manifest(Channel::Stable) + let manifest = verified_manifest(&source, Channel::Stable) .expect("应取到清单") .expect("清单存在"); assert_eq!(manifest.channel, Channel::Stable); @@ -212,9 +210,7 @@ fn missing_manifest_in_the_release_is_not_an_error() { let server = FakeHttp::start(Vec::new()); let source = local_source(&tmp, &server); - let outcome = source - .fetch_manifest(Channel::Stable) - .expect("404 不是错误"); + let outcome = verified_manifest(&source, Channel::Stable).expect("404 不是错误"); assert!(outcome.is_none(), "取不到清单 = 还没发过版"); assert_eq!( server.hits(), @@ -230,9 +226,7 @@ fn broken_json_is_reported_as_invalid_manifest() { let server = FakeHttp::start(signed_routes("{ not json")); let source = local_source(&tmp, &server); - let error = source - .fetch_manifest(Channel::Stable) - .expect_err("坏 JSON 必须报错"); + let error = verified_manifest(&source, Channel::Stable).expect_err("坏 JSON 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); assert!( error.message.contains("github:"), @@ -250,9 +244,7 @@ fn illegal_manifest_fields_are_reported() { let server = FakeHttp::start(signed_routes(json)); let source = local_source(&tmp, &server); - let error = source - .fetch_manifest(Channel::Stable) - .expect_err("size 为 0 必须报错"); + let error = verified_manifest(&source, Channel::Stable).expect_err("size 为 0 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); } @@ -262,9 +254,7 @@ fn manifest_self_reporting_another_channel_is_a_mismatch() { let server = FakeHttp::start(signed_routes(&manifest("beta", &[]))); let source = local_source(&tmp, &server); - let error = source - .fetch_manifest(Channel::Stable) - .expect_err("渠道不符必须报错"); + let error = verified_manifest(&source, Channel::Stable).expect_err("渠道不符必须报错"); assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch); } @@ -274,9 +264,7 @@ fn server_errors_are_network_errors() { let server = FakeHttp::start(vec![(manifest_path(), FakeResponse::status(500))]); let source = local_source(&tmp, &server); - let error = source - .fetch_manifest(Channel::Stable) - .expect_err("5xx 必须报错"); + let error = verified_manifest(&source, Channel::Stable).expect_err("5xx 必须报错"); assert_eq!(error.kind, UpdateErrorKind::Network); assert!(error.is_retryable(), "网络抖动值得隔一会儿再试"); } diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index bdb4ad6..5ec5af5 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -30,6 +30,7 @@ mod manifest_cases; mod signature_cases; mod source_cases; mod support; +mod support_sign; mod target_cases; mod verify_cases; mod version_cases; diff --git a/crates/core/src/update/tests/signature_cases.rs b/crates/core/src/update/tests/signature_cases.rs index 6b60c98..9716926 100644 --- a/crates/core/src/update/tests/signature_cases.rs +++ b/crates/core/src/update/tests/signature_cases.rs @@ -17,16 +17,15 @@ 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, + trust, verified_manifest, Channel, DirSource, Job, OsFamily, Target, UpdateChecker, + UpdateErrorKind, 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, +use super::support::{artifact, manifest, release, TempDir}; +use super::support_sign::{ + public_key_hex, sign_bytes, signature_entry, signature_entry_from_bytes, signature_envelope, + signature_envelope_of, signature_envelope_with, TEST_KEY_ID, TEST_SIGNING_SEED, }; const PAYLOAD: &[u8] = b"signed release payload"; @@ -57,7 +56,7 @@ fn current() -> Version { fn pinned_test_key_matches_the_test_seed() { // 信任表里那把测试钥必须就是测试种子推出来的那把。漂了的话「钉住的公钥」与「签名的钥」不是同一个, // 下面所有正路用例会一起变红——先在这里单独钉死。 - let derived = hex_of(&public_key_from_seed_for_tests(TEST_SIGNING_SEED)); + let derived = public_key_hex(TEST_SIGNING_SEED); let pinned = trust::keyring() .iter() .find(|key| key.key_id == TEST_KEY_ID) @@ -92,8 +91,7 @@ fn tampered_field_is_rejected() { 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) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("改过字段的清单必须验不过"); assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); assert!( @@ -107,7 +105,7 @@ fn tampered_field_is_rejected() { 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 mut signature = sign_bytes(TEST_SIGNING_SEED, json.as_bytes()); let good = signature_envelope(&json); signature[63] ^= 0x04; let broken = @@ -117,8 +115,7 @@ fn tampered_signature_only_is_rejected() { 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) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("只改签名也必须被拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); } @@ -127,8 +124,7 @@ fn tampered_signature_only_is_rejected() { 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) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("没有签名文件必须拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureMissing); assert!(!error.is_retryable(), "漏签名不是抖动,重试没有意义"); @@ -140,8 +136,7 @@ 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) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("空签名数组必须拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed); } @@ -156,8 +151,7 @@ fn unknown_field_in_envelope_is_malformed() { "stable.json.sig", br#"{"format":1,"signatures":[],"comment":"hi"}"#, ); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("未知字段必须拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed); } @@ -172,8 +166,7 @@ fn unknown_key_is_rejected() { "stable.json.sig", signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(), ); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("不认识的钥必须拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureUnknownKey); assert!( @@ -194,8 +187,7 @@ fn key_id_spoofing_does_not_help() { "stable.json.sig", signature_envelope_with(ATTACKER_SEED, TEST_KEY_ID, &json).as_bytes(), ); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("key_id 撞名不能替代公钥"); assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); } @@ -211,9 +203,8 @@ fn multi_signature_accepts_any_pinned_key() { 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("有一条能过就放行"); + let manifest = + verified_manifest(&DirSource::new(dir.path()), Channel::Stable).expect("有一条能过就放行"); assert!(manifest.is_some()); } @@ -280,7 +271,7 @@ fn no_runtime_keyring_override() { "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)); + let attacker_public = public_key_hex(ATTACKER_SEED); dir.write( "keyring.json", format!(r#"{{"keys":[{{"key_id":"attacker-key","public_key_hex":"{attacker_public}"}}]}}"#) @@ -290,7 +281,7 @@ fn no_runtime_keyring_override() { 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); + let outcome = verified_manifest(&DirSource::new(dir.path()), Channel::Stable); std::env::remove_var("ULL_UPDATE_KEYRING"); std::env::remove_var("ULL_UPDATE_KEYRING_FILE"); let error = outcome.expect_err("旁边的信任表文件不许生效"); diff --git a/crates/core/src/update/tests/source_cases.rs b/crates/core/src/update/tests/source_cases.rs index f31b416..bacdd97 100644 --- a/crates/core/src/update/tests/source_cases.rs +++ b/crates/core/src/update/tests/source_cases.rs @@ -3,7 +3,7 @@ use std::fs; use crate::update::manifest::ReleaseManifest; -use crate::update::{Channel, DirSource, UpdateErrorKind, UpdateSource}; +use crate::update::{verified_manifest, Channel, DirSource, UpdateErrorKind, UpdateSource}; use super::support::{artifact, manifest, release, TempDir}; @@ -17,9 +17,7 @@ fn publish(tmp: &TempDir, channel: Channel, json: &str) { fn missing_channel_manifest_is_not_an_error() { let tmp = TempDir::new("source-missing"); let source = DirSource::new(tmp.path()); - let manifest = source - .fetch_manifest(Channel::Stable) - .expect("缺清单不该报错"); + let manifest = verified_manifest(&source, Channel::Stable).expect("缺清单不该报错"); assert!(manifest.is_none(), "还没发过版 = Ok(None)"); } @@ -36,8 +34,7 @@ fn reads_and_validates_the_channel_manifest() { ); publish(&tmp, Channel::Stable, &json); let source = DirSource::new(tmp.path()); - let parsed = source - .fetch_manifest(Channel::Stable) + let parsed = verified_manifest(&source, Channel::Stable) .expect("应取到清单") .expect("清单存在"); assert_eq!(parsed.channel, Channel::Stable); @@ -52,8 +49,7 @@ fn channel_mismatch_is_an_error() { let tmp = TempDir::new("source-mismatch"); // 清单自称 beta,却躺在 stable.json 里 publish(&tmp, Channel::Stable, &manifest("beta", &[])); - let error = DirSource::new(tmp.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(tmp.path()), Channel::Stable) .expect_err("渠道不符必须报错"); assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch); } @@ -62,8 +58,7 @@ fn channel_mismatch_is_an_error() { fn broken_manifest_json_is_reported_with_path() { let tmp = TempDir::new("source-broken"); publish(&tmp, Channel::Stable, "{ not json"); - let error = DirSource::new(tmp.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(tmp.path()), Channel::Stable) .expect_err("坏 JSON 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); assert!( diff --git a/crates/core/src/update/tests/support.rs b/crates/core/src/update/tests/support.rs index 2047197..c725912 100644 --- a/crates/core/src/update/tests/support.rs +++ b/crates/core/src/update/tests/support.rs @@ -37,19 +37,6 @@ 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); @@ -226,59 +213,6 @@ 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 前缀只在该是后一种时生效。 diff --git a/crates/core/src/update/tests/support_sign.rs b/crates/core/src/update/tests/support_sign.rs new file mode 100644 index 0000000..d79c844 --- /dev/null +++ b/crates/core/src/update/tests/support_sign.rs @@ -0,0 +1,94 @@ +//! 清单签名的**测试夹具**:用测试钥签出 `manifest.json.sig` 的信封(M190)。 +//! +//! 为什么单独一个文件:`support.rs` 是 update 测试的通用夹具(临时目录、假 HTTP、清单片段), +//! 已经贴着 400 行红线;签名这一摊只被 `signature_cases` / `github_cases` 用到,搬出来两清。 +//! +//! 签名走 `ed25519-dalek` 的 `SigningKey`(与仓里验签用的 `verify_strict` 同一棵树),而**不是** +//! 自写实现——M190 r1 复审把自写曲线算术整体删掉了,这里也不留第二条算法。测试钥的种子是公开的 +//! (见下),它只对 `cfg(test)` 与 `update-test-source` 构建有意义。 + +use super::support::TempDir; + +/// 测试签名钥的 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"; + +/// 字节转小写十六进制。 +pub(crate) fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// 用测试钥签一份清单,返回 `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 { + signature_entry_from_bytes( + key_id, + &sign_bytes(seed, manifest_text.as_bytes()), + 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) + ) +} + +/// 用 32 字节种子签一段字节(dalek 的 `SigningKey`;测试专用,正式构建里没有签名代码)。 +pub(crate) fn sign_bytes(seed: &[u8; 32], message: &[u8]) -> [u8; 64] { + use ed25519_dalek::{Signer, SigningKey}; + let key = SigningKey::from_bytes(seed); + key.sign(message).to_bytes() +} + +/// 种子对应的公钥(用来让「钉住的公钥」与「签名的钥」对得上——漂了就当场红)。 +pub(crate) fn public_key_hex(seed: &[u8; 32]) -> String { + use ed25519_dalek::SigningKey; + hex_of(SigningKey::from_bytes(seed).verifying_key().as_bytes()) +} + +impl TempDir { + /// 写一份清单**连它的签名**(`` + `.sig`),返回清单路径。 + /// + /// M190 起「有清单没签名」是被拒的一档,所以夹具一律成对落盘:只写清单的用例都是为了 + /// 证明「缺签名会被拒」,它们自己显式 `write` 清单即可。 + pub(crate) fn write_signed(&self, name: &str, manifest_text: &str) -> std::path::PathBuf { + let path = self.write(name, manifest_text.as_bytes()); + self.write( + &format!("{name}.sig"), + signature_envelope(manifest_text).as_bytes(), + ); + path + } +} diff --git a/packaging/check_workflows_yaml.py b/packaging/check_workflows_yaml.py new file mode 100644 index 0000000..c0c565c --- /dev/null +++ b/packaging/check_workflows_yaml.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Parse every workflow file with a **real YAML parser** (M190 r1 复审 P1-1). + +Why this exists: a raw 0x01 byte slipped into `.github/workflows/release-build.yml` (a `sed` replacement +string that should have been `\\1`). GitHub refuses to parse such a file, so the whole run would not even +start — and no other guard noticed, because they all read workflow files as *text* (`check_release_semantics.py` +does `yaml_scalars()`, the file-size job only counts lines). A text scanner cannot see a control byte that +a YAML 1.2 reader rejects outright. + +So: hand every `.github/workflows/*.yml` to PyYAML. Two checks, both cheap: + ① it parses (real error message with position, not a mystery red); + ② no C0 control character other than TAB/LF/CR is present (YAML 1.2 forbids them; parsers differ in + whether they reject, so this is the belt-and-braces half). + +PyYAML is preinstalled on GitHub-hosted runners; if it is not, this script installs it. If that fails too +the gate **fails** rather than degrading to a text scan ("工具缺失也 fail" — same rule as the other guards). + + python3 packaging/check_workflows_yaml.py + python3 packaging/check_workflows_yaml.py --self-test +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +WORKFLOW_DIR = Path(__file__).resolve().parent.parent / ".github/workflows" +ALLOWED_CONTROL = {0x09, 0x0A, 0x0D} + + +def load_yaml(): + """Import PyYAML, installing it once if the runner lacks it.""" + try: + import yaml # noqa: PLC0415 + + return yaml + except ImportError: + print("PyYAML 缺失,尝试安装一次(pip install --quiet pyyaml)…", file=sys.stderr) + done = subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet", "pyyaml"], + capture_output=True, + text=True, + check=False, + ) + if done.returncode != 0: + print( + "error: 装不上 PyYAML,YAML 门禁无法运行(门禁不降级)。输出:\n" + done.stdout + done.stderr, + file=sys.stderr, + ) + raise SystemExit(2) from None + import yaml # noqa: PLC0415 + + return yaml + + +def control_characters(data: bytes) -> list[tuple[int, int]]: + """`[(位置, 字节)]`:YAML 1.2 不许出现的 C0 控制字符(TAB/LF/CR 除外)。""" + return [(index, byte) for index, byte in enumerate(data) if byte < 0x20 and byte not in ALLOWED_CONTROL] + + +def check_file(path: Path, yaml) -> list[str]: + """返回该文件没过的判据(空 = 通过)。""" + failures: list[str] = [] + data = path.read_bytes() + for index, byte in control_characters(data): + line = data[:index].count(b"\n") + 1 + failures.append( + f"::error file={path},line={line}::第 {line} 行含裸控制字符 {byte:#04x}(YAML 1.2 禁止;" + "常见成因是 sed/printf 把 `\\1` 写成了 0x01)" + ) + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + failures.append(f"::error file={path}::不是合法 UTF-8({error})") + return failures + try: + yaml.safe_load(text) + except Exception as error: # noqa: BLE001 - 解析器的异常类型随版本变,这里只关心「没通过」 + first = str(error).splitlines()[0] if str(error) else repr(error) + failures.append(f"::error file={path}::YAML 解析失败:{first}") + return failures + + +def self_test() -> int: + """注入一枚 0x01 到临时副本,断言门禁**会红**(否则这条门禁对任何输入都绿)。""" + import tempfile + + yaml = load_yaml() + failures = 0 + sample = "name: x\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: |\n echo hi\n" + with tempfile.TemporaryDirectory() as tmp: + good = Path(tmp) / "good.yml" + good.write_text(sample, encoding="utf-8") + if check_file(good, yaml): + print("FAIL: 合法 YAML 被判红(误杀)", file=sys.stderr) + failures += 1 + bad = Path(tmp) / "bad.yml" + bad.write_bytes(sample.replace('"/\\1/p"', "").encode("utf-8") + b'x\n v=$(s/"' + bytes([1]) + b'/p)\n') + if not check_file(bad, yaml): + print("FAIL: 含裸 0x01 的 YAML 竟然通过(判据失效)", file=sys.stderr) + failures += 1 + # 只让 PyYAML 判的那一半也要能红:把控制字节换成合法字符,但结构坏掉。 + broken = Path(tmp) / "broken.yml" + broken.write_text("a: [1, 2\nb: 3\n", encoding="utf-8") + if not check_file(broken, yaml): + print("FAIL: 结构坏的 YAML 竟然通过", 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="用真 YAML 解析器 parse 全部 workflow(M190 r1 P1-1)") + parser.add_argument("--self-test", action="store_true", help="自检:注入 0x01 / 坏结构必须红") + parser.add_argument("--dir", type=Path, default=WORKFLOW_DIR, help="workflow 目录(默认 .github/workflows)") + args = parser.parse_args(argv) + + if args.self_test: + return self_test() + + yaml = load_yaml() + files = sorted(args.dir.glob("*.yml")) + sorted(args.dir.glob("*.yaml")) + if not files: + print(f"error: {args.dir} 里一个 workflow 都没有(路径错了?门禁不降级)", file=sys.stderr) + return 2 + + failures: list[str] = [] + for path in files: + failures.extend(check_file(path, yaml)) + + for line in failures: + print(line, file=sys.stderr) + if failures: + print(f"FAIL:{len(failures)} 条判据没过({len(files)} 个文件)", file=sys.stderr) + return 1 + print(f"ok: {len(files)} 个 workflow 全部能被真 YAML 解析器 parse,且无裸控制字符") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/docs/signing.md b/packaging/docs/signing.md index c2d5a7b..3386f2e 100644 --- a/packaging/docs/signing.md +++ b/packaging/docs/signing.md @@ -77,12 +77,15 @@ python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a 在 `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 +python3 packaging/sign_manifest.py --self-test # ① RFC 8032 向量自检 +python3 packaging/sign_manifest.py --check-pinned-keyring # ② 密钥 ↔ trust.rs 必须对得上 +python3 packaging/sign_manifest.py --sign dist/manifest.json # ③ 产出 dist/manifest.json.sig +python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # ④ 用钉住的公钥回验产出 ``` +第 ④ 步复现「客户端会怎么判」:它**不碰私钥**,只拿 `trust.rs` 里那把公钥去验刚落盘的 `.sig`。 +因此「签名脚本自己说签好了」不构成证据,证据是「按客户端的判据能验过」。 + 四条命令任何一条非零退出 ⇒ **这一步红 ⇒ 整个 Release job 红 ⇒ 本次不出 Release**。常见红法: | 现象 | 原因 | 怎么补 | @@ -124,7 +127,19 @@ python3 packaging/sign_manifest.py --verify dist/manifest.json dist/manifest.jso 另外 CI 的构建槽位会跑 `check_pe_version.py`(`release-build.yml`):Windows 槽位上直接读 `target/release/unlucky-launcher-iced.exe` 与 `ull.exe` 的字节,断言 `CompanyName` 等字符串在。 -## 7. 本地自测(不需要私钥) +## 7. 实现分工(M190 r1 复审后) + +| 哪一半 | 在哪 | 用什么 | +|---|---|---| +| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(拒非规范编码 / 小阶点 / 非规范 `S`) | +| **签名**(只在发布侧) | `packaging/ed25519_ref.py` + `packaging/sign_manifest.py` | 纯 Python 参考实现,零第三方依赖(CI 不装 pip 包) | + +两套实现互相独立,`--self-test` 用 RFC 8032 §7.1 的同一组向量把两边各自钉住。**第一版曾把验签写成 +仓内自写**(5x51 素域 + 扩展坐标);r1 复审裁定不安全——信任锚的微妙错误后果是「验签通过但本不该通过」, +因此整段删除,改为官方实现。`crates/core/src/update/tests/ed25519_cases.rs` 现在钉的是 +**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`(不做小阶/可锻检查),那批用例当场红。 + +## 8. 本地自测(不需要私钥) ```bash python3 packaging/sign_manifest.py --self-test # RFC 8032 向量 + 标记解析 diff --git a/packaging/ed25519_ref.py b/packaging/ed25519_ref.py new file mode 100644 index 0000000..dea1ca5 --- /dev/null +++ b/packaging/ed25519_ref.py @@ -0,0 +1,142 @@ +"""Ed25519 的**参考实现**(RFC 8032),只给发布侧签名脚本用(M190)。 + +为什么这里自写一套:发布侧签名必须**零第三方依赖**(CI 不装 pip 包),而仓里 `crates/core` 的验签 +用的是 `ed25519-dalek`。两套实现互相独立,`sign_manifest.py --self-test` 用 RFC 8032 §7.1 的同一组 +向量把两边各自钉住 —— 任何一边坏了,向量那一半会红;两边都「一致地错」的概率极低。 + +⚠ 这里的私钥路径(`sign`)**只在发布侧**(`packaging/sign_manifest.py --sign`)跑,不进任何产物; +二进制里只有验签(`ed25519-dalek::verify_strict`)。扩展坐标公式与 dalek/ref10 同源,向量自检兜底。 + + import ed25519_ref as ref + seed = bytes.fromhex("…") # 32 字节种子 + pk = ref.public_key(seed) + sig = ref.sign(seed, b"message") # 64 字节 + assert ref.verify(pk, b"message", sig) +""" + +from __future__ import annotations + +import hashlib + +ALG = "ed25519" + +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))) + + + diff --git a/packaging/sign_manifest.py b/packaging/sign_manifest.py index 5686dc8..6e65508 100644 --- a/packaging/sign_manifest.py +++ b/packaging/sign_manifest.py @@ -13,10 +13,9 @@ Key discipline: the **private key** lives only in the repository Secret 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 +Usage: `--generate-key [--key-id K]` / `--sign dist/manifest.json`(钥从环境变量 +`ULL_MANIFEST_SIGNING_KEY` 取)/ `--check-pinned-keyring`(钥必须已钉住)/ `--verify-pinned +dist/manifest.json`(用 trust.rs 里钉的公钥回验产出,复现客户端判据)/ `--self-test`。 """ from __future__ import annotations @@ -41,125 +40,12 @@ KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{ # `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))) - +# Ed25519 的参考实现搬到了 `packaging/ed25519_ref.py`(M190 r1:本文件贴着 400 行红线)。 +# 目的与路径都写在那边的文件头:发布侧签名必须零第三方依赖,而仓里验签用的是 ed25519-dalek —— +# 两套实现互相独立,`--self-test` 用 RFC 8032 的同一组向量把两边各自钉住。 +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from ed25519_ref import ALG, sign, verify, public_key # noqa: E402 def load_key(text: str) -> tuple[str, bytes]: """Parse the secret JSON (`{"key_id":…,"seed_hex":…}`) or a bare 64-char hex seed.""" @@ -313,6 +199,36 @@ def do_check_pinned(key_path: str | None) -> int: print("error: 手册:packaging/docs/signing.md §3/§5", file=sys.stderr) return 1 +def do_verify_pinned(manifest: Path) -> int: + """用 trust.rs 里钉住的公钥回验 `.sig` —— 复现「客户端会怎么判」,不需要私钥。""" + keys = pinned_keys(TRUST_RS.read_text(encoding="utf-8")) + if not keys: + print("error: 信任表是空的,无从回验(先钉公钥,见 --check-pinned-keyring)", file=sys.stderr) + return 1 + sig_path = manifest.with_name(manifest.name + SIG_SUFFIX) + if not sig_path.is_file(): + print(f"error: 没有签名文件 {sig_path}", file=sys.stderr) + return 1 + raw = manifest.read_bytes() + envelope = json.loads(sig_path.read_text(encoding="utf-8")) + for item in envelope.get("signatures", []): + if item.get("alg") != ALG: + continue + try: + signature = bytes.fromhex(item.get("sig", "")) + except ValueError: + continue + for pinned_id, pinned_hex in keys: + if verify(bytes.fromhex(pinned_hex), raw, signature): + print(f"ok: {sig_path.name} 用钉住的公钥 {pinned_id} 能验过(客户端会接受)") + return 0 + print( + "error: 拿 trust.rs 里钉住的公钥回验不过 —— 这份 Release 的用户会全部拒装。别发版", + file=sys.stderr, + ) + return 1 + + def do_self_test() -> int: vectors = [ ("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", @@ -360,6 +276,7 @@ def main(argv: list[str] | None = None) -> int: 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("--verify-pinned", metavar="MANIFEST", type=Path, help="用 trust.rs 里钉的公钥回验产出") parser.add_argument("--self-test", action="store_true", help="跑 RFC 8032 向量与解析器自检") args = parser.parse_args(argv) @@ -376,6 +293,8 @@ def main(argv: list[str] | None = None) -> int: 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) + if args.verify_pinned: + return do_verify_pinned(args.verify_pinned) parser.print_help() return 2