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