2026 年 7 月 28 日晚上,我给自己的 macOS 小工具 Backlight Zero 做了一次真实付款测试。
付款成功。收据到了。邮件里有下载文件,也有许可证密钥。Creem 后台能看到订单和许可证。到这里,独立开发者最容易冒出来的那句话是:行了,终于可以卖了。
然后我把邮件里的密钥粘进刚下载的 App,点下 Activate。
红字跳了出来:The license service is temporarily unavailable. Please try again.

这不是“密钥输错了”那种干脆的失败。密钥来自刚刚的真实收据,App 也来自同一笔订单的下载链接。它更像是系统在告诉你:“我知道你想做什么,但现在没法告诉你哪里坏了。”
我先怀疑支付平台。真实付款和测试付款不是一回事,产品配置、API 地址、回调环境都可能不同。可 Creem 的许可证详情反而很平静:许可证是 Active,限制是 1,激活实例也确实已经创建。

这一步把排查方向掰回来了。支付成功,许可证也成功创建,说明问题不在“钱有没有收到”,也不在“Creem 有没有发许可证”。问题发生在更靠后的位置:Worker 向 Creem 确认激活后,还要给 App 签一份 entitlement。App 只有验证这份签名成功,才会把 Basic 功能打开。
一条看起来没毛病的代码
Worker 的签名代码一直写得很明确:它把环境变量里的私钥按 PKCS#8 导入,再用 Ed25519 签名。

这段代码没有什么“偶尔不兼容”的空间。传进来的是 PKCS#8,它就能签;不是,它就签不了。
奇怪的是,测试全绿。Worker 的 13 项测试都通过了,激活、验证、停用和设备迁移都覆盖到了。
翻到测试初始化代码后,答案已经露出来了:测试自己生成 Ed25519 密钥,然后直接导出 PKCS#8。

也就是说,测试里的“私钥产生方式”,和生产部署里的“私钥保存方式”,从一开始就不是同一条路。
测试把一个已经能被 Worker 导入的 PKCS#8 给了 Worker;生产环境里,我从 macOS CryptoKit 保存的是 rawRepresentation。它是 32 字节的原始 Ed25519 seed,不是 PKCS#8。
这两个值都能做 base64。看上去都像一长串“私钥”。复制进 Cloudflare Secret 时,界面也不会告诉你格式不对。问题直到 Worker 真正执行 importKey("pkcs8")、准备给刚激活的 App 签 entitlement 时才暴露。
于是出现了最让人困惑的现场:Creem 已经创建了激活实例,App 却拿不到可验证的 entitlement,于是只能显示“服务暂时不可用”。
没有换密钥,只换了它的包装
修复时最不该做的事情是重新生成一对密钥。
因为 App 已经签名、公证,里面嵌的是原有的公钥。临时换一对密钥,意味着还得重新编译、重新签名、重新公证,再重新上传下载包。那是在处理序列化问题时,顺手制造一次发布问题。
实际修复只有一件事:保留同一把 32 字节私钥,把它封装成 Ed25519 的 RFC 8410 PKCS#8,再更新 Cloudflare 的 ENTITLEMENT_PRIVATE_KEY。

这次没有任何私钥写入文件,也没有把私钥放进 Git。钥匙串里的原始值通过转换工具直接传给 wrangler secret put。公钥没变,已公证的 App 不需要重新打包。
更新 Secret 后,Worker 的生产验证接口返回 HTTP 200;用 App 内置的公钥验证 entitlement 签名也通过。最后重新在 App 里激活,Creem 后台显示 Active (1/1)。这次,付款、许可证、Worker 签名和 App 激活才终于连成了一条完整的线。
这个事故并不神秘,问题是我让它躲过了测试
回头看,根因不在 Creem,也不在 Ed25519 本身。
根因是我把“私钥”当成了一个普通字符串,而它其实是一份跨组件契约。CryptoKit、钥匙串、Cloudflare Web Crypto 和 App 内置公钥各自都没错;错的是它们之间没有被明确要求说同一种格式。
测试覆盖了 Worker 的业务逻辑,却绕开了生产里最危险的那一步:钥匙串中存出来的值,能不能被 Cloudflare Worker 以 PKCS#8 导入;Worker 签出来的东西,能不能被已经发出去的 App 验证。
这也是我现在更在意的发布检查,不是测试数量。
私钥在部署前,先用和 Worker 完全相同的导入方式验证一次。 Worker 返回 entitlement 后,用发行包内置的公钥做一次独立验签。 确认 App 的 Worker URL、公钥和生产 Secret 属于同一套密钥对与环境。 用一次受控的真实激活确认后台实例数符合限制;测试完成后释放不需要的实例。
如果你在做付费 App、许可证或离线授权,这四步比“接口请求能不能返回 200”更值钱。接口 200 只能证明某个服务醒着;它不能证明用户手上的二进制,真的能相信它。
这次事故最后没有要求重新上传 App,也没有让真实订单失效。它消耗的是一晚上排查时间,以及一次本该在发布前就发现的信任成本。
代码后来补上了转换工具、回归测试和发布说明。比起“修好了”三个字,我更愿意记住这件事:测试环境和生产环境之间,最危险的东西往往不是大架构,而是一串看起来完全一样的 base64。
本文基于 2026 年 7 月 28–29 日 Backlight Zero 的真实生产排查记录撰写。许可证、订单、客户和支付信息均已移除或裁剪。
夜雨聆风