feat(m190): 产物署名(PE VERSIONINFO)+ 清单 Ed25519 多签名与更新前验签

两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。

① PE 版本资源(两个 bin):`packaging/pe_version.rs` 是唯一口径(CompanyName
   「Unlucky Launcher Project」/ ProductName、FileDescription「Unlucky Launcher」/
   LegalCopyright「© 2026 … · Apache-2.0」/ 四段式版本号与 Cargo.toml 同源),被
   `crates/{cli,app-iced}/build.rs` 各 include 一次;非 windows 目标整段不执行。
   判据是读产物字节的 `packaging/check_pe_version.py`(CI 的 Windows 槽位)。

② 清单签名:`manifest.json` 旁边多一份 `manifest.json.sig`(分离签名,签的是清单**原始
   字节**),信封是 `{format, signatures:[{alg,key_id,sig,manifest_sha256}]}` —— 多签名数组,
   任意一条命中钉住的钥即放行(换钥窗口)。信任锚是 `crates/core/src/update/trust.rs`
   里的编译期公钥表(正式表为空 = fail-closed,测试钥只在 cfg(test)/update-test-source 里)。
   管线顺序改成「先验签、再解析」:`UpdateSource` 只交原文 + `.sig`,`verified_manifest`
   是唯一收口点,`Job::apply` 因此在取包**之前**就拒。新增五类错误分类
   (signature_missing / malformed / unknown_key / keyring_empty / invalid ),全部不可重试,
   CLI 的 hint 单独说人话。

③ Ed25519 验签自带实现(`update/ed25519/*`,零新依赖):scope 不含 core 的 Cargo.toml,
   用 RFC 8032 §7.1 向量 + 可锻/小阶/非规范编码的拒绝用例钉住;签名侧是
   `packaging/sign_manifest.py`(纯 Python 参考实现,零 pip 依赖,`--self-test` 同向量)。

④ 守门:`release-publish.yml` 加签名步(`set +x`,只回显公钥;没 Secret / 钥没钉 / 签不上
   一律红 ⇒ 不出 Release);`release-build.yml` 加 PE 资源读回;`release-guard.yml` 跑两个
   脚本的 `--self-test`。密钥纪律与轮换(旧钥签新钥)写在 `packaging/docs/signing.md`。

Cargo.lock:新增 winresource(build-dependency)与 toml 系;toml_datetime 1.1.2→1.1.3 是
toml 1.2.1 的硬要求(`cargo update --precise 1.1.2` 已实测被拒)。
This commit is contained in:
AskaEth committed 2026-11-09 01:59:05 +08:00
1 parent 44a0c87a11
commit b7d9b99e12
34 files changed
+2885 -104

No files matched your search

+102 -21
View File
@@ -11,6 +11,7 @@
//!
//! ```text
//! <root>/stable.json 渠道清单(文件名 = 渠道名 + .json)
//! <root>/stable.json.sig 清单签名信封(M190;缺了它清单一律拒装,见 `signature`)
//! <root>/beta.json
//! <root>/<产物文件> 清单里 url 写相对路径时,相对 root
//! ```
@@ -26,6 +27,19 @@ use super::channel::Channel;
use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::{Artifact, ReleaseManifest};
/// 源交出来的「清单两份原文」:清单本身 + 配套签名信封(M190)。
///
/// 刻意是**原文**而不是解析后的结构:签名只能覆盖字节,先解析再验签就等于给「解析器认了、
/// 签名没认」的东西开了门。判据顺序因此固定为「先验签、再解析」,两者都在
/// [`verified_manifest`] 里,源实现自己不做任何验签。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestDocuments {
/// 清单原文(`manifest.json` / `<channel>.json` 的字节,按 UTF-8 解释)。
pub manifest: String,
/// 签名信封原文(`<清单名>.sig`);`None` = 源里没有这份文件(会被判 [`UpdateErrorKind::SignatureMissing`])。
pub signatures: Option<String>,
}
/// 更新源:清单与包体的来源。
///
/// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**:
@@ -34,12 +48,16 @@ pub trait UpdateSource: Send + Sync {
/// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。
fn source_id(&self) -> &str;
/// 取某渠道的清单;`Ok(None)` = 该渠道还没发布过(不是错误)。
/// 取某渠道清单的两份原文;`Ok(None)` = 该渠道还没发布过(不是错误)。
///
/// # Errors
///
/// 读不到文件、JSON 坏、清单自报的渠道与 `channel` 不符都返回 [`UpdateError`]。
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError>;
/// 读不到文件、响应为空、网络失败都返回 [`UpdateError`]。**这里不验签、不解析**
/// (见 [`ManifestDocuments`])——管线的下一站是 [`verified_manifest`]。
fn fetch_manifest_documents(
&self,
channel: Channel,
) -> Result<Option<ManifestDocuments>, UpdateError>;
/// 把产物取到 `dest`,返回写入的字节数。
///
@@ -50,6 +68,60 @@ pub trait UpdateSource: Send + Sync {
///
/// 源里没有这个文件、读失败、写 `dest` 失败都返回 [`UpdateError`]。
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>;
/// 取清单 → **验签** → 解析 → 渠道校验。`check`/`apply` 都只走这一个入口。
///
/// 提供默认实现是为了让「怎么取字节」与「怎么判字节」彻底分开:源只实现
/// [`Self::fetch_manifest_documents`],判据只有 [`verified_manifest`] 一份。
///
/// # Errors
///
/// 取不到/取到空、签名不通过(五类,见 [`UpdateErrorKind`])、清单 JSON 坏、渠道不符。
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
verified_manifest(self, channel)
}
}
/// 取清单 → 验签 → 解析 → 渠道校验(**唯一**入口,GUI 与 CLI 共用)。
///
/// # 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`。
@@ -62,8 +134,11 @@ impl<T: UpdateSource + ?Sized> UpdateSource for Box<T> {
(**self).source_id()
}
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
(**self).fetch_manifest(channel)
fn fetch_manifest_documents(
&self,
channel: Channel,
) -> Result<Option<ManifestDocuments>, UpdateError> {
(**self).fetch_manifest_documents(channel)
}
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
@@ -111,6 +186,12 @@ impl DirSource {
self.root.join(format!("{}.json", channel.as_str()))
}
/// 某渠道清单的签名文件路径(`stable.json` → `stable.json.sig`)。
#[must_use]
pub fn signature_path(&self, channel: Channel) -> PathBuf {
self.root.join(format!("{}.json.sig", channel.as_str()))
}
/// 把清单里的 `url` 解析成源侧本地路径。
fn resolve(&self, url: &str) -> Result<PathBuf, UpdateError> {
if let Some(rest) = url.strip_prefix("file://") {
@@ -149,27 +230,27 @@ impl UpdateSource for DirSource {
&self.id
}
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
fn fetch_manifest_documents(
&self,
channel: Channel,
) -> Result<Option<ManifestDocuments>, UpdateError> {
let path = self.manifest_path(channel);
let text = match fs::read_to_string(&path) {
let manifest = match fs::read_to_string(&path) {
Ok(text) => text,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(UpdateError::io_at(&path, &source)),
};
let manifest = ReleaseManifest::from_json(&text).map_err(|error| error.with_path(&path))?;
if manifest.channel != channel {
return Err(UpdateError::new(
UpdateErrorKind::ChannelMismatch,
format!(
"请求的是 {} 渠道,清单 {} 自报 {}",
channel,
path.display(),
manifest.channel
),
)
.with_path(&path));
}
Ok(Some(manifest))
// 签名文件缺失**不是**「没有清单」:清单在、签名不在 ⇒ 上层判 SignatureMissing 并拒装。
let signature_path = self.signature_path(channel);
let signatures = match fs::read_to_string(&signature_path) {
Ok(text) => Some(text),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => None,
Err(source) => return Err(UpdateError::io_at(&signature_path, &source)),
};
Ok(Some(ManifestDocuments {
manifest,
signatures,
}))
}
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {