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

+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}…")
}