From bcf5324dae77fb1afbe1a04f31531239db8b1f87 Mon Sep 17 00:00:00 2001 From: jamesread Date: Wed, 22 Jul 2026 21:07:33 +0100 Subject: [PATCH] fix: macos signing checks --- .github/workflows/build-and-release.yml | 7 +++ docs/antora.yml | 2 - docs/modules/dev/nav.adoc | 2 + docs/modules/dev/pages/index.adoc | 3 + docs/modules/dev/pages/signing.adoc | 39 ++++++++++-- var/macos/install.md | 19 +++--- var/macos/verify-macos-sign-p12.sh | 65 +++++++++++++++++++ var/macos/verify-signed-darwin.sh | 72 ++++++++++++++++++++++ var/windows/goreleaser-release-with-msi.sh | 3 + 9 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 docs/modules/dev/nav.adoc create mode 100644 docs/modules/dev/pages/index.adoc create mode 100755 var/macos/verify-macos-sign-p12.sh create mode 100755 var/macos/verify-signed-darwin.sh diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 4f8b862..c8f7dca 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -133,6 +133,13 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false uses: docker/setup-buildx-action@v4 + - name: Verify macOS signing certificate chain + if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) + env: + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + run: ./var/macos/verify-macos-sign-p12.sh + - name: release id: release if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) diff --git a/docs/antora.yml b/docs/antora.yml index ec27f39..a0a19c0 100644 --- a/docs/antora.yml +++ b/docs/antora.yml @@ -11,5 +11,3 @@ asciidoc: toclevels: 2 nav: - modules/ROOT/nav.adoc - - diff --git a/docs/modules/dev/nav.adoc b/docs/modules/dev/nav.adoc new file mode 100644 index 0000000..56258eb --- /dev/null +++ b/docs/modules/dev/nav.adoc @@ -0,0 +1,2 @@ +* xref:index.adoc[Index] +* xref:signing.adoc[Signing] diff --git a/docs/modules/dev/pages/index.adoc b/docs/modules/dev/pages/index.adoc new file mode 100644 index 0000000..06502d0 --- /dev/null +++ b/docs/modules/dev/pages/index.adoc @@ -0,0 +1,3 @@ += Developer notes + +TODO. diff --git a/docs/modules/dev/pages/signing.adoc b/docs/modules/dev/pages/signing.adoc index 478bc8b..284b9dd 100644 --- a/docs/modules/dev/pages/signing.adoc +++ b/docs/modules/dev/pages/signing.adoc @@ -36,24 +36,39 @@ openssl req -new -key developer_id_app.key -out developer_id_app.csr \ 2. Create a certificate of type **Developer ID Application**. Prefer **G2 Sub-CA** if the portal asks. 3. Upload `developer_id_app.csr` and download the resulting `.cer` (often named `developerID_application.cer`). -Build a `.p12` that includes Apple's Developer ID G2 intermediate: +Build a `.p12` that includes the **full** chain: leaf + Developer ID G2 intermediate + Apple Root CA. + +The Apple Root **must** be present. With only leaf + G2, quill embeds a designated requirement of the form `certificate root[field.1.2.840.113635.100.6.2.6]`. On macOS that resolves to Apple Root CA (which does not have that OID), so AMFI SIGKILLs the binary with `does not satisfy its designated Requirement` even though notarization still succeeds. A correct chain produces `certificate 1[...]` instead. ```sh curl -fsSLO https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer +curl -fsSLO https://www.apple.com/appleca/AppleIncRootCertificate.cer openssl x509 -inform DER -in developerID_application.cer -out developerID_application.pem openssl x509 -inform DER -in DeveloperIDG2CA.cer -out DeveloperIDG2CA.pem +openssl x509 -inform DER -in AppleIncRootCertificate.cer -out AppleRootCA.pem + +# Chain file: intermediate then root (leaf is passed separately via -in). +cat DeveloperIDG2CA.pem AppleRootCA.pem > chain.pem # Export password becomes MACOS_SIGN_PASSWORD. # On OpenSSL 3 (e.g. Fedora), -legacy improves compatibility with some tooling: openssl pkcs12 -export -legacy \ -inkey developer_id_app.key \ -in developerID_application.pem \ - -certfile DeveloperIDG2CA.pem \ + -certfile chain.pem \ -out Certificates.p12 ``` -If you already have a Mac with the certificate in Keychain Access, you can export a `.p12` from there instead; the OpenSSL path above is enough when you do not. +Confirm the `.p12` has three certificates before base64-encoding: + +```sh +openssl pkcs12 -in Certificates.p12 -nodes -passin pass:"$MACOS_SIGN_PASSWORD" 2>/dev/null \ + | grep -c "BEGIN CERTIFICATE" +# expect: 3 +``` + +If you already have a Mac with the certificate in Keychain Access, you can export a `.p12` from there instead — include the full chain when exporting. #### 2. Create the notarization API key @@ -90,7 +105,7 @@ All five must be present for signing to run. GoReleaser enables the step when `M | Item | Typical lifetime | What to do | |------|------------------|------------| -| Developer ID Application certificate | ~5 years | Create a new certificate in the Apple portal, export a new `.p12`, update `MACOS_SIGN_P12` and `MACOS_SIGN_PASSWORD`. | +| Developer ID Application certificate | ~5 years | Create a new certificate in the Apple portal, export a new **full-chain** `.p12` (leaf + G2 intermediate + Apple Root CA), update `MACOS_SIGN_P12` and `MACOS_SIGN_PASSWORD`. | | App Store Connect API key | Does not expire, but can be revoked | Create a new key if compromised or lost; update `MACOS_NOTARY_KEY`, `MACOS_NOTARY_KEY_ID`, and optionally `MACOS_NOTARY_ISSUER_ID`. | | Apple Developer Program | Annual subscription | Renew membership before it lapses; existing certificates stop working if the account is inactive. | @@ -98,19 +113,31 @@ After updating secrets, the next release on `main` (via semantic-release) will u ### Verifying a signed release -On a Mac, download a `OliveTin-darwin-*.tar.gz` release artifact and run: +From any platform (no Mac required), check that quill did **not** emit the broken `certificate root[...]` designated requirement: + +```sh +go install github.com/anchore/quill/cmd/quill@latest +quill describe OliveTin-darwin-arm64/OliveTin +``` + +The requirements line must contain `certificate 1[field.1.2.840.113635.100.6.2.6]`. If it says `certificate root[field.1.2.840.113635.100.6.2.6]`, the `.p12` is missing Apple Root CA — rebuild it and update `MACOS_SIGN_P12`. + +On a Mac, also run: ```sh tar -xzf OliveTin-darwin-arm64.tar.gz +codesign --verify --strict -vvvv OliveTin-darwin-arm64/OliveTin spctl -a -vv -t execute OliveTin-darwin-arm64/OliveTin ``` -A signed and notarized binary should report `accepted` with `source=Notarized Developer ID`. +`codesign` should report both `valid on disk` and `satisfies its Designated Requirement`. `spctl` should report `accepted` with `source=Notarized Developer ID`. ### Configuration reference - GoReleaser: `notarize.macos` in link:https://github.com/OliveTin/OliveTin/blob/main/.goreleaser.yml[`.goreleaser.yml`] - CI secrets: link:https://github.com/OliveTin/OliveTin/blob/main/.github/workflows/build-and-release.yml[`.github/workflows/build-and-release.yml`] (`release` step) +- CI preflight (3-cert P12): link:https://github.com/OliveTin/OliveTin/blob/main/var/macos/verify-macos-sign-p12.sh[`var/macos/verify-macos-sign-p12.sh`] +- CI post-sign check (designated requirement): link:https://github.com/OliveTin/OliveTin/blob/main/var/macos/verify-signed-darwin.sh[`var/macos/verify-signed-darwin.sh`] - link:https://goreleaser.com/customization/notarize/[GoReleaser notarization docs] ## Windows release signing (SignPath) diff --git a/var/macos/install.md b/var/macos/install.md index 6c8ad6e..a932094 100644 --- a/var/macos/install.md +++ b/var/macos/install.md @@ -52,14 +52,14 @@ properly, see step 6 — you can install it **as your own user (no root)** or --- -## 3. Clear the Gatekeeper quarantine +## 3. Gatekeeper and notarization -Because the binary is downloaded from the internet and is **not notarized by -Apple**, macOS Gatekeeper will block the first run with a message like -*"OliveTin can't be opened because Apple cannot check it for malicious -software."* +Current release binaries are **Developer ID signed and notarized** by Apple. +After extract, you should be able to run `./OliveTin` normally. -Remove the quarantine attribute so it will run: +If Gatekeeper still blocks an older (unsigned) build, or you see a prompt that +Apple cannot check the binary for malicious software, clear the quarantine +attribute: ```sh xattr -dr com.apple.quarantine ./OliveTin @@ -356,9 +356,10 @@ tail -f /usr/local/var/log/olivetin.log **"Bad CPU type in executable"** — you downloaded the wrong architecture. Get the `arm64` build for Apple Silicon, `amd64` for Intel (see step 1). -**Gatekeeper still blocks it** — re-run the `xattr -dr com.apple.quarantine` -command in step 3, or approve the app under **System Settings → Privacy & -Security**. +**Gatekeeper still blocks it** — for older unsigned builds, re-run +`xattr -dr com.apple.quarantine ./OliveTin` (see step 3), or approve the app +under **System Settings → Privacy & Security**. Current signed releases should +not need this. **It runs but the page won't load** — check that nothing else is using port 1337 (`lsof -i :1337`), and that you're browsing to `http://` (not `https://`). diff --git a/var/macos/verify-macos-sign-p12.sh b/var/macos/verify-macos-sign-p12.sh new file mode 100755 index 0000000..251fa7b --- /dev/null +++ b/var/macos/verify-macos-sign-p12.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Preflight: ensure MACOS_SIGN_P12 has the full Developer ID chain +# (leaf + Developer ID G2 + Apple Root CA). Quill embeds an unsatisfiable +# designated requirement when Apple Root CA is missing — see signing.adoc. +set -euo pipefail + +if [[ -z "${MACOS_SIGN_P12:-}" ]]; then + echo "MACOS_SIGN_P12 unset; skipping macOS signing preflight." + exit 0 +fi + +if [[ -z "${MACOS_SIGN_PASSWORD:-}" ]]; then + echo "MACOS_SIGN_PASSWORD is required when MACOS_SIGN_P12 is set." >&2 + exit 1 +fi + +tmpdir="$(mktemp -d)" +trap 'rm -rf "${tmpdir}"' EXIT + +p12_path="${tmpdir}/Certificates.p12" +# GitHub secrets may include trailing newlines; strip them before decode. +printf '%s' "${MACOS_SIGN_P12}" | tr -d '\n\r' | base64 -d >"${p12_path}" + +pem_out="${tmpdir}/certs.pem" +extract_p12() { + local extra_args=("${@}") + openssl pkcs12 -in "${p12_path}" -nodes -passin "pass:${MACOS_SIGN_PASSWORD}" \ + "${extra_args[@]}" -out "${pem_out}" 2>/dev/null +} + +if ! extract_p12; then + # OpenSSL 3 may need -legacy for older P12 exports. + extract_p12 -legacy +fi + +cert_dir="${tmpdir}/certs" +mkdir -p "${cert_dir}" +awk -v dir="${cert_dir}" ' + /-----BEGIN CERTIFICATE-----/ { n++; file = sprintf("%s/cert-%02d.pem", dir, n) } + n { print > file } +' "${pem_out}" + +cert_count="$(find "${cert_dir}" -name 'cert-*.pem' | wc -l | tr -d ' ')" +if [[ "${cert_count}" -ne 3 ]]; then + echo "MACOS_SIGN_P12 must contain exactly 3 certificates (leaf + G2 + Apple Root CA); found ${cert_count}." >&2 + echo "Rebuild the .p12 per docs/modules/dev/pages/signing.adoc." >&2 + exit 1 +fi + +found_apple_root=0 +for cert in "${cert_dir}"/cert-*.pem; do + subject="$(openssl x509 -in "${cert}" -noout -subject 2>/dev/null || true)" + if [[ "${subject}" == *"Apple Root CA"* ]]; then + found_apple_root=1 + break + fi +done + +if [[ "${found_apple_root}" -ne 1 ]]; then + echo "MACOS_SIGN_P12 is missing Apple Root CA in the certificate chain." >&2 + echo "Rebuild the .p12 per docs/modules/dev/pages/signing.adoc." >&2 + exit 1 +fi + +echo "MACOS_SIGN_P12 preflight OK (3 certificates, including Apple Root CA)." diff --git a/var/macos/verify-signed-darwin.sh b/var/macos/verify-signed-darwin.sh new file mode 100755 index 0000000..79e5656 --- /dev/null +++ b/var/macos/verify-signed-darwin.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Post-sign: fail if quill embedded the broken designated requirement +# certificate root[field.1.2.840.113635.100.6.2.6] (missing Apple Root in P12). +set -euo pipefail + +if [[ -z "${MACOS_SIGN_P12:-}" ]]; then + echo "MACOS_SIGN_P12 unset; skipping darwin signature check." + exit 0 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +DIST_DIR="${REPO_ROOT}/dist" + +BAD_REQ='certificate root\[field\.1\.2\.840\.113635\.100\.6\.2\.6\]' +GOOD_REQ='certificate 1\[field\.1\.2\.840\.113635\.100\.6\.2\.6\]' + +if ! command -v quill >/dev/null 2>&1; then + echo "Installing quill..." + go install github.com/anchore/quill/cmd/quill@latest + export PATH="$(go env GOPATH)/bin:${PATH}" +fi + +shopt -s nullglob +archives=("${DIST_DIR}"/OliveTin-darwin-*.tar.gz) +if [[ "${#archives[@]}" -eq 0 ]]; then + echo "No OliveTin-darwin-*.tar.gz archives found under ${DIST_DIR}." >&2 + exit 1 +fi + +tmpdir="$(mktemp -d)" +trap 'rm -rf "${tmpdir}"' EXIT + +checked=0 +for archive in "${archives[@]}"; do + name="$(basename "${archive}" .tar.gz)" + extract_dir="${tmpdir}/${name}" + mkdir -p "${extract_dir}" + tar -xzf "${archive}" -C "${extract_dir}" + + # Prefer the top-level binary; archives also ship helper scripts named OliveTin. + binary="${extract_dir}/${name}/OliveTin" + if [[ ! -f "${binary}" ]]; then + binary="$(find "${extract_dir}" -type f -path "*/OliveTin" ! -path "*/var/*" | head -n 1)" + fi + if [[ -z "${binary}" || ! -f "${binary}" ]]; then + echo "OliveTin binary not found inside ${archive}." >&2 + exit 1 + fi + if ! file "${binary}" | grep -qi 'Mach-O'; then + echo "Expected a Mach-O binary at ${binary}, got: $(file "${binary}")" >&2 + exit 1 + fi + + + echo "Checking designated requirement in ${archive}..." + describe_out="$(quill describe "${binary}")" + if echo "${describe_out}" | grep -qE "${BAD_REQ}"; then + echo "Broken designated requirement in ${archive}:" >&2 + echo " found certificate root[field.1.2.840.113635.100.6.2.6]" >&2 + echo "MACOS_SIGN_P12 is missing Apple Root CA. Rebuild per docs/modules/dev/pages/signing.adoc." >&2 + exit 1 + fi + if ! echo "${describe_out}" | grep -qE "${GOOD_REQ}"; then + echo "Expected designated requirement with certificate 1[...] not found in ${archive}." >&2 + echo "${describe_out}" >&2 + exit 1 + fi + checked=$((checked + 1)) +done + +echo "Darwin signature check OK (${checked} archive(s); designated requirement uses certificate 1[...])." diff --git a/var/windows/goreleaser-release-with-msi.sh b/var/windows/goreleaser-release-with-msi.sh index a39d6f4..c06699d 100755 --- a/var/windows/goreleaser-release-with-msi.sh +++ b/var/windows/goreleaser-release-with-msi.sh @@ -51,3 +51,6 @@ done wait_for_stable_file "${ZIP_PATH}" "${SCRIPT_DIR}/build-msi.sh" wait "${goreleaser_pid}" + +# Fail the release job if macOS signing produced an unsatisfiable designated requirement. +"${REPO_ROOT}/var/macos/verify-signed-darwin.sh"