安全的自动更新怎么做: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 发版清单为例(条目结构至今相同),共六项:
- Linux x86_64(图形界面版)
- Linux x86_64 静态命令行版
- Linux aarch64 静态命令行版
- Windows 安装版
- Windows 便携版
- macOS 通用版(universal)
安装包本身不做 minisign 签名,而是通过哈希值绑定到已签名的清单上。
签名用的是 minisign,Frank Denis 基于 Ed25519 写的工具。它的格式有两点在这里很重要:
- 预哈希签名。 从 minisign 0.8 开始,默认签名算法(
ED)签的是文件的 BLAKE2b 哈希。MistTerm 只接受这种形式:key.verify(data, &sig, false)关闭了旧的非预哈希模式。 - 可信注释(trusted comment)。 minisign 签名带一行「可信注释」,另有一个全局签名同时覆盖文件签名和这行注释。它和不可信注释不同,没有私钥就改不了。
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 解析器和校验代码只会处理发布者签过名的字节。控制了下载路径的攻击者,再也没法利用解析环节的漏洞。
客户端内置两把公钥,一主一备,任意一把验证通过即可。这样就可以轮换签名密钥,或者淘汰一把泄露的密钥,而新发布的客户端仍能被另一把验证。
解析之后的校验很严格:
- schema 版本:不认识的版本报「不支持」。
product == "mistterm",渠道符合预期。- 版本号是 semver,日期是 RFC 3339。
- 每个安装包:文件名不含路径分隔符,SHA-256 是 64 位十六进制,大小不为零,URL 只允许
https。只有测试构建开关下才允许指向回环地址的http。
为什么还要核对可信注释。 签名有效,只能证明发布者在某个时刻签过这些字节;核对注释,证明这些字节就是它声称的那个版本。发版元数据存在两个地方,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)
}
第五步:安装,并留好退路
各平台的安装方式不同,但每条路径都保证上一个版本可以恢复:
-
Linux 和 Windows 便携版。 新程序先放在旧程序旁边,运行一次
--version做冒烟测试:必须在 20 秒内结束,并输出预期的版本号。通过之后才替换文件,旧文件移进.mist-update-backup/。- Linux 上
rename()是原子替换,正在运行的进程继续使用旧的 inode。 - Windows 上正在运行的
.exe不能覆盖,但可以改名,所以先把旧文件挪开。
任何一步失败,已经换掉的文件都会换回来。
- Linux 上
-
Windows 安装版。 校验通过的
setup.exe作为独立进程静默运行,由安装程序关闭 Mist,并可选择重新打开。 - macOS。 更新包是一个只含
Mist.app的 tar 包,用系统自带的/usr/bin/tar解压,以保留应用包里的符号链接和权限。解出的应用必须通过codesign --verify --deep --strict,旧应用才会被改名为.bak并替换;替换失败就恢复旧应用。codesign --verify确认的是应用包签名完好,它不是信任根。信任根是第四步核对过的、经 minisign 签名的哈希。
有些安装方式刻意不做自更新,更新器会告诉用户该怎么办:
- 从源码构建,或者通过包管理器安装。
- 安装目录不可写。
- 本机 glibc 比构建要求的旧。
- macOS 对应用做了「App Translocation」。Gatekeeper 的这一机制让带隔离属性的应用从一个随机的只读路径运行,在用户把它移进「应用程序」文件夹之前,无法原地替换。
更新器从不自行重启应用,安装完成后等下次启动生效。设置 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,软件更新还需要签名吗?
需要。HTTPS 认证的是下载服务器,不是内容。CDN 账号或发布页面被攻破时,恶意文件照样能通过有效的 HTTPS 下发。
minisign 的可信注释(trusted comment)是什么?
一行注释,和文件签名一起被第二个全局签名覆盖,没有私钥就无法修改。MistTerm 在其中写入产品、通道、版本和发布时间。
为什么要先验签再解析清单?
这样 JSON 解析和校验代码只会处理发布者签过名的字节,控制下载路径的攻击者就无法触发解析代码里的缺陷。
更新程序怎样防止回滚攻击?
记住验证过的清单中出现过的最高版本,拒绝更旧的清单,并且从不静默降级。