From 52bfc293890a23604f11a2167d845746e38bcb9d Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:51:19 +0200 Subject: [PATCH] Prepare Developer ID signing and notarization for macOS The release workflow already holds the macOS signing steps behind the MACOS_SIGNING_ENABLED variable. This makes them safe to switch on: - When signing is off, the macOS job says so in the run summary and as a notice, so a skip cannot be read as a pass. - When signing is on, the job checks the Developer ID Application authority, the Team ID, the hardened runtime and the timestamp after signing, and asserts that Gatekeeper reports the binary as Notarized Developer ID and that the package is signed, stapled and accepted. It writes the result to the run summary. - A new on-demand "macOS signing check" workflow, bound to the release-signing environment, runs the same chain on a throwaway executable and package. It fails and lists the names of any missing secret. - docs/release-signing.md describes the identities, the secrets, who may change them and what to do if they are compromised; docs/verifying-releases.md explains how to verify the signatures. The signing steps stay inline in both workflows: the signing job checks nothing out and runs no repository code, and a shared script would break that. Refs #50 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/workflows/macos-signing-check.yml | 206 ++++++++++++++++++++++ .github/workflows/release.yml | 77 +++++++- CHANGELOG.md | 6 + docs/release-signing.md | 90 +++++++++- docs/verifying-releases.md | 32 +++- 5 files changed, 398 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/macos-signing-check.yml diff --git a/.github/workflows/macos-signing-check.yml b/.github/workflows/macos-signing-check.yml new file mode 100644 index 0000000..0878cfb --- /dev/null +++ b/.github/workflows/macos-signing-check.yml @@ -0,0 +1,206 @@ +name: macOS signing check + +# On-demand check of the macOS signing secrets, without a release. It signs and notarizes a tiny +# throwaway executable and installer package with the stored Developer ID certificates and App +# Store Connect key, then verifies the result the way release.yml does. Nothing is published or +# uploaded. +# +# The job is bound to the `release-signing` environment, so it needs the same approval as a release +# and is the only way to read the secrets. If a secret is absent the run fails and lists the missing +# names: an explicit check never passes by skipping. +# +# The steps from the import to the verification mirror the `sign-macos` job of release.yml; keep +# the two in step. Like that job, this one checks nothing out and runs no repository code. +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: macos-signing-check + cancel-in-progress: false + +jobs: + check: + name: check the macOS signing secrets + runs-on: macos-latest + timeout-minutes: 60 + environment: release-signing + permissions: + contents: read # the job's default; nothing is checked out + steps: + - name: Check the signing inputs are present + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + MACOS_INSTALLER_CERTIFICATE: ${{ secrets.MACOS_INSTALLER_CERTIFICATE }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_IDENTITY: ${{ secrets.MACOS_INSTALLER_IDENTITY }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: | + set -euo pipefail + missing="" + for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PASSWORD MACOS_SIGNING_IDENTITY \ + MACOS_INSTALLER_CERTIFICATE MACOS_INSTALLER_CERTIFICATE_PASSWORD \ + MACOS_INSTALLER_IDENTITY APPLE_NOTARIZATION_KEY APPLE_NOTARIZATION_KEY_ID \ + APPLE_NOTARIZATION_ISSUER_ID; do + if [ -z "${!name}" ]; then + echo "::error::The secret $name is missing from the release-signing environment." + missing="$missing $name" + fi + done + if [ -n "$missing" ]; then + { + printf '### macOS signing check\n\n' + printf "Failed: these secrets are missing from the \`release-signing\` environment:\n\n" + for name in $missing; do printf -- "- \`%s\`\n" "$name"; done + printf '\nStore them as described in docs/release-signing.md, then run this check again.\n' + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - name: Build a universal test executable + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/check" + printf 'int main(void) { return 0; }\n' > "$RUNNER_TEMP/check/main.c" + cc -arch x86_64 -arch arm64 -o "$RUNNER_TEMP/check/signing-check" "$RUNNER_TEMP/check/main.c" + lipo -info "$RUNNER_TEMP/check/signing-check" + + - name: Import the Developer ID certificates into a temporary keychain + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_CERTIFICATE: ${{ secrets.MACOS_INSTALLER_CERTIFICATE }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + keychain="$RUNNER_TEMP/signing.keychain-db" + password="$(openssl rand -hex 24)" + echo "::add-mask::$password" + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 1800 "$keychain" + security unlock-keychain -p "$password" "$keychain" + printf '%s' "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/application.p12" + printf '%s' "$MACOS_INSTALLER_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/installer.p12" + security import "$RUNNER_TEMP/application.p12" -k "$keychain" \ + -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security import "$RUNNER_TEMP/installer.p12" -k "$keychain" \ + -P "$MACOS_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/productsign + rm -f "$RUNNER_TEMP/application.p12" "$RUNNER_TEMP/installer.p12" + security list-keychains -d user -s "$keychain" login.keychain + security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain" > /dev/null + + - name: Sign the executable (hardened runtime, secure timestamp) + env: + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + run: | + set -euo pipefail + bin="$RUNNER_TEMP/check/signing-check" + codesign --force --timestamp --options runtime \ + --identifier com.hodeitek.hodeishield.signing-check \ + --keychain "$RUNNER_TEMP/signing.keychain-db" \ + --sign "$MACOS_SIGNING_IDENTITY" "$bin" + codesign --verify --strict --verbose=2 "$bin" + # The signature must come from a Developer ID Application certificate of a team, with the + # hardened runtime and a secure timestamp. codesign prints no secrets. + details="$(codesign --display -vv "$bin" 2>&1)" + echo "$details" + team="$(sed -n 's/^TeamIdentifier=//p' <<< "$details")" + for expected in '^Authority=Developer ID Application: ' '^CodeDirectory .*flags=.*runtime' '^Timestamp='; do + if ! grep -q -- "$expected" <<< "$details"; then + echo "::error::The signature lacks the expected '${expected#^}'." + exit 1 + fi + done + if [ -z "$team" ] || [ "$team" = "not set" ]; then + echo "::error::The signature carries no TeamIdentifier." + exit 1 + fi + + - name: Build and sign a test installer package + env: + MACOS_INSTALLER_IDENTITY: ${{ secrets.MACOS_INSTALLER_IDENTITY }} + run: | + set -euo pipefail + root="$RUNNER_TEMP/pkgroot" + mkdir -p "$root/usr/local/bin" + cp -p "$RUNNER_TEMP/check/signing-check" "$root/usr/local/bin/" + pkgbuild --root "$root" --install-location / \ + --identifier com.hodeitek.hodeishield.signing-check --version 0.0.0 \ + "$RUNNER_TEMP/unsigned.pkg" + productsign --timestamp --keychain "$RUNNER_TEMP/signing.keychain-db" \ + --sign "$MACOS_INSTALLER_IDENTITY" \ + "$RUNNER_TEMP/unsigned.pkg" "$RUNNER_TEMP/signing-check.pkg" + pkgutil --check-signature "$RUNNER_TEMP/signing-check.pkg" + + - name: Remove the temporary keychain + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/signing.keychain-db" 2>/dev/null || true + rm -f "$RUNNER_TEMP/application.p12" "$RUNNER_TEMP/installer.p12" + + - name: Notarize the executable and the package, staple the package + env: + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: | + set -euo pipefail + key="$RUNNER_TEMP/notary.p8" + trap 'rm -f "$key"' EXIT + printf '%s' "$APPLE_NOTARIZATION_KEY" | base64 --decode > "$key" + notarize() { + xcrun notarytool submit "$1" --key "$key" --key-id "$APPLE_NOTARIZATION_KEY_ID" \ + --issuer "$APPLE_NOTARIZATION_ISSUER_ID" --wait --timeout 30m + } + ditto -c -k "$RUNNER_TEMP/check/signing-check" "$RUNNER_TEMP/notarize.zip" + notarize "$RUNNER_TEMP/notarize.zip" + pkg="$RUNNER_TEMP/signing-check.pkg" + notarize "$pkg" + xcrun stapler staple "$pkg" + xcrun stapler validate "$pkg" + spctl --assess --type install --verbose=2 "$pkg" + + - name: Verify the signed and notarized files + run: | + set -euo pipefail + bin="$RUNNER_TEMP/check/signing-check" + pkg="$RUNNER_TEMP/signing-check.pkg" + team="$(codesign --display -vv "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p')" + if [ -z "$team" ] || [ "$team" = "not set" ]; then + echo "::error::The signature carries no TeamIdentifier." + exit 1 + fi + accepted=false + for _ in 1 2 3 4 5 6; do + out="$(spctl --assess --type execute --verbose=2 "$bin" 2>&1 || true)" + if grep -qx 'source=Notarized Developer ID' <<< "$out"; then accepted=true; break; fi + sleep 20 + done + echo "$out" + if [ "$accepted" != true ]; then + echo "::error::Gatekeeper does not report the executable as 'Notarized Developer ID'." + exit 1 + fi + sig="$(pkgutil --check-signature "$pkg")" + echo "$sig" + if ! grep -q 'Developer ID Installer: ' <<< "$sig"; then + echo "::error::The package is not signed with a Developer ID Installer certificate." + exit 1 + fi + xcrun stapler validate "$pkg" + spctl --assess --type install --verbose=2 "$pkg" + { + printf '### macOS signing check\n\n' + printf 'Passed: the stored secrets sign and notarize with the Developer ID of team %s.\n\n' "$team" + printf -- '- All nine secrets are present.\n' + printf -- "- \`codesign --verify --strict\`: passed; Developer ID Application authority, hardened runtime and secure timestamp present.\n" + printf -- "- \`spctl --assess --type execute\`: \`source=Notarized Developer ID\`.\n" + printf -- "- \`pkgutil --check-signature\`: signed with a Developer ID Installer certificate.\n" + printf -- "- \`xcrun stapler validate\` and \`spctl --assess --type install\` on the package: passed.\n" + printf '\nOnly a throwaway test executable was signed; no release file was touched or uploaded.\n' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d740002..28a1251 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -255,6 +255,9 @@ jobs: # - a .pkg that installs /usr/local/bin/hodeishield, signed with Developer ID Installer, # notarized and stapled, so it also passes Gatekeeper offline and can be deployed by MDM. # + # The same import, sign, notarize and verify chain runs without a release in + # macos-signing-check.yml; keep the two in step. + # # Environment secrets (release-signing): MACOS_CERTIFICATE and MACOS_CERTIFICATE_PASSWORD # (Developer ID Application, base64 .p12), MACOS_SIGNING_IDENTITY, MACOS_INSTALLER_CERTIFICATE, # MACOS_INSTALLER_CERTIFICATE_PASSWORD, MACOS_INSTALLER_IDENTITY (Developer ID Installer), @@ -305,6 +308,22 @@ jobs: chmod 0755 "stage/${BIN_NAME}" find stage/man -type f -exec chmod 0644 {} \; + # A skip must never read as a pass: say so in the run summary and as an annotation. + - name: Record that macOS signing was skipped + if: env.SIGN != 'true' + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = push ]; then + why="MACOS_SIGNING_ENABLED is not true" + else + why="this is a manual dry run, which never signs" + fi + msg="macOS signing: skipped — ${why}; the binary is not Developer ID signed or notarized." + echo "::notice title=macOS signing skipped::${msg}" + printf '### macOS signing\n\n%s\n' "$msg" >> "$GITHUB_STEP_SUMMARY" + - name: Check the signing inputs are present if: env.SIGN == 'true' env: @@ -367,7 +386,21 @@ jobs: --keychain "$RUNNER_TEMP/signing.keychain-db" \ --sign "$MACOS_SIGNING_IDENTITY" "stage/${BIN_NAME}" codesign --verify --strict --verbose=2 "stage/${BIN_NAME}" - codesign --display --verbose=2 "stage/${BIN_NAME}" + # The signature must come from a Developer ID Application certificate of a team, with the + # hardened runtime and a secure timestamp. codesign prints no secrets. + details="$(codesign --display -vv "stage/${BIN_NAME}" 2>&1)" + echo "$details" + team="$(sed -n 's/^TeamIdentifier=//p' <<< "$details")" + for expected in '^Authority=Developer ID Application: ' '^CodeDirectory .*flags=.*runtime' '^Timestamp='; do + if ! grep -q -- "$expected" <<< "$details"; then + echo "::error::The signature lacks the expected '${expected#^}'." + exit 1 + fi + done + if [ -z "$team" ] || [ "$team" = "not set" ]; then + echo "::error::The signature carries no TeamIdentifier." + exit 1 + fi - name: Build and sign the installer package if: env.SIGN == 'true' @@ -418,6 +451,48 @@ jobs: xcrun stapler validate "$pkg" spctl --assess --type install --verbose=2 "$pkg" + # What Gatekeeper and the system tools say about the finished files, and the run summary. The + # binary cannot be stapled, so Gatekeeper looks its ticket up online; that can lag a little + # behind the notarization, hence the retries. + - name: Verify the signed and notarized files + if: env.SIGN == 'true' + run: | + set -euo pipefail + bin="stage/${BIN_NAME}" + pkg="dist/hodeishield-${VERSION}-${TARGET}.pkg" + team="$(codesign --display -vv "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p')" + if [ -z "$team" ] || [ "$team" = "not set" ]; then + echo "::error::The signature carries no TeamIdentifier." + exit 1 + fi + accepted=false + for _ in 1 2 3 4 5 6; do + out="$(spctl --assess --type execute --verbose=2 "$bin" 2>&1 || true)" + if grep -qx 'source=Notarized Developer ID' <<< "$out"; then accepted=true; break; fi + sleep 20 + done + echo "$out" + if [ "$accepted" != true ]; then + echo "::error::Gatekeeper does not report the binary as 'Notarized Developer ID'." + exit 1 + fi + sig="$(pkgutil --check-signature "$pkg")" + echo "$sig" + if ! grep -q 'Developer ID Installer: ' <<< "$sig"; then + echo "::error::The package is not signed with a Developer ID Installer certificate." + exit 1 + fi + xcrun stapler validate "$pkg" + spctl --assess --type install --verbose=2 "$pkg" + { + printf '### macOS signing\n\n' + printf 'macOS signing: signed and notarized with the Developer ID of team %s.\n\n' "$team" + printf -- "- \`codesign --verify --strict\`: passed; Developer ID Application authority, hardened runtime and secure timestamp present.\n" + printf -- "- \`spctl --assess --type execute\` on the binary: \`source=Notarized Developer ID\`.\n" + printf -- "- \`pkgutil --check-signature\` on the package: signed with a Developer ID Installer certificate.\n" + printf -- "- \`xcrun stapler validate\` and \`spctl --assess --type install\` on the package: passed.\n" + } >> "$GITHUB_STEP_SUMMARY" + - name: Package archive run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ddb06..353fead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,12 @@ commands, `--json` output and exit codes. Each release's notes start with its se `SECURITY.md`; `SECURITY.md` points to it. A weekly check validates the file and opens an issue when it is invalid or has less than 60 days left before its expiry date. ([#125](https://github.com/Hodeitek/hodeishield-cli/issues/125)) +- The release workflow can sign and notarize the macOS binaries with a Developer ID: the universal + binary is signed with the hardened runtime and notarized, and a `.pkg` installer is signed, + notarized and stapled. A new on-demand workflow, "macOS signing check", tests the stored signing + secrets without a release. Until the signing identity is stored, releases say in their run summary + that macOS signing was skipped. + ([#50](https://github.com/Hodeitek/hodeishield-cli/issues/50)) ### Changed diff --git a/docs/release-signing.md b/docs/release-signing.md index dafd098..10790ef 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -87,12 +87,58 @@ The signatures of every signed release so far (0.2.0 to 0.4.4) carry: | Subject | `CN=Hodeitek S.L., O=Hodeitek S.L., L=Aretxabaleta, S=Gipuzkoa, C=ES` | | Certificate profile EKU | `1.3.6.1.4.1.311.97.449523565.535854065.272767902.439799148` | -### macOS - -macOS signing is not active ([#50](https://github.com/Hodeitek/hodeishield-cli/issues/50)): -`MACOS_SIGNING_ENABLED` is not set, and no Apple certificate or notarization key is stored. The -workflow's macOS steps would use Developer ID certificates and an App Store Connect key kept as -`release-signing` environment secrets. This document must cover them before they are used. +### Developer ID (macOS) + +The `sign-macos` job of `release.yml` signs the universal `hodeishield` binary with an Apple +Developer ID Application certificate, with the hardened runtime and a secure timestamp. It builds +`hodeishield--universal-apple-darwin.pkg`, which installs `/usr/local/bin/hodeishield`, and +signs it with a Developer ID Installer certificate. Both the binary and the package are notarized +by Apple, and the package is stapled. A bare executable cannot carry a stapled ticket, so Gatekeeper +checks the binary's notarization online on first run. See +[#50](https://github.com/Hodeitek/hodeishield-cli/issues/50). + +- **Identities.** A Developer ID Application certificate for the binary and a Developer ID Installer + certificate for the package, both issued to Hodeitek S.L. by Apple's Developer ID certification + authority. The signature carries the Apple Developer Team ID of the account. +- **Notarization.** `notarytool` authenticates with an App Store Connect API key (a `.p8` private + key, its key ID and the issuer ID). +- **Where they are kept.** Only as secrets of the `release-signing` environment; none is in the + repository or in a variable. The job imports the certificates into a temporary keychain that is + deleted at the end of the job, and writes the API key to a temporary file that is deleted too. + + | Secret | Content | + |---|---| + | `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PASSWORD` | Developer ID Application certificate and key (`.p12`, base64) and its password | + | `MACOS_SIGNING_IDENTITY` | Name of that certificate, as `codesign` takes it | + | `MACOS_INSTALLER_CERTIFICATE`, `MACOS_INSTALLER_CERTIFICATE_PASSWORD` | Developer ID Installer certificate and key (`.p12`, base64) and its password | + | `MACOS_INSTALLER_IDENTITY` | Name of that certificate, as `productsign` takes it | + | `APPLE_NOTARIZATION_KEY` | App Store Connect API private key (`.p8`, base64) | + | `APPLE_NOTARIZATION_KEY_ID`, `APPLE_NOTARIZATION_ISSUER_ID` | Key ID and issuer ID of that key | + +- **Switch.** The repository variable `MACOS_SIGNING_ENABLED` turns the signing on, and only for a + run started by a pushed `v*` tag. The environment's secrets are visible only to a job bound to + the environment, so the workflow cannot tell in advance whether they exist: when the variable is + `true` and a secret is missing, the job fails and names it. When the variable is not `true`, the + release still builds and publishes, the macOS binary is not signed or notarized, and the run + summary says so. +- **Checking the secrets without a release.** The **macOS signing check** workflow + (`macos-signing-check.yml`, started by hand) is bound to the same environment, so it needs the + same approval. It signs and notarizes a throwaway test executable and package with the stored + secrets, verifies them like a release does, and uploads nothing. It fails, listing the names, if a + secret is missing. + +To enable it, a maintainer with the right to change the environment: + +1. stores the nine secrets in the `release-signing` environment; +2. lets the **macOS signing check** run: the environment accepts deployments from `v*` tags only, so + a repository administrator adds `main`, where the workflow runs, to its allowed branches for the + check and removes it afterwards; +3. runs **macOS signing check** and reads its summary: it must pass; +4. sets the repository variable `MACOS_SIGNING_ENABLED` to `true`. + +The first release signed this way is the first one whose run summary says +"macOS signing: signed and notarized". Releases before it are not signed with a Developer ID. To +check a download, see [verifying-releases.md](verifying-releases.md#5-platform-signatures-windows-macos). ### Package repository key @@ -111,11 +157,16 @@ separately in [#114](https://github.com/Hodeitek/hodeishield-cli/issues/114). runs after the Windows signing jobs, which wait for an approval, but needs no approval of its own: a `release.yml` changed at a tag could sign without one. - **The `release-signing` environment.** Every job that uses it waits for a required reviewer, and - only a `v*` tag can deploy to it. It holds the Azure identifiers and the signing account names. + only a `v*` tag can deploy to it. It holds the Azure identifiers and the signing account names, + and the Apple certificates and notarization key. - **The `packaging` environment.** The same protections. It gates the package managers and the container image. - **Repository settings.** Repository administrators manage the repository variables, the environments with their reviewers, secrets and variables, and the ruleset. +- **Apple Developer account.** The Developer ID certificates are issued, and revoked, in the Apple + Developer account of the organisation, and the notarization key in App Store Connect. Only the + account's Account Holder can create or revoke Developer ID certificates. Storing or replacing the + secrets needs administrator rights on the repository. - **Azure.** The signing account, its identity validations and certificate profiles are managed in the Azure subscription of the account. One account holds the Owner role on that subscription. Revoking a certificate needs that role and is done in the Azure portal. The app registration has @@ -231,10 +282,33 @@ the profile that the release workflow did not sign. document and in [verifying-releases.md](verifying-releases.md#6-releases-after-a-signing-change). Windows users check the new signature as described in -[verifying-releases.md](verifying-releases.md#5-platform-signatures-windows-macos-in-a-later-release): +[verifying-releases.md](verifying-releases.md#5-platform-signatures-windows-macos): `Status` is `Valid`, the subject names Hodeitek S.L., a timestamp is present, and the profile EKU is the new one given in the advisory. +## If the macOS Developer ID signing is compromised + +This applies when a Developer ID certificate or its private key, the notarization key or the +`release-signing` environment is compromised, or when a file is found signed with the certificate +that the release workflow did not sign. + +1. **Stop signing.** Set the repository variable `MACOS_SIGNING_ENABLED` to `false`, reject any + pending deployment to `release-signing` and cancel running Release workflow runs. A release + built meanwhile has an unsigned macOS binary and no `.pkg`: do not publish one before step 3. +2. **Revoke.** In the Apple Developer account, revoke the affected Developer ID certificate or + certificates. In App Store Connect, revoke the notarization API key. Apple stops trusting new + signatures made with a revoked certificate. Binaries notarized earlier stay valid unless Apple + revokes their notarization tickets, which the maintainers can ask Apple to do for files known to + be malicious. +3. **Replace.** Create new certificates and a new API key, store them as the secrets listed under + [Developer ID (macOS)](#developer-id-macos), run the **macOS signing check** workflow, and set + `MACOS_SIGNING_ENABLED` back to `true`. Then publish a new patch release of each + [supported version](../SECURITY.md#supported-versions) through the normal release process: a + published file cannot be signed again in place. +4. **Tell users** as in the Windows section above (security advisory, notice in the README, release + notes), naming the affected versions and the Team ID, and update this document and + [verifying-releases.md](verifying-releases.md#6-releases-after-a-signing-change). + ## If the release workflow identity is compromised This applies when a malicious change to `release.yml` reaches a tag, when a token or account that can diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md index 6dd9122..eb13afb 100644 --- a/docs/verifying-releases.md +++ b/docs/verifying-releases.md @@ -115,7 +115,7 @@ This confirms the archive was built by the `release.yml` GitHub Actions workflow, from the `Hodeitek/hodeishield-cli` source repository, at the tag you expect — not from a modified build or a malicious fork. -## 5. Platform signatures (Windows; macOS in a later release) +## 5. Platform signatures (Windows, macOS) From 0.2.0, `hodeishield.exe` also carries an Authenticode signature, which Windows checks by itself. It does not replace the steps above: it says the file comes from Hodeitek, not which @@ -142,9 +142,33 @@ starts with `1.3.6.1.4.1.311.97.`. To list the EKU values: From 0.2.0 the profile EKU is `1.3.6.1.4.1.311.97.449523565.535854065.272767902.439799148` ([release-signing.md](release-signing.md#authenticode-windows) lists it with the subject). -The macOS binary is not yet signed with a Developer ID or notarized; that, and a signed and -notarized `.pkg` installer, come in a later release -([#50](https://github.com/Hodeitek/hodeishield-cli/issues/50)). +### macOS + +This applies from the first release signed with a Developer ID; earlier macOS binaries are not +signed or notarized, and the release's run summary says whether a release's macOS files are. For a +release that is signed, the binary carries a Developer ID Application signature and is notarized by +Apple, and the `.pkg` installer carries a Developer ID Installer signature, is notarized and has its +ticket stapled. + +```sh +# The binary: signed by Hodeitek's Developer ID, with the hardened runtime and a timestamp. +codesign -dvv ./hodeishield 2>&1 | grep -E 'Authority|TeamIdentifier|Timestamp|flags' +codesign --verify --strict --verbose=2 ./hodeishield + +# Gatekeeper's verdict. Expect "accepted" and "source=Notarized Developer ID". A bare +# executable cannot be stapled, so this check needs a network connection. +spctl -a -vv -t exec ./hodeishield + +# The installer package. +pkgutil --check-signature hodeishield--universal-apple-darwin.pkg +xcrun stapler validate hodeishield--universal-apple-darwin.pkg +``` + +`Authority` must list `Developer ID Application: Hodeitek S.L.`, and `TeamIdentifier` must be the +Team ID that [release-signing.md](release-signing.md#developer-id-macos) and the release notes give. +The package must show a `Developer ID Installer: Hodeitek S.L.` certificate. As with Windows, this +says the file comes from Hodeitek; the Sigstore and SLSA checks above say which workflow run built +it. ## 6. Releases after a signing change