Electron 签名和公证

3,551 阅读5分钟

分发到 macOS 系统上的应用,为了保证安全性和可信度,都需要做签名和公证。

  • 签名: 是指使用开发者的数字证书对应用进行加密,以确保应用没有被篡改过。
  • 公证: 是指将应用提交给苹果公司进行审核,并获得苹果公司的认证和授权,以证明应用是安全可信的。

通过签名和公证,macOS 应用的安全性和可信度得到了保障,接下来分别进行讲解:

签名

操作系统为了保证用户安装的应用未被篡改,提供了签名机制。代码签名可以用来证明应用程序是由授权的开发者创建的,如果用户安装了未签名的应用,系统会弹出提示信息:

签名之后就不会弹这个框,接下来为大家介绍证书申请和签名的详细流程:

证书申请

签名需要向苹果公司申请证书,流程分三步:

  1. 在本地创建一个 csr 文件(Certificate Signing Request),作为获取密钥的凭证
  2. 通过 csr 文件从官网下载 cer 证书
  3. 双击将证书导入到钥匙串,然后导出成 p12 证书

首先打开钥匙串应用,点击左上角「钥匙串访问」菜单,选择「证书助理」子菜单中的「从证书颁发机构请求证书」:

然后填写邮箱(User Email 填写申请证书的邮箱,CA Email 填写 Apple 账号的邮箱)和常用名称,生成一个 csr 文件。

这里选择「存储到磁盘」,然后会生成一个 CertificateSigningRequest.certSigningRequest 文件,请保存下来。接下来登录苹果开发者官网证书列表,点击加号创建新证书。

然后选择 Mac App Distribution,并点击 Continue。

然后上传刚才在自己电脑上创建的 csr 文件,然后点击下一步:

然后就能下载 mac_app.cer 文件了:

下载完毕双击导入到钥匙串访问,注意导入的时候,macOS 系统会进行身份认证,比如输入指纹或者密码,请一定要按照提示操作,完整之后就可以在钥匙串当中看到这个证书了:

不过默认状态是不受信任的,可以双击证书,将其设置为「始终信任」:

注意此过程也需要进行身份认证,修改完毕后,证书状态就变为被所有用户信任了。

有了证书之后,接下来可以进行签名了。

证书签名

在 macOS 系统上,有两种签名方式:

用 osx-sign 库签名

Electron 官方提供了签名的库 osx-sign,全局安装之后,可以在命令行中使用:

$ electron-osx-sign Electron-Desktop.app \
--identity='Developer ID Application: XXX' \
--entitlements='./sign.entitlements' \
--hardened-runtime \
--timestamp='none' \
--ignore='Electron-Desktop.app/Contents/Resources/.gitignore' 

参数的含义为:

  • entitlements:声明你的 APP 需要使用哪些权限,例如音频、摄像头等等
  • hardened-runtime:如果应用需要公证,则必须开启,否则公证会失败
  • timestamp:去苹果服务器请求签名时间戳,可以通过 none 关闭时间戳,但公证环节会失败
  • ignore:指定忽略签名的文件
  • identity:签名使用的证书(会自动去系统中寻找该证书)

其中 sign.entitlements 文件的内容格式为:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.automation.apple-events</key>
    <true/>
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
    <true/>
    <key>com.apple.security.device.audio-input</key>
    <true/>
    <key>com.apple.security.device.camera</key>
    <true/>
  </dict>
</plist>

osx-sign 也可以在 Node.js 中使用:

const { signAsync } = require('@electron/osx-sign');
signAsync({
  app: 'path/to/my.app', // 需要签名的app路径
  entitlements: './sign.entitlements', // entitlements 文件路径
  hardenedRuntime: true, // hardenedRuntime配置
  identity: 'Developer ID Application: XXX', // 签名使用的证书名称
  platform: 'darwin', // darwin 或者 mas,后者表示在 App Store 上架
})
.then(() => {
  // 应用签名成功回调
})
.catch((err) => {
  // 应用签名失败回调
});

用自定义脚本签名

上面的签名方式虽然简单,但把所有文件,包括静态资源全部进行签名了,如果项目中的图片和文件多的话,速度会比较慢,但实际上只需要对 app 里面的二进制文件进行签名即可,因此可以封装一个签名脚本:

#!/usr/bin/env bash
function sign_app() {
  apppath=$1
  bundleid=$2
  echo "signApp $apppath"
  if [ ! -e "$apppath" ]; then
    echo "$apppath"' not exist.'
    return 0
  fi
  plistpath="$apppath/Contents/Info.plist"
  if [ -f "$plistpath" ]; then
    bundleid=$(/usr/libexec/PlistBuddy -c "print :CFBundleIdentifier" "$plistpath")
  fi
  if [ ! -n "$bundleid" ]; then
    bundleid="com.keliq.desktop"
  fi
  codesign_extra_options="--timestamp --options runtime"
  entitlements_path=./sign.entitlements
  codesign --deep $codesign_extra_options -f -s "Developer ID Application: XXX" --entitlements $entitlements_path -i $bundleid -v "$path"
}

然后手动把应用中的二进制文件(包括动态链接库等)单独拎出来,然后调用上面的函数进行签名,注意要先签里面的二进制文件,再签外面的 app:

DIR=Electron-Desktop-darwin-x64 # App 所在目录
NAME=Electron-Desktop # App 名称
OSX_SIGN=1 # 签名控制开关,0 表示不签名,其他表示签名

function codesign() {
  signApp "$DIR/$NAME.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries/libEGL.dylib" 'com.keliq.libEGL'
  signApp "$DIR/$NAME.app/Contents/Frameworks/Squirrel.framework/Versions/A/Resources/ShipIt" 'com.keliq.ShipIt'
  # ... 
  signApp "$DIR/$NAME.app" 'com.keliq.electron-desktop'
}

if [ "$OSX_SIGN" != "0" ]; then
    codesign
fi

筛选二进制文件的过程可能比较耗时,但好在只是一次性工作,后续几乎不需要改动。在项目打包构建阶段通过 childProcess 来执行上述签名脚本:

const path = require('path')
const childProcess = require('child_process')

const execPromise = (cmd, options = {}) => {
  return new Promise(resolve => {
    console.log(chalk.blue(`exec start: ${cmd}`))
    childProcess.exec(cmd, options, (err, stdout, stderr) => {
      console.log(chalk.blue(`exec done: ${cmd}`))
      err && console.log('exec err', chalk.red(err))
      stdout && console.log(`=====exec stdout=====\n${chalk.green(stdout)}`)
      stderr && console.log(`=====exec stderr=====\n${chalk.yellow(stderr)}`)
      resolve()
    })
  })
}

const cwd = process.cwd()
const script = path.join(cwd, 'script', 'sign.sh')
execPromise(`bash ${script}`, { cwd })

最后,可以用下面的命令验证 app 是否已经签名成功:

$ codesign -dvvv Electron-Desktop.app

公证

为什么需要公证呢?2020年2月3日起,Mac App Store 以外通过其他途径分发的 Mac 软件必须经过 Apple 公证,才能在 macOS Catalina 中运行,否则在 MacOS 10.14.5 之后,那么就会弹出“恶意软件”提示框:

这时就需要在应用签名之后,再进行公证(notarize app)。所谓的公证,简单说就是将签名后的安装包上传到 Apple 审查而已,获得公证后,应用会被苹果公司加入到“Gatekeeper”应用白名单中,这意味着用户的 macOS 系统会默认信任这个应用的来源,并允许其运行。

Electron 官方也提供了 electron-notarize 包来进行公证,使用方法如下:

import { notarize } from 'electron-notarize';

async function packageTask () {
  await notarize({
    appBundleId, // bundleId
    appPath, // 文件路径
    appleId, // apple公证账号
    appleIdPassword, // apple公证专用密钥
    ascProvider, // 证书提供者
  });
}

这里需要注意一下,appleIdPassword 是公证专用密钥,而不是 apple 账号的登录密码,密钥的格式一般是 xxxx-xxxx-xxxx-xxxx,具体生成方式参见官方文档。另外,不能直接上传 app 格式的文件(因为本质上是个目录),必须上传 dmg 或 zip 格式的文件,可以用下面的命令把 app 压缩成 zip 包:

$ ditto -c -k --keepParent Electron-Desktop.app Electron-Desktop.zip

在命令行里面也能公证,传递的参数是类似的:

$ xcrun altool --notarize-app \
--primary-bundle-id ${AppBundleID 唯一标识} \
--username ${AppleID} \
--asc-provider ${证书提供者} \
--password ${应用公证专用密码} \
--file ${公证文件}

如果你不知道 asc-provider 是什么的话,可以通过下面的命令来进行查询:

$ xcrun altool --list-providers \
-u  ${AppleID} \
-p ${应用公证专用密码}

由于公证是异步的,公证命令执行完毕之后,会先返回一个 RequestUUID,然后用户可以过段时间用这个 RequestUUID 来查询公证结果(不得不吐槽一下,为什么苹果不提供公证完成的回调能力呢?):

$ xcrun altool --notarize-app # ... 省略后面的参数
No errors getting notarization info.

       Date: 20XX-XX-XX 06:20:45 +0000
       Hash: 32sc535781b86ff0684e18adf4914
RequestUUID: 8b70k5u02-94b6-5124-9b71-42232h93a4
     Status: in progress

查询公证结果的命令为:

$ xcrun altool --notarize-info "uuid" \
--asc-provider $NOTARIZE_PROVIDER \
-u $NOTARIZE_ACCOUNT \
-p $NOTARIZE_PASSWORD

如果公证失败,会返回公证成功或失败的结果,例如公证成功:

$ xcrun altool --notarize-info "uuid" # ... 省略后面的参数
No errors getting notarization info.

          Date: 20XX-XX-XX 03:47:17 +0000
          Hash: 32sc535781b86ff0684e18adf4914
    LogFileURL: https://osxapps-ssl.itunes.apple.com/itunes-assets/2d323-a82b3-c8e7/developer_log.json?accessKey=xxx
   RequestUUID: 8b70k5u02-94b6-5124-9b71-42232h93a4
        Status: success
   Status Code: 0
Status Message: Package Approved

公证失败的返回结果为:

$ xcrun altool --notarize-info "uuid" # ... 省略后面的参数
No errors getting notarization info.

          Date: 20XX-XX-XX 03:47:17 +0000
          Hash: 32sc535781b86ff0684e18adf4914
    LogFileURL: https://osxapps-ssl.itunes.apple.com/itunes-assets/2d323-a82b3-c8e7/developer_log.json?accessKey=xxx
   RequestUUID: 8b70k5u02-94b6-5124-9b71-42232h93a4
        Status: invalid
   Status Code: 2
Status Message: Package Invalid

想看失败的具体原因,可以访问上面返回的 LogFileURL 地址,会列出详细的错误信息,例如下面就是没签名直接就公证的错误:

{
  "logFormatVersion": 1,
  "jobId": "xxxx",
  "status": "Invalid",
  "statusSummary": "Archive contains critical validation errors",
  "statusCode": 4000,
  "archiveFilename": "Electron-Desktop.zip",
  "issues": [
    {
      "severity": "error",
      "path": Electron-Desktop.zip/Electron-Desktop.app/Contents/MacOS/Electron-Desktop",
      "message": "The signature of the binary is invalid.",
      "architecture": "x86_64"
    },
  ]
}

可能还会有其他错误,例如某个 installer.app 在手动签名的时候,没有加 .app 后缀导致的:

{
  "logFormatVersion": 1,
  "status": "Invalid",
  "statusSummary": "Archive contains critical validation errors",
  "statusCode": 4000,
  "archiveFilename": "Electron-Desktop.zip"
  "issues": [
    {
      "severity": "error",
      "code": null,
      "path": "Electron-Desktop.zip/Electron-Desktop.app/Contents/Resources/Electron-Desktop/installer.app/Contents/MacOS/installer",
      "message": "The executable does not have the hardened runtime enabled.",
      "docUrl": null,
      "architecture": "x86_64"
    }
  ]
}

如果你采用上面自定义签名脚本的方式来签名,对于那些因为没有列入手动签名而导致公证失败的文件,可以把 LogFileURL 的结果保存为 result.json 文件,下面的脚本自动可以读取里面的内容,生成签名字符串,补充到上面的手动签名脚本中重新签名并公证即可:

const path = require('path')
const result = require('./result.json')
let arr = result.issues.map((it) => it.path.replace('Electron-Desktop.zip/Electron-Desktop.dmg/Electron-Desktop.app', '$DIR/$NAME.app'))
arr = [...new Set(arr)]
arr = arr.map((it) => {
  const filename = path.basename(it)
  const name = filename.replaceAll('(', '').replaceAll(')', '').replaceAll(' ', '')
  return `    signApp "${it}" 'com.example.Electron-Desktop.${name}'`
})
arr.forEach((it) => console.log(it))

如果找不到 RequestUUID 了,可以用下面的命令获取:

$ xcrun altool --notarization-history 0 \
-u ${AppleID} \
-p ${应用公证专用密码}

公证结束之后,会生成数字签名凭证,需要用下面的命令把凭证放到 dmg 文件中:

$ xcrun stapler staple "Electron-Desktop.dmg"
# Processing: Electron-Desktop.dmg
# The staple and validate action worked!

最后可以用 md5 或者 sha1sum 命令来验证新旧 dmg 文件是否相同。

本文正在参加「金石计划」