替 Alacritty fork 加上簽署與公證

/ , ,
簡體中文正體中文
摘要:
為了不讓朋友的電腦跳出警告,我在 Alacritty fork 的 GitHub Actions 工作流程中加入了 macOS 簽署與公證。

我之前 fork 了一份 Alacritty,合併了一些很實用、但上游維護者不願接受的 patch,像是上下左右可各自設定的 padding、選取範圍錯位的修正,以及 Big Sur 風格的圖示。另外也加了一些自己的改動。

之前 CI 只負責產出建置結果,沒有簽署,更沒有公證。傳給朋友之後,他們的電腦會跳出 Apple 的警告,大意是無法開啟,因為 Apple 無法檢查它是否含有惡意軟體。每次都得手動放行,很不方便。

我剛好有付費的開發者帳號,於是決定用它替建置好的應用程式簽署並公證,直接在 CI 裡完成。過程中踩了不少坑,所以寫篇文章記錄一下,程式碼在這個 PR。

簽署與公證

簽署就像用開發者憑證替應用程式蓋章,證明是誰發佈的,也讓系統能檢查簽署後是否遭到修改。公證則是將簽署好的應用程式提交給 Apple,自動掃描沒有問題後,Apple 就會記錄結果。開啟從網路下載的應用程式時,macOS 的 Gatekeeper 會檢查簽署與公證。有了這兩項,就能避免前面遇到的那種警告。

要做的就是在 CI 裡依序完成建置、簽署、公證,最後打包成 DMG 上傳。

Mermaid diagram

在 CI 裡匯入憑證

簽署需要 Developer ID Application 憑證。先在本機的鑰匙圈裡,將憑證連同私密金鑰匯出成 .p12,轉成 base64,再放進儲存庫的 Secrets。我用了三個與憑證相關的 secret:憑證本身、.p12 的密碼,以及暫用鑰匙圈的密碼。

每次 CI 執行時都建立一個暫用鑰匙圈,先匯入憑證,用完再刪除這個鑰匙圈。

YAML
- name: Install Apple certificate
  env:
    BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
    P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
    KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
  run: |
    CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
    KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
    echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
    security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
    security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
    security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
    security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
    security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
    security list-keychain -d user -s $KEYCHAIN_PATH
    security default-keychain -s $KEYCHAIN_PATH

其中的 set-key-partition-list 這一行絕對不能少。少了它,codesign 使用私密金鑰時,macOS 會跳出視窗詢問是否允許,但 CI 裡沒有人能按,建置就會一直卡住。(血淚教訓😭)

整個 job 的最後還有一個清理步驟,設定了 if: always(),不論前面成功或失敗,都會刪除暫用鑰匙圈。

另外在建置前,我也加了一行 security find-identity -v -p codesigning,把目前可用的簽署身分印到紀錄裡,方便除錯。

編輯 Makefile

Alacritty 隨附的 Makefile 在打包 .app 時,會用 codesign --sign - 做一次不使用憑證的 ad hoc 簽署。Gatekeeper 不會將它視為可信任的 Developer ID 簽署,所以我改成使用自己的憑證。

MAKE
@codesign --force --deep --entitlements $(APP_ENTITLEMENTS) --options runtime --timestamp --sign "Developer ID Application: Bojin Li (TEAMID)" -v "$(APP_DIR)/$(APP_NAME)"

這裡有三個與公證相關的參數。--options runtime 開啟 Hardened Runtime,--timestamp 則附上 Apple 時間戳記服務的時間戳記。這兩項都是公證的必要條件,少了其中一項,公證就會被拒絕。--entitlements 指向權限宣告檔。

另外,Info.plist 裡的 CFBundleIdentifier 原本是上游的 org.alacritty,我改成了自己的 xxxbrian.alacritty。

Entitlements

開啟 Hardened Runtime 後,應用程式若要存取相機、麥克風、聯絡人、行事曆、定位、照片等受保護資源,必須在簽署時宣告對應的 entitlement,否則存取就會遭到拒絕。

終端機比較特殊,雖然本身用不到這些資源,但裡面執行的程式可能會用到,例如存取相機的命令列工具。這些權限會歸到終端機上;如果沒有宣告,工具就無法在我的 Alacritty 裡使用這些資源。所以我加了一份 macos-entitlement.plist,宣告常見的幾項權限。

XML
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.personal-information.addressbook</key>
<true/>
<key>com.apple.security.personal-information.calendars</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>

公證與 staple

接著將簽署好的 universal .app 送去公證。我沒有自己寫 notarytool 的呼叫,而是直接使用 lando/notarize-action,提供 Apple ID、App 專用密碼與 Team ID 即可。App 專用密碼要在 appleid.apple.com 另外產生,不能使用 Apple ID 本身的密碼。

公證通過後,還要將公證票證附加到應用程式上,這一步叫 staple。我用的是 BoundfoxStudios/action-xcode-staple。附加票證後,使用者即使沒有網路,也能驗證應用程式已通過公證。

YAML
- name: Make Universal
  run: |
    security find-identity -v -p codesigning
    make app-universal
- name: Notarize Release Build
  uses: lando/notarize-action@v2
  with:
    product-path: "./target/release/osx/Alacritty.app"
    appstore-connect-username: ${{ secrets.NOTARIZATION_USERNAME }}
    appstore-connect-password: ${{ secrets.NOTARIZATION_PASSWORD }}
    appstore-connect-team-id: TEAMID
    verbose: true
- name: Staple Release Build
  uses: BoundfoxStudios/action-xcode-staple@v1
  with:
    product-path: "./target/release/osx/Alacritty.app"
- name: Make DMG
  run: make dmg-universal

產生 DMG

最麻煩的是最後的 DMG。

在 Alacritty 的 Makefile 裡,DMG 目標依賴 .app 目標。直接執行 make dmg-universal,會先重新建置 .app,包括重新複製檔案與簽署,可是前面已經完成簽署、公證和 staple 了。

我一開始想新增一個不依賴 .app 的目標,叫 dmg-universal-exist,直接拿現成的 .app 打包。但寫出來的規則和原本那條幾乎一樣,也沒有真正移除依賴關係,完全沒有作用。最後只好刪掉,改回 make dmg-universal。這就是 PR 最後那個 fix: corrected DMG creation process 提交的由來。

Important

這裡記錄的是當時 PR 的最終狀態,不是建議的打包順序。make dmg-universal 仍會重新建置並簽署應用程式,不能假設前一步附加的公證票證還在。更穩妥的流程是只打包已經簽署、公證並完成 staple 的應用程式,不再重新建置。發佈前,應從最終 DMG 中取出實際要分發的應用程式,再執行文末的驗證命令。

最後

最後的發佈流程是這樣:推送一個 tag,CI 就會在 macOS 上編譯 x86 與 ARM 兩個版本,合成 universal,用我的憑證簽署、提交公證、staple,再打包成 DMG 上傳到 GitHub Release。

建置好的應用程式可以在本機驗證。

BASH
codesign -dv --verbose=4 Alacritty.app
spctl -a -vv Alacritty.app
xcrun stapler validate Alacritty.app

如果 spctl 輸出 accepted 並且顯示 source=Notarized Developer ID,就表示通過了 Gatekeeper 的檢查。

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...