2024-04-09 11:03:41 -04:00
|
|
|
# typed: strict
|
2024-04-08 16:18:15 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
require "date"
|
|
|
|
require "json"
|
|
|
|
require "utils/popen"
|
2024-07-15 13:30:47 -04:00
|
|
|
require "utils/github/api"
|
2024-04-08 16:18:15 -04:00
|
|
|
require "exceptions"
|
2024-05-14 14:32:23 -04:00
|
|
|
require "system_command"
|
2024-04-08 16:18:15 -04:00
|
|
|
|
|
|
|
module Homebrew
|
|
|
|
module Attestation
|
2024-05-14 14:32:23 -04:00
|
|
|
extend SystemCommand::Mixin
|
|
|
|
|
2024-04-08 16:22:57 -04:00
|
|
|
# @api private
|
2024-04-08 16:18:15 -04:00
|
|
|
HOMEBREW_CORE_REPO = "Homebrew/homebrew-core"
|
|
|
|
|
2024-07-14 12:06:21 -04:00
|
|
|
# @api private
|
|
|
|
GH_ATTESTATION_MIN_VERSION = T.let(Version.new("2.49.0").freeze, Version)
|
|
|
|
|
2024-04-08 16:22:57 -04:00
|
|
|
# @api private
|
2024-04-08 16:18:15 -04:00
|
|
|
BACKFILL_REPO = "trailofbits/homebrew-brew-verify"
|
|
|
|
|
|
|
|
# No backfill attestations after this date are considered valid.
|
2024-04-09 10:50:49 -04:00
|
|
|
#
|
|
|
|
# This date is shortly after the backfill operation for homebrew-core
|
|
|
|
# completed, as can be seen here: <https://github.com/trailofbits/homebrew-brew-verify/attestations>.
|
|
|
|
#
|
|
|
|
# In effect, this means that, even if an attacker is able to compromise the backfill
|
|
|
|
# signing workflow, they will be unable to convince a verifier to accept their newer,
|
|
|
|
# malicious backfilled signatures.
|
|
|
|
#
|
2024-04-08 16:18:15 -04:00
|
|
|
# @api private
|
2024-04-09 11:03:41 -04:00
|
|
|
BACKFILL_CUTOFF = T.let(DateTime.new(2024, 3, 14).freeze, DateTime)
|
2024-04-08 16:18:15 -04:00
|
|
|
|
2024-07-23 16:59:52 +01:00
|
|
|
# Raised when the attestation was not found.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
class MissingAttestationError < RuntimeError; end
|
|
|
|
|
2024-04-09 10:52:48 -04:00
|
|
|
# Raised when attestation verification fails.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
class InvalidAttestationError < RuntimeError; end
|
|
|
|
|
2024-05-03 12:37:01 -04:00
|
|
|
# Raised if attestation verification cannot continue due to missing
|
|
|
|
# credentials.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
class GhAuthNeeded < RuntimeError; end
|
|
|
|
|
2024-07-18 16:11:25 +01:00
|
|
|
# Raised if attestation verification cannot continue due to invalid
|
|
|
|
# credentials.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
class GhAuthInvalid < RuntimeError; end
|
|
|
|
|
2024-07-13 10:51:49 -04:00
|
|
|
# Returns whether attestation verification is enabled.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
sig { returns(T::Boolean) }
|
|
|
|
def self.enabled?
|
2024-07-14 11:50:57 -04:00
|
|
|
return false if Homebrew::EnvConfig.no_verify_attestations?
|
2024-07-13 17:55:44 -04:00
|
|
|
return true if Homebrew::EnvConfig.verify_attestations?
|
2024-07-14 11:50:57 -04:00
|
|
|
return false if GitHub::API.credentials.blank?
|
2024-07-13 17:55:44 -04:00
|
|
|
return false if ENV.fetch("CI", false)
|
2024-07-18 16:11:25 +01:00
|
|
|
return false if OS.unsupported_configuration?
|
2024-07-13 14:58:07 -07:00
|
|
|
|
2024-07-14 11:50:57 -04:00
|
|
|
Homebrew::EnvConfig.developer? || Homebrew::EnvConfig.devcmdrun?
|
2024-07-13 10:51:49 -04:00
|
|
|
end
|
|
|
|
|
2024-04-09 10:48:17 -04:00
|
|
|
# Returns a path to a suitable `gh` executable for attestation verification.
|
|
|
|
#
|
|
|
|
# @api private
|
2024-04-09 11:03:41 -04:00
|
|
|
sig { returns(Pathname) }
|
2024-04-09 10:45:44 -04:00
|
|
|
def self.gh_executable
|
2024-07-14 16:30:12 -04:00
|
|
|
# NOTE: We set HOMEBREW_NO_VERIFY_ATTESTATIONS when installing `gh` itself,
|
2024-04-26 20:55:51 +02:00
|
|
|
# to prevent a cycle during bootstrapping. This can eventually be resolved
|
|
|
|
# by vendoring a pure-Ruby Sigstore verifier client.
|
2024-07-15 11:44:53 -04:00
|
|
|
@gh_executable ||= T.let(nil, T.nilable(Pathname))
|
2024-07-15 11:39:22 -04:00
|
|
|
return @gh_executable if @gh_executable.present?
|
|
|
|
|
|
|
|
with_env(HOMEBREW_NO_VERIFY_ATTESTATIONS: "1") do
|
2024-07-15 16:42:43 -04:00
|
|
|
@gh_executable = ensure_executable!("gh", reason: "verifying attestations")
|
2024-07-15 11:39:22 -04:00
|
|
|
|
|
|
|
gh_version = Version.new(system_command!(@gh_executable, args: ["--version"], print_stderr: false)
|
|
|
|
.stdout.match(/\d+(?:\.\d+)+/i).to_s)
|
|
|
|
if gh_version < GH_ATTESTATION_MIN_VERSION
|
2024-07-17 14:45:59 -04:00
|
|
|
if Formula["gh"].version < GH_ATTESTATION_MIN_VERSION
|
|
|
|
raise "#{@gh_executable} is too old, you must upgrade it to >=#{GH_ATTESTATION_MIN_VERSION} to continue"
|
|
|
|
end
|
|
|
|
|
2024-07-15 11:39:22 -04:00
|
|
|
@gh_executable = ensure_formula_installed!("gh", latest: true,
|
|
|
|
reason: "verifying attestations").opt_bin/"gh"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-07-15 11:44:53 -04:00
|
|
|
T.must(@gh_executable)
|
2024-04-09 10:45:44 -04:00
|
|
|
end
|
|
|
|
|
2024-07-18 21:32:50 -07:00
|
|
|
# Prioritize installing `gh` first if it's in the formula list
|
|
|
|
# or check for the existence of the `gh` executable elsewhere.
|
|
|
|
#
|
|
|
|
# This ensures that a valid version of `gh` is installed before
|
|
|
|
# we use it to check the attestations of any other formulae we
|
|
|
|
# want to install.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
sig { params(formulae: T::Array[Formula]).returns(T::Array[Formula]) }
|
|
|
|
def self.sort_formulae_for_install(formulae)
|
|
|
|
if formulae.include?(Formula["gh"])
|
|
|
|
[Formula["gh"]] | formulae
|
|
|
|
else
|
|
|
|
Homebrew::Attestation.gh_executable
|
|
|
|
formulae
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-04-08 16:18:15 -04:00
|
|
|
# Verifies the given bottle against a cryptographic attestation of build provenance.
|
|
|
|
#
|
2024-06-10 09:31:53 +01:00
|
|
|
# The provenance is verified as originating from `signing_repository`, which is a `String`
|
|
|
|
# that should be formatted as a GitHub `owner/repository`.
|
2024-04-08 16:18:15 -04:00
|
|
|
#
|
|
|
|
# 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.
|
2024-05-03 12:37:01 -04:00
|
|
|
# @raise [GhAuthNeeded] on any authentication failures
|
2024-04-08 16:22:57 -04:00
|
|
|
# @raise [InvalidAttestationError] on any verification failures
|
2024-04-08 16:18:15 -04:00
|
|
|
#
|
|
|
|
# @api private
|
2024-04-09 11:03:41 -04:00
|
|
|
sig {
|
|
|
|
params(bottle: Bottle, signing_repo: String,
|
2024-04-11 16:44:57 -04:00
|
|
|
signing_workflow: T.nilable(String), subject: T.nilable(String)).returns(T::Hash[T.untyped, T.untyped])
|
2024-04-09 11:03:41 -04:00
|
|
|
}
|
2024-04-11 16:44:57 -04:00
|
|
|
def self.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil)
|
2024-05-14 14:32:23 -04:00
|
|
|
cmd = ["attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format",
|
2024-04-09 10:45:44 -04:00
|
|
|
"json"]
|
2024-04-08 16:18:15 -04:00
|
|
|
|
2024-04-09 10:18:08 -04:00
|
|
|
cmd += ["--cert-identity", signing_workflow] if signing_workflow.present?
|
2024-04-08 16:18:15 -04:00
|
|
|
|
2024-05-03 12:37:01 -04:00
|
|
|
# Fail early if we have no credentials. The command below invariably
|
2024-07-18 12:06:37 -04:00
|
|
|
# fails without them, so this saves us an unnecessary subshell.
|
2024-05-03 12:37:01 -04:00
|
|
|
credentials = GitHub::API.credentials
|
|
|
|
raise GhAuthNeeded, "missing credentials" if credentials.blank?
|
|
|
|
|
2024-04-08 16:18:15 -04:00
|
|
|
begin
|
2024-07-18 16:11:25 +01:00
|
|
|
result = system_command!(gh_executable, args: cmd,
|
|
|
|
env: { "GH_TOKEN" => credentials, "GH_HOST" => "github.com" },
|
2024-07-17 17:26:59 +01:00
|
|
|
secrets: [credentials], print_stderr: false, chdir: HOMEBREW_TEMP)
|
2024-04-08 16:18:15 -04:00
|
|
|
rescue ErrorDuringExecution => e
|
2024-05-03 12:37:01 -04:00
|
|
|
# Even if we have credentials, they may be invalid or malformed.
|
2024-07-18 16:11:25 +01:00
|
|
|
if e.status.exitstatus == 4 || e.stderr.include?("HTTP 401: Bad credentials")
|
|
|
|
raise GhAuthInvalid, "invalid credentials"
|
|
|
|
end
|
2024-05-03 12:37:01 -04:00
|
|
|
|
2024-07-23 16:59:52 +01:00
|
|
|
raise MissingAttestationError, "attestation not found: #{e}" if e.stderr.include?("HTTP 404: Not Found")
|
|
|
|
|
2024-04-08 16:18:15 -04:00
|
|
|
raise InvalidAttestationError, "attestation verification failed: #{e}"
|
|
|
|
end
|
|
|
|
|
|
|
|
begin
|
2024-05-14 14:32:23 -04:00
|
|
|
attestations = JSON.parse(result.stdout)
|
2024-04-08 16:21:31 -04:00
|
|
|
rescue JSON::ParserError
|
2024-04-08 16:18:15 -04:00
|
|
|
raise InvalidAttestationError, "attestation verification returned malformed JSON"
|
|
|
|
end
|
|
|
|
|
2024-04-11 13:39:13 -04:00
|
|
|
# `gh attestation verify` returns a JSON array of one or more results,
|
|
|
|
# for all attestations that match the input's digest. We want to additionally
|
|
|
|
# filter these down to just the attestation whose subject matches the bottle's name.
|
2024-04-11 16:44:57 -04:00
|
|
|
subject = bottle.filename.to_s if subject.blank?
|
2024-06-06 11:23:03 -04:00
|
|
|
|
|
|
|
attestation = if bottle.tag.to_sym == :all
|
|
|
|
# :all-tagged bottles are created by `brew bottle --merge`, and are not directly
|
|
|
|
# bound to their own filename (since they're created by deduplicating other filenames).
|
|
|
|
# To verify these, we parse each attestation subject and look for one with a matching
|
|
|
|
# formula (name, version), but not an exact tag match.
|
|
|
|
# This is sound insofar as the signature has already been verified. However,
|
|
|
|
# longer term, we should also directly attest to `:all`-tagged bottles.
|
|
|
|
attestations.find do |a|
|
2024-06-06 11:41:21 -04:00
|
|
|
actual_subject = a.dig("verificationResult", "statement", "subject", 0, "name")
|
|
|
|
actual_subject.start_with? "#{bottle.filename.name}--#{bottle.filename.version}"
|
2024-06-06 11:23:03 -04:00
|
|
|
end
|
|
|
|
else
|
|
|
|
attestations.find do |a|
|
|
|
|
a.dig("verificationResult", "statement", "subject", 0, "name") == subject
|
|
|
|
end
|
2024-04-11 13:39:13 -04:00
|
|
|
end
|
|
|
|
|
2024-07-26 14:26:45 -04:00
|
|
|
raise InvalidAttestationError, "no attestation matches subject: #{subject}" if attestation.blank?
|
2024-04-08 16:18:15 -04:00
|
|
|
|
2024-04-11 13:39:13 -04:00
|
|
|
attestation
|
2024-04-08 16:18:15 -04:00
|
|
|
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.
|
2024-04-08 16:22:57 -04:00
|
|
|
#
|
|
|
|
# @return [Hash] the JSON-decoded response
|
2024-05-03 12:37:01 -04:00
|
|
|
# @raise [GhAuthNeeded] on any authentication failures
|
2024-04-08 16:22:57 -04:00
|
|
|
# @raise [InvalidAttestationError] on any verification failures
|
|
|
|
#
|
|
|
|
# @api private
|
2024-04-09 11:03:41 -04:00
|
|
|
sig { params(bottle: Bottle).returns(T::Hash[T.untyped, T.untyped]) }
|
2024-04-08 16:18:15 -04:00
|
|
|
def self.check_core_attestation(bottle)
|
|
|
|
begin
|
2024-05-18 10:04:53 -04:00
|
|
|
# Ideally, we would also constrain the signing workflow here, but homebrew-core
|
|
|
|
# currently uses multiple signing workflows to produce bottles
|
|
|
|
# (e.g. `dispatch-build-bottle.yml`, `dispatch-rebottle.yml`, etc.).
|
|
|
|
#
|
|
|
|
# We could check each of these (1) explicitly (slow), (2) by generating a pattern
|
|
|
|
# to pass into `--cert-identity-regex` (requires us to build up a Go-style regex),
|
|
|
|
# or (3) by checking the resulting JSON for the expected signing workflow.
|
|
|
|
#
|
|
|
|
# Long term, we should probably either do (3) *or* switch to a single reusable
|
|
|
|
# workflow, which would then be our sole identity. However, GitHub's
|
|
|
|
# attestations currently do not include reusable workflow state by default.
|
|
|
|
attestation = check_attestation bottle, HOMEBREW_CORE_REPO
|
2024-04-08 16:18:15 -04:00
|
|
|
return attestation
|
2024-07-23 16:59:52 +01:00
|
|
|
rescue MissingAttestationError
|
2024-04-09 10:18:08 -04:00
|
|
|
odebug "falling back on backfilled attestation for #{bottle}"
|
2024-04-11 16:44:57 -04:00
|
|
|
|
|
|
|
# Our backfilled attestation is a little unique: the subject is not just the bottle
|
|
|
|
# filename, but also has the bottle's hosted URL hash prepended to it.
|
|
|
|
# This was originally unintentional, but has a virtuous side effect of further
|
|
|
|
# limiting domain separation on the backfilled signatures (by committing them to
|
|
|
|
# their original bottle URLs).
|
2024-07-26 14:26:45 -04:00
|
|
|
url_sha256 = if EnvConfig.bottle_domain == HOMEBREW_BOTTLE_DEFAULT_DOMAIN
|
|
|
|
Digest::SHA256.hexdigest(bottle.url)
|
|
|
|
else
|
|
|
|
# If our bottle is coming from a mirror, we need to recompute the expected
|
|
|
|
# non-mirror URL to make the hash match.
|
|
|
|
path, = Utils::Bottles.path_resolved_basename HOMEBREW_BOTTLE_DEFAULT_DOMAIN, bottle.name,
|
|
|
|
bottle.resource.checksum, bottle.filename
|
|
|
|
url = "#{HOMEBREW_BOTTLE_DEFAULT_DOMAIN}/#{path}"
|
|
|
|
|
|
|
|
Digest::SHA256.hexdigest(url)
|
|
|
|
end
|
2024-04-11 16:44:57 -04:00
|
|
|
subject = "#{url_sha256}--#{bottle.filename}"
|
|
|
|
|
2024-04-30 10:50:33 -04:00
|
|
|
# We don't pass in a signing workflow for backfill signatures because
|
|
|
|
# some backfilled bottle signatures were signed from the 'backfill'
|
2024-04-30 10:52:36 -04:00
|
|
|
# branch, and others from 'main' of trailofbits/homebrew-brew-verify
|
|
|
|
# so the signing workflow is slightly different which causes some bottles to incorrectly
|
|
|
|
# fail when checking their attestation. This shouldn't meaningfully affect security
|
2024-04-30 10:50:33 -04:00
|
|
|
# because if somehow someone could generate false backfill attestations
|
|
|
|
# from a different workflow we will still catch it because the
|
|
|
|
# attestation would have been generated after our cutoff date.
|
2024-04-30 09:52:04 -04:00
|
|
|
backfill_attestation = check_attestation bottle, BACKFILL_REPO, nil, subject
|
2024-04-11 13:39:13 -04:00
|
|
|
timestamp = backfill_attestation.dig("verificationResult", "verifiedTimestamps",
|
2024-04-08 16:18:15 -04:00
|
|
|
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
|