From 35706e23d3b5d37c1ac8232fe21058f62ab05499 Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Fri, 14 Aug 2026 17:46:08 +0800 Subject: [PATCH] fix: sign macOS releases with Developer ID and notarize them Releases up to v2.4.1 were signed with an Apple Distribution certificate, an App Store submission identity that carries no Developer ID leaf extension, and were never submitted to notarytool. When that certificate was revoked on 2026-08-12 during the signing overhaul in continuum-router, Gatekeeper began reporting CSSMERR_TP_CERT_REVOKED for every installed bssh binary: macOS kills the process on launch (SIGKILL) and deletes the file as malware, which broke every Homebrew installation. This ports the signing and notarization pipeline from continuum-router (lablup/continuum-router#1313), which itself mirrors backend.ai-go. Two composite actions replace the import-codesign-certs plus codesign steps: macos-signing-setup extracts the p12 to a PEM with an OpenSSL selected for pkcs12 -legacy support, rejects any p12 that holds no Developer ID Application certificate, and installs rcodesign; macos-sign-notarize-binary signs a staged copy with the hardened runtime and a pinned reverse-DNS identifier, asserts the resulting authority, runtime flag, and identifier, packages the flat zip with ditto, and gates on notarytool status Accepted. Each of the three released binaries (bssh, bssh-server, bssh-keygen) goes through its own sign-package-notarize call, sealed as BUNDLE_ID, BUNDLE_ID-server, and BUNDLE_ID-keygen respectively. The zip names and flat layout (binary plus man page) are unchanged, so the Homebrew formula and the update_homebrew_formula workflow need no changes. The release now requires APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_ID, APPLE_TEAM_ID, and APPLE_PASSWORD secrets plus the BUNDLE_ID variable on the packaging environment; the old DEV_ID_CERT_P12 and DEV_ID_CERT_PASSWORD secrets are no longer used. The rationale is recorded in the ARCHITECTURE.md Security Model section. --- .../macos-sign-notarize-binary/action.yml | 241 ++++++++++++++++++ .../actions/macos-signing-setup/action.yml | 240 +++++++++++++++++ .github/workflows/release.yml | 117 +++++---- ARCHITECTURE.md | 30 +++ 4 files changed, 585 insertions(+), 43 deletions(-) create mode 100644 .github/actions/macos-sign-notarize-binary/action.yml create mode 100644 .github/actions/macos-signing-setup/action.yml diff --git a/.github/actions/macos-sign-notarize-binary/action.yml b/.github/actions/macos-sign-notarize-binary/action.yml new file mode 100644 index 00000000..f042dbfb --- /dev/null +++ b/.github/actions/macos-sign-notarize-binary/action.yml @@ -0,0 +1,241 @@ +name: "Sign and notarize a macOS binary" +description: >- + Take one bare Mach-O executable through Developer ID signing with the hardened + runtime, package it into a zip alongside any extra release files, and submit + the zip to notarytool. Requires macos-signing-setup to have run earlier in the + same job. + +inputs: + binary-path: + description: "Path to the built Mach-O executable. It is copied before signing, never signed in place." + required: true + zip-name: + description: "File name of the zip to produce, for example bssh-macos-aarch64.zip." + required: true + identifier: + description: >- + Code signature identifier, in reverse-DNS form, supplied by the caller from + the BUNDLE_ID variable or secret. Required whenever the binary is actually + signed. + required: true + extra-files: + description: >- + Newline-separated list of additional files to place next to the binary in + the zip (man page, LICENSE, NOTICE). Not signable content; notarytool + ignores them. + required: false + default: "" + apple-id: + description: "Apple ID used for notarization (the APPLE_ID secret)." + required: false + apple-team-id: + description: "Apple Developer team identifier (the APPLE_TEAM_ID secret)." + required: false + apple-password: + description: "App-specific password for notarization (the APPLE_PASSWORD secret)." + required: false + +outputs: + zip-path: + description: "Path of the produced zip." + value: ${{ steps.package.outputs.zip-path }} + signed: + description: "'true' when the binary carries a Developer ID signature, 'false' when it was packaged unsigned." + value: ${{ steps.package.outputs.signed }} + +# secrets.* is not readable from inside a composite action, so every secret this +# action needs is passed in by the caller as an input. +runs: + using: composite + steps: + - name: Sign and package ${{ inputs.zip-name }} + id: package + shell: bash + env: + BINARY_PATH: ${{ inputs.binary-path }} + ZIP_NAME: ${{ inputs.zip-name }} + EXTRA_FILES: ${{ inputs.extra-files }} + IDENTIFIER: ${{ inputs.identifier }} + run: | + set -euo pipefail + + if [ ! -f "$BINARY_PATH" ]; then + echo "::error::binary to sign not found: $BINARY_PATH" + exit 1 + fi + + # Sign a staged copy, never the build output in place, so the signature + # is never written back into the cargo target directory that + # actions/cache restores across runs. + STAGE_DIR="$(mktemp -d "$RUNNER_TEMP/macos-sign.XXXXXX")" + # Remove the staging copy on every exit path, not only the success one. + # A failed signature assertion below would otherwise leave a + # Developer-ID-signed binary sitting in $RUNNER_TEMP. + trap 'rm -rf "$STAGE_DIR"' EXIT + BIN_NAME="$(basename "$BINARY_PATH")" + STAGED="$STAGE_DIR/$BIN_NAME" + cp "$BINARY_PATH" "$STAGED" + chmod +x "$STAGED" + + SIGNED=false + if [ "${SIGNING_READY:-}" != "true" ]; then + echo "::warning::SIGNING_READY is not 'true'; packaging $ZIP_NAME without a Developer ID signature" + else + echo "=== Signing $BIN_NAME with the hardened runtime ===" + # No --entitlements-xml-path: bssh's binaries are self-contained Rust + # CLIs that link no third-party dylib and need no entitlement, so the + # hardened runtime applies cleanly with Apple's defaults. + # + # rcodesign requests a secure timestamp from Apple's timestamp server + # by default, which notarization requires. + # + # --binary-identifier pins the code signature identifier instead of + # letting rcodesign derive it from the file name. This repository + # releases three binaries (bssh, bssh-server, bssh-keygen); the + # derived value would be the bare file name rather than a stable + # reverse-DNS identifier, so each caller pins its own identifier from + # the shared BUNDLE_ID base. + # + # A composite action does not enforce `required: true` on its inputs at + # runtime, so the identifier is checked here. Failing is deliberate: an + # unset BUNDLE_ID would otherwise fall back to the file name and + # reintroduce exactly the drift this pins down. + if [ -z "$IDENTIFIER" ]; then + echo "::error::no code signature identifier was supplied; set BUNDLE_ID, as either a variable or a secret on the packaging environment, to a reverse-DNS identifier such as com.lablup.bssh" + exit 1 + fi + + rcodesign sign \ + --pem-file "$PEM_FILE" \ + --code-signature-flags runtime \ + --binary-identifier "$IDENTIFIER" \ + "$STAGED" + + echo "=== Verifying signature ===" + # `|| true` so a codesign failure surfaces as the explicit assertion + # errors below rather than as a bare non-zero exit under `set -e`. + CODESIGN_OUTPUT="$(codesign -dv --verbose=4 "$STAGED" 2>&1 || true)" + echo "$CODESIGN_OUTPUT" + + # This assertion is the one that would have caught the defect that + # shipped through v2.4.1: releases were signed by an "Apple + # Distribution" authority, which Gatekeeper rejects for anything + # downloaded outside the App Store, and nothing in the workflow ever + # looked at the authority. + # + # Matched from a here-string, not `echo | grep`: this step runs under + # pipefail, where `grep -q` closing the pipe early can surface as a + # failed pipeline regardless of whether the pattern matched. + if ! grep -q "Authority=Developer ID Application" <<<"$CODESIGN_OUTPUT"; then + echo "::error::$BIN_NAME is not signed by a Developer ID Application authority, so Gatekeeper will refuse it on download" + exit 1 + fi + # Match the runtime flag by name, not by a literal flags value: other + # code signature flags combine into the same hex field. + if ! grep -Eq 'flags=0x[0-9a-f]+\(.*runtime.*\)' <<<"$CODESIGN_OUTPUT"; then + echo "::error::$BIN_NAME is missing the hardened runtime flag, which notarization requires" + exit 1 + fi + if ! grep -qxF "Identifier=$IDENTIFIER" <<<"$CODESIGN_OUTPUT"; then + echo "::error::$BIN_NAME was signed with a code signature identifier other than the requested $IDENTIFIER" + exit 1 + fi + SIGNED=true + fi + + # Stage the non-executable release files next to the signed binary. The + # zip stays flat, with one entry per file and no wrapping directory: the + # Homebrew formula unzips in place and expects the binary and its man + # page at the top level. + while IFS= read -r EXTRA; do + [ -n "$EXTRA" ] || continue + if [ ! -f "$EXTRA" ]; then + echo "::error::extra file to package not found: $EXTRA" + exit 1 + fi + cp "$EXTRA" "$STAGE_DIR/" + done <<<"$EXTRA_FILES" + + ZIP_PATH="$GITHUB_WORKSPACE/$ZIP_NAME" + rm -f "$ZIP_PATH" + # ditto preserves the exec bit and the embedded signature. + ditto -c -k --sequesterRsrc "$STAGE_DIR" "$ZIP_PATH" + + echo "zip-path=$ZIP_PATH" >> "$GITHUB_OUTPUT" + echo "signed=$SIGNED" >> "$GITHUB_OUTPUT" + echo "Produced $ZIP_PATH (signed=$SIGNED)" + unzip -l "$ZIP_PATH" + + - name: Notarize ${{ inputs.zip-name }} + shell: bash + env: + APPLE_ID: ${{ inputs.apple-id }} + APPLE_TEAM_ID: ${{ inputs.apple-team-id }} + APPLE_PASSWORD: ${{ inputs.apple-password }} + ZIP_PATH: ${{ steps.package.outputs.zip-path }} + BINARY_PATH: ${{ inputs.binary-path }} + run: | + set -uo pipefail + + if [ "${SIGNING_READY:-}" != "true" ]; then + echo "Binary was packaged unsigned; skipping notarization" + exit 0 + fi + + # Reaching this line means the binary WAS signed with the Developer ID + # identity, so skipping notarization here would publish a signed but + # unnotarized zip that Gatekeeper still refuses on a quarantined + # download. Signing and notarization are one decision: SIGNING_READY + # above is the only place a run is allowed to degrade to an unsigned + # artifact. + if [ -z "$APPLE_ID" ] || [ -z "$APPLE_TEAM_ID" ] || [ -z "$APPLE_PASSWORD" ]; then + echo "::error::$(basename "$ZIP_PATH") was signed with the Developer ID identity but the Apple notarization credentials (APPLE_ID, APPLE_TEAM_ID, APPLE_PASSWORD) are missing, so it cannot be notarized" + exit 1 + fi + + echo "=== Submitting $(basename "$ZIP_PATH") for notarization ===" + # notarytool accepts a zip of signed bare executables. `stapler staple` + # is deliberately not attempted: it supports bundles, disk images, and + # installer packages, not a bare Mach-O or a zip. The ticket stays on + # Apple's servers and Gatekeeper looks it up online. + SUBMISSION_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_PASSWORD" \ + --wait --timeout 30m 2>&1) || true + + echo "$SUBMISSION_OUTPUT" + + SUBMISSION_ID=$(grep "id:" <<<"$SUBMISSION_OUTPUT" | head -1 | awk '{print $2}' || true) + + # Gate positively on Accepted. Testing only for "status: Invalid" would + # let Rejected, a timeout, and every other non-Accepted outcome through. + # Matched from a here-string for the same pipefail reason as the + # codesign assertions above. + if ! grep -q "status: Accepted" <<<"$SUBMISSION_OUTPUT"; then + echo "::error::notarization of $(basename "$ZIP_PATH") did not reach 'status: Accepted'" + if [ -n "$SUBMISSION_ID" ]; then + echo "Fetching detailed notarization log..." + xcrun notarytool log "$SUBMISSION_ID" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_PASSWORD" || true + fi + exit 1 + fi + + echo "Notarization accepted" + + # Informational only. A freshly issued ticket takes time to propagate, + # so this must not gate the job; the hard gates are the codesign + # assertions and the Accepted status above. + # `set -uo pipefail` above does not turn -e back off: composite + # `shell: bash` already started this as `bash -eo pipefail`. Guard the + # whole block, or an unzip failure here would fail the job after + # notarization was already accepted, and the zip would never be + # uploaded. + VERIFY_DIR="$(mktemp -d "$RUNNER_TEMP/macos-spctl.XXXXXX")" + if unzip -q -o "$ZIP_PATH" -d "$VERIFY_DIR"; then + spctl -a -vv -t exec "$VERIFY_DIR/$(basename "$BINARY_PATH")" || true + fi + rm -rf "$VERIFY_DIR" diff --git a/.github/actions/macos-signing-setup/action.yml b/.github/actions/macos-signing-setup/action.yml new file mode 100644 index 00000000..f729184e --- /dev/null +++ b/.github/actions/macos-signing-setup/action.yml @@ -0,0 +1,240 @@ +name: "macOS signing setup" +description: >- + Extract the Developer ID Application signing certificate to a PEM, verify it + really is a Developer ID certificate, and install rcodesign, exporting + PEM_FILE and SIGNING_READY for the rest of the job. + +inputs: + certificate: + description: "Base64-encoded Developer ID Application .p12 (the APPLE_CERTIFICATE secret)." + required: true + certificate-password: + description: "Password for the .p12 (the APPLE_CERTIFICATE_PASSWORD secret)." + required: true + required: + description: >- + 'true' fails the job when the certificate secrets are missing or unusable. + 'false' emits a warning, sets SIGNING_READY=false, and lets the job carry + on producing unsigned artifacts. + required: false + default: "true" + +# secrets.* is not readable from inside a composite action, so every secret this +# action needs is passed in by the caller as an input. +runs: + using: composite + steps: + - name: Prepare signing certificate (rcodesign) + shell: bash + env: + APPLE_CERTIFICATE: ${{ inputs.certificate }} + APPLE_CERTIFICATE_PASSWORD: ${{ inputs.certificate-password }} + CERTIFICATE_REQUIRED: ${{ inputs.required }} + run: | + # Use rcodesign instead of Apple's codesign. rcodesign reads the p12 + # file directly: no keychain, no SecurityAgent, no GUI session required, + # and no ambiguous substring matching against whatever identities happen + # to be in the keychain. + + echo "=== Checking certificate availability ===" + MISSING="" + if [ -z "$APPLE_CERTIFICATE" ]; then + MISSING="APPLE_CERTIFICATE" + fi + if [ -z "$APPLE_CERTIFICATE_PASSWORD" ]; then + MISSING="${MISSING:+$MISSING, }APPLE_CERTIFICATE_PASSWORD" + fi + + if [ -n "$MISSING" ]; then + if [ "$CERTIFICATE_REQUIRED" = "true" ]; then + echo "::error::signing certificate secrets are not set or empty: $MISSING" + exit 1 + fi + echo "::warning::signing certificate secrets are not set or empty ($MISSING); continuing without a Developer ID signature" + echo "SIGNING_READY=false" >> "$GITHUB_ENV" + exit 0 + fi + + echo "=== Decoding certificate ===" + echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/original.p12" + + # Apple's exported p12 uses RC2-40-CBC, which only OpenSSL's legacy + # provider reads, so the extraction needs `openssl pkcs12 -legacy`. + # rcodesign's own p12 parser cannot read it either, which is why it is + # handed a PEM instead. + # + # Do NOT let PATH decide which openssl runs. Apple's /usr/bin/openssl is + # LibreSSL, which has no -legacy option at all and exits 1 on it. + # Installing a Homebrew openssl does not make the bare name safe either: + # the versioned formulae are keg-only, so they are never symlinked into + # the prefix bin. + # + # Select by capability rather than by version. Whichever binary + # advertises -legacy is by definition the one that can do this, which + # keeps working on a future openssl@5 and rejects LibreSSL for the + # reason that matters. + echo "=== Selecting an OpenSSL with legacy provider support ===" + # Probe through a variable rather than `cmd | grep -q`. A composite + # action's `shell: bash` runs with `-o pipefail`. `grep -q` exits as soon + # as it matches, so the writer on the left can take a SIGPIPE on a later + # line and pipefail would report the probe as failed even though + # `-legacy` was present. A here-string has no writer process, so it + # cannot. + supports_legacy() { + local help_output + help_output="$("$1" pkcs12 -help 2>&1 || true)" + grep -q -- '-legacy' <<<"$help_output" + } + + OPENSSL_BIN="" + for FORMULA in openssl@4 openssl@3 openssl; do + PREFIX="$(brew --prefix "$FORMULA" 2>/dev/null || true)" + [ -n "$PREFIX" ] || continue + CANDIDATE="$PREFIX/bin/openssl" + if [ -x "$CANDIDATE" ] && supports_legacy "$CANDIDATE"; then + OPENSSL_BIN="$CANDIDATE" + break + fi + done + if [ -z "$OPENSSL_BIN" ]; then + CANDIDATE="$(command -v openssl || true)" + if [ -n "$CANDIDATE" ] && supports_legacy "$CANDIDATE"; then + OPENSSL_BIN="$CANDIDATE" + fi + fi + + if [ -z "$OPENSSL_BIN" ]; then + echo "::error::No openssl on this runner supports 'pkcs12 -legacy', which is required to read the RC2-40-CBC signing certificate. Install one with: brew install openssl@3" + echo "openssl resolved from PATH: $(command -v openssl || echo none)" + command -v openssl >/dev/null 2>&1 && openssl version || true + rm -f "$RUNNER_TEMP/original.p12" + exit 1 + fi + echo "Using $OPENSSL_BIN ($("$OPENSSL_BIN" version))" + + echo "=== Extracting certificate to PEM ===" + # stderr is deliberately kept. openssl writes diagnostics here, not key + # material, and without them a failure surfaces only as "exit code 1". + if ! "$OPENSSL_BIN" pkcs12 -in "$RUNNER_TEMP/original.p12" \ + -passin "pass:$APPLE_CERTIFICATE_PASSWORD" \ + -legacy -nodes -out "$RUNNER_TEMP/signing.pem"; then + echo "::error::openssl could not extract the signing certificate from the p12" + rm -f "$RUNNER_TEMP/original.p12" "$RUNNER_TEMP/signing.pem" + exit 1 + fi + + rm -f "$RUNNER_TEMP/original.p12" + + if [ ! -s "$RUNNER_TEMP/signing.pem" ]; then + echo "::error::Extraction reported success but produced an empty PEM" + exit 1 + fi + + echo "Certificate extracted successfully" + + echo "=== Certificate details ===" + "$OPENSSL_BIN" x509 -in "$RUNNER_TEMP/signing.pem" \ + -noout -subject -issuer -dates || echo "::warning::Could not read certificate details" + + # Gate on the certificate TYPE, not just its presence. + # + # Releases up to v2.4.1 shipped signed with "Apple Distribution: Lablup + # Inc.", an App Store / TestFlight submission certificate. Its leaf + # carries 1.2.840.113635.100.6.1.7 but not 1.2.840.113635.100.6.1.13, so + # Gatekeeper refused every quarantined download even though `codesign` + # itself verified the signature happily; when that certificate was later + # revoked, macOS started killing installed binaries on launch and + # deleting them as malware. Only a "Developer ID Application" + # certificate satisfies the Developer ID policy. + # + # Check the whole PEM, not only the first block: `pkcs12 -nodes` emits + # the private key and the full chain, and the bag order that puts the + # leaf first is a convention rather than a guarantee. + echo "=== Verifying this is a Developer ID Application certificate ===" + # crl2pkcs7 | pkcs7 -print_certs lists every certificate in the PEM in + # one pass and ignores the private key block. Assigned into a variable + # with `|| true` so a pipefail-visible SIGPIPE cannot fail the step. + CERT_SUBJECTS="$("$OPENSSL_BIN" crl2pkcs7 -nocrl -certfile "$RUNNER_TEMP/signing.pem" 2>/dev/null \ + | "$OPENSSL_BIN" pkcs7 -print_certs -noout 2>/dev/null || true)" + if [ -z "$CERT_SUBJECTS" ]; then + CERT_SUBJECTS="$("$OPENSSL_BIN" x509 -in "$RUNNER_TEMP/signing.pem" -noout -subject 2>/dev/null || true)" + fi + echo "$CERT_SUBJECTS" + + if ! grep -q "Developer ID Application" <<<"$CERT_SUBJECTS"; then + echo "::error::the p12 in APPLE_CERTIFICATE contains no 'Developer ID Application' certificate, so anything signed with it is rejected by Gatekeeper on download. Issue a Developer ID Application certificate in the Apple Developer portal (Account Holder role required), export it as a .p12, and replace the secret." + if grep -qE "Apple Distribution|Apple Development|iPhone Distribution" <<<"$CERT_SUBJECTS"; then + echo "::error::the certificate present is an App Store / development certificate, which is only valid for submissions through App Store Connect" + fi + rm -f "$RUNNER_TEMP/signing.pem" + exit 1 + fi + echo "Developer ID Application certificate confirmed" + + echo "PEM_FILE=$RUNNER_TEMP/signing.pem" >> "$GITHUB_ENV" + echo "SIGNING_READY=true" >> "$GITHUB_ENV" + + - name: Install rcodesign + shell: bash + run: | + if [ "${SIGNING_READY:-}" != "true" ]; then + echo "Signing is not ready; skipping rcodesign installation" + exit 0 + fi + + # rcodesign is a keychain-free Apple code signing tool. + RCODESIGN_VERSION="0.29.0" + RCODESIGN_DIR="$HOME/.rcodesign" + RCODESIGN_BIN="$RCODESIGN_DIR/rcodesign" + + case "$(uname -m)" in + arm64|aarch64) RCODESIGN_ARCH="aarch64" ;; + x86_64) RCODESIGN_ARCH="x86_64" ;; + *) + echo "::error::no rcodesign release build for $(uname -m)" + exit 1 + ;; + esac + + # Read the version into a variable and match that, so a SIGPIPE on the + # writer cannot be mistaken for a version mismatch under pipefail. -F + # because the version is a literal, not a regex whose dots match any + # character. + INSTALLED_VERSION="" + if [ -x "$RCODESIGN_BIN" ]; then + INSTALLED_VERSION="$("$RCODESIGN_BIN" --version 2>/dev/null || true)" + fi + + if grep -qF "$RCODESIGN_VERSION" <<<"$INSTALLED_VERSION"; then + echo "rcodesign $RCODESIGN_VERSION already installed" + else + echo "Installing rcodesign $RCODESIGN_VERSION for $RCODESIGN_ARCH..." + mkdir -p "$RCODESIGN_DIR" + curl -fsSL "https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCODESIGN_VERSION}/apple-codesign-${RCODESIGN_VERSION}-${RCODESIGN_ARCH}-apple-darwin.tar.gz" \ + | tar xz -C "$RCODESIGN_DIR" --strip-components=1 + echo "Installed: $("$RCODESIGN_BIN" --version)" + fi + + echo "$RCODESIGN_DIR" >> "$GITHUB_PATH" + + - name: Verify signing tools + shell: bash + run: | + if [ "${SIGNING_READY:-}" != "true" ]; then + echo "Signing is not ready; skipping signing tool verification" + exit 0 + fi + + # $GITHUB_PATH from the previous step does not apply until the next step + # boundary, which this is, so rcodesign resolves by bare name here. + echo "rcodesign: $(rcodesign --version)" + + # The trailing `|| true` is load-bearing. A composite action's + # `shell: bash` runs `bash --noprofile --norc -eo pipefail`. `head -20` + # closes the pipe as soon as it has its 20 lines, so rcodesign + # (line-buffered stdout, well over 20 lines of output) can take a + # SIGPIPE on a later line. Without this, pipefail would surface that as + # exit 141 and a purely informational step would fail the release job. + echo "=== Verifying PEM readability ===" + rcodesign analyze-certificate --pem-file "$PEM_FILE" 2>&1 | head -20 || true + echo "rcodesign can read the signing certificate" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70f0cafa..9c1b28dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,12 +61,9 @@ jobs: os: macos-14 artifact_name: bssh asset_name: bssh-macos-aarch64 + asset_suffix: macos-aarch64 archive_ext: ".zip" - env: - BIN_NAME: bssh - BUNDLE_ID: ${{ vars.BUNDLE_ID }} - steps: # 1) Checkout repository - name: Checkout code @@ -102,24 +99,84 @@ jobs: - name: Build release binaries run: cargo build --release --target ${{ matrix.target }} --locked --bin bssh --bin bssh-server --bin bssh-keygen - # 6) macOS code signing - - name: Import Distribution certificate + # 6) macOS code signing and notarization + # + # Gatekeeper accepts a downloaded binary only when BOTH hold: the code is + # signed by a "Developer ID Application" authority, and Apple has issued a + # notarization ticket for it. Releases up to v2.4.1 satisfied neither. + # They were signed with an "Apple Distribution" certificate, which is an + # App Store submission identity that carries no Developer ID leaf + # extension, and they were never submitted to notarytool. When that + # certificate was later revoked, macOS went from warning to actively + # killing installed binaries on launch and deleting them as malware. + # + # The two composite actions below are mirrored from continuum-router + # (itself mirrored from backend.ai-go), which takes its binaries through + # the same procedure. macos-signing-setup rejects a certificate that is + # not Developer ID Application before anything is signed, and + # macos-sign-notarize-binary asserts the resulting authority and hardened + # runtime flag before it submits, so a wrong certificate now fails the + # release instead of shipping quietly. + - name: Prepare signing certificate and tools if: runner.os == 'macOS' - uses: apple-actions/import-codesign-certs@v7 + uses: ./.github/actions/macos-signing-setup with: - p12-file-base64: ${{ secrets.DEV_ID_CERT_P12 }} - p12-password: ${{ secrets.DEV_ID_CERT_PASSWORD }} + certificate: ${{ secrets.APPLE_CERTIFICATE }} + certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + # Official binaries must never ship unsigned, so a missing or wrong + # certificate is a hard failure. + required: "true" - - name: Code sign macOS binaries + # 7) Package binaries (separate packages for bssh, bssh-server, and bssh-keygen) + # + # macOS packaging happens inside the signing action, because each zip has + # to be built from the signed copy and then handed to notarytool as a + # single artifact. One call per released binary. + # + # The identifier is passed from BUNDLE_ID (variable preferred over + # secret: it is not sensitive, any user can read it with `codesign -dv`, + # and a secret would be masked to *** in the verification output). With + # BUNDLE_ID=com.lablup.bssh the three binaries seal as com.lablup.bssh, + # com.lablup.bssh-server, and com.lablup.bssh-keygen. + - name: Sign, package, and notarize bssh (macOS) if: runner.os == 'macOS' - run: | - BIN_DIR=target/${{ matrix.target }}/release - for bin in bssh bssh-server bssh-keygen; do - codesign --force --timestamp --options runtime \ - --sign "Distribution" "$BIN_DIR/$bin" - done + uses: ./.github/actions/macos-sign-notarize-binary + with: + binary-path: target/${{ matrix.target }}/release/bssh + zip-name: ${{ matrix.asset_name }}${{ matrix.archive_ext }} + identifier: ${{ vars.BUNDLE_ID || secrets.BUNDLE_ID }} + extra-files: | + docs/man/bssh.1 + apple-id: ${{ secrets.APPLE_ID }} + apple-team-id: ${{ secrets.APPLE_TEAM_ID }} + apple-password: ${{ secrets.APPLE_PASSWORD }} + + - name: Sign, package, and notarize bssh-server (macOS) + if: runner.os == 'macOS' + uses: ./.github/actions/macos-sign-notarize-binary + with: + binary-path: target/${{ matrix.target }}/release/bssh-server + zip-name: bssh-server-${{ matrix.asset_suffix }}${{ matrix.archive_ext }} + identifier: ${{ vars.BUNDLE_ID || secrets.BUNDLE_ID }}-server + extra-files: | + docs/man/bssh-server.8 + apple-id: ${{ secrets.APPLE_ID }} + apple-team-id: ${{ secrets.APPLE_TEAM_ID }} + apple-password: ${{ secrets.APPLE_PASSWORD }} + + - name: Sign, package, and notarize bssh-keygen (macOS) + if: runner.os == 'macOS' + uses: ./.github/actions/macos-sign-notarize-binary + with: + binary-path: target/${{ matrix.target }}/release/bssh-keygen + zip-name: bssh-keygen-${{ matrix.asset_suffix }}${{ matrix.archive_ext }} + identifier: ${{ vars.BUNDLE_ID || secrets.BUNDLE_ID }}-keygen + extra-files: | + docs/man/bssh-keygen.1 + apple-id: ${{ secrets.APPLE_ID }} + apple-team-id: ${{ secrets.APPLE_TEAM_ID }} + apple-password: ${{ secrets.APPLE_PASSWORD }} - # 7) Package binaries (separate packages for bssh, bssh-server, and bssh-keygen) - name: Package Linux binaries (tar.gz) if: runner.os == 'Linux' run: | @@ -146,32 +203,6 @@ jobs: cp docs/man/bssh-keygen.1 package-bssh-keygen/ tar -C package-bssh-keygen -czf "${KEYGEN_ASSET_BASE}.tar.gz" . - - name: Package macOS binaries (zip) - if: runner.os == 'macOS' - run: | - BIN_DIR="target/${{ matrix.target }}/release" - ASSET_BASE="${{ matrix.asset_name }}" - SERVER_ASSET_BASE="${ASSET_BASE/bssh/bssh-server}" - KEYGEN_ASSET_BASE="${ASSET_BASE/bssh/bssh-keygen}" - - # Package bssh - mkdir -p package-bssh - cp "$BIN_DIR/bssh" package-bssh/ - cp docs/man/bssh.1 package-bssh/ - ditto -c -k --sequesterRsrc package-bssh "${ASSET_BASE}.zip" - - # Package bssh-server - mkdir -p package-bssh-server - cp "$BIN_DIR/bssh-server" package-bssh-server/ - cp docs/man/bssh-server.8 package-bssh-server/ - ditto -c -k --sequesterRsrc package-bssh-server "${SERVER_ASSET_BASE}.zip" - - # Package bssh-keygen - mkdir -p package-bssh-keygen - cp "$BIN_DIR/bssh-keygen" package-bssh-keygen/ - cp docs/man/bssh-keygen.1 package-bssh-keygen/ - ditto -c -k --sequesterRsrc package-bssh-keygen "${KEYGEN_ASSET_BASE}.zip" - # 8) Generate checksums - name: Generate checksums run: | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b952a232..9398e3bd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -873,6 +873,36 @@ but not with themselves; omit the key (plain `#[serial]`) when in doubt. - Port forwarding for secure tunneling - SSH config directive support for security policies +### Release Signing and Notarization (macOS) + +Official macOS release binaries (bssh, bssh-server, bssh-keygen) are signed +with a "Developer ID Application" certificate and notarized through Apple's +notarytool. This is handled by two composite actions in `.github/actions/`, +mirrored from continuum-router (originally from backend.ai-go): + +- **macos-signing-setup** extracts the Developer ID p12 to a PEM (via + `openssl pkcs12 -legacy`, selected by capability since Apple's LibreSSL + lacks the option), rejects any p12 that holds no Developer ID Application + certificate, and installs rcodesign (keychain-free signing). +- **macos-sign-notarize-binary** signs a staged copy with the hardened + runtime and a pinned reverse-DNS identifier (`BUNDLE_ID` base, with + `-server` / `-keygen` suffixes), asserts the resulting authority, runtime + flag, and identifier, packages the flat zip with `ditto`, submits it to + `notarytool --wait`, and gates on `status: Accepted`. Bare Mach-O binaries + cannot be stapled, so Gatekeeper resolves the ticket online. + +Rationale: releases up to v2.4.1 were signed with an "Apple Distribution" +certificate (an App Store submission identity without the Developer ID leaf +extension) and never notarized. When that certificate was revoked, macOS +killed installed binaries on launch and deleted them as malware. The +authority assertion exists so a wrong certificate fails the release instead +of shipping quietly. + +Required release credentials (GitHub `packaging` environment): +`APPLE_CERTIFICATE` (base64 Developer ID Application p12), +`APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_PASSWORD` +(app-specific password), and the `BUNDLE_ID` variable (`com.lablup.bssh`). + ## Dependencies and Licensing ### Core Dependencies