Files
Unlucky-Launcher/crates/core/src/update/signature.rs
T
AskaEth b7d9b99e12 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` 已实测被拒)。
2026-11-09 01:59:05 +08:00

257 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 清单签名信封:`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}…")
}