diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb new file mode 100644 index 0000000000..4bfc18e96d --- /dev/null +++ b/Library/Homebrew/attestation.rb @@ -0,0 +1,82 @@ +# typed: true +# frozen_string_literal: true + +require "date" +require "json" +require "utils/popen" +require "exceptions" + +module Homebrew + module Attestation + HOMEBREW_CORE_REPO = "Homebrew/homebrew-core" + HOMEBREW_CORE_CI_URI = "https://github.com/Homebrew/homebrew-core/.github/workflows/publish-commit-bottles.yml@refs/heads/master" + + BACKFILL_REPO = "trailofbits/homebrew-brew-verify" + BACKFILL_REPO_CI_URI = "https://github.com/trailofbits/homebrew-brew-verify/.github/workflows/backfill_signatures.yml@refs/heads/main" + + # No backfill attestations after this date are considered valid. + # @api private + BACKFILL_CUTOFF = DateTime.new(2024, 3, 14) + + # Verifies the given bottle against a cryptographic attestation of build provenance. + # + # The provenance is verified as originating from `signing_repo`, which is a `String` + # that should be formatted as a GitHub `owner/repo`. + # + # Callers may additionally pass in `signing_workflow`, which will scope the attestation + # down to an exact GitHub Actions workflow, in + # `https://github/OWNER/REPO/.github/workflows/WORKFLOW.yml@REF` format. + # + # @return [Hash] the JSON-decoded response. + # @raises [InvalidAttestationError] on any verification failures. + # + # @api private + def self.check_attestation(bottle, signing_repo, signing_workflow = nil) + cmd = [HOMEBREW_GH, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", "json"] + + unless signing_workflow.nil? + cmd += ["--cert-identity", signing_workflow] + end + + begin + output = Utils.safe_popen_read(*cmd) + rescue ErrorDuringExecution => e + raise InvalidAttestationError, "attestation verification failed: #{e}" + end + + begin + data = JSON.parse(output) + rescue JSON::ParserError => e + raise InvalidAttestationError, "attestation verification returned malformed JSON" + end + + raise InvalidAttestationError, "attestation output is empty" if data.empty? + + data + end + + # Verifies the given bottle against a cryptographic attestation of build provenance + # from homebrew-core's CI, falling back on a "backfill" attestation for older bottles. + # + # This is a specialization of `check_attestation` for homebrew-core. + def self.check_core_attestation(bottle) + begin + attestation = check_attestation bottle, HOMEBREW_CORE_REPO + return attestation + rescue InvalidAttestationError + odebug "falling back on backfilled attestation" + backfill_attestation = check_attestation bottle, BACKFILL_REPO, BACKFILL_REPO_CI_URI + timestamp = backfill_attestation.dig(0, "verificationResult", "verifiedTimestamps", + 0, "timestamp") + + raise InvalidAttestationError, "backfill attestation is missing verified timestamp" if timestamp.nil? + + if DateTime.parse(timestamp) > BACKFILL_CUTOFF + raise InvalidAttestationError, "backfill attestation post-dates cutoff" + end + end + + backfill_attestation + end + end +end diff --git a/Library/Homebrew/brew.sh b/Library/Homebrew/brew.sh index a00510b400..7469af7b04 100644 --- a/Library/Homebrew/brew.sh +++ b/Library/Homebrew/brew.sh @@ -454,8 +454,23 @@ setup_git() { fi } +setup_gh() { + # This is set by the user environment. + # shellcheck disable=SC2154 + if [[ -n "${HOMEBREW_VERIFY_ATTESTATIONS}" && -x "${HOMEBREW_PREFIX}/opt/gh/bin/gh" ]] + then + HOMEBREW_GH="${HOMEBREW_PREFIX}/opt/gh/bin/gh" + elif [[ -n "${HOMEBREW_GH_PATH}" ]] + then + HOMEBREW_GH="${HOMEBREW_GH_PATH}" + else + HOMEBREW_GH="gh" + fi +} + setup_curl setup_git +setup_gh HOMEBREW_VERSION="$("${HOMEBREW_GIT}" -C "${HOMEBREW_REPOSITORY}" describe --tags --dirty --abbrev=7 2>/dev/null)" HOMEBREW_USER_AGENT_VERSION="${HOMEBREW_VERSION}" @@ -721,6 +736,7 @@ export HOMEBREW_API_DEFAULT_DOMAIN export HOMEBREW_BOTTLE_DEFAULT_DOMAIN export HOMEBREW_CURL_SPEED_LIMIT export HOMEBREW_CURL_SPEED_TIME +export HOMEBREW_GH if [[ -n "${HOMEBREW_MACOS}" && -x "/usr/bin/xcode-select" ]] then diff --git a/Library/Homebrew/cmd/update.sh b/Library/Homebrew/cmd/update.sh index b3806891df..25ae436498 100644 --- a/Library/Homebrew/cmd/update.sh +++ b/Library/Homebrew/cmd/update.sh @@ -15,7 +15,7 @@ # HOMEBREW_LIBRARY, HOMEBREW_PREFIX, HOMEBREW_REPOSITORY are set by bin/brew # HOMEBREW_BREW_DEFAULT_GIT_REMOTE, HOMEBREW_BREW_GIT_REMOTE, HOMEBREW_CACHE, HOMEBREW_CELLAR, HOMEBREW_CURL # HOMEBREW_DEV_CMD_RUN, HOMEBREW_FORCE_BREWED_CURL, HOMEBREW_FORCE_BREWED_GIT, HOMEBREW_SYSTEM_CURL_TOO_OLD -# HOMEBREW_USER_AGENT_CURL are set by brew.sh +# HOMEBREW_USER_AGENT_CURL, HOMEBREW_GH are set by brew.sh # shellcheck disable=SC2154 source "${HOMEBREW_LIBRARY}/Homebrew/utils/lock.sh" @@ -415,6 +415,17 @@ user account: EOS fi + # we need `gh` if the user enables attestation verification + if [[ -n "${HOMEBREW_VERIFY_ATTESTATIONS}" && ! -x "${HOMEBREW_GH}" ]] + then + # we cannot install `gh` if homebrew/core is unavailable. + # we don't enable attestations on `gh` itself, to prevent a bootstrap cycle. + if [[ -z "${HOMEBREW_CORE_AVAILABLE}" ]] || ! HOMEBREW_VERIFY_ATTESTATIONS='' brew install gh + then + odie "'gh' must be installed and in your PATH!" + fi + fi + # we may want to use Homebrew CA certificates if [[ -n "${HOMEBREW_FORCE_BREWED_CA_CERTIFICATES}" && ! -f "${HOMEBREW_PREFIX}/etc/ca-certificates/cert.pem" ]] then diff --git a/Library/Homebrew/env_config.rb b/Library/Homebrew/env_config.rb index 16d2db6222..4f6b8e62fd 100644 --- a/Library/Homebrew/env_config.rb +++ b/Library/Homebrew/env_config.rb @@ -422,6 +422,11 @@ module Homebrew "useful to avoid long-running Homebrew commands being killed due to no output.", boolean: true, }, + HOMEBREW_VERIFY_ATTESTATIONS: { + description: "If set, Homebrew will use the `gh` tool to verify cryptographic attestations " \ + "of build provenance for bottles from homebrew-core.", + boolean: true, + }, SUDO_ASKPASS: { description: "If set, pass the `-A` option when calling `sudo`(8).", }, diff --git a/Library/Homebrew/exceptions.rb b/Library/Homebrew/exceptions.rb index 1094c7e517..d625491e94 100644 --- a/Library/Homebrew/exceptions.rb +++ b/Library/Homebrew/exceptions.rb @@ -783,3 +783,6 @@ class CyclicDependencyError < RuntimeError EOS end end + +# Raised when attestation verification fails. +class InvalidAttestationError < RuntimeError; end diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index a0effec872..5bcecd2544 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -22,6 +22,7 @@ require "utils/spdx" require "deprecate_disable" require "unlink" require "service" +require "attestation" # Installer for a formula. # @@ -1256,6 +1257,11 @@ on_request: installed_on_request?, options:) sig { void } def pour + if Homebrew::EnvConfig.verify_attestations? && formula.tap&.core_tap? + ohai "Verifying attestation for #{formula.name}" + Homebrew::Attestation.check_core_attestation formula.bottle + end + HOMEBREW_CELLAR.cd do downloader.stage end diff --git a/Library/Homebrew/global.rb b/Library/Homebrew/global.rb index fdebb4a470..f0a95bd670 100644 --- a/Library/Homebrew/global.rb +++ b/Library/Homebrew/global.rb @@ -66,6 +66,8 @@ HOMEBREW_PULL_OR_COMMIT_URL_REGEX = %r[https://github\.com/([\w-]+)/([\w-]+)?/(?:pull/(\d+)|commit/[0-9a-fA-F]{4,40})] HOMEBREW_BOTTLES_EXTNAME_REGEX = /\.([a-z0-9_]+)\.bottle\.(?:(\d+)\.)?tar\.gz$/ +HOMEBREW_GH = Pathname(ENV.fetch("HOMEBREW_GH")).freeze + require "env_config" require "macos_version" require "os"