分发到 macOS 系统上的应用,为了保证安全性和可信度,都需要做签名和公证。
- 签名: 是指使用开发者的数字证书对应用进行加密,以确保应用没有被篡改过。
- 公证: 是指将应用提交给苹果公司进行审核,并获得苹果公司的认证和授权,以证明应用是安全可信的。
通过签名和公证,macOS 应用的安全性和可信度得到了保障,接下来分别进行讲解:
签名
操作系统为了保证用户安装的应用未被篡改,提供了签名机制。代码签名可以用来证明应用程序是由授权的开发者创建的,如果用户安装了未签名的应用,系统会弹出提示信息:
签名之后就不会弹这个框,接下来为大家介绍证书申请和签名的详细流程:
证书申请
签名需要向苹果公司申请证书,流程分三步:
- 在本地创建一个 csr 文件(Certificate Signing Request),作为获取密钥的凭证
- 通过 csr 文件从官网下载 cer 证书
- 双击将证书导入到钥匙串,然后导出成 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 文件是否相同。
本文正在参加「金石计划」