自动更新,以及为何还需要额外一层签名

第 1 篇中,我们完成了 Developer ID 证书和公证的配置。至此,应用已具备首次交付给用户的条件。但应用并非发布一次就结束——你需要持续推出修复 Bug、增加功能的新版本。

对于 Mac App Store 应用,更新由 App Store 全权处理。直接分发的应用则不然,你必须在应用内自行集成自动更新功能。在 macOS 上,承担这一角色的事实标准是开源框架 Sparkle。集成 Sparkle 后,应用会定期检查"更新 Feed (appcast)",如有新版本则通知用户并自动下载安装。

这里产生了一个疑问:第 1 篇已经用 Developer ID 证书对应用进行了签名,为什么还需要另一把密钥?

原因在于两种签名验证的对象不同:

  • Developer ID 证书 — macOS Gatekeeper 用于判断"是否允许安装此应用"
  • Sparkle EdDSA 密钥 — 应用内的 Sparkle 用于判断"刚下载的更新文件是否真的由该应用的开发者制作"

自动更新是一种安全敏感操作——应用从互联网下载文件并覆盖自身。如果有人拦截更新服务器或通信路径并植入伪造文件,后果将非常严重。为此,Sparkle 只接受用开发者独有的私钥签名的更新,签名不匹配则拒绝安装。这是独立于证书之外的额外验证层

本文将创建用于该验证的 EdDSA (Ed25519) 密钥对

与第 1 篇相同,所有名称和路径(FocusTimerexample.com 等)均为示例值,实际使用时请替换为你自己的应用信息。

前提 — 应用中必须已添加 Sparkle

创建密钥之前,应用项目中必须已将 Sparkle 框架添加为依赖项。若尚未添加,请在 Xcode 中通过 Swift Package Manager (SPM) 添加。

  1. 在 Xcode 中打开项目 → File → Add Package Dependencies…
  2. 在搜索框中输入仓库地址:https://github.com/sparkle-project/Sparkle
  3. 将版本规则设为 2.x(最新主版本)并添加

完成后构建一次项目,SPM 会下载 Sparkle 包。随包附带的命令行工具是下一步的关键。

第 1 步 — 找到 Sparkle 命令行工具

Sparkle 包内附有用于密钥生成和签名的命令行工具。这些工具位于 SPM 下载包的文件夹中,但其路径因 Xcode 版本和 DerivedData 设置而异,因此直接查找更为稳妥。

SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData \
  -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1)
echo "$SPARKLE_BIN"

若输出一行路径则表示成功。若无任何输出,说明跳过了前面"构建一次项目"的步骤,请先在 Xcode 中执行一次构建后再试。

该文件夹内包含以下工具:

  • generate_keys — 生成、备份、恢复和验证签名密钥(本文使用)
  • sign_update — 对更新文件进行签名(实际发布时使用)
  • generate_appcast — 生成更新 Feed(appcast.xml)(第 3 篇中出现)

第 2 步 — 生成签名密钥

现在创建密钥对。

"$SPARKLE_BIN/generate_keys"

输出类似如下内容:

Generating a new signing key...
A key has been generated and saved in your keychain. Add the SUPublicEDKey
key to the Info.plist of each app for which you intend to use Sparkle...

    <key>SUPublicEDKey</key>
    <string>5vT3kQbA9mZ0wR1yX8cD2eF4gH6jK7lN0pS2uV5xW8c=</string>

这一条命令会生成两个密钥:

  • 公钥 (public key) — 输出中显示的 SUPublicEDKey 值。这不是秘密,是需要嵌入应用的密钥。
  • 私钥 (private key) — 不会显示在屏幕上。自动以"Private key for signing Sparkle updates"为名保存到 macOS 钥匙串中。这是绝对不能以明文文件形式留在磁盘上的真正秘密。

将公钥嵌入应用

将输出的公钥字符串写入应用的 Info.plist。以示例应用为例,在 FocusTimer-Info.plist 中添加以下键:

<key>SUFeedURL</key>
<string>https://updates.example.com/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>5vT3kQbA9mZ0wR1yX8cD2eF4gH6jK7lN0pS2uV5xW8c=</string>
  • SUPublicEDKey — 刚生成的公钥。应用使用此密钥验证已下载更新的签名。
  • SUFeedURL — 更新 Feed 的地址。该域名目前尚不存在,将在第 3 篇中创建。现在只是先占个位置。

由于公钥嵌入在应用中,而私钥只有开发者持有,因此应用只会接受用私钥签名的更新。这就是 Sparkle 更新验证的核心结构。

第 3 步 — 备份私钥(务必执行!)

跳过此步骤,日后可能追悔莫及。

私钥存储在钥匙串中,在当前使用的电脑上没有问题。但如果电脑丢失、磁盘损坏或重装 macOS,密钥也会随之消失。

私钥丢失会怎样?用新密钥签名的更新将被现有用户的应用(嵌入了旧公钥的应用)拒绝验证。也就是说,将永远无法向已在使用应用的用户推送自动更新,只能逐一通知用户"请手动下载新版本并重新安装"。

因此,密钥创建后请立即备份。

"$SPARKLE_BIN/generate_keys" -x ~/focustimer-sparkle-private.key
cat ~/focustimer-sparkle-private.key

cat 输出的单行 base64 字符串即为私钥,示例如下:

Hn4Kp9Lr2Qs5Tv8Wx1Yz3Ab6Cd0Ef7Gh4Ij5Kl8MnQ0=

将此字符串以安全备注 (secure note) 的形式保存到 1Password 等密码管理器中。 备注名称建议使用便于日后查找的名称,例如 FocusTimer Sparkle EdDSA Private Key

确认保存完毕后,立即删除磁盘上的明文文件。

rm ~/focustimer-sparkle-private.key

原则是不将私钥以明文文件形式留在磁盘上。备份只保存在加密的密码管理器中。

关键陷阱 — % 符号不是密钥的一部分

cat 输出密钥时,终端(尤其是 zsh)可能在行末附加 % 符号

Hn4Kp9Lr2Qs5Tv8Wx1Yz3Ab6Cd0Ef7Gh4Ij5Kl8MnQ0=%

这个 % 只是 Shell 用于提示"输出结束时无换行符"的标记,不是密钥的一部分。如果将 % 一同复制到备份中,日后恢复时密钥会损坏。base64 字符串通常以 = 结尾,请在保存时去掉 = 后面的 %

第 4 步 — 在其他电脑上恢复

需要在新电脑上构建应用时,将备份的私钥重新导入钥匙串。

echo "备份的_base64_字符串" > ~/focustimer-sparkle-private.key
"$SPARKLE_BIN/generate_keys" -f ~/focustimer-sparkle-private.key
rm ~/focustimer-sparkle-private.key

-f 选项的含义是"将文件中的密钥导入钥匙串"。恢复完成后,同样立即删除明文文件。

第 5 步 — 验证

确认密钥是否已正确安装。

"$SPARKLE_BIN/generate_keys" -p

输出单行公钥。此值必须与第 2 步中写入 Info.plistSUPublicEDKey 完全一致。若不一致,说明应用中嵌入的公钥与实际签名密钥不匹配,更新验证将会失败。

第 2 篇小结

跟到这里,你现在已经准备好以下内容:

  • ✅ 生成了 Sparkle EdDSA 密钥对(公钥 + 私钥)
  • ✅ 将公钥嵌入应用的 Info.plistSUPublicEDKey
  • ✅ 将私钥安全备份到密码管理器
  • ✅ 了解了在其他电脑上恢复密钥的方法

应用现在已具备验证"下载的更新是否真实"的手段。但有一件事仍未完成:第 2 篇中为 SUFeedURL 填写的 https://updates.example.com/appcast.xml该地址目前还什么都没有

下一篇将创建用于存放更新 Feed(appcast.xml)和 .dmg 文件的公开仓库,将其连接到我们自己控制的域名,并完成构建配置,从而结束全部一次性准备工作。