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
@@ -0,0 +1,247 @@
|
||||
//! 素域 `GF(2^255 - 19)` 的算术:5 条 51 位肢(radix-2^51)+ 128 位中间积。
|
||||
//!
|
||||
//! 为什么自己写而不是引 `curve25519-dalek`:M190 的 scope 不含 `crates/core/Cargo.toml`
|
||||
//! (见 `ed25519/mod.rs` 文件头的取舍说明)。这一层只服务**验签**,不接触任何私钥,
|
||||
//! 也不需要常数时间性质:输入全是公开数据(公钥、签名、清单字节)。
|
||||
//!
|
||||
//! 表示是「宽松的」:`[u64; 5]` 只是数值的内部形态,同一元素有多种写法(肢可以不规范)。
|
||||
//! 需要规范字节时一律走 [`Fe::to_bytes`](它先进位、再按 `h >= p` 条件减 `p`)。
|
||||
|
||||
/// 单肢掩码(51 位)。
|
||||
const MASK: u64 = (1 << 51) - 1;
|
||||
|
||||
/// 域元素。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Fe(pub(crate) [u64; 5]);
|
||||
|
||||
impl Fe {
|
||||
/// 0。
|
||||
pub(crate) const ZERO: Self = Self([0; 5]);
|
||||
|
||||
/// 1。
|
||||
pub(crate) const ONE: Self = Self([1, 0, 0, 0, 0]);
|
||||
|
||||
/// 从整数构造(只用于 121665 / 121666 这类小常数)。
|
||||
pub(crate) fn from_u64(value: u64) -> Self {
|
||||
Self([value & MASK, value >> 51, 0, 0, 0])
|
||||
}
|
||||
|
||||
/// 读 32 字节小端(不检查是否 `< p`:调用方要自己判定规范性)。
|
||||
pub(crate) fn from_bytes(bytes: &[u8; 32]) -> Self {
|
||||
let load = |offset: usize| -> u64 {
|
||||
let mut buf = [0_u8; 8];
|
||||
buf.copy_from_slice(&bytes[offset..offset + 8]);
|
||||
u64::from_le_bytes(buf)
|
||||
};
|
||||
// 51 位一段:位 0..51 从字节 0,位 51..102 从字节 6 起右移 3,依此类推。
|
||||
Self([
|
||||
load(0) & MASK,
|
||||
(load(6) >> 3) & MASK,
|
||||
(load(12) >> 6) & MASK,
|
||||
(load(19) >> 1) & MASK,
|
||||
(load(24) >> 12) & MASK,
|
||||
])
|
||||
}
|
||||
|
||||
/// 规范 32 字节小端编码(先完全进位,再按 `>= p` 条件减一次 `p`)。
|
||||
pub(crate) fn to_bytes(self) -> [u8; 32] {
|
||||
let mut h = carry(self.0);
|
||||
// `q = 1` 当且仅当 `h >= p`:`h + 19` 溢出 2^255 就说明 `h >= 2^255 - 19`。
|
||||
let mut q = (h[0] + 19) >> 51;
|
||||
q = (h[1] + q) >> 51;
|
||||
q = (h[2] + q) >> 51;
|
||||
q = (h[3] + q) >> 51;
|
||||
q = (h[4] + q) >> 51;
|
||||
h[0] += 19 * q;
|
||||
h[1] += h[0] >> 51;
|
||||
h[0] &= MASK;
|
||||
h[2] += h[1] >> 51;
|
||||
h[1] &= MASK;
|
||||
h[3] += h[2] >> 51;
|
||||
h[2] &= MASK;
|
||||
h[4] += h[3] >> 51;
|
||||
h[3] &= MASK;
|
||||
h[4] &= MASK;
|
||||
let mut out = [0_u8; 32];
|
||||
let mut acc: u128 = 0;
|
||||
let mut bits = 0_u32;
|
||||
let mut at = 0_usize;
|
||||
for limb in h {
|
||||
acc |= u128::from(limb) << bits;
|
||||
bits += 51;
|
||||
while bits >= 8 {
|
||||
out[at] = (acc & 0xFF) as u8;
|
||||
acc >>= 8;
|
||||
bits -= 8;
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
while at < 32 {
|
||||
out[at] = (acc & 0xFF) as u8;
|
||||
acc >>= 8;
|
||||
at += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 加法。
|
||||
pub(crate) fn add(&self, rhs: &Self) -> Self {
|
||||
let f = self.0;
|
||||
let g = rhs.0;
|
||||
Self(carry([
|
||||
f[0] + g[0],
|
||||
f[1] + g[1],
|
||||
f[2] + g[2],
|
||||
f[3] + g[3],
|
||||
f[4] + g[4],
|
||||
]))
|
||||
}
|
||||
|
||||
/// 减法(加 `2p` 再减,避免借位)。
|
||||
pub(crate) fn sub(&self, rhs: &Self) -> Self {
|
||||
let f = self.0;
|
||||
let g = rhs.0;
|
||||
// 2p 的肢:`2*(2^51-19)` 与 `2*(2^51-1)`。
|
||||
let two_p0 = 0x000F_FFFF_FFFF_FFDA;
|
||||
let two_p = 0x000F_FFFF_FFFF_FFFE;
|
||||
Self(carry([
|
||||
f[0] + two_p0 - g[0],
|
||||
f[1] + two_p - g[1],
|
||||
f[2] + two_p - g[2],
|
||||
f[3] + two_p - g[3],
|
||||
f[4] + two_p - g[4],
|
||||
]))
|
||||
}
|
||||
|
||||
/// 取负。
|
||||
pub(crate) fn neg(&self) -> Self {
|
||||
Self::ZERO.sub(self)
|
||||
}
|
||||
|
||||
/// 乘法(教科书式 5x5,中间积用 128 位)。
|
||||
pub(crate) fn mul(&self, rhs: &Self) -> Self {
|
||||
let f = self.0;
|
||||
let g = rhs.0;
|
||||
let g1 = u128::from(g[1]) * 19;
|
||||
let g2 = u128::from(g[2]) * 19;
|
||||
let g3 = u128::from(g[3]) * 19;
|
||||
let g4 = u128::from(g[4]) * 19;
|
||||
let f0 = u128::from(f[0]);
|
||||
let f1 = u128::from(f[1]);
|
||||
let f2 = u128::from(f[2]);
|
||||
let f3 = u128::from(f[3]);
|
||||
let f4 = u128::from(f[4]);
|
||||
let g0 = u128::from(g[0]);
|
||||
let mut h0 = f0 * g0 + f1 * g4 + f2 * g3 + f3 * g2 + f4 * g1;
|
||||
let mut h1 = f0 * u128::from(g[1]) + f1 * g0 + f2 * g4 + f3 * g3 + f4 * g2;
|
||||
let mut h2 = f0 * u128::from(g[2]) + f1 * u128::from(g[1]) + f2 * g0 + f3 * g4 + f4 * g3;
|
||||
let mut h3 = f0 * u128::from(g[3])
|
||||
+ f1 * u128::from(g[2])
|
||||
+ f2 * u128::from(g[1])
|
||||
+ f3 * g0
|
||||
+ f4 * g4;
|
||||
let mut h4 = f0 * u128::from(g[4])
|
||||
+ f1 * u128::from(g[3])
|
||||
+ f2 * u128::from(g[2])
|
||||
+ f3 * u128::from(g[1])
|
||||
+ f4 * g0;
|
||||
let mask128 = u128::from(MASK);
|
||||
let mut c = h0 >> 51;
|
||||
h0 &= mask128;
|
||||
h1 += c;
|
||||
c = h1 >> 51;
|
||||
h1 &= mask128;
|
||||
h2 += c;
|
||||
c = h2 >> 51;
|
||||
h2 &= mask128;
|
||||
h3 += c;
|
||||
c = h3 >> 51;
|
||||
h3 &= mask128;
|
||||
h4 += c;
|
||||
c = h4 >> 51;
|
||||
h4 &= mask128;
|
||||
h0 += c * 19;
|
||||
c = h0 >> 51;
|
||||
h0 &= mask128;
|
||||
h1 += c;
|
||||
Self([h0 as u64, h1 as u64, h2 as u64, h3 as u64, h4 as u64])
|
||||
}
|
||||
|
||||
/// 平方(乘法的特化,只为可读性)。
|
||||
pub(crate) fn square(&self) -> Self {
|
||||
self.mul(self)
|
||||
}
|
||||
|
||||
/// `self^exponent`,指数以 32 字节小端给出(从最高位往下平方-乘)。
|
||||
pub(crate) fn pow(&self, exponent: &[u8; 32]) -> Self {
|
||||
let mut acc = Self::ONE;
|
||||
for byte_index in (0..32).rev() {
|
||||
for bit in (0..8).rev() {
|
||||
acc = acc.square();
|
||||
if (exponent[byte_index] >> bit) & 1 == 1 {
|
||||
acc = acc.mul(self);
|
||||
}
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// 乘法逆元(`p` 是素数,故 `x^(p-2)`)。
|
||||
pub(crate) fn invert(&self) -> Self {
|
||||
// p - 2 = 2^255 - 21
|
||||
let mut e = [0xFF_u8; 32];
|
||||
e[0] = 0xEB;
|
||||
e[31] = 0x7F;
|
||||
self.pow(&e)
|
||||
}
|
||||
|
||||
/// `x^((p-5)/8)`,开方用(`(p-5)/8 = 2^252 - 3`)。
|
||||
pub(crate) fn pow_p58(&self) -> Self {
|
||||
let mut e = [0xFF_u8; 32];
|
||||
e[0] = 0xFD;
|
||||
e[31] = 0x0F;
|
||||
self.pow(&e)
|
||||
}
|
||||
|
||||
/// `sqrt(-1) = 2^((p-1)/4)`(`p ≡ 5 mod 8` 时开方失败要用它翻一次符号)。
|
||||
pub(crate) fn sqrt_m1() -> Self {
|
||||
// (p-1)/4 = 2^253 - 5
|
||||
let mut e = [0xFF_u8; 32];
|
||||
e[0] = 0xFB;
|
||||
e[31] = 0x1F;
|
||||
Self::from_u64(2).pow(&e)
|
||||
}
|
||||
|
||||
/// 最低位(规范编码的第 0 位);点解压时与签名的符号位比对。
|
||||
pub(crate) fn is_odd(&self) -> bool {
|
||||
self.to_bytes()[0] & 1 == 1
|
||||
}
|
||||
|
||||
/// 是否等于 0(按规范编码比)。
|
||||
pub(crate) fn is_zero(&self) -> bool {
|
||||
self.to_bytes() == [0_u8; 32]
|
||||
}
|
||||
}
|
||||
|
||||
/// 把肢挽到 51 位以内(高位的溢出折回第 0 肢,因为 `2^255 ≡ 19`)。
|
||||
fn carry(mut h: [u64; 5]) -> [u64; 5] {
|
||||
let mut c = h[0] >> 51;
|
||||
h[0] &= MASK;
|
||||
h[1] += c;
|
||||
c = h[1] >> 51;
|
||||
h[1] &= MASK;
|
||||
h[2] += c;
|
||||
c = h[2] >> 51;
|
||||
h[2] &= MASK;
|
||||
h[3] += c;
|
||||
c = h[3] >> 51;
|
||||
h[3] &= MASK;
|
||||
h[4] += c;
|
||||
c = h[4] >> 51;
|
||||
h[4] &= MASK;
|
||||
h[0] += c * 19;
|
||||
c = h[0] >> 51;
|
||||
h[0] &= MASK;
|
||||
h[1] += c;
|
||||
h
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! 扭曲爱德华兹曲线上的群运算(`a = -1`,扩展坐标 `(X:Y:Z:T)`,`xy = T/Z`)。
|
||||
//!
|
||||
//! 只用两条公式(Hisil-Wong-Carter-Dawson 2008):加法 `add-2008-hwcd-3`、倍点 `dbl-2008-hwcd`,
|
||||
//! 以及一个最朴素的「从最高位往下的平方-乘」标量乘。验签一次只要两次标量乘,朴素实现足够,
|
||||
//! 换来的是每一步都能对着公式读——这里宁可慢一点也不要一个看不懂的窗口表。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::field::Fe;
|
||||
|
||||
/// 曲线常数 `d = -121665/121666`。
|
||||
fn d() -> &'static Fe {
|
||||
static D: OnceLock<Fe> = OnceLock::new();
|
||||
D.get_or_init(|| {
|
||||
Fe::from_u64(121665)
|
||||
.mul(&Fe::from_u64(121666).invert())
|
||||
.neg()
|
||||
})
|
||||
}
|
||||
|
||||
/// `2d`(加法公式里那一步)。
|
||||
fn d2() -> &'static Fe {
|
||||
static D2: OnceLock<Fe> = OnceLock::new();
|
||||
D2.get_or_init(|| d().add(d()))
|
||||
}
|
||||
|
||||
/// 基点 `B` 的压缩编码(RFC 8032 §5.1 的标准常量,解压即得基点)。
|
||||
const BASE_ENCODING: [u8; 32] = [
|
||||
0x58, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66,
|
||||
0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66,
|
||||
];
|
||||
|
||||
/// 曲线上的点(扩展坐标)。
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Point {
|
||||
x: Fe,
|
||||
y: Fe,
|
||||
z: Fe,
|
||||
t: Fe,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
/// 单位元 `(0, 1, 1, 0)`。
|
||||
pub(crate) fn identity() -> Self {
|
||||
Self {
|
||||
x: Fe::ZERO,
|
||||
y: Fe::ONE,
|
||||
z: Fe::ONE,
|
||||
t: Fe::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// 基点 `B`。
|
||||
pub(crate) fn base() -> Self {
|
||||
// 常量本身是合法压缩点;万一实现坏了这一步会返回 None,用身份元兜底并让上层验签必然失败。
|
||||
Self::decompress(&BASE_ENCODING).unwrap_or_else(Self::identity)
|
||||
}
|
||||
|
||||
/// 点加。
|
||||
pub(crate) fn add(&self, rhs: &Self) -> Self {
|
||||
let a = self.y.sub(&self.x).mul(&rhs.y.sub(&rhs.x));
|
||||
let b = self.y.add(&self.x).mul(&rhs.y.add(&rhs.x));
|
||||
let c = self.t.mul(&d2().mul(&rhs.t));
|
||||
let dd = self.z.mul(&rhs.z);
|
||||
let dd = dd.add(&dd);
|
||||
let e = b.sub(&a);
|
||||
let f = dd.sub(&c);
|
||||
let g = dd.add(&c);
|
||||
let h = b.add(&a);
|
||||
Self {
|
||||
x: e.mul(&f),
|
||||
y: g.mul(&h),
|
||||
t: e.mul(&h),
|
||||
z: f.mul(&g),
|
||||
}
|
||||
}
|
||||
|
||||
/// 倍点。
|
||||
pub(crate) fn double(&self) -> Self {
|
||||
let a = self.x.square();
|
||||
let b = self.y.square();
|
||||
let z2 = self.z.square();
|
||||
let c = z2.add(&z2);
|
||||
let dd = a.neg();
|
||||
let e = self.x.add(&self.y).square().sub(&a).sub(&b);
|
||||
let g = dd.add(&b);
|
||||
let f = g.sub(&c);
|
||||
let h = dd.sub(&b);
|
||||
Self {
|
||||
x: e.mul(&f),
|
||||
y: g.mul(&h),
|
||||
t: e.mul(&h),
|
||||
z: f.mul(&g),
|
||||
}
|
||||
}
|
||||
|
||||
/// 压缩成 32 字节(`y` 的规范编码 + `x` 的最低位放进第 255 位)。
|
||||
pub(crate) fn compress(&self) -> [u8; 32] {
|
||||
let z_inv = self.z.invert();
|
||||
let x = self.x.mul(&z_inv);
|
||||
let y = self.y.mul(&z_inv);
|
||||
let mut out = y.to_bytes();
|
||||
if x.is_odd() {
|
||||
out[31] |= 0x80;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 从 32 字节压缩编码解压;**严格**:非规范的 `y`、解不出 `x`、`x = 0` 却带符号位都返回 `None`。
|
||||
pub(crate) fn decompress(bytes: &[u8; 32]) -> Option<Self> {
|
||||
let mut raw = *bytes;
|
||||
let sign = raw[31] >> 7;
|
||||
raw[31] &= 0x7F;
|
||||
let y = Fe::from_bytes(&raw);
|
||||
// 规范编码才收:`y >= p` 的写法(同一元素的第二种编法)一律拒绝,防「同一签名两种字节」。
|
||||
if y.to_bytes() != raw {
|
||||
return None;
|
||||
}
|
||||
// x^2 = (y^2 - 1) / (d y^2 + 1)
|
||||
let y2 = y.square();
|
||||
let u = y2.sub(&Fe::ONE);
|
||||
let v = d().mul(&y2).add(&Fe::ONE);
|
||||
let v3 = v.square().mul(&v);
|
||||
let v7 = v3.square().mul(&v);
|
||||
let mut x = u.mul(&v3).mul(&u.mul(&v7).pow_p58());
|
||||
if v.mul(&x.square()).to_bytes() != u.to_bytes() {
|
||||
// p ≡ 5 mod 8:这一支解不出时,乘 sqrt(-1) 再试一次(另一半平方根)。
|
||||
x = x.mul(&Fe::sqrt_m1());
|
||||
if v.mul(&x.square()).to_bytes() != u.to_bytes() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if x.is_zero() {
|
||||
// x = 0 时符号位只能是 0(否则就是「-0」,非规范)。
|
||||
if sign == 1 {
|
||||
return None;
|
||||
}
|
||||
} else if x.is_odd() != (sign == 1) {
|
||||
x = x.neg();
|
||||
}
|
||||
Some(Self {
|
||||
x,
|
||||
y,
|
||||
z: Fe::ONE,
|
||||
t: x.mul(&y),
|
||||
})
|
||||
}
|
||||
|
||||
/// `[scalar] self`,标量以 32 字节小端给出(从最高位往下:倍点,置位才加)。
|
||||
pub(crate) fn mul(&self, scalar: &[u8; 32]) -> Self {
|
||||
let mut acc = Self::identity();
|
||||
for byte_index in (0..32).rev() {
|
||||
for bit in (0..8).rev() {
|
||||
acc = acc.double();
|
||||
if (scalar[byte_index] >> bit) & 1 == 1 {
|
||||
acc = acc.add(self);
|
||||
}
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// 是否小阶点(`[8]P` 是单位元就是;`verify_strict` 的第一道严格性判据)。
|
||||
pub(crate) fn is_small_order(&self) -> bool {
|
||||
let p8 = self.double().double().double();
|
||||
p8.x.is_zero() && p8.y.to_bytes() == p8.z.to_bytes()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Ed25519 **严格验签**(RFC 8032 §5.1.7),只验不签、只用公开数据。
|
||||
//!
|
||||
//! # 为什么这里没有引 `curve25519-dalek` / `ed25519-dalek`
|
||||
//!
|
||||
//! M190 的 scope 不含 `crates/core/Cargo.toml`,而清单验签必须在 `core::update` 里(GUI 与 CLI
|
||||
//! 共用 `Job`,验签只能有一个收口点)。取舍:把验签实现**就地**写成三个文件(`field` 素域算术 /
|
||||
//! `group` 曲线运算 / 本文件判据),零新依赖,判据对着 RFC 8032 的官方测试向量逐条验
|
||||
//! (`tests/ed25519_cases.rs`)。
|
||||
//!
|
||||
//! 这条取舍的代价必须说清:
|
||||
//!
|
||||
//! * 实现只有「验」这一半——没有私钥、没有 nonce、没有常数时间要求,攻击面比完整签名库小得多;
|
||||
//! * 但它是**自写密码学**。日后 scope 允许时应当整体换成 `ed25519-dalek` 的
|
||||
//! `verify_strict`,对外只留 [`verify`] 一个函数,替换是改这一个函数体;
|
||||
//! * 严格性按 `verify_strict` 的口径来:拒绝非规范编码(`y >= p`、`-0`)、拒绝小阶公钥与小阶 `R`、
|
||||
//! 拒绝非规范标量 `S`(`S >= L`,即可锻性)。少任何一条都能造出「改一个字节仍通过」的假签。
|
||||
//!
|
||||
//! # 判据(逐条,都能单独变红)
|
||||
//!
|
||||
//! ```text
|
||||
//! ① 解 A、解 R 都必须成功且是规范编码 (非规范编码 → 拒)
|
||||
//! ② A、R 都不能是小阶点 (小阶 → 拒)
|
||||
//! ③ S < L(L = 2^252 + 27742317777372353535851937790883648493)
|
||||
//! ④ k = SHA-512(R‖A‖M) mod L
|
||||
//! ⑤ [S]B == R + [k]A(压缩后逐字节比)
|
||||
//! ```
|
||||
|
||||
mod field;
|
||||
mod group;
|
||||
|
||||
use sha2::{Digest, Sha512};
|
||||
|
||||
use self::group::Point;
|
||||
|
||||
/// 公钥字节数。
|
||||
pub(crate) const PUBLIC_KEY_LEN: usize = 32;
|
||||
/// 签名字节数。
|
||||
pub(crate) const SIGNATURE_LEN: usize = 64;
|
||||
|
||||
/// `L = 2^252 + 27742317777372353535851937790883648493`(小端 4 条 64 位肢)。
|
||||
const L: [u64; 4] = [
|
||||
0x5812_631a_5cf5_d3ed,
|
||||
0x14de_f9de_a2f7_9cd6,
|
||||
0x0000_0000_0000_0000,
|
||||
0x1000_0000_0000_0000,
|
||||
];
|
||||
|
||||
/// 验一条 Ed25519 签名。
|
||||
///
|
||||
/// `public_key` 与 `signature` 的语义就是 RFC 8032 的那两段字节;`message` 是任意长原文
|
||||
/// (这里恒为清单文件的原始字节,见 [`super::signature`])。
|
||||
#[must_use]
|
||||
pub(crate) fn verify(
|
||||
public_key: &[u8; PUBLIC_KEY_LEN],
|
||||
message: &[u8],
|
||||
signature: &[u8; SIGNATURE_LEN],
|
||||
) -> bool {
|
||||
let mut r_bytes = [0_u8; PUBLIC_KEY_LEN];
|
||||
r_bytes.copy_from_slice(&signature[..PUBLIC_KEY_LEN]);
|
||||
let mut s_bytes = [0_u8; PUBLIC_KEY_LEN];
|
||||
s_bytes.copy_from_slice(&signature[PUBLIC_KEY_LEN..]);
|
||||
// ③ S 必须是规范标量:`S >= L` 时同一个「消息 + 公钥」存在多个可验证的签名,签名的不可锻性就没了。
|
||||
if !scalar_is_canonical(&s_bytes) {
|
||||
return false;
|
||||
}
|
||||
let Some(public_point) = Point::decompress(public_key) else {
|
||||
return false;
|
||||
};
|
||||
let Some(r_point) = Point::decompress(&r_bytes) else {
|
||||
return false;
|
||||
};
|
||||
// ② 小阶点会让「谁都能构造一个自洽的四元组」,严格验签一律拒。
|
||||
if public_point.is_small_order() || r_point.is_small_order() {
|
||||
return false;
|
||||
}
|
||||
// ④ k = SHA-512(R ‖ A ‖ M) mod L
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(r_bytes);
|
||||
hasher.update(public_key);
|
||||
hasher.update(message);
|
||||
let digest = hasher.finalize();
|
||||
let mut wide = [0_u8; 64];
|
||||
wide.copy_from_slice(&digest);
|
||||
let k = reduce_512(&wide);
|
||||
// ⑤ [S]B 与 R + [k]A 必须是同一个点(压缩后逐字节比即「同一个点」,因为压缩是单射)。
|
||||
let left = Point::base().mul(&s_bytes);
|
||||
let right = r_point.add(&public_point.mul(&k));
|
||||
left.compress() == right.compress()
|
||||
}
|
||||
|
||||
/// 从 32 字节种子推出公钥(**只给测试用**:正式构建的验签不需要它,也没有任何私钥代码)。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn public_key_from_seed_for_tests(seed: &[u8; 32]) -> [u8; PUBLIC_KEY_LEN] {
|
||||
let (scalar, _) = expand_seed(seed);
|
||||
Point::base().mul(&scalar).compress()
|
||||
}
|
||||
|
||||
/// 用 32 字节种子签一条消息(**只给测试用**,见 [`public_key_from_seed_for_tests`])。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn sign_for_tests(seed: &[u8; 32], message: &[u8]) -> [u8; SIGNATURE_LEN] {
|
||||
let (scalar, prefix) = expand_seed(seed);
|
||||
let public_key = Point::base().mul(&scalar).compress();
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(prefix);
|
||||
hasher.update(message);
|
||||
let mut wide = [0_u8; 64];
|
||||
wide.copy_from_slice(&hasher.finalize());
|
||||
let r = reduce_512(&wide);
|
||||
let r_encoded = Point::base().mul(&r).compress();
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(r_encoded);
|
||||
hasher.update(public_key);
|
||||
hasher.update(message);
|
||||
let mut wide = [0_u8; 64];
|
||||
wide.copy_from_slice(&hasher.finalize());
|
||||
let k = reduce_512(&wide);
|
||||
let s = scalar_mul_add_mod_l(&k, &scalar, &r);
|
||||
let mut out = [0_u8; SIGNATURE_LEN];
|
||||
out[..PUBLIC_KEY_LEN].copy_from_slice(&r_encoded);
|
||||
out[PUBLIC_KEY_LEN..].copy_from_slice(&s);
|
||||
out
|
||||
}
|
||||
|
||||
/// RFC 8032 §5.1.5 的种子展开:`SHA-512(seed)` 的前半是按位裁剪的标量、后半是 nonce 前缀。
|
||||
#[cfg(test)]
|
||||
fn expand_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
|
||||
let digest = Sha512::digest(seed);
|
||||
let mut scalar = [0_u8; 32];
|
||||
scalar.copy_from_slice(&digest[..32]);
|
||||
scalar[0] &= 248;
|
||||
scalar[31] &= 127;
|
||||
scalar[31] |= 64;
|
||||
let mut prefix = [0_u8; 32];
|
||||
prefix.copy_from_slice(&digest[32..]);
|
||||
(scalar, prefix)
|
||||
}
|
||||
|
||||
/// `S < L` 判定(小端 32 字节)。
|
||||
fn scalar_is_canonical(bytes: &[u8; 32]) -> bool {
|
||||
let mut limbs = [0_u64; 4];
|
||||
let (chunks, _) = bytes.as_chunks::<8>();
|
||||
for (limb, chunk) in limbs.iter_mut().zip(chunks) {
|
||||
*limb = u64::from_le_bytes(*chunk);
|
||||
}
|
||||
// 从最高肢往下比:高于 L 的最高肢就不可能小于 L。
|
||||
for index in (0..4).rev() {
|
||||
if limbs[index] < L[index] {
|
||||
return true;
|
||||
}
|
||||
if limbs[index] > L[index] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 把一个 512 位大端-无关的字节串按小端解释,对 `L` 取模。
|
||||
///
|
||||
/// `bytes[63]` 是最高字节。做法是最朴素的竖式:每次左移 8 位再补一个字节,随后反复减 `L`
|
||||
/// 直到小于 `L`。一次验签跑两遍、每遍最坏 64*256 次减——比任何花招都更容易对着看懂。
|
||||
fn reduce_512(bytes: &[u8; 64]) -> [u8; 32] {
|
||||
let mut acc = [0_u64; 5];
|
||||
for byte in bytes.iter().rev() {
|
||||
let mut carry = u128::from(*byte);
|
||||
for limb in &mut acc {
|
||||
let wide = (u128::from(*limb) << 8) | carry;
|
||||
*limb = wide as u64;
|
||||
carry = wide >> 64;
|
||||
}
|
||||
while !below_l(&acc) {
|
||||
subtract_l(&mut acc);
|
||||
}
|
||||
}
|
||||
let mut out = [0_u8; 32];
|
||||
for index in 0..4 {
|
||||
out[index * 8..index * 8 + 8].copy_from_slice(&acc[index].to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `acc < L`(`acc` 是 5 肢,最高肢非零即「远大于 L」)。
|
||||
fn below_l(acc: &[u64; 5]) -> bool {
|
||||
if acc[4] != 0 {
|
||||
return false;
|
||||
}
|
||||
for index in (0..4).rev() {
|
||||
if acc[index] < L[index] {
|
||||
return true;
|
||||
}
|
||||
if acc[index] > L[index] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `acc -= L`(调用方保证 `acc >= L`)。
|
||||
fn subtract_l(acc: &mut [u64; 5]) {
|
||||
let mut borrow = 0_u64;
|
||||
for index in 0..4 {
|
||||
let (value, first) = acc[index].overflowing_sub(L[index]);
|
||||
let (value, second) = value.overflowing_sub(borrow);
|
||||
acc[index] = value;
|
||||
borrow = u64::from(first || second);
|
||||
}
|
||||
let (value, _) = acc[4].overflowing_sub(borrow);
|
||||
acc[4] = value;
|
||||
}
|
||||
|
||||
/// `(k*a + r) mod L`(测试签名用;`k`、`a`、`r` 都已是规范标量)。
|
||||
#[cfg(test)]
|
||||
fn scalar_mul_add_mod_l(k: &[u8; 32], a: &[u8; 32], r: &[u8; 32]) -> [u8; 32] {
|
||||
// 256x256 -> 512 位乘积:8 条 64 位肢,逐肢进位后再按字节展开成 64 字节(小端)。
|
||||
let mut product = [0_u64; 8];
|
||||
for (left_index, left) in limbs_of(k).iter().enumerate() {
|
||||
let mut carry = 0_u128;
|
||||
for (right_index, right) in limbs_of(a).iter().enumerate() {
|
||||
let cell = u128::from(*left) * u128::from(*right)
|
||||
+ u128::from(product[left_index + right_index])
|
||||
+ carry;
|
||||
product[left_index + right_index] = cell as u64;
|
||||
carry = cell >> 64;
|
||||
}
|
||||
product[left_index + 4] = u64::try_from(carry).unwrap_or(0);
|
||||
}
|
||||
let mut wide = [0_u8; 64];
|
||||
for (index, limb) in product.iter().enumerate() {
|
||||
wide[index * 8..index * 8 + 8].copy_from_slice(&limb.to_le_bytes());
|
||||
}
|
||||
let reduced = reduce_512(&wide);
|
||||
// 加上 r 再取模一次(`acc` 5 肢 + 反复减 L,与 reduce_512 同一手法)。
|
||||
let mut acc = [0_u64; 5];
|
||||
for index in 0..4 {
|
||||
let mut buf = [0_u8; 8];
|
||||
buf.copy_from_slice(&reduced[index * 8..index * 8 + 8]);
|
||||
acc[index] = u64::from_le_bytes(buf);
|
||||
}
|
||||
let mut carry = 0_u64;
|
||||
for (slot, rhs) in acc.iter_mut().zip(limbs_of(r)) {
|
||||
let (sum, overflow) = slot.overflowing_add(rhs);
|
||||
let (sum, overflow2) = sum.overflowing_add(carry);
|
||||
*slot = sum;
|
||||
carry = u64::from(overflow || overflow2);
|
||||
}
|
||||
acc[4] = carry;
|
||||
while !below_l(&acc) {
|
||||
subtract_l(&mut acc);
|
||||
}
|
||||
let mut out = [0_u8; 32];
|
||||
for index in 0..4 {
|
||||
out[index * 8..index * 8 + 8].copy_from_slice(&acc[index].to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 32 字节小端 -> 4 条 64 位肢。
|
||||
#[cfg(test)]
|
||||
fn limbs_of(bytes: &[u8; 32]) -> [u64; 4] {
|
||||
let mut limbs = [0_u64; 4];
|
||||
let (chunks, _) = bytes.as_chunks::<8>();
|
||||
for (limb, chunk) in limbs.iter_mut().zip(chunks) {
|
||||
*limb = u64::from_le_bytes(*chunk);
|
||||
}
|
||||
limbs
|
||||
}
|
||||
@@ -33,6 +33,21 @@ pub enum UpdateErrorKind {
|
||||
DigestMismatch,
|
||||
/// 长度不符:典型成因是下载被截断,也可能清单写错。
|
||||
SizeMismatch,
|
||||
/// 清单没有配套签名文件(`<清单名>.sig` 不在源里)。
|
||||
///
|
||||
/// 与 [`Self::SignatureInvalid`] 分开:这条是**发布侧漏了签名**,不是清单被改;
|
||||
/// 处置也不同(等发布侧补,或回滚发布),所以必须是两个分类。
|
||||
SignatureMissing,
|
||||
/// 签名文件本身坏了:不是 JSON、字段名拼错、`format` 不认识、`signatures` 是空的。
|
||||
SignatureMalformed,
|
||||
/// 本构建没有钉任何清单签名公钥(正式信任表为空)。
|
||||
///
|
||||
/// 这是「构建形态」的问题,不是清单的问题:空表一律拒装,绝不「没钥就放行」。
|
||||
KeyringEmpty,
|
||||
/// 清单的签名者不在钉住的信任表里(`key_id` 一个都没命中)。
|
||||
SignatureUnknownKey,
|
||||
/// 签名对不上清单字节(清单被改过,或签名是给另一份清单的)。
|
||||
SignatureInvalid,
|
||||
}
|
||||
|
||||
impl UpdateErrorKind {
|
||||
@@ -49,6 +64,11 @@ impl UpdateErrorKind {
|
||||
Self::Io => "io",
|
||||
Self::DigestMismatch => "digest_mismatch",
|
||||
Self::SizeMismatch => "size_mismatch",
|
||||
Self::SignatureMissing => "signature_missing",
|
||||
Self::SignatureMalformed => "signature_malformed",
|
||||
Self::KeyringEmpty => "keyring_empty",
|
||||
Self::SignatureUnknownKey => "signature_unknown_key",
|
||||
Self::SignatureInvalid => "signature_invalid",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +76,9 @@ impl UpdateErrorKind {
|
||||
///
|
||||
/// 清单本身不合法、协议不支持、渠道不符这几类换多少源都一样,所以只有字节层
|
||||
/// (摘要/长度)、网络(可能是瞬时抖动)与 IO(可能是瞬时占用)算可重试。
|
||||
///
|
||||
/// 签名五兄弟**一律不可重试**:签名不对不是抖动,重试一千次还是不对。把它们标成可重试
|
||||
/// 会让壳层反复弹「网络问题,稍后重试」,掩盖真正的原因(发布侧漏签 / 钥没钉 / 清单被改)。
|
||||
#[must_use]
|
||||
pub const fn is_retryable(self) -> bool {
|
||||
matches!(
|
||||
@@ -63,6 +86,19 @@ impl UpdateErrorKind {
|
||||
Self::DigestMismatch | Self::SizeMismatch | Self::Network | Self::Io
|
||||
)
|
||||
}
|
||||
|
||||
/// 是否属于「清单签名」这一组(壳层据此换文案:这不是网络问题,别让用户重试)。
|
||||
#[must_use]
|
||||
pub const fn is_signature(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::SignatureMissing
|
||||
| Self::SignatureMalformed
|
||||
| Self::KeyringEmpty
|
||||
| Self::SignatureUnknownKey
|
||||
| Self::SignatureInvalid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自更新错误。
|
||||
|
||||
@@ -60,8 +60,8 @@ use crate::download::{
|
||||
|
||||
use super::channel::Channel;
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::manifest::{Artifact, ReleaseManifest};
|
||||
use super::source::UpdateSource;
|
||||
use super::manifest::Artifact;
|
||||
use super::source::{ManifestDocuments, UpdateSource};
|
||||
|
||||
/// 默认仓库 owner。
|
||||
pub const DEFAULT_OWNER: &str = "AskaEth";
|
||||
@@ -71,6 +71,8 @@ pub const DEFAULT_REPO: &str = "Unlucky-Launcher";
|
||||
pub const DEFAULT_BASE: &str = "https://github.com";
|
||||
/// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。
|
||||
pub const MANIFEST_ASSET: &str = "manifest.json";
|
||||
/// 清单签名的资产名(M190:与清单并列挂在同一个 release 上)。
|
||||
pub const MANIFEST_SIGNATURE_ASSET: &str = "manifest.json.sig";
|
||||
|
||||
/// GitHub Releases 更新源。
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -265,27 +267,24 @@ impl UpdateSource for GitHubReleaseSource {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
|
||||
fn fetch_manifest_documents(
|
||||
&self,
|
||||
channel: Channel,
|
||||
) -> Result<Option<ManifestDocuments>, UpdateError> {
|
||||
let Some(url) = self.manifest_url(channel) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(text) = self.fetch_text(&url)? else {
|
||||
let Some(manifest) = self.fetch_text(&url)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let manifest = ReleaseManifest::from_json(&text).map_err(|mut error| {
|
||||
error.message = format!("{}(来源 {url})", error.message);
|
||||
error
|
||||
})?;
|
||||
if manifest.channel != channel {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::ChannelMismatch,
|
||||
format!(
|
||||
"请求的是 {channel} 渠道,清单 {url} 自报 {}",
|
||||
manifest.channel
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(Some(manifest))
|
||||
// 签名文件的 404 与清单的 404 处理不同:清单没有 = 该渠道没发过版(不是错误);
|
||||
// 签名没有 = 发布侧漏了签名(上层判 SignatureMissing 并拒装)。
|
||||
let signature_url = format!("{}/{MANIFEST_SIGNATURE_ASSET}", self.download_base());
|
||||
let signatures = self.fetch_text(&signature_url)?;
|
||||
Ok(Some(ManifestDocuments {
|
||||
manifest,
|
||||
signatures,
|
||||
}))
|
||||
}
|
||||
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
|
||||
|
||||
@@ -227,11 +227,11 @@ impl UpdateSource for SourceRef<'_> {
|
||||
self.0.source_id()
|
||||
}
|
||||
|
||||
fn fetch_manifest(
|
||||
fn fetch_manifest_documents(
|
||||
&self,
|
||||
channel: Channel,
|
||||
) -> Result<Option<super::manifest::ReleaseManifest>, UpdateError> {
|
||||
self.0.fetch_manifest(channel)
|
||||
) -> Result<Option<super::source::ManifestDocuments>, UpdateError> {
|
||||
self.0.fetch_manifest_documents(channel)
|
||||
}
|
||||
|
||||
fn fetch_artifact(
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态
|
||||
//! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50)
|
||||
//! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急
|
||||
//! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring)
|
||||
//! ed25519 Ed25519 严格验签(自写,无新依赖)
|
||||
//! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点)
|
||||
//! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口)
|
||||
//! ```
|
||||
@@ -49,17 +51,32 @@
|
||||
//! `--apply-to`),属启动器外壳的编排;本模块只保证搬运本身原子且可回滚。
|
||||
//! * **不装安装器形态**(msi/dmg/deb/rpm):交给系统包管理器。
|
||||
//! * **不做差分/增量**:清单里没有 patch 字段,宁可每次全量,也别先上一套没验过的差分协议。
|
||||
//!
|
||||
//! # 信任根(M190)
|
||||
//!
|
||||
//! ```text
|
||||
//! 源只取字节 ──> signature::verify_manifest(清单**原始字节** + `manifest.json.sig`)
|
||||
//! │ 判据:钉死的公钥(trust::keyring,编译期常量)验 Ed25519
|
||||
//! ├─ 过了 ──> manifest::from_json ──> checker/apply
|
||||
//! └─ 不过 ──> 五类签名错误(不可重试),**什么都不会被安装**
|
||||
//! ```
|
||||
//!
|
||||
//! 一句话:**先验签、再解析**。清单的 sha256 只保证「包体和清单对上」,清单本身可不可信由签名回答——
|
||||
//! 两条判据互不替代(`tests/signature_cases.rs` 分别证明)。
|
||||
|
||||
pub mod channel;
|
||||
pub mod checker;
|
||||
mod ed25519;
|
||||
pub mod error;
|
||||
pub mod github;
|
||||
pub mod install;
|
||||
pub mod job;
|
||||
pub mod manifest;
|
||||
mod replace_io;
|
||||
mod signature;
|
||||
pub mod source;
|
||||
pub mod target;
|
||||
mod trust;
|
||||
pub mod verify;
|
||||
pub mod version;
|
||||
|
||||
@@ -77,7 +94,8 @@ pub use self::job::{
|
||||
default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV,
|
||||
};
|
||||
pub use self::manifest::{Artifact, Release, ReleaseManifest};
|
||||
pub use self::source::{DirSource, UpdateSource};
|
||||
pub use self::signature::VerifiedSignature;
|
||||
pub use self::source::{verified_manifest, DirSource, ManifestDocuments, UpdateSource};
|
||||
pub use self::target::{Arch, PackageFormat, Target};
|
||||
pub use self::verify::{stage, verify_file, StagedPackage};
|
||||
pub use self::version::{Stage, Version, VersionError};
|
||||
|
||||
@@ -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}…")
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -17,7 +17,7 @@ fn target() -> Target {
|
||||
/// 新建一个只放了这一份清单的本地源;返回的 `TempDir` 要活到用例结束。
|
||||
fn source_with(json: &str, channel: Channel) -> (TempDir, DirSource) {
|
||||
let dir = TempDir::new("checker");
|
||||
dir.write(&format!("{}.json", channel.as_str()), json.as_bytes());
|
||||
dir.write_signed(&format!("{}.json", channel.as_str()), json);
|
||||
let source = DirSource::new(dir.path());
|
||||
(dir, source)
|
||||
}
|
||||
@@ -215,7 +215,7 @@ fn falls_back_to_the_newest_release_that_does_have_the_target() {
|
||||
#[test]
|
||||
fn channel_mismatch_in_the_source_is_an_error() {
|
||||
let tmp = TempDir::new("checker-mismatch");
|
||||
tmp.write("stable.json", manifest("beta", &[]).as_bytes());
|
||||
tmp.write_signed("stable.json", &manifest("beta", &[]));
|
||||
let source = crate::update::DirSource::new(tmp.path());
|
||||
let current = crate::update::Version::parse("0.2.1").expect("版本");
|
||||
let error = UpdateChecker::new(source, target())
|
||||
@@ -228,7 +228,7 @@ fn channel_mismatch_in_the_source_is_an_error() {
|
||||
fn prepare_stages_the_selected_artifact() {
|
||||
let tmp = TempDir::new("checker-prepare");
|
||||
tmp.write("a.zip", PAYLOAD);
|
||||
tmp.write("stable.json", stable_release("0.3.0").as_bytes());
|
||||
tmp.write_signed("stable.json", &stable_release("0.3.0"));
|
||||
let source = crate::update::DirSource::new(tmp.path());
|
||||
let checker = UpdateChecker::new(source, target());
|
||||
let current = crate::update::Version::parse("0.2.0").expect("版本");
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Ed25519 严格验签的判据(RFC 8032 §7.1 官方测试向量 + 六条「该拒就拒」)。
|
||||
//!
|
||||
//! 这一组用例是自写密码学的**唯一**公信力来源:向量来自 RFC 附录(不是自己签自己验),
|
||||
//! 拒绝类用例覆盖「改一个字节仍然通过」的全部已知构造(改 R / 改 S / S += L / 小阶公钥 /
|
||||
//! 非规范 R 编码 / 空签名)。
|
||||
|
||||
use crate::update::ed25519::{
|
||||
public_key_from_seed_for_tests, sign_for_tests, verify, PUBLIC_KEY_LEN, SIGNATURE_LEN,
|
||||
};
|
||||
|
||||
/// 十六进制转字节(长度不对/字符非法当场 panic:这些向量是写死的常量)。
|
||||
fn unhex(text: &str) -> Vec<u8> {
|
||||
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
assert!(
|
||||
cleaned.len().is_multiple_of(2),
|
||||
"十六进制串长度必须是偶数:{text}"
|
||||
);
|
||||
cleaned
|
||||
.as_bytes()
|
||||
.chunks(2)
|
||||
.map(|pair| {
|
||||
let hi = (pair[0] as char).to_digit(16).expect("十六进制字符");
|
||||
let lo = (pair[1] as char).to_digit(16).expect("十六进制字符");
|
||||
u8::try_from(hi * 16 + lo).expect("字节")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn array32(bytes: &[u8]) -> [u8; 32] {
|
||||
let mut out = [0_u8; 32];
|
||||
out.copy_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn array64(bytes: &[u8]) -> [u8; 64] {
|
||||
let mut out = [0_u8; 64];
|
||||
out.copy_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
/// L(群阶)的小端字节,用于构造「S + L」这种可锻变体。
|
||||
const L_BYTES: [u8; 32] = [
|
||||
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10,
|
||||
];
|
||||
|
||||
/// RFC 8032 的四个向量:(public, message, signature)。
|
||||
fn rfc_vectors() -> Vec<(String, String, String)> {
|
||||
vec![
|
||||
(
|
||||
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a".to_string(),
|
||||
String::new(),
|
||||
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b".to_string(),
|
||||
),
|
||||
(
|
||||
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c".to_string(),
|
||||
"72".to_string(),
|
||||
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00".to_string(),
|
||||
),
|
||||
(
|
||||
"fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025".to_string(),
|
||||
"af82".to_string(),
|
||||
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a".to_string(),
|
||||
),
|
||||
(
|
||||
"ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf".to_string(),
|
||||
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f".to_string(),
|
||||
"dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b58909351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704".to_string(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8032_vectors_verify() {
|
||||
for (index, (public, message, signature)) in rfc_vectors().into_iter().enumerate() {
|
||||
let public = array32(&unhex(&public));
|
||||
let message = unhex(&message);
|
||||
let signature = array64(&unhex(&signature));
|
||||
assert!(
|
||||
verify(&public, &message, &signature),
|
||||
"RFC 8032 第 {} 条向量必须通过",
|
||||
index + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_bit_flip_is_rejected() {
|
||||
let (public, message, signature) = &rfc_vectors()[1];
|
||||
let public = array32(&unhex(public));
|
||||
let mut message = unhex(message);
|
||||
message[0] ^= 0x01;
|
||||
let signature = array64(&unhex(signature));
|
||||
assert!(
|
||||
!verify(&public, &message, &signature),
|
||||
"改一个消息字节必须被拒"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_bit_flip_is_rejected() {
|
||||
let (public, message, signature) = &rfc_vectors()[2];
|
||||
let public = array32(&unhex(public));
|
||||
let message = unhex(message);
|
||||
let mut signature = array64(&unhex(signature));
|
||||
// 只改 R 的最后一位(不动消息、不动 S):证明「只改签名字节」也会红。
|
||||
signature[31] ^= 0x01;
|
||||
assert!(!verify(&public, &message, &signature), "改 R 必须被拒");
|
||||
let mut signature = array64(&unhex(&rfc_vectors()[2].2));
|
||||
signature[63] ^= 0x04;
|
||||
assert!(!verify(&public, &message, &signature), "改 S 必须被拒");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malleable_s_plus_l_is_rejected() {
|
||||
// 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余),严格实现必须拒。
|
||||
let (public, message, signature) = &rfc_vectors()[0];
|
||||
let public = array32(&unhex(public));
|
||||
let message = unhex(message);
|
||||
let mut signature = array64(&unhex(signature));
|
||||
let mut carry = 0_u16;
|
||||
for index in 0..32 {
|
||||
let sum = u16::from(signature[32 + index]) + u16::from(L_BYTES[index]) + carry;
|
||||
signature[32 + index] = (sum & 0xFF) as u8;
|
||||
carry = sum >> 8;
|
||||
}
|
||||
assert_eq!(
|
||||
carry, 0,
|
||||
"S + L 不该溢出 256 位(否则这条用例本身没造出来)"
|
||||
);
|
||||
assert!(
|
||||
!verify(&public, &message, &signature),
|
||||
"S >= L 必须被拒(可锻变体)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_order_public_key_is_rejected() {
|
||||
// 阶为 1 的点(y = 1, x = 0)与阶为 2 的点(y = 0):用它们做公钥,谁都能凑出「自洽」的签名。
|
||||
let message = b"manifest bytes";
|
||||
let identity_key = {
|
||||
let mut key = [0_u8; PUBLIC_KEY_LEN];
|
||||
key[0] = 1;
|
||||
key
|
||||
};
|
||||
let order_two_key = [0_u8; PUBLIC_KEY_LEN];
|
||||
let mut signature = [0_u8; SIGNATURE_LEN];
|
||||
signature[0] = 1; // R = 单位元
|
||||
assert!(
|
||||
!verify(&identity_key, message, &signature),
|
||||
"小阶公钥必须被拒"
|
||||
);
|
||||
assert!(
|
||||
!verify(&order_two_key, message, &signature),
|
||||
"零公钥必须被拒"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_canonical_r_encoding_is_rejected() {
|
||||
// R 的 y = p(= 2^255-19)与 y = 1 同值但编码不同:非规范写法必须拒。
|
||||
let (public, message, _) = &rfc_vectors()[0];
|
||||
let public = array32(&unhex(public));
|
||||
let message = unhex(message);
|
||||
let p_bytes: [u8; 32] = [
|
||||
0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0x7f,
|
||||
];
|
||||
let mut signature = [0_u8; SIGNATURE_LEN];
|
||||
signature[..32].copy_from_slice(&p_bytes);
|
||||
assert!(
|
||||
!verify(&public, &message, &signature),
|
||||
"非规范 R 编码必须被拒"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_signature_is_rejected() {
|
||||
let (public, message, _) = &rfc_vectors()[0];
|
||||
assert!(!verify(
|
||||
&array32(&unhex(public)),
|
||||
&unhex(message),
|
||||
&[0_u8; 64]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signer_round_trips_and_uses_rfc_seed() {
|
||||
// 测试钥不是「另写一套算法」:RFC 8032 的 secret 反推出 public 就对上了,签出来的也能验过。
|
||||
let seed = array32(&unhex(
|
||||
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
|
||||
));
|
||||
let expected = array32(&unhex(
|
||||
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
|
||||
));
|
||||
assert_eq!(
|
||||
public_key_from_seed_for_tests(&seed),
|
||||
expected,
|
||||
"种子展开必须与 RFC 一致"
|
||||
);
|
||||
let signature = sign_for_tests(&seed, b"");
|
||||
assert_eq!(
|
||||
signature,
|
||||
array64(&unhex(&rfc_vectors()[0].2)),
|
||||
"空消息的签名必须逐字节等于 RFC 向量(证明签名侧与向量同一套算术语义)"
|
||||
);
|
||||
assert!(verify(
|
||||
&expected,
|
||||
b"unlucky",
|
||||
&sign_for_tests(&seed, b"unlucky")
|
||||
));
|
||||
}
|
||||
@@ -14,7 +14,9 @@ use crate::update::{
|
||||
UpdateErrorKind, UpdateSource, Version,
|
||||
};
|
||||
|
||||
use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir};
|
||||
use super::support::{
|
||||
artifact, engine_for, manifest, release, signature_envelope, FakeHttp, FakeResponse, TempDir,
|
||||
};
|
||||
|
||||
const PAYLOAD: &[u8] = b"github release payload";
|
||||
|
||||
@@ -23,6 +25,22 @@ fn manifest_path() -> String {
|
||||
format!("/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}")
|
||||
}
|
||||
|
||||
/// 假服务上签名所在路径(M190:与清单并列)。
|
||||
fn signature_path() -> String {
|
||||
format!("{}.sig", manifest_path())
|
||||
}
|
||||
|
||||
/// 「清单 + 签名」两条路由:签名用测试钥现签,与 `DirSource` 夹具同一把钥。
|
||||
fn signed_routes(manifest_json: &str) -> Vec<(String, FakeResponse)> {
|
||||
vec![
|
||||
(manifest_path(), FakeResponse::json(manifest_json)),
|
||||
(
|
||||
signature_path(),
|
||||
FakeResponse::json(&signature_envelope(manifest_json)),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// 按 D49 命名规则的产物文件名。
|
||||
fn asset_name() -> &'static str {
|
||||
"unlucky-launcher_0.6.0_windows_x64.exe"
|
||||
@@ -104,10 +122,7 @@ fn manifest_url_is_the_fixed_latest_download_path() {
|
||||
#[test]
|
||||
fn channels_other_than_stable_publish_nothing() {
|
||||
let tmp = TempDir::new("gh-channels");
|
||||
let server = FakeHttp::start(vec![(
|
||||
manifest_path().as_str(),
|
||||
FakeResponse::json(&stable_manifest_json()),
|
||||
)]);
|
||||
let server = FakeHttp::start(signed_routes(&stable_manifest_json()));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] {
|
||||
@@ -173,10 +188,7 @@ fn artifact_url_appends_the_release_download_path() {
|
||||
#[test]
|
||||
fn fetches_and_parses_the_manifest_from_the_base() {
|
||||
let tmp = TempDir::new("gh-manifest-ok");
|
||||
let server = FakeHttp::start(vec![(
|
||||
manifest_path().as_str(),
|
||||
FakeResponse::json(&stable_manifest_json()),
|
||||
)]);
|
||||
let server = FakeHttp::start(signed_routes(&stable_manifest_json()));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let manifest = source
|
||||
@@ -186,14 +198,18 @@ fn fetches_and_parses_the_manifest_from_the_base() {
|
||||
assert_eq!(manifest.channel, Channel::Stable);
|
||||
assert_eq!(manifest.releases.len(), 1);
|
||||
assert_eq!(manifest.releases[0].version.to_string(), "0.6.0");
|
||||
assert_eq!(server.hits(), vec![manifest_path()]);
|
||||
assert_eq!(
|
||||
server.hits(),
|
||||
vec![manifest_path(), signature_path()],
|
||||
"先取清单、再取它旁边的签名,两个请求都发过"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_manifest_in_the_release_is_not_an_error() {
|
||||
// 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。
|
||||
let tmp = TempDir::new("gh-manifest-404");
|
||||
let server = FakeHttp::start(vec![]);
|
||||
let server = FakeHttp::start(Vec::new());
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let outcome = source
|
||||
@@ -210,10 +226,8 @@ fn missing_manifest_in_the_release_is_not_an_error() {
|
||||
#[test]
|
||||
fn broken_json_is_reported_as_invalid_manifest() {
|
||||
let tmp = TempDir::new("gh-manifest-broken");
|
||||
let server = FakeHttp::start(vec![(
|
||||
manifest_path().as_str(),
|
||||
FakeResponse::json("{ not json"),
|
||||
)]);
|
||||
// 签名是对的(签的就是这串坏字节)——只有「签名对、内容坏」才走得到解析这一步。
|
||||
let server = FakeHttp::start(signed_routes("{ not json"));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error = source
|
||||
@@ -221,8 +235,8 @@ fn broken_json_is_reported_as_invalid_manifest() {
|
||||
.expect_err("坏 JSON 必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
assert!(
|
||||
error.message.contains(&server.base()),
|
||||
"错误里要带出来源地址,日志才定位得到(实际:{})",
|
||||
error.message.contains("github:"),
|
||||
"错误里要带出来源标识,日志才定位得到(实际:{})",
|
||||
error.message
|
||||
);
|
||||
assert!(!error.is_retryable(), "清单坏重试多少次都一样");
|
||||
@@ -233,7 +247,7 @@ fn illegal_manifest_fields_are_reported() {
|
||||
let tmp = TempDir::new("gh-manifest-illegal");
|
||||
// size 为 0(发布脚本写漏)——字段层面的非法,走同一条 InvalidManifest 分类。
|
||||
let json = r#"{"channel":"stable","releases":[{"version":"0.6.0","artifacts":[{"target":"win-x64","format":"portable-exe","url":"ull.exe","size":0,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}]}"#;
|
||||
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::json(json))]);
|
||||
let server = FakeHttp::start(signed_routes(json));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error = source
|
||||
@@ -245,10 +259,7 @@ fn illegal_manifest_fields_are_reported() {
|
||||
#[test]
|
||||
fn manifest_self_reporting_another_channel_is_a_mismatch() {
|
||||
let tmp = TempDir::new("gh-manifest-mismatch");
|
||||
let server = FakeHttp::start(vec![(
|
||||
manifest_path().as_str(),
|
||||
FakeResponse::json(&manifest("beta", &[])),
|
||||
)]);
|
||||
let server = FakeHttp::start(signed_routes(&manifest("beta", &[])));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error = source
|
||||
@@ -260,7 +271,7 @@ fn manifest_self_reporting_another_channel_is_a_mismatch() {
|
||||
#[test]
|
||||
fn server_errors_are_network_errors() {
|
||||
let tmp = TempDir::new("gh-manifest-500");
|
||||
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::status(500))]);
|
||||
let server = FakeHttp::start(vec![(manifest_path(), FakeResponse::status(500))]);
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error = source
|
||||
@@ -273,7 +284,7 @@ fn server_errors_are_network_errors() {
|
||||
#[test]
|
||||
fn downloads_the_artifact_through_the_engine() {
|
||||
let tmp = TempDir::new("gh-artifact-download");
|
||||
let server = FakeHttp::start(vec![(asset_path().as_str(), FakeResponse::bin(PAYLOAD))]);
|
||||
let server = FakeHttp::start(vec![(asset_path(), FakeResponse::bin(PAYLOAD))]);
|
||||
let source = local_source(&tmp, &server);
|
||||
let dest = tmp.join("staging/package.exe");
|
||||
|
||||
@@ -292,7 +303,7 @@ fn downloads_the_artifact_through_the_engine() {
|
||||
#[test]
|
||||
fn missing_artifact_is_reported_as_source_missing() {
|
||||
let tmp = TempDir::new("gh-artifact-404");
|
||||
let server = FakeHttp::start(vec![]);
|
||||
let server = FakeHttp::start(Vec::new());
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error: UpdateError = source
|
||||
@@ -354,18 +365,14 @@ fn agrees_with_dir_source_on_the_same_manifest() {
|
||||
let json = stable_manifest_json();
|
||||
|
||||
// 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。
|
||||
tmp.write(
|
||||
&format!("{}.json", Channel::Stable.as_str()),
|
||||
json.as_bytes(),
|
||||
);
|
||||
tmp.write_signed(&format!("{}.json", Channel::Stable.as_str()), &json);
|
||||
tmp.write(asset_name(), PAYLOAD);
|
||||
let dir = DirSource::new(tmp.path());
|
||||
|
||||
// GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。
|
||||
let server = FakeHttp::start(vec![
|
||||
(manifest_path().as_str(), FakeResponse::json(&json)),
|
||||
(asset_path().as_str(), FakeResponse::bin(PAYLOAD)),
|
||||
]);
|
||||
let mut routes = signed_routes(&json);
|
||||
routes.push((asset_path(), FakeResponse::bin(PAYLOAD)));
|
||||
let server = FakeHttp::start(routes);
|
||||
let github = local_source(&tmp, &server);
|
||||
|
||||
let current = Version::parse("0.5.0").expect("版本号合法");
|
||||
|
||||
@@ -22,17 +22,16 @@ fn target() -> Target {
|
||||
/// 造一个本地源 + 一个替换目标;返回的 `TempDir` 要活到用例结束。
|
||||
fn fixture(format: &str, version: &str) -> (TempDir, Job) {
|
||||
let dir = TempDir::new("job");
|
||||
dir.write(
|
||||
dir.write_signed(
|
||||
"stable.json",
|
||||
manifest(
|
||||
&manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
version,
|
||||
"",
|
||||
&[artifact("win-x64", format, "asset.bin", PAYLOAD)],
|
||||
)],
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
);
|
||||
dir.write("asset.bin", PAYLOAD);
|
||||
let dest = dir.join("bin/unlucky-launcher.exe");
|
||||
@@ -119,17 +118,16 @@ fn a_portable_exe_runs_the_whole_chain() {
|
||||
#[test]
|
||||
fn nothing_to_apply_does_not_touch_the_target() {
|
||||
let dir = TempDir::new("job-none");
|
||||
dir.write(
|
||||
dir.write_signed(
|
||||
"stable.json",
|
||||
manifest(
|
||||
&manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.2.2",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)],
|
||||
)],
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
);
|
||||
let dest = dir.join("bin/unlucky-launcher.exe");
|
||||
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录");
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
|
||||
mod channel_cases;
|
||||
mod checker_cases;
|
||||
mod ed25519_cases;
|
||||
mod github_cases;
|
||||
mod install_cases;
|
||||
mod install_io_cases;
|
||||
mod install_xdev_cases;
|
||||
mod job_cases;
|
||||
mod manifest_cases;
|
||||
mod signature_cases;
|
||||
mod source_cases;
|
||||
mod support;
|
||||
mod target_cases;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! 清单签名:**先验签再解析**(M190)。
|
||||
//!
|
||||
//! 这一组用例是 mission 守卫里「更新侧」的那几条,每条都能单独变红:
|
||||
//!
|
||||
//! | 用例 | 红掉意味着什么 |
|
||||
//! |---|---|
|
||||
//! | `tampered_field_is_rejected` | 改清单里任意一个字段仍能装 → 签名判据被绕过 |
|
||||
//! | `tampered_signature_only_is_rejected` | 只改签名文件、内容不动仍能装 → 「只改签名不换内容」没被拒 |
|
||||
//! | `missing_signature_is_rejected` | 没有 `.sig` 也照装 → 发布侧漏签名即可绕过整套信任根 |
|
||||
//! | `unknown_key_is_rejected` | 别人用自己的钥签的清单被认 → 信任锚不是钉死的那把 |
|
||||
//! | `key_id_spoofing_does_not_help` | 按 `key_id` 名字信任而不是按公钥字节验 → 伪个名字就能签 |
|
||||
//! | `unsigned_install_never_touches_the_target` | 验签失败却已经动过目标 exe → 判据跑在安装之后(顺序错) |
|
||||
//! | `no_runtime_keyring_override` | 环境变量/旁边的 keyring 文件能换信任锚 → 钉在二进制里的公钥白钉 |
|
||||
//!
|
||||
//! 还有一条「两条判据互不替代」:`sha256_mismatch_is_reported_separately` 证明签名过了、包体摘要
|
||||
//! 不符时仍是 `DigestMismatch`——签名管「清单是不是我们发的」,sha256 管「包体和清单对不对得上」。
|
||||
|
||||
use std::fs;
|
||||
|
||||
use crate::update::ed25519::public_key_from_seed_for_tests;
|
||||
use crate::update::{
|
||||
trust, Channel, DirSource, Job, OsFamily, Target, UpdateChecker, UpdateErrorKind, UpdateSource,
|
||||
Version,
|
||||
};
|
||||
|
||||
use super::support::{
|
||||
artifact, hex_of, manifest, release, signature_entry, signature_entry_from_bytes,
|
||||
signature_envelope, signature_envelope_of, signature_envelope_with, TempDir, TEST_KEY_ID,
|
||||
TEST_SIGNING_SEED,
|
||||
};
|
||||
|
||||
const PAYLOAD: &[u8] = b"signed release payload";
|
||||
|
||||
/// 另一把「攻击者的钥」:种子换一个词,公钥就完全不同。
|
||||
const ATTACKER_SEED: &[u8; 32] = b"ull-m190-attacker-seed-000000001";
|
||||
|
||||
fn target() -> Target {
|
||||
Target::new(OsFamily::Windows, crate::update::Arch::X64)
|
||||
}
|
||||
|
||||
fn stable_json(version: &str) -> String {
|
||||
manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
version,
|
||||
"",
|
||||
&[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)],
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn current() -> Version {
|
||||
Version::parse("0.2.0").expect("版本号合法")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_test_key_matches_the_test_seed() {
|
||||
// 信任表里那把测试钥必须就是测试种子推出来的那把。漂了的话「钉住的公钥」与「签名的钥」不是同一个,
|
||||
// 下面所有正路用例会一起变红——先在这里单独钉死。
|
||||
let derived = hex_of(&public_key_from_seed_for_tests(TEST_SIGNING_SEED));
|
||||
let pinned = trust::keyring()
|
||||
.iter()
|
||||
.find(|key| key.key_id == TEST_KEY_ID)
|
||||
.expect("测试构建的信任表里必须有测试钥");
|
||||
assert_eq!(
|
||||
pinned.public_key_hex, derived,
|
||||
"钉住的测试公钥与测试种子不匹配"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_manifest_is_accepted_and_picks_the_candidate() {
|
||||
let dir = TempDir::new("sig-ok");
|
||||
dir.write_signed("stable.json", &stable_json("0.3.0"));
|
||||
let outcome = UpdateChecker::new(DirSource::new(dir.path()), target())
|
||||
.check(¤t(), Channel::Stable)
|
||||
.expect("签名对得上就不该报错");
|
||||
assert_eq!(
|
||||
outcome.candidate().map(|c| c.release.version.to_string()),
|
||||
Some("0.3.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_field_is_rejected() {
|
||||
// 守卫①:清单里改一个字段(版本号多一个数字),签名不跟着换 ⇒ 必须拒。
|
||||
let original = stable_json("0.3.0");
|
||||
let tampered = stable_json("0.3.1");
|
||||
assert_ne!(original, tampered, "两次生成的清单必须真的不同");
|
||||
|
||||
let dir = TempDir::new("sig-tamper-field");
|
||||
dir.write("stable.json", tampered.as_bytes());
|
||||
// 签名还是**原始**那份的签名。
|
||||
dir.write("stable.json.sig", signature_envelope(&original).as_bytes());
|
||||
let error = DirSource::new(dir.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("改过字段的清单必须验不过");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid);
|
||||
assert!(
|
||||
error.message.contains("sha256"),
|
||||
"要说清「签名声明的是哪一份、手上是哪一份」(实际:{})",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_signature_only_is_rejected() {
|
||||
// 守卫①的另一半:清单字节一个不动,只把签名的最后一个字节改掉。
|
||||
let json = stable_json("0.3.0");
|
||||
let mut signature = crate::update::ed25519::sign_for_tests(TEST_SIGNING_SEED, json.as_bytes());
|
||||
let good = signature_envelope(&json);
|
||||
signature[63] ^= 0x04;
|
||||
let broken =
|
||||
signature_envelope_of(&[signature_entry_from_bytes(TEST_KEY_ID, &signature, &json)]);
|
||||
assert_ne!(good, broken, "两份信封必须真的不同");
|
||||
|
||||
let dir = TempDir::new("sig-tamper-sig");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write("stable.json.sig", broken.as_bytes());
|
||||
let error = DirSource::new(dir.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("只改签名也必须被拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_signature_is_rejected() {
|
||||
let dir = TempDir::new("sig-missing");
|
||||
dir.write("stable.json", stable_json("0.3.0").as_bytes());
|
||||
let error = DirSource::new(dir.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("没有签名文件必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureMissing);
|
||||
assert!(!error.is_retryable(), "漏签名不是抖动,重试没有意义");
|
||||
assert!(error.kind.is_signature(), "壳层据此换文案");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_signature_array_is_malformed() {
|
||||
let dir = TempDir::new("sig-empty-array");
|
||||
dir.write("stable.json", stable_json("0.3.0").as_bytes());
|
||||
dir.write("stable.json.sig", br#"{"format":1,"signatures":[]}"#);
|
||||
let error = DirSource::new(dir.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("空签名数组必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_field_in_envelope_is_malformed() {
|
||||
// `deny_unknown_fields`:发布侧拼错字段名要在 CI/发布侧当场炸,而不是变成用户机器上的「验签失败」。
|
||||
let json = stable_json("0.3.0");
|
||||
let dir = TempDir::new("sig-unknown-field");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write(
|
||||
"stable.json.sig",
|
||||
br#"{"format":1,"signatures":[],"comment":"hi"}"#,
|
||||
);
|
||||
let error = DirSource::new(dir.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("未知字段必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_is_rejected() {
|
||||
// 攻击者用自己那把钥签了一份**完全合法**的清单:字节没问题,只是钥不在钉住的表里。
|
||||
let json = stable_json("9.9.9");
|
||||
let dir = TempDir::new("sig-unknown-key");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write(
|
||||
"stable.json.sig",
|
||||
signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(),
|
||||
);
|
||||
let error = DirSource::new(dir.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("不认识的钥必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureUnknownKey);
|
||||
assert!(
|
||||
error.message.contains("attacker-key"),
|
||||
"要说出是谁签的(实际:{})",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_id_spoofing_does_not_help() {
|
||||
// 攻击者把自己那把钥的 key_id 伪造成钉住的那个:查表命中,但**公钥字节**对不上 ⇒ 仍拒。
|
||||
// 这条就是「公钥钉在二进制里,不是按名字信任」的直接证据。
|
||||
let json = stable_json("9.9.9");
|
||||
let dir = TempDir::new("sig-key-id-spoof");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write(
|
||||
"stable.json.sig",
|
||||
signature_envelope_with(ATTACKER_SEED, TEST_KEY_ID, &json).as_bytes(),
|
||||
);
|
||||
let error = DirSource::new(dir.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("key_id 撞名不能替代公钥");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_signature_accepts_any_pinned_key() {
|
||||
// 轮换窗口:一份清单同时带「钉住的旧钥」与「本构建不认识的新钥」的签名,只要有一条能过就放行。
|
||||
let json = stable_json("0.3.0");
|
||||
let dir = TempDir::new("sig-multi");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
let merged = signature_envelope_of(&[
|
||||
signature_entry(ATTACKER_SEED, "someone-else", &json),
|
||||
signature_entry(TEST_SIGNING_SEED, TEST_KEY_ID, &json),
|
||||
]);
|
||||
dir.write("stable.json.sig", merged.as_bytes());
|
||||
let manifest = DirSource::new(dir.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect("有一条能过就放行");
|
||||
assert!(manifest.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_install_never_touches_the_target() {
|
||||
// 顺序证据:验签在**取包之前**,因此目标 exe 与暂存目录一个字节都没动过。
|
||||
let dir = TempDir::new("sig-apply-order");
|
||||
dir.write("stable.json", stable_json("0.3.0").as_bytes());
|
||||
dir.write("asset.bin", PAYLOAD);
|
||||
let dest = dir.join("bin/unlucky-launcher.exe");
|
||||
fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录");
|
||||
fs::write(&dest, b"old-bytes").expect("写旧 exe");
|
||||
|
||||
let job = Job::new(
|
||||
Box::new(DirSource::new(dir.path())),
|
||||
target(),
|
||||
dir.join("staging"),
|
||||
dest.clone(),
|
||||
);
|
||||
let error = job
|
||||
.apply(¤t(), Channel::Stable)
|
||||
.expect_err("没有签名不许 apply");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureMissing);
|
||||
assert_eq!(
|
||||
fs::read(&dest).expect("目标仍在"),
|
||||
b"old-bytes",
|
||||
"目标 exe 不许被动过"
|
||||
);
|
||||
let staging_empty = match fs::read_dir(dir.join("staging")) {
|
||||
Ok(mut entries) => entries.next().is_none(),
|
||||
Err(_) => true,
|
||||
};
|
||||
assert!(staging_empty, "暂存目录不该留下任何东西(验签在取包之前)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_mismatch_is_reported_separately() {
|
||||
// 两条判据互不替代:签名对(内容就是发布侧签的那份),但包体摘要对不上 ⇒ DigestMismatch。
|
||||
let dir = TempDir::new("sig-vs-digest");
|
||||
dir.write_signed("stable.json", &stable_json("0.3.0"));
|
||||
// 等长但不同内容:先过长度判据,才走得到摘要判据(顺序在 verify.rs 里是有意的)。
|
||||
dir.write("asset.bin", &[b'X'; PAYLOAD.len()]);
|
||||
let job = Job::new(
|
||||
Box::new(DirSource::new(dir.path())),
|
||||
target(),
|
||||
dir.join("staging"),
|
||||
dir.join("bin/unlucky-launcher.exe"),
|
||||
);
|
||||
let error = job
|
||||
.apply(¤t(), Channel::Stable)
|
||||
.expect_err("摘要不符必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::DigestMismatch);
|
||||
assert!(!error.kind.is_signature(), "这是摘要判据,不是签名判据");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_runtime_keyring_override() {
|
||||
// 守卫②:信任锚是编译期常量。运行期旁边放一份「信任表」、或设环境变量,都不该有任何作用
|
||||
// (现在压根没有这种口子;将来谁加了,这条会红)。
|
||||
let json = stable_json("9.9.9");
|
||||
let dir = TempDir::new("sig-no-override");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write(
|
||||
"stable.json.sig",
|
||||
signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(),
|
||||
);
|
||||
let attacker_public = hex_of(&public_key_from_seed_for_tests(ATTACKER_SEED));
|
||||
dir.write(
|
||||
"keyring.json",
|
||||
format!(r#"{{"keys":[{{"key_id":"attacker-key","public_key_hex":"{attacker_public}"}}]}}"#)
|
||||
.as_bytes(),
|
||||
);
|
||||
dir.write("attacker.pub", attacker_public.as_bytes());
|
||||
let keyring_path = dir.join("keyring.json");
|
||||
std::env::set_var("ULL_UPDATE_KEYRING", &keyring_path);
|
||||
std::env::set_var("ULL_UPDATE_KEYRING_FILE", &keyring_path);
|
||||
let outcome = DirSource::new(dir.path()).fetch_manifest(Channel::Stable);
|
||||
std::env::remove_var("ULL_UPDATE_KEYRING");
|
||||
std::env::remove_var("ULL_UPDATE_KEYRING_FILE");
|
||||
let error = outcome.expect_err("旁边的信任表文件不许生效");
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
UpdateErrorKind::SignatureUnknownKey,
|
||||
"改不了信任锚:攻击者的钥仍在表外(实际:{})",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use super::support::{artifact, manifest, release, TempDir};
|
||||
const PAYLOAD: &[u8] = b"source payload";
|
||||
|
||||
fn publish(tmp: &TempDir, channel: Channel, json: &str) {
|
||||
tmp.write(&format!("{}.json", channel.as_str()), json.as_bytes());
|
||||
tmp.write_signed(&format!("{}.json", channel.as_str()), json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -66,7 +66,11 @@ fn broken_manifest_json_is_reported_with_path() {
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("坏 JSON 必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
assert!(error.path.is_some(), "错误应带清单路径");
|
||||
assert!(
|
||||
error.message.contains("来源 file:"),
|
||||
"错误里要带出来源(本地源的 id 就是根目录),日志才定位得到(实际:{})",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -37,6 +37,19 @@ impl TempDir {
|
||||
self.path.join(name)
|
||||
}
|
||||
|
||||
/// 写一份清单**连它的签名**(`<name>` + `<name>.sig`),返回清单路径。
|
||||
///
|
||||
/// M190 起「有清单没签名」是被拒的一档,所以夹具一律成对落盘:只写清单的用例都是为了
|
||||
/// 证明「缺签名会被拒」,它们自己显式 `write` 清单即可。
|
||||
pub(crate) fn write_signed(&self, name: &str, manifest_text: &str) -> PathBuf {
|
||||
let path = self.write(name, manifest_text.as_bytes());
|
||||
self.write(
|
||||
&format!("{name}.sig"),
|
||||
signature_envelope(manifest_text).as_bytes(),
|
||||
);
|
||||
path
|
||||
}
|
||||
|
||||
/// 写一个文件(父目录自动建),返回路径。
|
||||
pub(crate) fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
|
||||
let path = self.join(name);
|
||||
@@ -213,6 +226,59 @@ pub(crate) fn manifest(channel: &str, releases: &[String]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试签名钥的 32 字节种子。
|
||||
///
|
||||
/// **它是公开的,故意不保密**:它只对 `cfg(test)` 与 `update-test-source` 构建有意义
|
||||
/// (`trust::TEST_ONLY` 才认这把钥),正式构建的信任表里没有它,所以拿它签不出任何正式版本
|
||||
/// 能装的清单。正式私钥只能来自仓库 Secret(见 `packaging/docs/signing.md`)。
|
||||
pub(crate) const TEST_SIGNING_SEED: &[u8; 32] = b"ull-m190-test-key-seed-000000001";
|
||||
|
||||
/// 测试钥的 `key_id`(与 `trust::TEST_ONLY` 里那条一致)。
|
||||
pub(crate) const TEST_KEY_ID: &str = "test-only-key";
|
||||
|
||||
/// 用测试钥签一份清单,返回 `manifest.json.sig` 的信封原文。
|
||||
pub(crate) fn signature_envelope(manifest_text: &str) -> String {
|
||||
signature_envelope_with(TEST_SIGNING_SEED, TEST_KEY_ID, manifest_text)
|
||||
}
|
||||
|
||||
/// 用指定种子/`key_id` 签一份清单(负向用例要造「不认识的钥」「改过内容的签名」)。
|
||||
pub(crate) fn signature_envelope_with(
|
||||
seed: &[u8; 32],
|
||||
key_id: &str,
|
||||
manifest_text: &str,
|
||||
) -> String {
|
||||
signature_envelope_of(&[signature_entry(seed, key_id, manifest_text)])
|
||||
}
|
||||
|
||||
/// 多签名信封(轮换窗口的夹具:一份清单同时带几把钥的签名)。
|
||||
pub(crate) fn signature_envelope_of(entries: &[String]) -> String {
|
||||
format!(r#"{{"format":1,"signatures":[{}]}}"#, entries.join(","))
|
||||
}
|
||||
|
||||
/// 一条签名条目(可以直接被 [`signature_envelope_of`] 拼起来,也可以单独改坏再拼)。
|
||||
pub(crate) fn signature_entry(seed: &[u8; 32], key_id: &str, manifest_text: &str) -> String {
|
||||
let signature = crate::update::ed25519::sign_for_tests(seed, manifest_text.as_bytes());
|
||||
signature_entry_from_bytes(key_id, &signature, manifest_text)
|
||||
}
|
||||
|
||||
/// 用**现成的签名字节**拼一条条目(「只改签名字节」的用例靠它)。
|
||||
pub(crate) fn signature_entry_from_bytes(
|
||||
key_id: &str,
|
||||
signature: &[u8; 64],
|
||||
manifest_text: &str,
|
||||
) -> String {
|
||||
let sha = crate::update::signature::hex_sha256(manifest_text.as_bytes());
|
||||
format!(
|
||||
r#"{{"alg":"ed25519","key_id":"{key_id}","sig":"{}","manifest_sha256":"{sha}"}}"#,
|
||||
hex_of(signature)
|
||||
)
|
||||
}
|
||||
|
||||
/// 字节转小写十六进制。
|
||||
pub(crate) fn hex_of(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// 造一台测试用下载引擎:镜像表用内置清单,健康度只在内存里,缓存落临时目录。
|
||||
///
|
||||
/// `region` 决定「官方优先」还是「镜像优先」——gh-proxy 前缀只在该是后一种时生效。
|
||||
@@ -280,13 +346,9 @@ pub(crate) struct FakeHttp {
|
||||
|
||||
impl FakeHttp {
|
||||
/// 起服务(`routes` 是「路径 → 应答」表)。
|
||||
pub(crate) fn start(routes: Vec<(&str, FakeResponse)>) -> Self {
|
||||
pub(crate) fn start(routes: Vec<(String, FakeResponse)>) -> Self {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定本地端口");
|
||||
let addr = listener.local_addr().expect("取本地地址");
|
||||
let routes: Vec<(String, FakeResponse)> = routes
|
||||
.into_iter()
|
||||
.map(|(path, response)| (path.to_string(), response))
|
||||
.collect();
|
||||
let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! 钉进二进制的清单签名信任锚(keyring):**多把**公钥,`key_id` 只是查表用的标签。
|
||||
//!
|
||||
//! # 为什么是一张编译期常量表
|
||||
//!
|
||||
//! 自更新的信任根必须不可被「用户机器上的一枚文件」替换:清单与签名都能被能改 Release 的人一起
|
||||
//! 换掉,唯一挡得住的是「客户端**编进二进制**的那几把公钥」。所以这张表是 `const`:
|
||||
//!
|
||||
//! * 改仓库里的本文件只会改变**下一次构建**的行为,不会改变任何**已发布二进制**的验签结果;
|
||||
//! * 没有环境变量、没有设置键、没有「旁边的 keyring.json」这类运行期覆盖口子
|
||||
//! (`tests/signature_cases.rs::no_runtime_keyring_override` 守这条)。
|
||||
//!
|
||||
//! # 为什么是「一组」而不是「一把」
|
||||
//!
|
||||
//! 换签名方(例如日后接 SignPath 的托管签名,或旧钥泄露后轮换)时,用户机器上装着的是**旧**二进制,
|
||||
//! 它只认旧钥。做成数组才有两条路同时成立:新签出的清单可以同时带新旧两把钥的签名(旧二进制认旧的、
|
||||
//! 新二进制认新的),以及新二进制可以一把一把地收紧。步骤见 `packaging/docs/signing.md`。
|
||||
//!
|
||||
//! # 唯一的加钥匙纪律:**旧钥签新钥**
|
||||
//!
|
||||
//! 往本表里加一把公钥,必须同时有「旧钥对新钥的背书」——即那次发布的清单同时带旧钥签名(见上),
|
||||
//! 以及 changelog 里点名新钥的 `key_id` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表:
|
||||
//! 那等于让「能改仓库的人」变成「能改信任根的人」。
|
||||
//!
|
||||
//! # 正式构建的表当前是**空的**(fail-closed)
|
||||
//!
|
||||
//! 私钥只能来自仓库 Secret,本轮没有(也不该由代码仓库产生)私钥,所以正式表里一个条目都没有:
|
||||
//! 任何正式构建拿到清单都会以 [`super::error::UpdateErrorKind::SignatureMissing`] 之类的分类拒绝更新,
|
||||
//! 而 `packaging/sign_manifest.py --check-pinned-keyring` 会让发布流水线在「Secret 没配 / 公钥没钉」
|
||||
//! 时**当场红**(宁可不出 Release,也不出未签名的 Release)。
|
||||
//!
|
||||
//! 录一把真钥的两步(细节见 `packaging/docs/signing.md`):
|
||||
//!
|
||||
//! ```text
|
||||
//! python packaging/sign_manifest.py --generate-key --out-key release.key # ① 生成,私钥写进 Secret
|
||||
//! # ② 把打印出来的 `PinnedKey { … }` 一行贴到下面 PINNED-KEYS 标记之间
|
||||
//! ```
|
||||
//!
|
||||
//! 测试/验收构建另有一把**只在** `cfg(test)` 与 `update-test-source` feature 里存在的测试钥:
|
||||
//! 它的私钥种子就写在测试夹具里、公开可见,因此它可以签出「本仓库自己认得」的清单,但对任何正式
|
||||
//! 构建毫无意义(正式表里没有它)。这条也由 `packaging/check_test_source_off.py` 的同一道闸守着
|
||||
//! ——正式发布流水线与打包脚本都不许打开 `update-test-source`。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 一把钉死的公钥。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct PinnedKey {
|
||||
/// 标签(清单签名里的 `key_id` 靠它查表;**改名就是换钥**)。
|
||||
pub(crate) key_id: &'static str,
|
||||
/// Ed25519 公钥的 64 位十六进制小写形态(32 字节)。
|
||||
pub(crate) public_key_hex: &'static str,
|
||||
}
|
||||
|
||||
/// 正式构建的信任锚(发布二进制里就是这一份)。
|
||||
///
|
||||
/// ── 加钥匙只改下面 `PINNED-KEYS` 标记之间的内容,别改标记本身 ──
|
||||
/// (`packaging/sign_manifest.py --check-pinned-keyring` 按标记解析这一段,标记变了它就找不到表)。
|
||||
pub(crate) const PRODUCTION: &[PinnedKey] = &[
|
||||
// >>> PINNED-KEYS >>>
|
||||
// <<< PINNED-KEYS <<<
|
||||
];
|
||||
|
||||
/// 测试钥:**只**在 `cfg(test)` 与 `update-test-source` 构建里存在(正式表里没有它)。
|
||||
///
|
||||
/// 公钥由 `tests/support.rs` 里那串公开可见的 32 字节种子推出来(种子 = `ull-m190-test-key-seed-000000001`),
|
||||
/// 两者一旦漂开,`tests/signature_cases.rs::pinned_test_key_matches_the_test_seed` 会当场红。
|
||||
pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey {
|
||||
key_id: "test-only-key",
|
||||
public_key_hex: "f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b",
|
||||
}];
|
||||
|
||||
/// 本构建实际的信任锚(正式表 + 仅测试构建才有的那把测试钥)。
|
||||
pub(crate) fn keyring() -> &'static [PinnedKey] {
|
||||
static ALL: OnceLock<Vec<PinnedKey>> = OnceLock::new();
|
||||
ALL.get_or_init(|| {
|
||||
let mut keys = PRODUCTION.to_vec();
|
||||
if cfg!(any(test, feature = "update-test-source")) {
|
||||
keys.extend_from_slice(TEST_ONLY);
|
||||
}
|
||||
keys
|
||||
})
|
||||
}
|
||||
|
||||
/// 十六进制解码(只收小写/大写十六进制,长度必须是偶数)。
|
||||
pub(crate) fn decode_hex(text: &str) -> Option<Vec<u8>> {
|
||||
if !text.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||
let (pairs, _) = bytes.as_chunks::<2>();
|
||||
for pair in pairs {
|
||||
let hi = (pair[0] as char).to_digit(16)?;
|
||||
let lo = (pair[1] as char).to_digit(16)?;
|
||||
out.push(u8::try_from(hi * 16 + lo).ok()?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
Reference in new issue
Block a user