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"
|
|
|
|
require "exceptions"
|
|
|
|
|
|
|
|
module Homebrew
|
|
|
|
module Attestation
|
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-04-08 16:22:57 -04:00
|
|
|
# @api private
|
2024-04-08 16:18:15 -04:00
|
|
|
HOMEBREW_CORE_CI_URI = "https://github.com/Homebrew/homebrew-core/.github/workflows/publish-commit-bottles.yml@refs/heads/master"
|
|
|
|
|
2024-04-08 16:22:57 -04:00
|
|
|
# @api private
|
2024-04-08 16:18:15 -04:00
|
|
|
BACKFILL_REPO = "trailofbits/homebrew-brew-verify"
|
2024-04-08 16:22:57 -04:00
|
|
|
# @api private
|
2024-04-08 16:18:15 -04:00
|
|
|
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.
|
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-04-09 10:52:48 -04:00
|
|
|
# Raised when attestation verification fails.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
class InvalidAttestationError < RuntimeError; 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-04-09 10:48:17 -04:00
|
|
|
# NOTE: We disable HOMEBREW_VERIFY_ATTESTATIONS when installing `gh` itself,
|
|
|
|
# to prevent a cycle during bootstrapping. This can eventually be resolved
|
|
|
|
# by vendoring a pure-Ruby Sigstore verifier client.
|
2024-04-09 11:03:41 -04:00
|
|
|
@gh_executable ||= T.let(with_env("HOMEBREW_VERIFY_ATTESTATIONS" => nil) do
|
2024-04-09 10:45:44 -04:00
|
|
|
ensure_executable!("gh")
|
2024-04-09 11:03:41 -04:00
|
|
|
end, T.nilable(Pathname))
|
2024-04-09 10:45:44 -04:00
|
|
|
end
|
|
|
|
|
2024-04-08 16:18:15 -04:00
|
|
|
# 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.
|
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-04-09 10:45:44 -04:00
|
|
|
cmd = [gh_executable, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format",
|
|
|
|
"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
|
|
|
|
|
|
|
begin
|
|
|
|
output = Utils.safe_popen_read(*cmd)
|
|
|
|
rescue ErrorDuringExecution => e
|
|
|
|
raise InvalidAttestationError, "attestation verification failed: #{e}"
|
|
|
|
end
|
|
|
|
|
|
|
|
begin
|
2024-04-11 13:39:13 -04:00
|
|
|
attestations = JSON.parse(output)
|
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-04-11 13:39:13 -04:00
|
|
|
attestation = attestations.find do |a|
|
2024-04-11 16:44:57 -04:00
|
|
|
a.dig("verificationResult", "statement", "subject", 0, "name") == subject
|
2024-04-11 13:39:13 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
raise InvalidAttestationError, "no attestation matches 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
|
|
|
|
# @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-04-10 17:57:01 -04:00
|
|
|
attestation = check_attestation bottle, HOMEBREW_CORE_REPO, HOMEBREW_CORE_CI_URI
|
2024-04-08 16:18:15 -04:00
|
|
|
return attestation
|
|
|
|
rescue InvalidAttestationError
|
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).
|
|
|
|
url_sha256 = Digest::SHA256.hexdigest(bottle.url)
|
|
|
|
subject = "#{url_sha256}--#{bottle.filename}"
|
|
|
|
|
|
|
|
backfill_attestation = check_attestation bottle, BACKFILL_REPO, BACKFILL_REPO_CI_URI, 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
|