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:
1 parent
44a0c87a11
commit
b7d9b99e12
34 files changed
+2885
-104
No files matched your search
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user