Merge branch 'feat/m190-signing-publisher-metadata-and-mani'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-09 02:42:28 +08:00
commit 393d6f188f
40 files changed
+2729 -131

No files matched your search

+19
View File
@@ -212,6 +212,25 @@ jobs:
ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true
fi 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 = "\(.*\)"/\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"
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL) - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: bash shell: bash
+16
View File
@@ -68,3 +68,19 @@ jobs:
run: python3 packaging/check_test_source_off.py --self-test run: python3 packaging/check_test_source_off.py --self-test
- name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开) - name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开)
run: python3 packaging/check_test_source_off.py 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
# 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
+40 -1
View File
@@ -27,6 +27,14 @@ on:
PUBLISH_TOKEN: PUBLISH_TOKEN:
description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)" description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)"
required: true 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)。 # 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。
permissions: permissions:
@@ -131,6 +139,35 @@ jobs:
--asset-base-url "$RELEASE_BASE/$TAG" \ --asset-base-url "$RELEASE_BASE/$TAG" \
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --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
# ④ 产出后**用 trust.rs 里钉住的公钥回验一遍**:复现「客户端会怎么判」,而不是信签名脚本
# 自己那句「签好了」。私钥不需要参与,所以这一步跑在同一个环境里也不多一个泄露面。
"$PY" packaging/sign_manifest.py --verify-pinned dist/manifest.json
ls -l dist/manifest.json dist/manifest.json.sig
- name: 生成 Release 说明(支持矩阵模板) - name: 生成 Release 说明(支持矩阵模板)
shell: bash shell: bash
env: env:
@@ -172,7 +209,9 @@ jobs:
echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})" echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})"
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。 # Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。 # 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 if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。 # 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber gh release upload "$TAG" "${assets[@]}" --clobber
+3
View File
@@ -150,3 +150,6 @@ jobs:
secrets: secrets:
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。 # 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# (M190)清单签名私钥:被调方 release-publish.yml 用它签 `manifest.json`。没配时是空串,
# 签名步会带着「缺什么、怎么补」的注解当场红(宁可不出 Release,也不出未签名的 Release)。
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
Generated
+160
View File
@@ -368,6 +368,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -660,6 +666,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.11.0" version = "0.11.0"
@@ -846,6 +858,33 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" 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]] [[package]]
name = "darling" name = "darling"
version = "0.24.1" version = "0.24.1"
@@ -892,6 +931,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" 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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -977,6 +1026,30 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 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]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.42" version = "0.8.42"
@@ -1094,6 +1167,12 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.29" version = "0.2.29"
@@ -3087,6 +3166,16 @@ dependencies = [
"futures-io", "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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -3332,6 +3421,15 @@ dependencies = [
"rand_core 0.9.5", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -4125,6 +4223,15 @@ dependencies = [
"syn 3.0.9", "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]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -4188,6 +4295,15 @@ dependencies = [
"libc", "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]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -4388,6 +4504,16 @@ dependencies = [
"bitflags 2.13.2", "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]] [[package]]
name = "sse-stream" name = "sse-stream"
version = "0.2.6" version = "0.2.6"
@@ -4738,6 +4864,21 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.2+spec-1.1.0" version = "1.1.2+spec-1.1.0"
@@ -4768,6 +4909,12 @@ dependencies = [
"winnow", "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]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -5007,6 +5154,7 @@ dependencies = [
"unlucky-design", "unlucky-design",
"unlucky-i18n", "unlucky-i18n",
"unlucky-presenter", "unlucky-presenter",
"winresource",
] ]
[[package]] [[package]]
@@ -5018,12 +5166,14 @@ dependencies = [
"tokio", "tokio",
"unlucky-agent", "unlucky-agent",
"unlucky-core", "unlucky-core",
"winresource",
] ]
[[package]] [[package]]
name = "unlucky-core" name = "unlucky-core"
version = "0.2.3" version = "0.2.3"
dependencies = [ dependencies = [
"ed25519-dalek",
"flate2", "flate2",
"futures-util", "futures-util",
"md-5", "md-5",
@@ -5968,6 +6118,16 @@ dependencies = [
"memchr", "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]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
+7
View File
@@ -71,6 +71,13 @@ md-5 = "0.10"
# 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。 # 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。
zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] } 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`, # M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`,
# 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。 # 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。
flate2 = "1" flate2 = "1"
+6
View File
@@ -100,3 +100,9 @@ update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/updat
[lints] [lints]
workspace = true workspace = true
# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。
# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回),
# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。
[build-dependencies]
winresource = "0.1"
+14
View File
@@ -0,0 +1,14 @@
//! `unlucky-launcher-iced` 的 PE 版本资源(M190)。
//!
//! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的
//! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。
//!
//! ⚠ 本文件**只**写版本资源,不碰子系统:PE 的 `Subsystem` 由 rustc 的
//! `windows_subsystem` 属性决定(本分支上还没有那个属性,是 M196 的事),版本资源与子系统是两件事。
//! 判据:`packaging/check_pe_version.py` 会把它读到的 `subsystem` 打出来,加资源前后必须一致。
include!("../../packaging/pe_version.rs");
fn main() {
emit_pe_version_resource("unlucky-launcher-iced");
}
+6
View File
@@ -38,3 +38,9 @@ workspace = true
# M169:转发 core 的测试通道(`--features update-test-source` 才编进来,默认关)。 # M169:转发 core 的测试通道(`--features update-test-source` 才编进来,默认关)。
# `ull update check/apply` 的端到端验收要靠它指向本地目录源。 # `ull update check/apply` 的端到端验收要靠它指向本地目录源。
update-test-source = ["unlucky-core/update-test-source"] 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"
+10
View File
@@ -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");
}
+9 -1
View File
@@ -77,7 +77,15 @@ fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
/// **底层原因**(`digest_mismatch` / `network` / `invalid_manifest` / `io` …,据此决定「重试有没有意义」)。 /// **底层原因**(`digest_mismatch` / `network` / `invalid_manifest` / `io` …,据此决定「重试有没有意义」)。
/// 以前原因只写在人读 message 里,脚本不解析散文就分不出来。 /// 以前原因只写在人读 message 里,脚本不解析散文就分不出来。
fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError { 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 { } else {
"这不是瞬时失败:清单或版本号本身有问题,等发布侧修" "这不是瞬时失败:清单或版本号本身有问题,等发布侧修"
+1
View File
@@ -4,6 +4,7 @@
#![allow(dead_code)] #![allow(dead_code)]
pub mod update_e2e; pub mod update_e2e;
pub mod update_e2e_sign;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
+6 -5
View File
@@ -27,6 +27,7 @@
use std::path::{Path, PathBuf}; 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}; use unlucky_core::update::{HashAlgo, OsFamily, Target, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV};
/// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。 /// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。
@@ -258,11 +259,11 @@ impl Fixture {
/// 发一版,摘要/长度由调用方给(篡改用例用)。 /// 发一版,摘要/长度由调用方给(篡改用例用)。
pub fn publish(&self, sha256: &str, size: u64) { 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(&self.asset), &self.payload).expect("写产物");
std::fs::write( let path = self.dist.path().join("stable.json");
self.dist.path().join("stable.json"), std::fs::write(&path, manifest(&self.version, &self.asset, size, sha256)).expect("写清单");
manifest(&self.version, &self.asset, size, sha256), // M190:清单与签名成对落盘。篡改用例给的是「错的 sha/size」,签的是**改过之后**那份清单
) // —— 那正是「清单本身可信、包体不对」的现场(摘要判据要独立于签名判据被证到)。
.expect("写清单"); write_signature(&path);
} }
/// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。 /// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。
@@ -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"
);
}
+2
View File
@@ -26,6 +26,8 @@ futures-util.workspace = true
sha1.workspace = true sha1.workspace = true
sha2.workspace = true sha2.workspace = true
zip.workspace = true zip.workspace = true
# M190(core::update::ed25519):清单签名验签(官方实现,见 workspace 依赖处的取舍说明)。
ed25519-dalek.workspace = true
# M31(core::auth):离线 UUID = MD5 v3(与 HMCL / 服务端 nameUUIDFromBytes 对拍)。 # M31(core::auth):离线 UUID = MD5 v3(与 HMCL / 服务端 nameUUIDFromBytes 对拍)。
md-5.workspace = true md-5.workspace = true
# M33(core::game::java)预接线:tar.gz 解压(Temurin 的 Linux/macOS 产物)。 # M33(core::game::java)预接线:tar.gz 解压(Temurin 的 Linux/macOS 产物)。
+1 -1
View File
@@ -132,7 +132,7 @@ impl<S: UpdateSource> UpdateChecker<S> {
/// 清单取不到、清单不合法、渠道不符都返回 [`UpdateError`]。 /// 清单取不到、清单不合法、渠道不符都返回 [`UpdateError`]。
/// 「没有更新」不是错误,走 [`CheckOutcome::UpToDate`]。 /// 「没有更新」不是错误,走 [`CheckOutcome::UpToDate`]。
pub fn check(&self, current: &Version, channel: Channel) -> Result<CheckOutcome, UpdateError> { pub fn check(&self, current: &Version, channel: Channel) -> Result<CheckOutcome, UpdateError> {
let Some(manifest) = self.source.fetch_manifest(channel)? else { let Some(manifest) = super::source::verified_manifest(&self.source, channel)? else {
return Ok(CheckOutcome::UpToDate { return Ok(CheckOutcome::UpToDate {
current: current.clone(), current: current.clone(),
latest: None, latest: None,
+52
View File
@@ -0,0 +1,52 @@
//! Ed25519 **严格验签**(`ed25519-dalek::verify_strict`),只验不签、只用公开数据。
//!
//! # 为什么是官方实现,而不是本仓自写
//!
//! M190 第一版为了「scope 里不含 `crates/core/Cargo.toml`」把曲线算术写在了仓里
//! (5x51 素域 + 扩展坐标 + 平方-乘)。r1 复审的裁定是**不允许**:这是**信任锚**,
//! 非规范编码、小阶点这类微妙错误的后果是「验签通过但本不该通过」,自写的向量覆盖再全也证明不了
//! 「严格性判据选对了」。当时的时机也最好:正式信任表(`trust::PRODUCTION`)还是空表,换实现
//! **零兼容负担**。于是这里只剩一层薄封装。
//!
//! # 判据分工(**实测口径**,`ed25519-dalek 2.2.0`;别照抄「常识」)
//!
//! | 步骤 | 它实际做什么 | 它**不**做什么 |
//! |---|---|---|
//! | `VerifyingKey::from_bytes` | 解压成曲线上的合法点;解不出来才 `Err` | **不**校验编码规范性:`y >= p` 这种第二种写法照样 `Ok`(此时 `is_weak() == true`)。M190 r2 复审实测并纠正了先前「要求规范编码」的说法 |
//! | `VerifyingKey::verify`(**非 strict**,本仓不用) | 验方程 `[S]B = R + [k]A`,并检查 `S` 是**规范标量**(`S >= L` 会拒) | **不**检查小阶点:小阶公钥 / 小阶 `R` 下「自洽的伪造四元组」能验过 |
//! | `VerifyingKey::verify_strict`(**本仓用的这个**) | 上面全部 + 拒**小阶公钥**与**小阶 `R`** | —— |
//!
//! 换成大白话:在这批拒绝类用例里,strict 与**非** strict 唯一的分歧是**小阶点**(不是可锻性——
//! `verify` 自己就查 `S` 规范性)。所以守卫里真正承重的是
//! `tests/ed25519_cases.rs::small_order_public_key_is_rejected` 与
//! `small_order_r_is_rejected_only_by_strict` 这两条;`malleable_s_plus_l_is_rejected` 等其余几条在
//! 非 strict 下**照样绿**,它们钉的是「判据没被删成恒真」,不要误当成 strict 的守卫。
//!
//! # 签名侧不在二进制里
//!
//! 本仓没有任何私钥代码:清单由 `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()
}
+36
View File
@@ -33,6 +33,21 @@ pub enum UpdateErrorKind {
DigestMismatch, DigestMismatch,
/// 长度不符:典型成因是下载被截断,也可能清单写错。 /// 长度不符:典型成因是下载被截断,也可能清单写错。
SizeMismatch, SizeMismatch,
/// 清单没有配套签名文件(`<清单名>.sig` 不在源里)。
///
/// 与 [`Self::SignatureInvalid`] 分开:这条是**发布侧漏了签名**,不是清单被改;
/// 处置也不同(等发布侧补,或回滚发布),所以必须是两个分类。
SignatureMissing,
/// 签名文件本身坏了:不是 JSON、字段名拼错、`format` 不认识、`signatures` 是空的。
SignatureMalformed,
/// 本构建没有钉任何清单签名公钥(正式信任表为空)。
///
/// 这是「构建形态」的问题,不是清单的问题:空表一律拒装,绝不「没钥就放行」。
KeyringEmpty,
/// 清单的签名者不在钉住的信任表里(`key_id` 一个都没命中)。
SignatureUnknownKey,
/// 签名对不上清单字节(清单被改过,或签名是给另一份清单的)。
SignatureInvalid,
} }
impl UpdateErrorKind { impl UpdateErrorKind {
@@ -49,6 +64,11 @@ impl UpdateErrorKind {
Self::Io => "io", Self::Io => "io",
Self::DigestMismatch => "digest_mismatch", Self::DigestMismatch => "digest_mismatch",
Self::SizeMismatch => "size_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(可能是瞬时占用)算可重试。 /// (摘要/长度)、网络(可能是瞬时抖动)与 IO(可能是瞬时占用)算可重试。
///
/// 签名五兄弟**一律不可重试**:签名不对不是抖动,重试一千次还是不对。把它们标成可重试
/// 会让壳层反复弹「网络问题,稍后重试」,掩盖真正的原因(发布侧漏签 / 钥没钉 / 清单被改)。
#[must_use] #[must_use]
pub const fn is_retryable(self) -> bool { pub const fn is_retryable(self) -> bool {
matches!( matches!(
@@ -63,6 +86,19 @@ impl UpdateErrorKind {
Self::DigestMismatch | Self::SizeMismatch | Self::Network | Self::Io 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
)
}
} }
/// 自更新错误。 /// 自更新错误。
+17 -18
View File
@@ -60,8 +60,8 @@ use crate::download::{
use super::channel::Channel; use super::channel::Channel;
use super::error::{UpdateError, UpdateErrorKind}; use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::{Artifact, ReleaseManifest}; use super::manifest::Artifact;
use super::source::UpdateSource; use super::source::{ManifestDocuments, UpdateSource};
/// 默认仓库 owner。 /// 默认仓库 owner。
pub const DEFAULT_OWNER: &str = "AskaEth"; 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"; pub const DEFAULT_BASE: &str = "https://github.com";
/// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。 /// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。
pub const MANIFEST_ASSET: &str = "manifest.json"; pub const MANIFEST_ASSET: &str = "manifest.json";
/// 清单签名的资产名(M190:与清单并列挂在同一个 release 上)。
pub const MANIFEST_SIGNATURE_ASSET: &str = "manifest.json.sig";
/// GitHub Releases 更新源。 /// GitHub Releases 更新源。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -265,27 +267,24 @@ impl UpdateSource for GitHubReleaseSource {
&self.id &self.id
} }
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> { fn fetch_manifest_documents(
&self,
channel: Channel,
) -> Result<Option<ManifestDocuments>, UpdateError> {
let Some(url) = self.manifest_url(channel) else { let Some(url) = self.manifest_url(channel) else {
return Ok(None); return Ok(None);
}; };
let Some(text) = self.fetch_text(&url)? else { let Some(manifest) = self.fetch_text(&url)? else {
return Ok(None); return Ok(None);
}; };
let manifest = ReleaseManifest::from_json(&text).map_err(|mut error| { // 签名文件的 404 与清单的 404 处理不同:清单没有 = 该渠道没发过版(不是错误);
error.message = format!("{}(来源 {url})", error.message); // 签名没有 = 发布侧漏了签名(上层判 SignatureMissing 并拒装)。
error let signature_url = format!("{}/{MANIFEST_SIGNATURE_ASSET}", self.download_base());
})?; let signatures = self.fetch_text(&signature_url)?;
if manifest.channel != channel { Ok(Some(ManifestDocuments {
return Err(UpdateError::new( manifest,
UpdateErrorKind::ChannelMismatch, signatures,
format!( }))
"请求的是 {channel} 渠道,清单 {url} 自报 {}",
manifest.channel
),
));
}
Ok(Some(manifest))
} }
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> { fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
+3 -3
View File
@@ -227,11 +227,11 @@ impl UpdateSource for SourceRef<'_> {
self.0.source_id() self.0.source_id()
} }
fn fetch_manifest( fn fetch_manifest_documents(
&self, &self,
channel: Channel, channel: Channel,
) -> Result<Option<super::manifest::ReleaseManifest>, UpdateError> { ) -> Result<Option<super::source::ManifestDocuments>, UpdateError> {
self.0.fetch_manifest(channel) self.0.fetch_manifest_documents(channel)
} }
fn fetch_artifact( fn fetch_artifact(
+19 -1
View File
@@ -30,6 +30,8 @@
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态 //! manifest 清单模型 + JSON 校验 target 目标平台与包形态
//! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50) //! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50)
//! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急 //! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急
//! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring)
//! ed25519 Ed25519 严格验签(ed25519-dalek::verify_strict 的薄封装)
//! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点) //! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点)
//! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) //! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口)
//! ``` //! ```
@@ -49,17 +51,32 @@
//! `--apply-to`),属启动器外壳的编排;本模块只保证搬运本身原子且可回滚。 //! `--apply-to`),属启动器外壳的编排;本模块只保证搬运本身原子且可回滚。
//! * **不装安装器形态**(msi/dmg/deb/rpm):交给系统包管理器。 //! * **不装安装器形态**(msi/dmg/deb/rpm):交给系统包管理器。
//! * **不做差分/增量**:清单里没有 patch 字段,宁可每次全量,也别先上一套没验过的差分协议。 //! * **不做差分/增量**:清单里没有 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 channel;
pub mod checker; pub mod checker;
mod ed25519;
pub mod error; pub mod error;
pub mod github; pub mod github;
pub mod install; pub mod install;
pub mod job; pub mod job;
pub mod manifest; pub mod manifest;
mod replace_io; mod replace_io;
mod signature;
pub mod source; pub mod source;
pub mod target; pub mod target;
mod trust;
pub mod verify; pub mod verify;
pub mod version; 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, 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::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::target::{Arch, PackageFormat, Target};
pub use self::verify::{stage, verify_file, StagedPackage}; pub use self::verify::{stage, verify_file, StagedPackage};
pub use self::version::{Stage, Version, VersionError}; pub use self::version::{Stage, Version, VersionError};
+256
View File
@@ -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<EnvelopeSignature>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EnvelopeSignature {
alg: String,
key_id: String,
sig: String,
manifest_sha256: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawEnvelope {
format: u32,
#[serde(default)]
signatures: Vec<RawEnvelopeSignature>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawEnvelopeSignature {
alg: String,
key_id: String,
sig: String,
manifest_sha256: Option<String>,
}
/// 验一份清单的签名。
///
/// # 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<VerifiedSignature, UpdateError> {
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::<Vec<_>>()
.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<Envelope, UpdateError> {
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}…")
}
+94 -21
View File
@@ -11,6 +11,7 @@
//! //!
//! ```text //! ```text
//! <root>/stable.json 渠道清单(文件名 = 渠道名 + .json) //! <root>/stable.json 渠道清单(文件名 = 渠道名 + .json)
//! <root>/stable.json.sig 清单签名信封(M190;缺了它清单一律拒装,见 `signature`)
//! <root>/beta.json //! <root>/beta.json
//! <root>/<产物文件> 清单里 url 写相对路径时,相对 root //! <root>/<产物文件> 清单里 url 写相对路径时,相对 root
//! ``` //! ```
@@ -26,6 +27,19 @@ use super::channel::Channel;
use super::error::{UpdateError, UpdateErrorKind}; use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::{Artifact, ReleaseManifest}; use super::manifest::{Artifact, ReleaseManifest};
/// 源交出来的「清单两份原文」:清单本身 + 配套签名信封(M190)。
///
/// 刻意是**原文**而不是解析后的结构:签名只能覆盖字节,先解析再验签就等于给「解析器认了、
/// 签名没认」的东西开了门。判据顺序因此固定为「先验签、再解析」,两者都在
/// [`verified_manifest`] 里,源实现自己不做任何验签。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestDocuments {
/// 清单原文(`manifest.json` / `<channel>.json` 的字节,按 UTF-8 解释)。
pub manifest: String,
/// 签名信封原文(`<清单名>.sig`);`None` = 源里没有这份文件(会被判 [`UpdateErrorKind::SignatureMissing`])。
pub signatures: Option<String>,
}
/// 更新源:清单与包体的来源。 /// 更新源:清单与包体的来源。
/// ///
/// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**: /// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**:
@@ -34,12 +48,16 @@ pub trait UpdateSource: Send + Sync {
/// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。 /// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。
fn source_id(&self) -> &str; fn source_id(&self) -> &str;
/// 取某渠道的清单;`Ok(None)` = 该渠道还没发布过(不是错误)。 /// 取某渠道清单的两份原文;`Ok(None)` = 该渠道还没发布过(不是错误)。
/// ///
/// # Errors /// # Errors
/// ///
/// 读不到文件、JSON 坏、清单自报的渠道与 `channel` 不符都返回 [`UpdateError`]。 /// 读不到文件、响应为空、网络失败都返回 [`UpdateError`]。**这里不验签、不解析**
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError>; /// (见 [`ManifestDocuments`])——管线的下一站是 [`verified_manifest`]。
fn fetch_manifest_documents(
&self,
channel: Channel,
) -> Result<Option<ManifestDocuments>, UpdateError>;
/// 把产物取到 `dest`,返回写入的字节数。 /// 把产物取到 `dest`,返回写入的字节数。
/// ///
@@ -52,6 +70,52 @@ pub trait UpdateSource: Send + Sync {
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>; fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>;
} }
/// 取清单 → 验签 → 解析 → 渠道校验(**唯一**入口,GUI 与 CLI 共用)。
///
/// 它是个**自由函数**而不是 trait 的默认方法:trait 方法可被任何实现方覆写,那样「信任根的收口点」
/// 就只剩纪律(M190 r1 复审 P2-2)。删掉那条默认实现之后,调用方手里只有源交出来的**原文**
/// ([`ManifestDocuments`]),想绕过验签就必须自己重写一遍解析 + 渠道判据 —— 不再是「一行覆写」。
///
/// # Errors
///
/// 源里没有该渠道清单 ⇒ `Ok(None)`;签名/解析/渠道任一不过 ⇒ [`UpdateError`]。
/// 签名相关的分类见 [`UpdateErrorKind::is_signature`],它们**不可重试**。
pub fn verified_manifest<S: UpdateSource + ?Sized>(
source: &S,
channel: Channel,
) -> Result<Option<ReleaseManifest>, 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<dyn UpdateSource>` 也是 `UpdateSource`。 /// `Box<dyn UpdateSource>` 也是 `UpdateSource`。
/// ///
/// 为什么需要:壳层要**运行期**才决定用哪个源(生产走 GitHub、测试/内网走本地目录),而 /// 为什么需要:壳层要**运行期**才决定用哪个源(生产走 GitHub、测试/内网走本地目录),而
@@ -62,8 +126,11 @@ impl<T: UpdateSource + ?Sized> UpdateSource for Box<T> {
(**self).source_id() (**self).source_id()
} }
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> { fn fetch_manifest_documents(
(**self).fetch_manifest(channel) &self,
channel: Channel,
) -> Result<Option<ManifestDocuments>, UpdateError> {
(**self).fetch_manifest_documents(channel)
} }
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> { fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
@@ -111,6 +178,12 @@ impl DirSource {
self.root.join(format!("{}.json", channel.as_str())) 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` 解析成源侧本地路径。 /// 把清单里的 `url` 解析成源侧本地路径。
fn resolve(&self, url: &str) -> Result<PathBuf, UpdateError> { fn resolve(&self, url: &str) -> Result<PathBuf, UpdateError> {
if let Some(rest) = url.strip_prefix("file://") { if let Some(rest) = url.strip_prefix("file://") {
@@ -149,27 +222,27 @@ impl UpdateSource for DirSource {
&self.id &self.id
} }
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> { fn fetch_manifest_documents(
&self,
channel: Channel,
) -> Result<Option<ManifestDocuments>, UpdateError> {
let path = self.manifest_path(channel); 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, Ok(text) => text,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(UpdateError::io_at(&path, &source)), Err(source) => return Err(UpdateError::io_at(&path, &source)),
}; };
let manifest = ReleaseManifest::from_json(&text).map_err(|error| error.with_path(&path))?; // 签名文件缺失**不是**「没有清单」:清单在、签名不在 ⇒ 上层判 SignatureMissing 并拒装。
if manifest.channel != channel { let signature_path = self.signature_path(channel);
return Err(UpdateError::new( let signatures = match fs::read_to_string(&signature_path) {
UpdateErrorKind::ChannelMismatch, Ok(text) => Some(text),
format!( Err(source) if source.kind() == std::io::ErrorKind::NotFound => None,
"请求的是 {} 渠道,清单 {} 自报 {}", Err(source) => return Err(UpdateError::io_at(&signature_path, &source)),
channel, };
path.display(), Ok(Some(ManifestDocuments {
manifest.channel manifest,
), signatures,
) }))
.with_path(&path));
}
Ok(Some(manifest))
} }
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> { fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
@@ -17,7 +17,7 @@ fn target() -> Target {
/// 新建一个只放了这一份清单的本地源;返回的 `TempDir` 要活到用例结束。 /// 新建一个只放了这一份清单的本地源;返回的 `TempDir` 要活到用例结束。
fn source_with(json: &str, channel: Channel) -> (TempDir, DirSource) { fn source_with(json: &str, channel: Channel) -> (TempDir, DirSource) {
let dir = TempDir::new("checker"); 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()); let source = DirSource::new(dir.path());
(dir, source) (dir, source)
} }
@@ -215,7 +215,7 @@ fn falls_back_to_the_newest_release_that_does_have_the_target() {
#[test] #[test]
fn channel_mismatch_in_the_source_is_an_error() { fn channel_mismatch_in_the_source_is_an_error() {
let tmp = TempDir::new("checker-mismatch"); 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 source = crate::update::DirSource::new(tmp.path());
let current = crate::update::Version::parse("0.2.1").expect("版本"); let current = crate::update::Version::parse("0.2.1").expect("版本");
let error = UpdateChecker::new(source, target()) 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() { fn prepare_stages_the_selected_artifact() {
let tmp = TempDir::new("checker-prepare"); let tmp = TempDir::new("checker-prepare");
tmp.write("a.zip", PAYLOAD); 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 source = crate::update::DirSource::new(tmp.path());
let checker = UpdateChecker::new(source, target()); let checker = UpdateChecker::new(source, target());
let current = crate::update::Version::parse("0.2.0").expect("版本"); let current = crate::update::Version::parse("0.2.0").expect("版本");
@@ -0,0 +1,286 @@
//! Ed25519 验签判据:**钉住 `ed25519-dalek::verify_strict` 的行为**(RFC 8032 §7.1 向量 + 六条拒绝类)。
//!
//! M190 r1 复审把自写曲线算术整体换成了官方实现(理由见 `crate::update::ed25519` 的模块文档)。
//! 这批用例的定位因此从「测自己的实现」变成「**钉 dalek 的严格语义**」:
//!
//! | 用例 | 它挡住什么 | 把 `verify_strict` 换成 `verify` 后会怎样(r2 实测) |
//! |---|---|---|
//! | `small_order_public_key_is_rejected` | 小阶公钥下「自洽的伪造」(谁都能凑) | **红** —— 这是**唯一**承重的守卫 |
//! | `small_order_r_is_rejected_only_by_strict` | 小阶 `R`(R = 单位元)的同一族伪造 | **红**(其余几条不会) |
//! | `rfc8032_vectors_verify` | 实现换成任何一个错的版本(向量来自 RFC 附录) | 仍绿(`verify` 也验方程) |
//! | `malleable_s_plus_l_is_rejected` | `S >= L` 的可锻变体 | **仍绿**:`verify` 自己就查 `S` 规范性,别把它当 strict 的守卫 |
//! | `non_canonical_r_encoding_is_rejected` | 非规范编码的点(`y >= p` 的第二种写法) | 仍绿(dalek 靠自己的下游判据拒,见该用例注释) |
//! | `empty_signature_is_rejected` / `message_bit_flip` / `signature_bit_flip` | 判据本身失效(恒真) | 仍绿 |
//! | `signing_key_round_trips` | 测试夹具(dalek `SigningKey`)与验签侧同一套语义 | 仍绿 |
//!
//! 口径来源:M190 r2 复审在 `ed25519-dalek 2.2.0` 上逐条实测(并把先前的四处错误说法打回)。
//! **结论**:strict 与**非** strict 在这批输入上唯一的分歧是**小阶点**——所以想确认「strict 还开着」,
//! 看前两条;其余几条只在「判据被删成恒真」时才会红。
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
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<u8> {
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
cleaned.len().is_multiple_of(2),
"十六进制串长度必须是偶数:{text}"
);
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] {
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 §7.1 的四条向量:(public, message, signature)。
fn rfc_vectors() -> Vec<(&'static str, &'static str, &'static str)> {
vec![
(
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"",
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b",
),
(
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
"72",
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00",
),
(
"fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
"af82",
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a",
),
(
"ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf",
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
"dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b58909351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704",
),
]
}
#[test]
fn rfc8032_vectors_verify() {
for (index, (public, message, signature)) in rfc_vectors().into_iter().enumerate() {
assert!(
verify(
&array32(&unhex(public)),
&unhex(message),
&array64(&unhex(signature))
),
"RFC 8032 第 {} 条向量必须通过",
index + 1
);
}
}
#[test]
fn message_bit_flip_is_rejected() {
let (public, message, signature) = rfc_vectors()[1];
let mut message = unhex(message);
message[0] ^= 0x01;
assert!(
!verify(
&array32(&unhex(public)),
&message,
&array64(&unhex(signature))
),
"改一个消息字节必须被拒"
);
}
#[test]
fn signature_bit_flip_is_rejected() {
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 同余)。RFC 8032 的最低要求允许它。
// ⚠ 这条**不是** strict 的守卫:dalek 的非 strict `verify` 自己就检查 `S` 规范性,换掉 strict 它
// 仍绿(r2 实测)。它挡的是「判据被删成恒真」。strict 的守卫见 `small_order_*` 两条。
let (public, message, signature) = rfc_vectors()[0];
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(&array32(&unhex(public)), &unhex(message), &signature),
"S >= L 必须被拒(可锻变体)"
);
}
#[test]
fn small_order_public_key_is_rejected() {
// 阶为 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 mut signature = [0_u8; SIGNATURE_LEN];
signature[0] = 1; // R = 单位元
assert!(
!verify(&identity_key, message, &signature),
"小阶公钥必须被拒"
);
assert!(
!verify(&[0_u8; PUBLIC_KEY_LEN], message, &signature),
"零公钥必须被拒"
);
}
#[test]
fn non_canonical_r_encoding_is_rejected() {
// R = p(= 2^255-19)的字节:它与 y = 0 同值但编码不同(非规范)。
// dalek 的实际行为(r2 实测,与先前的说法不同):
// * `CompressedEdwardsY::decompress` **不**校验编码规范性 —— 这串字节解出来是**阶为 4**的点;
// * 于是它被 `verify_strict` 的**小阶**判据拒,而不是被「编码非规范」拒;
// * 公钥侧同理:`VerifyingKey::from_bytes` 对非规范编码返回 **Ok**(`is_weak() == true`),
// 所以要拒它只能靠下游判据 —— 下面把 dalek 的这个真实行为**断言下来**,将来 dalek 升级
// 改了口径会红(否则这条断言对任何实现都绿,等于没测)。
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,
0xff, 0x7f,
];
let mut signature = [0_u8; SIGNATURE_LEN];
signature[..32].copy_from_slice(&p_bytes);
assert!(
!verify(&array32(&unhex(public)), &unhex(message), &signature),
"非规范 R 编码必须被拒(dalek 的理由是小阶点)"
);
// dalek 的真实行为:非规范编码的公钥**能**通过 `from_bytes`,并被标成 weak。
let weak = VerifyingKey::from_bytes(&p_bytes).expect("dalek 2.2 不校验编码规范性,应返回 Ok");
assert!(
weak.is_weak(),
"这个非规范编码解出的是小阶点,dalek 应标记 is_weak"
);
// 我们的 `verify` 对同一串公钥仍然拒(下游方程/小阶判据兜住)。
assert!(
!verify(
&p_bytes,
&unhex(message),
&array64(&unhex(rfc_vectors()[0].2))
),
"非规范/小阶公钥不得验过"
);
}
#[test]
fn small_order_r_is_rejected_only_by_strict() {
// **第二条只由 strict 承重的判据**(r2 复审建议补的):小阶 `R`。
// 构造:R = 单位元(阶 1),S = k·a mod L,其中 k = SHA-512(R‖A‖M) mod L、a 是测试钥的标量。
// 这样方程 `[S]B = R + [k]A` 自洽 ⇒ **非 strict 的 `verify` 会通过**;`verify_strict` 因
// 「R 是小阶点」拒。下面两边都断言,所以将来谁把 strict 换成非 strict,这条会红。
let public = array32(&unhex(
"f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b",
));
let message = b"manifest bytes";
let forged = array64(&unhex(concat!(
"0100000000000000000000000000000000000000000000000000000000000000",
"698a94a4c82aa13c1a4da6248799849f2254ef333b5f5d69dcbd71b6320e5e0e"
)));
// 非 strict 侧:dalek 的 `verify` 会接受这条伪造(这就是 strict 存在的理由)。
let key = VerifyingKey::from_bytes(&public).expect("合法公钥");
assert!(
key.verify(message, &Signature::from_bytes(&forged)).is_ok(),
"非 strict 的 verify 应当接受这条伪造(构造前提;若 dalek 改了行为,这条先红)"
);
// strict 侧:我们导出的 `verify` 必须拒。
assert!(
!verify(&public, message, &forged),
"小阶 R 的伪造必须被 verify_strict 拒"
);
}
#[test]
fn empty_signature_is_rejected() {
let (public, message, _) = rfc_vectors()[0];
assert!(!verify(
&array32(&unhex(public)),
&unhex(message),
&[0_u8; 64]
));
}
#[test]
fn signing_key_round_trips() {
// 测试夹具(dalek `SigningKey`)与验签侧同一套语义:RFC 的 secret 反推出的公钥要对得上,
// 签出来的能验过,改一个字节就验不过。
let seed = array32(&unhex(
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
));
assert_eq!(
public_key_hex(&seed),
rfc_vectors()[0].0,
"种子展开必须与 RFC 一致"
);
assert_eq!(
hex_of(&sign_bytes(&seed, b"")),
rfc_vectors()[0].2,
"空消息的签名必须逐字节等于 RFC 向量(测试夹具与向量同一套语义)"
);
let signature = sign_bytes(&seed, b"unlucky");
assert!(verify(
&array32(&unhex(rfc_vectors()[0].0)),
b"unlucky",
&signature
));
let mut tampered = signature;
tampered[0] ^= 0x80;
assert!(!verify(
&array32(&unhex(rfc_vectors()[0].0)),
b"unlucky",
&tampered
));
}
+47 -52
View File
@@ -10,11 +10,12 @@ use crate::download::mirrors::SOURCE_GH_PROXY;
use crate::download::{DownloadEngine, RegionProfile, SOURCE_OFFICIAL}; use crate::download::{DownloadEngine, RegionProfile, SOURCE_OFFICIAL};
use crate::update::github::{DEFAULT_BASE, DEFAULT_OWNER, DEFAULT_REPO, MANIFEST_ASSET}; use crate::update::github::{DEFAULT_BASE, DEFAULT_OWNER, DEFAULT_REPO, MANIFEST_ASSET};
use crate::update::{ use crate::update::{
Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, UpdateChecker, UpdateError, verified_manifest, Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target,
UpdateErrorKind, UpdateSource, Version, UpdateChecker, UpdateError, UpdateErrorKind, UpdateSource, Version,
}; };
use super::support::{artifact, engine_for, manifest, release, 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"; const PAYLOAD: &[u8] = b"github release payload";
@@ -23,6 +24,22 @@ fn manifest_path() -> String {
format!("/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}") 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 命名规则的产物文件名。 /// 按 D49 命名规则的产物文件名。
fn asset_name() -> &'static str { fn asset_name() -> &'static str {
"unlucky-launcher_0.6.0_windows_x64.exe" "unlucky-launcher_0.6.0_windows_x64.exe"
@@ -104,10 +121,7 @@ fn manifest_url_is_the_fixed_latest_download_path() {
#[test] #[test]
fn channels_other_than_stable_publish_nothing() { fn channels_other_than_stable_publish_nothing() {
let tmp = TempDir::new("gh-channels"); let tmp = TempDir::new("gh-channels");
let server = FakeHttp::start(vec![( let server = FakeHttp::start(signed_routes(&stable_manifest_json()));
manifest_path().as_str(),
FakeResponse::json(&stable_manifest_json()),
)]);
let source = local_source(&tmp, &server); let source = local_source(&tmp, &server);
for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] { for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] {
@@ -115,7 +129,7 @@ fn channels_other_than_stable_publish_nothing() {
source.manifest_url(channel).is_none(), source.manifest_url(channel).is_none(),
"{channel} 不该有清单地址" "{channel} 不该有清单地址"
); );
let manifest = source.fetch_manifest(channel).expect("没开通道不是错误"); let manifest = verified_manifest(&source, channel).expect("没开通道不是错误");
assert!( assert!(
manifest.is_none(), manifest.is_none(),
"{channel} 应返回 Ok(None)(不发布即无更新)" "{channel} 应返回 Ok(None)(不发布即无更新)"
@@ -173,32 +187,30 @@ fn artifact_url_appends_the_release_download_path() {
#[test] #[test]
fn fetches_and_parses_the_manifest_from_the_base() { fn fetches_and_parses_the_manifest_from_the_base() {
let tmp = TempDir::new("gh-manifest-ok"); let tmp = TempDir::new("gh-manifest-ok");
let server = FakeHttp::start(vec![( let server = FakeHttp::start(signed_routes(&stable_manifest_json()));
manifest_path().as_str(),
FakeResponse::json(&stable_manifest_json()),
)]);
let source = local_source(&tmp, &server); let source = local_source(&tmp, &server);
let manifest = source let manifest = verified_manifest(&source, Channel::Stable)
.fetch_manifest(Channel::Stable)
.expect("应取到清单") .expect("应取到清单")
.expect("清单存在"); .expect("清单存在");
assert_eq!(manifest.channel, Channel::Stable); assert_eq!(manifest.channel, Channel::Stable);
assert_eq!(manifest.releases.len(), 1); assert_eq!(manifest.releases.len(), 1);
assert_eq!(manifest.releases[0].version.to_string(), "0.6.0"); 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] #[test]
fn missing_manifest_in_the_release_is_not_an_error() { fn missing_manifest_in_the_release_is_not_an_error() {
// 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。 // 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。
let tmp = TempDir::new("gh-manifest-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 source = local_source(&tmp, &server);
let outcome = source let outcome = verified_manifest(&source, Channel::Stable).expect("404 不是错误");
.fetch_manifest(Channel::Stable)
.expect("404 不是错误");
assert!(outcome.is_none(), "取不到清单 = 还没发过版"); assert!(outcome.is_none(), "取不到清单 = 还没发过版");
assert_eq!( assert_eq!(
server.hits(), server.hits(),
@@ -210,19 +222,15 @@ fn missing_manifest_in_the_release_is_not_an_error() {
#[test] #[test]
fn broken_json_is_reported_as_invalid_manifest() { fn broken_json_is_reported_as_invalid_manifest() {
let tmp = TempDir::new("gh-manifest-broken"); let tmp = TempDir::new("gh-manifest-broken");
let server = FakeHttp::start(vec![( // 签名是对的(签的就是这串坏字节)——只有「签名对、内容坏」才走得到解析这一步。
manifest_path().as_str(), let server = FakeHttp::start(signed_routes("{ not json"));
FakeResponse::json("{ not json"),
)]);
let source = local_source(&tmp, &server); let source = local_source(&tmp, &server);
let error = source let error = verified_manifest(&source, Channel::Stable).expect_err("坏 JSON 必须报错");
.fetch_manifest(Channel::Stable)
.expect_err("坏 JSON 必须报错");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
assert!( assert!(
error.message.contains(&server.base()), error.message.contains("github:"),
"错误里要带出来源地址,日志才定位得到(实际:{})", "错误里要带出来源标识,日志才定位得到(实际:{})",
error.message error.message
); );
assert!(!error.is_retryable(), "清单坏重试多少次都一样"); assert!(!error.is_retryable(), "清单坏重试多少次都一样");
@@ -233,39 +241,30 @@ fn illegal_manifest_fields_are_reported() {
let tmp = TempDir::new("gh-manifest-illegal"); let tmp = TempDir::new("gh-manifest-illegal");
// size 为 0(发布脚本写漏)——字段层面的非法,走同一条 InvalidManifest 分类。 // 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 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 source = local_source(&tmp, &server);
let error = source let error = verified_manifest(&source, Channel::Stable).expect_err("size 为 0 必须报错");
.fetch_manifest(Channel::Stable)
.expect_err("size 为 0 必须报错");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
} }
#[test] #[test]
fn manifest_self_reporting_another_channel_is_a_mismatch() { fn manifest_self_reporting_another_channel_is_a_mismatch() {
let tmp = TempDir::new("gh-manifest-mismatch"); let tmp = TempDir::new("gh-manifest-mismatch");
let server = FakeHttp::start(vec![( let server = FakeHttp::start(signed_routes(&manifest("beta", &[])));
manifest_path().as_str(),
FakeResponse::json(&manifest("beta", &[])),
)]);
let source = local_source(&tmp, &server); let source = local_source(&tmp, &server);
let error = source let error = verified_manifest(&source, Channel::Stable).expect_err("渠道不符必须报错");
.fetch_manifest(Channel::Stable)
.expect_err("渠道不符必须报错");
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch); assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
} }
#[test] #[test]
fn server_errors_are_network_errors() { fn server_errors_are_network_errors() {
let tmp = TempDir::new("gh-manifest-500"); 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 source = local_source(&tmp, &server);
let error = source let error = verified_manifest(&source, Channel::Stable).expect_err("5xx 必须报错");
.fetch_manifest(Channel::Stable)
.expect_err("5xx 必须报错");
assert_eq!(error.kind, UpdateErrorKind::Network); assert_eq!(error.kind, UpdateErrorKind::Network);
assert!(error.is_retryable(), "网络抖动值得隔一会儿再试"); assert!(error.is_retryable(), "网络抖动值得隔一会儿再试");
} }
@@ -273,7 +272,7 @@ fn server_errors_are_network_errors() {
#[test] #[test]
fn downloads_the_artifact_through_the_engine() { fn downloads_the_artifact_through_the_engine() {
let tmp = TempDir::new("gh-artifact-download"); 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 source = local_source(&tmp, &server);
let dest = tmp.join("staging/package.exe"); let dest = tmp.join("staging/package.exe");
@@ -292,7 +291,7 @@ fn downloads_the_artifact_through_the_engine() {
#[test] #[test]
fn missing_artifact_is_reported_as_source_missing() { fn missing_artifact_is_reported_as_source_missing() {
let tmp = TempDir::new("gh-artifact-404"); 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 source = local_source(&tmp, &server);
let error: UpdateError = source let error: UpdateError = source
@@ -354,18 +353,14 @@ fn agrees_with_dir_source_on_the_same_manifest() {
let json = stable_manifest_json(); let json = stable_manifest_json();
// 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。 // 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。
tmp.write( tmp.write_signed(&format!("{}.json", Channel::Stable.as_str()), &json);
&format!("{}.json", Channel::Stable.as_str()),
json.as_bytes(),
);
tmp.write(asset_name(), PAYLOAD); tmp.write(asset_name(), PAYLOAD);
let dir = DirSource::new(tmp.path()); let dir = DirSource::new(tmp.path());
// GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。 // GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。
let server = FakeHttp::start(vec![ let mut routes = signed_routes(&json);
(manifest_path().as_str(), FakeResponse::json(&json)), routes.push((asset_path(), FakeResponse::bin(PAYLOAD)));
(asset_path().as_str(), FakeResponse::bin(PAYLOAD)), let server = FakeHttp::start(routes);
]);
let github = local_source(&tmp, &server); let github = local_source(&tmp, &server);
let current = Version::parse("0.5.0").expect("版本号合法"); let current = Version::parse("0.5.0").expect("版本号合法");
+6 -8
View File
@@ -22,17 +22,16 @@ fn target() -> Target {
/// 造一个本地源 + 一个替换目标;返回的 `TempDir` 要活到用例结束。 /// 造一个本地源 + 一个替换目标;返回的 `TempDir` 要活到用例结束。
fn fixture(format: &str, version: &str) -> (TempDir, Job) { fn fixture(format: &str, version: &str) -> (TempDir, Job) {
let dir = TempDir::new("job"); let dir = TempDir::new("job");
dir.write( dir.write_signed(
"stable.json", "stable.json",
manifest( &manifest(
"stable", "stable",
&[release( &[release(
version, version,
"", "",
&[artifact("win-x64", format, "asset.bin", PAYLOAD)], &[artifact("win-x64", format, "asset.bin", PAYLOAD)],
)], )],
) ),
.as_bytes(),
); );
dir.write("asset.bin", PAYLOAD); dir.write("asset.bin", PAYLOAD);
let dest = dir.join("bin/unlucky-launcher.exe"); let dest = dir.join("bin/unlucky-launcher.exe");
@@ -119,17 +118,16 @@ fn a_portable_exe_runs_the_whole_chain() {
#[test] #[test]
fn nothing_to_apply_does_not_touch_the_target() { fn nothing_to_apply_does_not_touch_the_target() {
let dir = TempDir::new("job-none"); let dir = TempDir::new("job-none");
dir.write( dir.write_signed(
"stable.json", "stable.json",
manifest( &manifest(
"stable", "stable",
&[release( &[release(
"0.2.2", "0.2.2",
"", "",
&[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)], &[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)],
)], )],
) ),
.as_bytes(),
); );
let dest = dir.join("bin/unlucky-launcher.exe"); let dest = dir.join("bin/unlucky-launcher.exe");
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录"); std::fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录");
+3
View File
@@ -20,14 +20,17 @@
mod channel_cases; mod channel_cases;
mod checker_cases; mod checker_cases;
mod ed25519_cases;
mod github_cases; mod github_cases;
mod install_cases; mod install_cases;
mod install_io_cases; mod install_io_cases;
mod install_xdev_cases; mod install_xdev_cases;
mod job_cases; mod job_cases;
mod manifest_cases; mod manifest_cases;
mod signature_cases;
mod source_cases; mod source_cases;
mod support; mod support;
mod support_sign;
mod target_cases; mod target_cases;
mod verify_cases; mod verify_cases;
mod version_cases; mod version_cases;
@@ -0,0 +1,294 @@
//! 清单签名:**先验签再解析**(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::{
trust, verified_manifest, Channel, DirSource, Job, OsFamily, Target, UpdateChecker,
UpdateErrorKind, Version,
};
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";
/// 另一把「攻击者的钥」:种子换一个词,公钥就完全不同。
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 = public_key_hex(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(&current(), 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 = verified_manifest(&DirSource::new(dir.path()), 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 = sign_bytes(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 = verified_manifest(&DirSource::new(dir.path()), 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 = verified_manifest(&DirSource::new(dir.path()), 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 = verified_manifest(&DirSource::new(dir.path()), 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 = verified_manifest(&DirSource::new(dir.path()), 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 = verified_manifest(&DirSource::new(dir.path()), 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 = verified_manifest(&DirSource::new(dir.path()), 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 =
verified_manifest(&DirSource::new(dir.path()), 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(&current(), 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(&current(), 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 = public_key_hex(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 = 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("旁边的信任表文件不许生效");
assert_eq!(
error.kind,
UpdateErrorKind::SignatureUnknownKey,
"改不了信任锚:攻击者的钥仍在表外(实际:{})",
error.message
);
}
+11 -12
View File
@@ -3,23 +3,21 @@
use std::fs; use std::fs;
use crate::update::manifest::ReleaseManifest; 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}; use super::support::{artifact, manifest, release, TempDir};
const PAYLOAD: &[u8] = b"source payload"; const PAYLOAD: &[u8] = b"source payload";
fn publish(tmp: &TempDir, channel: Channel, json: &str) { 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] #[test]
fn missing_channel_manifest_is_not_an_error() { fn missing_channel_manifest_is_not_an_error() {
let tmp = TempDir::new("source-missing"); let tmp = TempDir::new("source-missing");
let source = DirSource::new(tmp.path()); let source = DirSource::new(tmp.path());
let manifest = source let manifest = verified_manifest(&source, Channel::Stable).expect("缺清单不该报错");
.fetch_manifest(Channel::Stable)
.expect("缺清单不该报错");
assert!(manifest.is_none(), "还没发过版 = Ok(None)"); assert!(manifest.is_none(), "还没发过版 = Ok(None)");
} }
@@ -36,8 +34,7 @@ fn reads_and_validates_the_channel_manifest() {
); );
publish(&tmp, Channel::Stable, &json); publish(&tmp, Channel::Stable, &json);
let source = DirSource::new(tmp.path()); let source = DirSource::new(tmp.path());
let parsed = source let parsed = verified_manifest(&source, Channel::Stable)
.fetch_manifest(Channel::Stable)
.expect("应取到清单") .expect("应取到清单")
.expect("清单存在"); .expect("清单存在");
assert_eq!(parsed.channel, Channel::Stable); assert_eq!(parsed.channel, Channel::Stable);
@@ -52,8 +49,7 @@ fn channel_mismatch_is_an_error() {
let tmp = TempDir::new("source-mismatch"); let tmp = TempDir::new("source-mismatch");
// 清单自称 beta,却躺在 stable.json 里 // 清单自称 beta,却躺在 stable.json 里
publish(&tmp, Channel::Stable, &manifest("beta", &[])); publish(&tmp, Channel::Stable, &manifest("beta", &[]));
let error = DirSource::new(tmp.path()) let error = verified_manifest(&DirSource::new(tmp.path()), Channel::Stable)
.fetch_manifest(Channel::Stable)
.expect_err("渠道不符必须报错"); .expect_err("渠道不符必须报错");
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch); assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
} }
@@ -62,11 +58,14 @@ fn channel_mismatch_is_an_error() {
fn broken_manifest_json_is_reported_with_path() { fn broken_manifest_json_is_reported_with_path() {
let tmp = TempDir::new("source-broken"); let tmp = TempDir::new("source-broken");
publish(&tmp, Channel::Stable, "{ not json"); publish(&tmp, Channel::Stable, "{ not json");
let error = DirSource::new(tmp.path()) let error = verified_manifest(&DirSource::new(tmp.path()), Channel::Stable)
.fetch_manifest(Channel::Stable)
.expect_err("坏 JSON 必须报错"); .expect_err("坏 JSON 必须报错");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
assert!(error.path.is_some(), "错误应带清单路径"); assert!(
error.message.contains("来源 file:"),
"错误里要带出来源(本地源的 id 就是根目录),日志才定位得到(实际:{})",
error.message
);
} }
#[test] #[test]
+1 -5
View File
@@ -280,13 +280,9 @@ pub(crate) struct FakeHttp {
impl FakeHttp { impl FakeHttp {
/// 起服务(`routes` 是「路径 → 应答」表)。 /// 起服务(`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 listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定本地端口");
let addr = listener.local_addr().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 hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
@@ -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 {
/// 写一份清单**连它的签名**(`<name>` + `<name>.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
}
}
+98
View File
@@ -0,0 +1,98 @@
//! 钉进二进制的清单签名信任锚(keyring):**多把**公钥,`key_id` 只是查表用的标签。
//!
//! # 为什么是一张编译期常量表
//!
//! 自更新的信任根必须不可被「用户机器上的一枚文件」替换:清单与签名都能被能改 Release 的人一起
//! 换掉,唯一挡得住的是「客户端**编进二进制**的那几把公钥」。所以这张表是 `const`:
//!
//! * 改仓库里的本文件只会改变**下一次构建**的行为,不会改变任何**已发布二进制**的验签结果;
//! * 没有环境变量、没有设置键、没有「旁边的 keyring.json」这类运行期覆盖口子
//! (`tests/signature_cases.rs::no_runtime_keyring_override` 守这条)。
//!
//! # 为什么是「一组」而不是「一把」
//!
//! 换签名方(例如日后接 SignPath 的托管签名,或旧钥泄露后轮换)时,用户机器上装着的是**旧**二进制,
//! 它只认旧钥。做成数组才有两条路同时成立:新签出的清单可以同时带新旧两把钥的签名(旧二进制认旧的、
//! 新二进制认新的),以及新二进制可以一把一把地收紧。步骤见 `packaging/docs/signing.md`。
//!
//! # 唯一的加钥匙纪律:**旧钥签新钥**
//!
//! 往本表里加一把公钥,必须同时有「旧钥对新钥的背书」——即那次发布的清单同时带旧钥签名(见上),
//! 以及 changelog 里点名新钥的 `key_id` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表:
//! 那等于让「能改仓库的人」变成「能改信任根的人」。
//!
//! # 正式构建的表当前是**空的**(fail-closed)
//!
//! 私钥只能来自仓库 Secret,本轮没有(也不该由代码仓库产生)私钥,所以正式表里一个条目都没有:
//! 任何正式构建拿到清单都会以 [`super::error::UpdateErrorKind::SignatureMissing`] 之类的分类拒绝更新,
//! 而 `packaging/sign_manifest.py --check-pinned-keyring` 会让发布流水线在「Secret 没配 / 公钥没钉」
//! 时**当场红**(宁可不出 Release,也不出未签名的 Release)。
//!
//! 录一把真钥的两步(细节见 `packaging/docs/signing.md`):
//!
//! ```text
//! python packaging/sign_manifest.py --generate-key --out-key release.key # ① 生成,私钥写进 Secret
//! # ② 把打印出来的 `PinnedKey { … }` 一行贴到下面 PINNED-KEYS 标记之间
//! ```
//!
//! 测试/验收构建另有一把**只在** `cfg(test)` 与 `update-test-source` feature 里存在的测试钥:
//! 它的私钥种子就写在测试夹具里、公开可见,因此它可以签出「本仓库自己认得」的清单,但对任何正式
//! 构建毫无意义(正式表里没有它)。这条也由 `packaging/check_test_source_off.py` 的同一道闸守着
//! ——正式发布流水线与打包脚本都不许打开 `update-test-source`。
use std::sync::OnceLock;
/// 一把钉死的公钥。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PinnedKey {
/// 标签(清单签名里的 `key_id` 靠它查表;**改名就是换钥**)。
pub(crate) key_id: &'static str,
/// Ed25519 公钥的 64 位十六进制小写形态(32 字节)。
pub(crate) public_key_hex: &'static str,
}
/// 正式构建的信任锚(发布二进制里就是这一份)。
///
/// ── 加钥匙只改下面 `PINNED-KEYS` 标记之间的内容,别改标记本身 ──
/// (`packaging/sign_manifest.py --check-pinned-keyring` 按标记解析这一段,标记变了它就找不到表)。
pub(crate) const PRODUCTION: &[PinnedKey] = &[
// >>> PINNED-KEYS >>>
// <<< PINNED-KEYS <<<
];
/// 测试钥:**只**在 `cfg(test)` 与 `update-test-source` 构建里存在(正式表里没有它)。
///
/// 公钥由 `tests/support.rs` 里那串公开可见的 32 字节种子推出来(种子 = `ull-m190-test-key-seed-000000001`),
/// 两者一旦漂开,`tests/signature_cases.rs::pinned_test_key_matches_the_test_seed` 会当场红。
pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey {
key_id: "test-only-key",
public_key_hex: "f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b",
}];
/// 本构建实际的信任锚(正式表 + 仅测试构建才有的那把测试钥)。
pub(crate) fn keyring() -> &'static [PinnedKey] {
static ALL: OnceLock<Vec<PinnedKey>> = OnceLock::new();
ALL.get_or_init(|| {
let mut keys = PRODUCTION.to_vec();
if cfg!(any(test, feature = "update-test-source")) {
keys.extend_from_slice(TEST_ONLY);
}
keys
})
}
/// 十六进制解码(只收小写/大写十六进制,长度必须是偶数)。
pub(crate) fn decode_hex(text: &str) -> Option<Vec<u8>> {
if !text.len().is_multiple_of(2) {
return None;
}
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len() / 2);
let (pairs, _) = bytes.as_chunks::<2>();
for pair in pairs {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out.push(u8::try_from(hi * 16 + lo).ok()?);
}
Some(out)
}
+14
View File
@@ -146,3 +146,17 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
`ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` + `ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` +
`portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。 `portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。
**不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。 **不验**真 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 证书,那是另一条线。
+188
View File
@@ -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 <Cargo.toml version, four parts>
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 <exe>... --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("<I", data, 0x3C)[0]
if offset + 0x5C > len(data) or data[offset : offset + 4] != PE_MAGIC:
return None
return struct.unpack_from("<H", data, offset + 24 + 0x44)[0]
def check(path: Path, expect_version: str | None) -> 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("<II", data, index + 8)
actual = (ms >> 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("<I", fake, 0x3C, 0x40)
fake[0x40:0x44] = PE_MAGIC
struct.pack_into("<H", fake, 0x40 + 24 + 0x44, 2)
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as handle:
handle.write(bytes(fake))
name = handle.name
problems = check(Path(name), "0.2.3")
os.unlink(name)
if not problems:
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", 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="读产物字节断言 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())
+143
View File
@@ -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())
+154
View File
@@ -0,0 +1,154 @@
# 产物签名与更新验签(M190)
本文件是**发布侧手册**:私钥从哪来、怎么用、怎么轮换、哪一步失败必须让发布变红。
用户面的说明(README 里「为什么第一个包要手动下」那类话)不在本文件范围。
## 0. 先说清这件事**不**解决什么
| 有 / 没有 | 状态 |
|---|---|
| 更新器验「清单是不是我们发的」 | **有**(Ed25519 清单签名 + 二进制里钉住的公钥) |
| Windows 上「未知发布者」提示消失 | **没有**。项目自有签名与 Authenticode 是两件事 |
| SmartScreen 信誉 | **没有**。只有买了 OV/EV 证书并真签名才可能逐步积累 |
> 一句话:**项目自有签名保护的是自更新这条链,不是 Windows 的信任判断。** 用户双击 exe 时看到的
> 「未知发布者」来自 SmartScreen,与本文件的方案无关;买证书是另一条线(见 M190 报告里的
> 「后续要用户拍板的两件事」)。
## 1. 威胁模型(为什么要签名)
清单里的 `sha256` 只保证「包体与清单对得上」。**能改 Release 的人可以同时改包和摘要**,所以摘要
本身不是信任根。签名的对象因此是**清单文件的原始字节**:改了任意一个字段(哪怕只改版本号里一个数字),
签名立刻对不上。
```
发布侧 用户机器
------ --------
manifest.json(字节)──签名──> manifest.json.sig
│ │
└── 挂到同一个 Release ─────────────┤
├─ 取清单 + .sig
├─ 用**编译进二进制**的公钥验签(trust.rs)
├─ 过了 → 再解析、再比 sha256、再装
└─ 不过 → 五类签名错误,**什么都不会被安装**
```
## 2. 信任根在哪:`crates/core/src/update/trust.rs`
* 公钥是**编译期常量数组**(`PRODUCTION`),不是用户机器上的文件 —— 没有环境变量、没有设置键、
没有「清单旁边的 keyring.json」这类运行期口子(`tests/signature_cases.rs::no_runtime_keyring_override`
守着这条)。
* 是**数组**而不是一把:换签名方时新旧钥可以并存(见 §5)。
* `key_id` 只是查表标签,**判据永远是公钥字节**:把 `key_id` 伪造成钉住的那个也过不了
(`key_id_spoofing_does_not_help`)。
### 空表 = fail-closed
正式表当前**是空的**:私钥只能来自仓库 Secret,任何代码仓库都不该自己生成并落盘私钥。
空表的效果是「任何正式构建都拒装任何清单」,同时 `--check-pinned-keyring` 让发布流水线当场红。
**这是有意的**:宁可不出 Release,也不出未签名的 Release。
## 3. 一次性录入(只做一次)
```bash
# ① 生成:打印一段 Secret JSON + 一行可以直接贴进 trust.rs 的 PinnedKey
python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a
```
* 打印出来的 **第一行 JSON**(含 `seed_hex`)= 私钥,**只**贴进仓库 Secret:
Settings → Secrets and variables → Actions → New repository secret,名字
**`ULL_MANIFEST_SIGNING_KEY`**。
* 打印出来的 **`PinnedKey { … }` 那一行**贴进 `crates/core/src/update/trust.rs` 的
`>>> PINNED-KEYS >>>` 与 `<<< PINNED-KEYS <<<` 标记之间(标记本身不要动,脚本按它解析)。
* 提交这次改动(公钥进 git 是对的:它是信任锚,本来就该公开可审计)。
### 私钥纪律(违反任何一条都算事故)
1. **只**存在于仓库 Secret `ULL_MANIFEST_SIGNING_KEY`。本地需要签测试产物时,走 `--key <文件>`
并把那个文件放在**仓库外**(gitignore 不保险,别放仓库里)。
2. **不得**出现在日志、产物、提交、issue、聊天记录里。CI 里任何 `echo`/`cat`/`set -x` 都不许碰到它
(工作流里的签名步骤显式 `set +x` 且只回显**公钥**)。
3. 一旦怀疑泄露:立即按 §5 轮换,并在下一个 Release 的说明里点名旧 `key_id` 作废。
4. 私钥文件/Secret 的备份不归仓库管;**丢了 = 旧钥作废**,用户机器上的旧二进制只能靠
「先手动装一次新版本」恢复(`trust.rs` 里的旧条目那时才可以删)。
## 4. 发布时发生什么(`.github/workflows/release-publish.yml`)
在 `manifest.json` 生成之后、`gh release create/upload` 之前,多了一步:
```bash
python3 packaging/sign_manifest.py --self-test # ① RFC 8032 向量自检
python3 packaging/sign_manifest.py --check-pinned-keyring # ② 密钥 ↔ trust.rs 必须对得上
python3 packaging/sign_manifest.py --sign dist/manifest.json # ③ 产出 dist/manifest.json.sig
python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # ④ 用钉住的公钥回验产出
```
第 ④ 步复现「客户端会怎么判」:它**不碰私钥**,只拿 `trust.rs` 里那把公钥去验刚落盘的 `.sig`。
因此「签名脚本自己说签好了」不构成证据,证据是「按客户端的判据能验过」。
四条命令任何一条非零退出 ⇒ **这一步红 ⇒ 整个 Release job 红 ⇒ 本次不出 Release**。常见红法:
| 现象 | 原因 | 怎么补 |
|---|---|---|
| `没有签名密钥` | Secret `ULL_MANIFEST_SIGNING_KEY` 没配 / 名字拼错 | 按 §3 录入 |
| `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那行贴进标记之间 |
| `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 |
| `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue |
`dist/manifest.json.sig` 与 `dist/manifest.json` 一起作为 Release 资产上传(`assets=(...)` 那一行)。
## 5. 轮换(换钥 / 旧钥泄露 / 接托管签名)
**纪律:旧钥签新钥。** 不允许在没有旧钥签名的情况下替换信任表 —— 那等于让「能改仓库的人」变成
「能改信任根的人」。
1. 生成新钥(§3),得到 `key_id = ull-release-2027a`。
2. `trust.rs` 里**保留**旧条目、**追加**新条目(数组顺序无关;`key_id` 不能重名)。
3. 在一次发布里,让清单**同时带两把钥的签名**(两条 `signatures`,客户端「任意一条通过即放行」):
用旧钥签一次、新钥签一次,把两个信封的 `signatures` 数组拼起来放进 `manifest.json.sig`。
这一步让「装着旧二进制的用户」和「装着新二进制的用户」都过得去。
4. 之后至少**一个完整发布周期**保持双签名,再观察下载/更新失败率。
5. 确认新钥已被广泛接受后,用新钥单独签发下一次清单,并从 `trust.rs` 删掉旧条目
(删条目的那个版本必须**只**由新钥签)。
6. changelog 里点名:新增/作废的 `key_id` 与公钥指纹。
托管签名(例如 SignPath Foundation)作为**后续可替换路径**:它的产物签名由 CI 侧调用其服务完成,
`trust.rs` 的数组形状不用变(多一条 `PinnedKey` 即可);清单签名这一层保持本项目自有钥不动。
**不允许**为了接托管签名把判据改成「只认证书」。
## 6. 三条守门(都能单独变红)
| 守门 | 脚本 / 用例 | 变红意味着 |
|---|---|---|
| 版本资源真的在产物里 | `packaging/check_pe_version.py <exe> --expect-version <版本>` | 读回产物字节找不到 `VS_VERSIONINFO` / 字段值不对(构建日志绿也没用) |
| 篡改清单 ⇒ 验签失败且不安装 | `cargo test -p unlucky-core --lib update::tests::signature_cases` | 改字段/只改签名/漏签名/陌生钥 任一仍能装 |
| 签名失败阻断发布 | `release-publish.yml` 的签名步 + `--check-pinned-keyring` | 没钥/钥没钉/钥不匹配时仍发出 Release |
另外 CI 的构建槽位会跑 `check_pe_version.py`(`release-build.yml`):Windows 槽位上直接读
`target/release/unlucky-launcher-iced.exe` 与 `ull.exe` 的字节,断言 `CompanyName` 等字符串在。
## 7. 实现分工(M190 r1 复审后)
| 哪一半 | 在哪 | 用什么 |
|---|---|---|
| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(在非 strict 的基础上额外拒**小阶公钥**与**小阶 `R`**;`S` 规范性是非 strict 的 `verify` 也查的) |
| **签名**(只在发布侧) | `packaging/ed25519_ref.py` + `packaging/sign_manifest.py` | 纯 Python 参考实现,零第三方依赖(CI 不装 pip 包) |
两套实现互相独立,`--self-test` 用 RFC 8032 §7.1 的同一组向量把两边各自钉住。**第一版曾把验签写成
仓内自写**(5x51 素域 + 扩展坐标);r1 复审裁定不安全——信任锚的微妙错误后果是「验签通过但本不该通过」,
因此整段删除,改为官方实现。`crates/core/src/update/tests/ed25519_cases.rs` 现在钉的是
**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`,`small_order_public_key_is_rejected` 与
`small_order_r_is_rejected_only_by_strict` 会红(这是 r2 实测的结论——其余几条拒绝类用例在非 strict
下仍绿,别把它们当 strict 的守卫)。
## 8. 本地自测(不需要私钥)
```bash
python3 packaging/sign_manifest.py --self-test # RFC 8032 向量 + 标记解析
python3 packaging/check_pe_version.py --self-test # 口径表与 pe_version.rs 对拍
cargo test -p unlucky-core --lib update::tests::signature_cases
cargo test -p unlucky-core --lib update::tests::ed25519_cases
```
带 `update-test-source` 的验收构建里另有一把**公开的测试钥**(种子写在测试夹具里):它只用于
「本地目录源 + 真二进制」的端到端验收,**不在**正式信任表里,签不出任何正式版本能装的清单。
+142
View File
@@ -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)))
+101
View File
@@ -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 名 / `<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::<u64>().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;
}
+298
View File
@@ -0,0 +1,298 @@
#!/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: `--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
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 的参考实现搬到了 `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."""
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_verify_pinned(manifest: Path) -> int:
"""用 trust.rs 里钉住的公钥回验 `<manifest>.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", "",
"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("--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)
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)
if args.verify_pinned:
return do_verify_pinned(args.verify_pinned)
parser.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())