两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。
① 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` 已实测被拒)。
257 lines
10 KiB
Rust
257 lines
10 KiB
Rust
//! 清单签名信封:`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}…")
|
||
}
|