个人应用签名与自动更新

个人应用签名与自动更新

周一 9月 21 2026
2372 字 · 9 分钟

个人应用的签名与自动更新

个人应用的签名与自动更新

个人开发者发布桌面应用,最容易漏掉的一环是:用户凭什么相信这个安装包是你发的、没被换过。这套流程里没有服务器、没有证书颁发机构,只有一对密钥和几条必须写死的校验——写对了,更新链路就是可信的;漏掉任何一条,它就退化成「从网上下载一个 exe 然后运行」。

本文以 Tauri updater + Minisign 为实例(这是最省事的一种实现),但模型与框架无关,换 Electron 或自研 updater 只是换字段名。

三条硬规则

  1. 私钥只放 CI Secret——不进仓库、不进笔记、不进日志、不进构建产物、不进 Release 资产。
  2. 公钥内置在客户端源码里,且是唯一配置源;客户端只认这一把——换公钥就是换信任根,已发布的旧版本不认新公钥。
  3. 自动更新必须同时验四样:安装包 URL、版本号、SHA-256 摘要、签名。少一样就白做。

🔑 第一步:生成密钥对

POWERSHELL
# 生成 updater 密钥对(同时输出公钥和加密后的私钥)
npm run tauri -- signer generate --write-keys <离线安全目录>/<app>.key

生成过程会要求设置私钥密码。三个纪律:

  1. 私钥落在离线安全目录或密码管理器,不放仓库、不放同步盘、不放构建产物目录。
  2. 命令输出的公钥转成 Base64 字符串,写进客户端源码。
  3. 私钥全文写进 CI 的 Secret;设了密码就再写一个独立的密码 Secret(分开才能独立轮换)。

核对「发布用的私钥」和「客户端内置的公钥」是不是一对(key ID 是公钥指纹,可以公开):

POWERSHELL
$config = Get-Content -Raw <项目根>/src-tauri/tauri.conf.json | ConvertFrom-Json
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($config.plugins.updater.pubkey))

⚙️ 第二步:三处配置,只有三处

放哪放什么谁读它能公开吗
客户端源码 plugins.updater.pubkeyBase64 包装的 Minisign 公钥每个已安装的客户端能(公钥)
CI Secret TAURI_SIGNING_PRIVATE_KEY私钥全文构建步骤绝不能
CI Secret TAURI_SIGNING_PRIVATE_KEY_PASSWORD私钥密码构建步骤绝不能

工作流里只在构建那一步注入,其它步骤一律不引用:

YAML
env:
  TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
  TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}

构建产物是「安装包 + 同名 .sig」成对出现,架构分开:

TEXT
<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 存在手动发出一个拼错的版本
2tag → 不可变 commit,构建 checkout 的必须是这个 commit「从 main 发旧 tag」时混进新提交
3x64 / 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.createUpdaterArtifactsCI Secret、tauri.conf.json
独立验签失败私钥与内置公钥不配对;.sig 与安装包不是同一次构建公钥 key ID、产物来源
manifest 校验失败平台键 / 签名字符串 / 下载 URL 错配发布 workflow 的 manifest 步骤
客户端拒绝下载API 摘要、大小、版本、URL 在检查与下载之间变了Release 资产、API 返回、重定向目标
客户端签名验证失败发布时换了公钥,旧客户端仍内置旧信任根客户端版本、公钥 key ID、发布用的私钥
某个平台收不到更新发布矩阵里没有这个平台的安装器,或安装命令只实现了一部分平台发布矩阵 + 安装分支代码

以下是原理与设计说明(只想把流程跑通的可以到此为止)。

信任模型

TEXT
              私钥(全世界只有一份)
   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 桌面工具的完整签名 + 自动更新链路。


Thanks for reading!

个人应用签名与自动更新

周一 9月 21 2026
2372 字 · 9 分钟
-
-

Comments

cover

Beautiful Dreams

Vivienne