安全的自动更新怎么做:minisign 签名、先验签后解析与防回滚

「SSH 终端是怎样做成的」系列第 7 篇。代码摘自 MistTerm v1.2.26(MistLab 出品的开源 SSH 终端客户端,AGPL 协议),只做删减。

要点

HTTPS 认证的是下载服务器,不是更新内容本身。安全的更新程序只接受由客户端内置公钥签名的更新。MistTerm 用 minisign(Ed25519)签名 JSON 清单,先验签、再解析,通过 SHA-256 把每个安装包绑定到清单上,并记住验证过的最高版本,拒绝被重放的旧清单。

SSH 客户端手里握着用户所有服务器的钥匙,它的更新通道因此是高价值目标。谁能让客户端装上自己指定的程序,谁就拿下了用户连接的每一台机器。光靠 HTTPS 挡不住:HTTPS 认证的是你下载的那台服务器,不是内容。CDN 账号被盗、发布页被劫持、镜像站运营者作恶,都可以通过一个完全合法的 HTTPS 响应送来错误的文件。

所以设计目标是:只有用客户端内置的密钥签过名的更新才接受;在验证通过之前,下载到的一切都当作恶意输入。 客户端实际的顺序是:带大小上限地下载,先验 minisign 签名,拒绝更旧的清单,核对安装包哈希,然后安装并留退路。

更新检查与安装流程
更新检查与安装流程

清单和它的签名

每次发版会发布一个小的 JSON 清单 latest.json,外加一个分离签名 latest.json.minisig。清单里有版本号、发布时间,每个平台的安装包占一项,每项包括文件名、大小和 SHA-256。

下面以 v1.2.26 发版清单为例(条目结构至今相同),共六项:

安装包本身不做 minisign 签名,而是通过哈希值绑定到已签名的清单上。

签名用的是 minisign,Frank Denis 基于 Ed25519 写的工具。它的格式有两点在这里很重要:

MistTerm 把发版身份写进可信注释,格式是 product channel version pub_date。这次 v1.2.26 发版签名的注释是:

trusted comment: mistterm stable 1.2.26 2026-10-20T16:22:55Z

任何人都可以用仓库里的公钥自行验证:

$ minisign -V -p resources/update/minisign-primary.pub -m latest.json
Signature and comment signature verified
Trusted comment: mistterm stable 1.2.26 2026-10-20T16:22:55Z

第一步:带上限地下载

客户端知道两个清单地址,按顺序尝试:

// src/core/updater/mod.rs
"https://mistlab.dev/downloads/mistterm/stable/latest.json",
"https://github.com/mistlab-dev/MistTerm/releases/latest/download/latest.json",

截至发稿,第一个地址返回 HTTP 404:发版流水线里负责往那里发布的镜像任务还没有实现,处于关闭状态。实际上,所有客户端都是从 GitHub Releases 拿到清单的。由于信任来自签名而不是主机,多一个源或少一个源只影响可用性,不影响安全性。

解析任何内容之前,客户端先限定愿意下载的大小:清单最多 256 KiB,签名最多 4 KiB。

let body = fetcher.get_small(url, MAX_MANIFEST_BYTES)?;   // 256 KiB
let sig  = fetcher.get_small(&sig_url, 4096)?;

第二步:先验签名,再解析

接下来两步的顺序是整个设计的关键:

/// 先验签、再解析:签名通过后才解析 JSON,并核对可信注释与清单内容一致。
pub fn verify_manifest(manifest_bytes: &[u8], signature_bytes: &[u8], keys: &TrustedKeys,
                       expected_channel: &str, allow_loopback_http: bool)
    -> Result<Manifest, UpdateError>
{
    let sig_text = std::str::from_utf8(signature_bytes).map_err(|_| UpdateError::BadSignature)?;
    let sig = keys.verify(manifest_bytes, sig_text)?;
    let manifest = Manifest::parse_and_validate(manifest_bytes, expected_channel, allow_loopback_http)?;
    if sig.trusted_comment().trim() != manifest.expected_trusted_comment() {
        return Err(UpdateError::InvalidManifest(/* "trusted comment … does not match manifest" */));
    }
    Ok(manifest)
}

先验签,意味着 JSON 解析器和校验代码只会处理发布者签过名的字节。控制了下载路径的攻击者,再也没法利用解析环节的漏洞。

客户端内置两把公钥,一主一备,任意一把验证通过即可。这样就可以轮换签名密钥,或者淘汰一把泄露的密钥,而新发布的客户端仍能被另一把验证。

解析之后的校验很严格:

为什么还要核对可信注释。 签名有效,只能证明发布者在某个时刻签过这些字节;核对注释,证明这些字节就是它声称的那个版本。发版元数据存在两个地方,JSON 和已签名的注释,两者必须一致。

第三步:防回滚,拒绝倒退

签过名的清单永远有效。伪造不了签名的攻击者,仍然可以重放一份旧的、确实签过名的清单,让用户停留在有已知漏洞的版本上。这就是回滚攻击(或冻结攻击),The Update Framework(TUF)列出的经典威胁之一。

MistTerm 的防御办法是:记住验证通过的清单里见过的最高版本及其发布时间。

/// 清单是否比见过的还旧(版本更低,或同版本但发布时间更早)。
pub fn is_stale(&self, version: &semver::Version, pub_date: Option<DateTime<Utc>>) -> bool {
    // … 与 highest_seen_version / highest_seen_pub_date 比较
}

/// 记录一份验签通过的清单(只会往高处走)。
pub fn record_seen(&mut self, version: &semver::Version, pub_date: &str) { /* … */ }

某个源返回过期清单,就跳过它,试下一个源。如果所有源返回的都是过期清单,结果是一个专门的 StaleManifest 错误,而不是「已是最新」。只有清单版本严格大于当前运行版本,才会提示更新;降级默认关闭(allow_downgrade: false)。

错误报告也有一条小规则:多个源都失败时,签名错误或 schema 错误优先于之后的网络错误保留,因为对用户来说,「文件被篡改了」比「备用源连不上」有用得多。

这还不是完整的 TUF:没有 timestamp 角色,所以对扣住新清单不发的冻结攻击,只能靠用户自己察觉。但廉价的重放攻击被堵住了。

第四步:下载并核对安装包

平台安装包下载到缓存目录,大小上限取自清单,使用之前先和清单项比对 SHA-256。缓存里已有且哈希匹配的文件会直接复用。

解压同样谨慎。更新器不做通用解压,只接受它预期的那几个程序文件,路径只能是 name 或 顶层目录/name,且只能由普通路径分量组成。每个解压出来的文件上限 1 GiB,防止压缩炸弹:

// 只接受 `name` 或 `顶层目录/name`,不做通用解压,从根本上避免路径穿越。
fn entry_basename_if_wanted<'a>(path: &Path, wanted: &[&'a str]) -> Option<&'a str> {
    let comps: Vec<_> = path.components().collect();
    if comps.is_empty() || comps.len() > 2 { return None; }
    if !comps.iter().all(|c| matches!(c, std::path::Component::Normal(_))) { return None; }
    let name = path.file_name()?.to_str()?;
    wanted.iter().copied().find(|w| *w == name)
}

第五步:安装,并留好退路

各平台的安装方式不同,但每条路径都保证上一个版本可以恢复:

有些安装方式刻意不做自更新,更新器会告诉用户该怎么办:

更新器从不自行重启应用,安装完成后等下次启动生效。设置 MIST_DISABLE_UPDATE_CHECK=1 可以完全关闭检查。

在构建阶段守住密钥

签名机制的强度取决于编进客户端的密钥。MistTerm 的 build.rs 对正式构建(MIST_DIST_CHANNEL=github-release)强制三条规则:

panic!("the update-test feature must never be enabled in an official release build");
// … 必须是真实公钥:文件里仍含 PLACEHOLDER 就构建失败
panic!("primary and backup update signing public keys must be different keys");

也就是说,正式版不能带着测试密钥、占位密钥,或者一把只是主密钥副本的「备用」密钥发布。发版流水线(第 8 篇)还会在发布前用 minisign -V 验证自己生成的签名。

小结

常见问题

有了 HTTPS,软件更新还需要签名吗?

需要。HTTPS 认证的是下载服务器,不是内容。CDN 账号或发布页面被攻破时,恶意文件照样能通过有效的 HTTPS 下发。

minisign 的可信注释(trusted comment)是什么?

一行注释,和文件签名一起被第二个全局签名覆盖,没有私钥就无法修改。MistTerm 在其中写入产品、通道、版本和发布时间。

为什么要先验签再解析清单?

这样 JSON 解析和校验代码只会处理发布者签过名的字节,控制下载路径的攻击者就无法触发解析代码里的缺陷。

更新程序怎样防止回滚攻击?

记住验证过的清单中出现过的最高版本,拒绝更旧的清单,并且从不静默降级。

相关文章

← 第 6 篇 · 系列目录 · 第 8 篇 →