Cypress 二进制代码签名实战:macOS 与 Windows 证书轮换及 electron-builder 签名链路解析
Cypress 在 CI 中构建 Windows 与 Mac 发行版时会执行代码签名,签名的具体工作由 electron-builder 在 create-build-artifacts 构建任务中完成。本文基于仓库内的 code-signing 指南 展开,完整覆盖 Mac(Apple Developer ID 证书)与 Windows(SSL.com 证书)两条签名密钥的轮换流程,并结合 electron-builder.json、windows-sign.js 等仓库源码,深入讲解签名配置、公证(notarize)与远程签名委托的实际实现,读完后可理解 Cypress 发布产物"签名 → 公证 → 校验"的完整链路及其安全设计。
何时进行代码签名:CI 构建流程中的定位
code-signing 指南 开宗明义:Cypress 的 Windows 和 Mac 发行版在 CI 构建时执行代码签名,签名动作由 electron-builder 在 create-build-artifacts 任务中承担。指南同时声明了一个适用前提:读者应已熟悉 electron-builder 官方 Code Signing 文档中关于 CSC_LINK、CSC_KEY_PASSWORD 等环境变量的一般约定——下文正是在这个前提下,把 Cypress 仓库中"证书如何申请、密钥如何存放、构建时如何消费"三件事讲透。
从源码结构看,这条签名链路的调用入口是 scripts/binary/build.ts 中的 electronBuilder.build(...) 调用:
await electronBuilder.build({
publish: 'never',
config: {
electronVersion,
directories: {
app: appFolder,
output: outputFolder,
},
icon: iconFilename,
// for now we cannot pack source files in asar file
// because electron-builder does not copy nested folders
// from packages/*/node_modules
asar: false,
},
})
根目录 package.json 中声明的依赖为 "electron-builder": "^25.1.8",因此本文所述的签名行为均针对该版本的 electron-builder API。构建流程中签名并非孤立步骤,而是嵌在如下顺序里:
lerna run build/lerna run build-prod构建各 package(见 build.ts);- 把各 package 的产物复制到 dist 目录;
electronBuilder.build(...)打包并签名(Windows 走委托脚本,macOS 走CSC_LINK证书自动签名);- 触发 electron-builder.json 中声明的
afterPack/afterSign钩子完成后续加工与公证。
buildCypressApp 的选项接口中还暴露了 skipSigning?: boolean(见 build.ts),允许本地构建时跳过签名——签名失败时若设置了该选项会吞掉异常(build.ts),这为"本地打包调试"与"CI 正式发布"两种场景做了区分。
electron-builder.json:签名相关配置一览
仓库根目录的 electron-builder.json 是签名行为的配置中枢,关键项如下:
{
"productName": "Cypress",
"appId": "com.electron.cypress",
"mac": {
"target": "zip",
"forceCodeSigning": true,
"hardenedRuntime": true,
"entitlements": "./scripts/entitlements.mac.inherit.plist",
"entitlementsInherit": "./scripts/entitlements.mac.inherit.plist",
"type": "distribution"
},
"win": {
"signingHashAlgorithms": ["sha256"],
"sign": "./scripts/windows-sign.js",
"target": "dir"
},
"afterPack": "./scripts/after-pack-hook.js",
"afterSign": "./scripts/after-sign-hook.js"
}
逐项说明:
mac.forceCodeSigning: true:强制要求提供签名证书(即上文CSC_LINK/CSC_KEY_PASSWORD),否则打包直接失败,保证 CI 产物不会静默产出未签名包。mac.hardenedRuntime: true:启用 macOS 强化运行时,这是通过 Apple 公证的前提。mac.entitlements/mac.entitlementsInherit:都指向 scripts/entitlements.mac.inherit.plist。该文件授予三项能力:com.apple.security.cs.allow-jit—— 允许 JIT,Electron/V8 运行时所需;com.apple.security.cs.allow-unsigned-executable-memory—— 允许未签名可执行内存;com.apple.security.cs.allow-dyld-environment-variables—— 允许 dyld 环境变量,Cypress 二进制依赖环境变量注入(如云协议相关配置),从源码中大量的process.env消费可以推断其必要性。
win.signingHashAlgorithms: ["sha256"]:仅使用 SHA-256 摘要算法签名,符合当前 Windows Authenticode 要求。win.sign: "./scripts/windows-sign.js":把 Windows 签名委托给自定义脚本(详见后文远程签名一节),而不是由 electron-builder 用本地证书直接签。afterPack/afterSign:分别指向 after-pack-hook.js 与 after-sign-hook.js。前者负责把各 package 的node_modules拷入产物、翻转 Electron Fuses、生成 V8 快照等打包后加工(与签名无直接关系,但会改变最终进签/进公证的对象);后者负责 macOS 公证。
轮换 macOS 签名密钥:从 Apple 证书到 CircleCI 上下文
这部分完整继承 code-signing 指南 中 "Rotating the Mac code signing key" 的 4 步流程:
- 在 Mac 上用 Cypress 的 Apple 开发者计划身份登录 Xcode。
- 按 Apple 官方"Create, export, and delete signing certificates"文档操作:
- 先执行 "View signing certificates" 查看现有证书;
- 执行 "Create a signing certificate",提示选择类型时选择 Developer ID Application(注意:用于应用分发签名的是 Developer ID Application 证书,而非 App Store 证书);
- 执行 "Export a signing certificate",导出时设置一个强口令,该口令之后会成为环境变量
CSC_KEY_PASSWORD。
- 把导出的、已加密的
.p12文件上传到 Google Drive 的 Code Signing 文件夹,并生成一个公共的直接下载链接。 - 在 CircleCI 的
test-runner:sign-mac-binary上下文中,把CSC_LINK设为该直接下载 URL,把CSC_KEY_PASSWORD设为加密 p12 时使用的口令。
几点值得强调的工程细节:
- 凭据存放采用"外部密文文件 + 下载链接"模式:私钥从不落进代码仓库或 CI 明文环境变量,
CSC_LINK只指向加密后的.p12,CSC_KEY_PASSWORD单独存放。electron-builder 构建时会自动从CSC_LINK下载证书文件并用口令解密后签名。 - 上下文按平台隔离:macOS 与 Windows 分别使用
test-runner:sign-mac-binary与test-runner:sign-windows-binary两个独立上下文,密钥轮换互不影响,也便于按平台最小化授权。 - 轮换时口令变更必须同步:
CSC_KEY_PASSWORD与.p12的加密口令是绑定的,二者不同步会导致 CI 签名阶段解密失败。
源码纵深:签名之后还要"公证"(notarize)
macOS 上仅签名并不够,还需向 Apple 提交公证。Cypress 通过 after-sign-hook.js 在 electron-builder 的 afterSign 阶段完成,逻辑如下:
- 平台守卫:
process.platform !== 'darwin'时直接跳过(日志提示not Mac, skipping after sign hook); - 逃生开关:设置了
SKIP_NOTARIZATION环境变量时跳过公证,便于调试构建; - 定位应用:在
params.appOutDir下查找<productFilename>.app,找不到则抛错;appId硬编码为com.electron.cypress,与 electron-builder.json 中的appId一致; - 三项必需凭据:
NOTARIZE_APP_APPLE_ID、NOTARIZE_APP_PASSWORD、NOTARIZE_APP_TEAM_ID,任一缺失立即抛错(注意公证用的是 Apple ID + App 专用密码 + Team ID,与签名用的 Developer ID 证书是两套凭据); - 调用
@electron/notarize的notarize(...)提交公证,失败会打印could not notarize application并向上抛出,使构建失败。
此外,build.ts 在 darwin 平台且未 skipSigning 时,会用 macOS 自带的 Gatekeeper 校验工具对产物做一次自证:
const args = ['-a', '-vvvv', appFolder]
console.log(`cmd: spctl ${args.join(' ')}`)
const sp = spawn('spctl', args, { stdio: 'inherit' })
即执行 spctl -a -vvvv <产物路径>,退出码非 0 则构建失败——相当于在 CI 中模拟了"用户机器上 Gatekeeper 是否放行"的终检。
轮换 Windows 签名密钥:CSR、SSL.com 证书与 PFX 转换
这部分完整继承 code-signing 指南 中 "Rotating the Windows code signing key" 的 6 步流程。
第 1 步:用 openssl 生成私钥与 CSR
# generate a new private key
openssl genrsa -out win-code-signing.key 4096
# create a CSR using the private key
openssl req -new -key win-code-signing.key -out win-code-signing.csr
第 2 步:把 CSR 提交给 SSL.com(使用 Cypress 的 SSL.com 账户)换取证书
- 若是续期(renewing),按 SSL.com 官方的 Renewing EV/OV and IV Certificates 指引操作;
- 若是轮换(rotating),需联系 SSL.com 支持申请重新签发。
第 3 步:从 SSL.com 控制台获取完整证书链,以 ASCII-armored PEM 格式保存为 win-code-signing.crt(内容形如 -----BEGIN CERTIFICATE----- 等块)。
第 4 步:用 openssl 把明文 PEM 的私钥与证书转为二进制的 PKCS#12/PFX 并加密,口令同样是强口令,之后成为 CSC_KEY_PASSWORD:
➜ openssl pkcs12 -export -inkey win-code-signing.key -in win-code-signing.crt -out encrypted-win-code-signing.pfx
Enter Export Password: <password>
Verifying - Enter Export Password: <password>
第 5 步:把 encrypted-win-code-signing.pfx 上传到 Google Drive 的 Code Signing 文件夹,获取公共直接下载链接。
第 6 步:在 CircleCI 的 test-runner:sign-windows-binary 上下文中,把 CSC_LINK 设为该直接下载 URL,把 CSC_KEY_PASSWORD 设为加密 pfx 的口令。
源码纵深:为什么 Windows 不直接用 pfx 本地签,而是远程签名
按 electron-builder 的一般约定,CSC_LINK 指向的 pfx 会被拉取后在构建机上执行 Authenticode 签名。但 Cypress 的 windows-sign.js 文件头注释说明了偏离这一默认做法的原因:
This signing procedure only runs on windows binary builds to leverage remote signing in order to fullfil new requirements around OV and IV code signing.
即为了满足 SSL.com 自 2023 年 6 月起对 OV/IV 证书密钥存储的新要求(私钥不能长期离开受控环境),Cypress 改为把签名委托给 SSL.com 的远程签名服务。这与 electron-builder.json 中 "sign": "./scripts/windows-sign.js" 的委托配置相呼应,完整链路见 windows-sign.js 的 sign(configuration) 函数:
-
读取四个远程签名凭据(均来自 CI 环境变量,任一缺失则打印缺失项并
process.exit(1)):WINDOWS_SIGN_USER_NAME、WINDOWS_SIGN_USER_PASSWORD—— SSL.com 账户凭据;WINDOWS_SIGN_CREDENTIAL_ID—— SSL.com 侧证书/凭据标识;WINDOWS_SIGN_USER_TOTP—— TOTP 二次验证密钥。
-
下载并校验 SSL.com 的 CodeSignTool:脚本把工具版本钉死为 v1.3.2,并预置了下载包的 SHA-256 校验和:
const CODE_SIGN_TOOL_VERSION = 'v1.3.2' const CODE_SIGN_TOOL_SHA256 = '4afc32e8b7f79bbe1de7e4e7049aaad4e0f754357613b9bbec0e3052f06fd36b'下载后先计算实际 SHA-256,与预期值不符立即抛错("Downloaded CodeSignTool archive checksum ... does not match expected ...")。注释还贴心地给出升级工具时的校验和再生成方法:
curl -fSL <CODE_SIGN_TOOL_URL> | shasum -a 256。这保证了"能签我们二进制的工具"只能经受控的版本提升才会变化,防止供应链篡改。 -
调用 CodeSignTool 完成远程签名:
childProcess.execSync(`CodeSignTool.bat sign -input_file_path="${configuration.path}" -output_dir_path="${TEMP_DIR}" -credential_id="${CREDENTIAL_ID}" -username="${USER_NAME}" -password="${USER_PASSWORD}" -totp_secret="${USER_TOTP}"`) -
回写产物:由于 CodeSignTool 无法在无交互确认的情况下原地覆盖文件,脚本先签出到临时目录
os.tmpdir()/release/tmp,再把签名后的文件移回原位置覆盖(mv "${tempFile}" "${dir}"),对 electron-builder 而言签名对象路径不变。
从源码结构看,configuration.path 由 electron-builder 在逐个签包文件时传入,因此该委托脚本只对 Windows 可执行文件生效,与 electron-builder.json 中 win.target: "dir"(产出目录而非压缩包)相配合。
小结:两套密钥、两条链路、统一的 CI 凭据模型
| 维度 | macOS | Windows |
|---|---|---|
| 证书来源 | Apple Developer ID Application 证书 | SSL.com 签发的 OV/IV 证书 |
| 密钥形态 | 加密 .p12 |
加密 .pfx(PKCS#12) |
| 凭据上下文 | test-runner:sign-mac-binary |
test-runner:sign-windows-binary |
| 核心环境变量 | CSC_LINK、CSC_KEY_PASSWORD |
CSC_LINK、CSC_KEY_PASSWORD,另加 WINDOWS_SIGN_USER_NAME/_PASSWORD/_CREDENTIAL_ID/_USER_TOTP |
| 签名方式 | electron-builder 本地签名(forceCodeSigning) |
委托脚本走 SSL.com 远程签名(win.sign) |
| 签名后动作 | afterSign 钩子公证(NOTARIZE_APP_*)+ spctl 自证 |
— |
两个平台共享同一套"加密密钥文件存 Google Drive、CSC_LINK 指下载链接、CSC_KEY_PASSWORD 存口令"的凭据模型,差异在于 Windows 因密钥托管合规要求额外走了一条版本钉死、校验和校验的远程签名通道。对需要维护自签发布流程的 Electron 项目而言,这套"证书轮换 SOP + 委托签名 + 公证钩子 + Gatekeeper 自证"的组合是一个可以直接对照仓库文件复现参考的完整范本。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00