
个人应用签名与自动更新
一套可复用的自签名发布链路(以 Tauri updater + Minisign 为实例):密钥生成与存放纪律、三处配置、CI 的 9 道发布门禁、客户端必须同时验的四样,以及密钥轮换为什么最危险。

个人应用的签名与自动更新
个人开发者发布桌面应用,最容易漏掉的一环是:用户凭什么相信这个安装包是你发的、没被换过。这套流程里没有服务器、没有证书颁发机构,只有一对密钥和几条必须写死的校验——写对了,更新链路就是可信的;漏掉任何一条,它就退化成「从网上下载一个 exe 然后运行」。
本文以 Tauri updater + Minisign 为实例(这是最省事的一种实现),但模型与框架无关,换 Electron 或自研 updater 只是换字段名。
三条硬规则
- 私钥只放 CI Secret——不进仓库、不进笔记、不进日志、不进构建产物、不进 Release 资产。
- 公钥内置在客户端源码里,且是唯一配置源;客户端只认这一把——换公钥就是换信任根,已发布的旧版本不认新公钥。
- 自动更新必须同时验四样:安装包 URL、版本号、SHA-256 摘要、签名。少一样就白做。
🔑 第一步:生成密钥对
# 生成 updater 密钥对(同时输出公钥和加密后的私钥)
npm run tauri -- signer generate --write-keys <离线安全目录>/<app>.key生成过程会要求设置私钥密码。三个纪律:
- 私钥落在离线安全目录或密码管理器,不放仓库、不放同步盘、不放构建产物目录。
- 命令输出的公钥转成 Base64 字符串,写进客户端源码。
- 私钥全文写进 CI 的 Secret;设了密码就再写一个独立的密码 Secret(分开才能独立轮换)。
核对「发布用的私钥」和「客户端内置的公钥」是不是一对(key ID 是公钥指纹,可以公开):
$config = Get-Content -Raw <项目根>/src-tauri/tauri.conf.json | ConvertFrom-Json
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($config.plugins.updater.pubkey))⚙️ 第二步:三处配置,只有三处
| 放哪 | 放什么 | 谁读它 | 能公开吗 |
|---|---|---|---|
客户端源码 plugins.updater.pubkey | Base64 包装的 Minisign 公钥 | 每个已安装的客户端 | 能(公钥) |
CI Secret TAURI_SIGNING_PRIVATE_KEY | 私钥全文 | 构建步骤 | 绝不能 |
CI Secret TAURI_SIGNING_PRIVATE_KEY_PASSWORD | 私钥密码 | 构建步骤 | 绝不能 |
工作流里只在构建那一步注入,其它步骤一律不引用:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}构建产物是「安装包 + 同名 .sig」成对出现,架构分开:
<App>_<版本>_x64-setup.exe
<App>_<版本>_x64-setup.exe.sig
<App>_<版本>_arm64-setup.exe
<App>_<版本>_arm64-setup.exe.sig🚀 第三步:CI 里的 9 道发布门禁
签名这件事的坑几乎都不在「怎么签」,而在「发布这一步会不会发错东西」。下面这套清单来自实际项目(EmbedPix),可以照抄:
| # | 门禁 | 防的是什么 |
|---|---|---|
| 1 | 版本号 → v<version>,确认远程 tag 存在 | 手动发出一个拼错的版本 |
| 2 | tag → 不可变 commit,构建 checkout 的必须是这个 commit | 「从 main 发旧 tag」时混进新提交 |
| 3 | x64 / ARM64 分别构建、分别暂存 | 架构产物串包 |
| 4 | 从 tag commit 里读 tauri.conf.json 的公钥 | 验签公钥与被签名的构建不同源 |
| 5 | 用独立的 verifier 对每个 .sig 单独验签 | 构建自己说”我签了”不算数 |
| 6 | 生成 latest.json,核对平台 / 版本 / 签名字符串 / 下载 URL 一一对应 | manifest 指到别的产物 |
| 7 | 生成 provenance 文件(仓库 / tag / commit / workflow ref / workflow SHA / 触发事件) | 出事时无法回溯 |
| 8 | 对安装包与 provenance 生成 Artifact Attestation | 第三方无法独立核验来源 |
| 9 | 生成 SHA256SUMS.txt 后才发布 Release | 手动下载的用户没有可核对的口径 |
第 5 条是关键:签名由构建产生、验签又由构建自己完成,等于自己给自己判卷。必须有一个独立步骤,拿着公开可得的公钥和产物重新验一次。
✅ 第四步:客户端必须验的四样
| 校验项 | 防的是什么 | 防不了什么 |
|---|---|---|
| URL 白名单(固定仓库、固定下载路径) | 把用户导向第三方镜像 | 仓库本身被攻破 |
| 版本号(SemVer 规范化比较) | 降级攻击、回滚到有漏洞的版本 | 版本号被伪造(需配合摘要) |
| SHA-256 摘要 | 传输损坏、内容被替换 | 摘要来自同一处时无意义(需配合签名) |
| Minisign 签名 | 别人拿到发布权限也伪造不出安装包 | 公钥被换掉的旧客户端 |
链路分三段,每段都要独立成立:
1) 检查阶段:从固定仓库的 releases API 取最新版 → 拒绝 Draft / Pre-release → SemVer 规范化比较 → 按 OS + CPU 架构挑包 → 只接受固定仓库、固定 release 路径、固定版本、受支持架构的 URL → 要求 API 返回 sha256:<64 位十六进制> → 记下 URL / 摘要 / 大小传给下载阶段。
2) 下载阶段:不信任检查阶段传回来的参数,重新请求一次 release 逐项比对(版本、URL、摘要、大小),防止检查与下载之间被换掉。然后从安装包 URL 派生签名 URL(<installer-url>.sig),下载完成后按顺序验:响应大小与上限 → 内容的 SHA-256 → 内置公钥验 Minisign 签名。任何一步失败就删掉临时文件,不写缓存。
3) 安装阶段:安装前把缓存目录、安装包路径、大小、SHA-256、.sig 再验一遍;签名与摘要都通过、且用户明确确认后才启动安装。下载与签名都用临时文件 + 原子重命名,避免留下「安装包完整但签名缺失」的半成品。
🔧 排障速查
| 现象 | 常见原因 | 查哪 |
|---|---|---|
构建没有生成 .sig | 私钥 Secret 缺失,或没开 bundle.createUpdaterArtifacts | CI Secret、tauri.conf.json |
| 独立验签失败 | 私钥与内置公钥不配对;.sig 与安装包不是同一次构建 | 公钥 key ID、产物来源 |
| manifest 校验失败 | 平台键 / 签名字符串 / 下载 URL 错配 | 发布 workflow 的 manifest 步骤 |
| 客户端拒绝下载 | API 摘要、大小、版本、URL 在检查与下载之间变了 | Release 资产、API 返回、重定向目标 |
| 客户端签名验证失败 | 发布时换了公钥,旧客户端仍内置旧信任根 | 客户端版本、公钥 key ID、发布用的私钥 |
| 某个平台收不到更新 | 发布矩阵里没有这个平台的安装器,或安装命令只实现了一部分平台 | 发布矩阵 + 安装分支代码 |
以下是原理与设计说明(只想把流程跑通的可以到此为止)。
信任模型
私钥(全世界只有一份)
CI Secret / 离线密码库
│ 构建时签名
▼
安装包 + 安装包.sig ──► Release
│
│ HTTPS 下载
▼
客户端 ── 内置公钥(源码里的 pubkey 字段)
│
│ ① URL 约束 ② 版本约束
│ ③ SHA-256 ④ 签名验签
▼
通过后才允许启动安装程序.sig 本身不是信任根。签名的意义在于「能被客户端内置的那把公钥验证通过」——脱离公钥谈 .sig,等于相信攻击者自己附上的签名。同理,只有 SHA-256 不证明来源(摘要可以和安装包一起被换掉),只有签名也拦不住错误版本、错误架构或非官方 URL。
密钥轮换:为什么它是最危险的操作
已经发布的旧客户端只信任它编译进去的那把旧公钥,所以:
- 只删除/替换 CI Secret → 旧客户端照旧信任旧公钥,拿到旧私钥的人仍能伪造「旧客户端愿意装」的更新;
- 正确顺序是先设计过渡方案(用可信的旧版本推一次带新公钥的更新,或让用户手动下载重装),再替换公钥;
- 私钥疑似泄露时,除了换密钥,还要决定:旧版本的更新通道是否立刻切断、已发布的资产如何处理、用户怎么通知。
发布私钥一旦泄露,“撤销”这个词就不存在了——这是它不放进仓库、不放进同步盘、不放进笔记的唯一理由。
别混两层信任
上面整套是应用自更新签名:信任根是你自己生成的密钥对,客户端内置公钥,回答”这个更新是不是我发的”。
它和平台代码签名(Windows Authenticode、macOS 签名与公证)是两层不同的东西:那一层解决的是”这个 exe 是谁签的、系统要不要弹 SmartScreen”。两者可以同时用、互不替代——自更新签名防的是更新链路被劫持,平台代码签名影响的是系统对安装包的信任与提示。
小结
- 值得抄的只有三件事:私钥只在 CI、公钥内置客户端、客户端同时验四样;
- 真正容易出事的地方在发布流程,不在签名命令本身——所以那 9 道门禁比任何加密细节都重要;
- 密钥轮换要有预案,因为它必须由旧客户端自己完成信任迁移。
参考项目(本文归纳的实际实现):EmbedPix↗ —— 一个 Tauri 桌面工具的完整签名 + 自动更新链路。
