diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb new file mode 100644 index 0000000000..63678dc1db --- /dev/null +++ b/Library/Homebrew/attestation.rb @@ -0,0 +1,138 @@ +# typed: strict +# frozen_string_literal: true + +require "date" +require "json" +require "utils/popen" +require "exceptions" + +module Homebrew + module Attestation + # @api private + HOMEBREW_CORE_REPO = "Homebrew/homebrew-core" + # @api private + HOMEBREW_CORE_CI_URI = "https://github.com/Homebrew/homebrew-core/.github/workflows/publish-commit-bottles.yml@refs/heads/master" + + # @api private + BACKFILL_REPO = "trailofbits/homebrew-brew-verify" + # @api private + 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. + # + # This date is shortly after the backfill operation for homebrew-core + # completed, as can be seen here: . + # + # 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. + # + # @api private + BACKFILL_CUTOFF = T.let(DateTime.new(2024, 3, 14).freeze, DateTime) + + # Raised when attestation verification fails. + # + # @api private + class InvalidAttestationError < RuntimeError; end + + # Returns a path to a suitable `gh` executable for attestation verification. + # + # @api private + sig { returns(Pathname) } + def self.gh_executable + # 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. + @gh_executable ||= T.let(with_env("HOMEBREW_VERIFY_ATTESTATIONS" => nil) do + ensure_executable!("gh") + end, T.nilable(Pathname)) + end + + # 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. + # @raise [InvalidAttestationError] on any verification failures + # + # @api private + sig { + params(bottle: Bottle, signing_repo: String, + signing_workflow: T.nilable(String), subject: T.nilable(String)).returns(T::Hash[T.untyped, T.untyped]) + } + def self.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil) + cmd = [gh_executable, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", + "json"] + + cmd += ["--cert-identity", signing_workflow] if signing_workflow.present? + + begin + output = Utils.safe_popen_read(*cmd) + rescue ErrorDuringExecution => e + raise InvalidAttestationError, "attestation verification failed: #{e}" + end + + begin + attestations = JSON.parse(output) + rescue JSON::ParserError + raise InvalidAttestationError, "attestation verification returned malformed JSON" + end + + # `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. + subject = bottle.filename.to_s if subject.blank? + attestation = attestations.find do |a| + a.dig("verificationResult", "statement", "subject", 0, "name") == subject + end + + raise InvalidAttestationError, "no attestation matches subject" if attestation.blank? + + attestation + 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. + # + # @return [Hash] the JSON-decoded response + # @raise [InvalidAttestationError] on any verification failures + # + # @api private + sig { params(bottle: Bottle).returns(T::Hash[T.untyped, T.untyped]) } + def self.check_core_attestation(bottle) + begin + attestation = check_attestation bottle, HOMEBREW_CORE_REPO, HOMEBREW_CORE_CI_URI + return attestation + rescue InvalidAttestationError + odebug "falling back on backfilled attestation for #{bottle}" + + # 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 + timestamp = backfill_attestation.dig("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/env_config.rb b/Library/Homebrew/env_config.rb index c34a8a749a..c7eccd09e3 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/formula_installer.rb b/Library/Homebrew/formula_installer.rb index a0effec872..4c1503033b 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,25 @@ 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}" + begin + Homebrew::Attestation.check_core_attestation formula.bottle + rescue Homebrew::Attestation::InvalidAttestationError => e + raise CannotInstallFormulaError, <<~EOS + The bottle for #{formula.name} has an invalid build provenance attestation. + + This may indicate that the bottle was not produced by the expected + tap, or was maliciously inserted into the expected tap's bottle + storage. + + Additional context: + + #{e} + EOS + end + end + HOMEBREW_CELLAR.cd do downloader.stage end diff --git a/Library/Homebrew/software_spec.rb b/Library/Homebrew/software_spec.rb index 42d2477bb4..fffd5116a4 100644 --- a/Library/Homebrew/software_spec.rb +++ b/Library/Homebrew/software_spec.rb @@ -423,6 +423,11 @@ class Bottle github_packages_manifest_resource_tab(github_packages_manifest_resource) end + sig { returns(Filename) } + def filename + Filename.create(resource.owner, @tag, @spec.rebuild) + end + private def github_packages_manifest_resource_tab(github_packages_manifest_resource) diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi index a1a75c94cb..e7a56d8586 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi @@ -260,5 +260,8 @@ module Homebrew::EnvConfig sig { returns(T::Boolean) } def verbose_using_dots?; end + + sig { returns(T::Boolean) } + def verify_attestations?; end end end diff --git a/Library/Homebrew/test/attestation_spec.rb b/Library/Homebrew/test/attestation_spec.rb new file mode 100644 index 0000000000..b04b0946a7 --- /dev/null +++ b/Library/Homebrew/test/attestation_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "diagnostic" + +RSpec.describe Homebrew::Attestation do + let(:fake_gh) { Pathname.new("/extremely/fake/gh") } + let(:cached_download) { "/fake/cached/download" } + let(:fake_bottle_filename) { instance_double(Bottle::Filename, to_s: "fakebottle--1.0.faketag.bottle.tar.gz") } + let(:fake_bottle_url) { "https://example.com/#{fake_bottle_filename}" } + let(:fake_bottle) do + instance_double(Bottle, cached_download:, filename: fake_bottle_filename, url: fake_bottle_url) + end + let(:fake_json_resp) do + JSON.dump([ + { verificationResult: { + verifiedTimestamps: [{ timestamp: "2024-03-13T00:00:00Z" }], + statement: { subject: [{ name: fake_bottle_filename.to_s }] }, + } }, + ]) + end + let(:fake_json_resp_backfill) do + JSON.dump([ + { verificationResult: { + verifiedTimestamps: [{ timestamp: "2024-03-13T00:00:00Z" }], + statement: { + subject: [{ name: "#{Digest::SHA256.hexdigest(fake_bottle_url)}--#{fake_bottle_filename}" }], + }, + } }, + ]) + end + let(:fake_json_resp_too_new) do + JSON.dump([ + { verificationResult: { + verifiedTimestamps: [{ timestamp: "2024-03-15T00:00:00Z" }], + statement: { subject: [{ name: fake_bottle_filename.to_s }] }, + } }, + ]) + end + let(:fake_json_resp_wrong_sub) do + JSON.dump([ + { verificationResult: { + verifiedTimestamps: [{ timestamp: "2024-03-13T00:00:00Z" }], + statement: { subject: [{ name: "wrong-subject.tar.gz" }] }, + } }, + ]) + end + + describe "::gh_executable" do + it "calls ensure_executable" do + expect(described_class).to receive(:ensure_executable!) + .with("gh") + .and_return(fake_gh) + + described_class.gh_executable + end + end + + describe "::check_attestation" do + before do + allow(described_class).to receive(:gh_executable) + .and_return(fake_gh) + end + + it "raises when gh subprocess fails" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json") + .and_raise(ErrorDuringExecution.new(["foo"], status: 1)) + + expect do + described_class.check_attestation fake_bottle, + described_class::HOMEBREW_CORE_REPO + end.to raise_error(described_class::InvalidAttestationError) + end + + it "raises when gh returns invalid JSON" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json") + .and_return("\"invalid json") + + expect do + described_class.check_attestation fake_bottle, + described_class::HOMEBREW_CORE_REPO + end.to raise_error(described_class::InvalidAttestationError) + end + + it "raises when gh returns other subjects" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json") + .and_return(fake_json_resp_wrong_sub) + + expect do + described_class.check_attestation fake_bottle, + described_class::HOMEBREW_CORE_REPO + end.to raise_error(described_class::InvalidAttestationError) + end + end + + describe "::check_core_attestation" do + before do + allow(described_class).to receive(:gh_executable) + .and_return(fake_gh) + end + + it "calls gh with args for homebrew-core" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json", "--cert-identity", + described_class::HOMEBREW_CORE_CI_URI) + .and_return(fake_json_resp) + + described_class.check_core_attestation fake_bottle + end + + it "calls gh with args for backfill when homebrew-core fails" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json", "--cert-identity", + described_class::HOMEBREW_CORE_CI_URI) + .once + .and_raise(described_class::InvalidAttestationError) + + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::BACKFILL_REPO, "--format", "json", "--cert-identity", + described_class::BACKFILL_REPO_CI_URI) + .and_return(fake_json_resp_backfill) + + described_class.check_core_attestation fake_bottle + end + + it "raises when the backfilled attestation is too new" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json", "--cert-identity", + described_class::HOMEBREW_CORE_CI_URI) + .once + .and_raise(described_class::InvalidAttestationError) + + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::BACKFILL_REPO, "--format", "json", "--cert-identity", + described_class::BACKFILL_REPO_CI_URI) + .and_return(fake_json_resp_too_new) + + expect do + described_class.check_core_attestation fake_bottle + end.to raise_error(described_class::InvalidAttestationError) + end + end +end diff --git a/Library/Homebrew/test/software_spec/bottle_spec.rb b/Library/Homebrew/test/software_spec/bottle_spec.rb index 98aa029bcd..e6d8d9928a 100644 --- a/Library/Homebrew/test/software_spec/bottle_spec.rb +++ b/Library/Homebrew/test/software_spec/bottle_spec.rb @@ -1,62 +1,17 @@ # frozen_string_literal: true require "software_spec" +require "test/support/fixtures/testball_bottle" -RSpec.describe BottleSpecification do - subject(:bottle_spec) { described_class.new } +RSpec.describe Bottle do + describe "#filename" do + it "renders the bottle filename" do + bottle_spec = BottleSpecification.new + bottle_spec.sha256(arm64_big_sur: "deadbeef" * 8) + tag = Utils::Bottles::Tag.from_symbol :arm64_big_sur + bottle = described_class.new(TestballBottle.new, bottle_spec, tag) - describe "#sha256" do - it "works without cellar" do - checksums = { - arm64_big_sur: "deadbeef" * 8, - big_sur: "faceb00c" * 8, - catalina: "baadf00d" * 8, - mojave: "8badf00d" * 8, - } - - checksums.each_pair do |cat, digest| - bottle_spec.sha256(cat => digest) - tag_spec = bottle_spec.tag_specification_for(Utils::Bottles::Tag.from_symbol(cat)) - expect(Checksum.new(digest)).to eq(tag_spec.checksum) - end - end - - it "works with cellar" do - checksums = [ - { cellar: :any_skip_relocation, tag: :arm64_big_sur, digest: "deadbeef" * 8 }, - { cellar: :any, tag: :big_sur, digest: "faceb00c" * 8 }, - { cellar: "/usr/local/Cellar", tag: :catalina, digest: "baadf00d" * 8 }, - { cellar: Homebrew::DEFAULT_CELLAR, tag: :mojave, digest: "8badf00d" * 8 }, - ] - - checksums.each do |checksum| - bottle_spec.sha256(cellar: checksum[:cellar], checksum[:tag] => checksum[:digest]) - tag_spec = bottle_spec.tag_specification_for(Utils::Bottles::Tag.from_symbol(checksum[:tag])) - expect(Checksum.new(checksum[:digest])).to eq(tag_spec.checksum) - expect(checksum[:tag]).to eq(tag_spec.tag.to_sym) - checksum[:cellar] ||= Homebrew::DEFAULT_CELLAR - expect(checksum[:cellar]).to eq(tag_spec.cellar) - end - end - end - - describe "#compatible_locations?" do - it "checks if the bottle cellar is relocatable" do - expect(bottle_spec.compatible_locations?).to be false - end - end - - describe "#tag_to_cellar" do - it "returns the cellar for a tag" do - expect(bottle_spec.tag_to_cellar).to eq Utils::Bottles.tag.default_cellar - end - end - - %w[root_url rebuild].each do |method| - specify "##{method}" do - object = Object.new - bottle_spec.public_send(method, object) - expect(bottle_spec.public_send(method)).to eq(object) + expect(bottle.filename.to_s).to eq("testball_bottle--0.1.arm64_big_sur.bottle.tar.gz") end end end diff --git a/Library/Homebrew/test/software_spec/bottle_specification_spec.rb b/Library/Homebrew/test/software_spec/bottle_specification_spec.rb new file mode 100644 index 0000000000..98aa029bcd --- /dev/null +++ b/Library/Homebrew/test/software_spec/bottle_specification_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "software_spec" + +RSpec.describe BottleSpecification do + subject(:bottle_spec) { described_class.new } + + describe "#sha256" do + it "works without cellar" do + checksums = { + arm64_big_sur: "deadbeef" * 8, + big_sur: "faceb00c" * 8, + catalina: "baadf00d" * 8, + mojave: "8badf00d" * 8, + } + + checksums.each_pair do |cat, digest| + bottle_spec.sha256(cat => digest) + tag_spec = bottle_spec.tag_specification_for(Utils::Bottles::Tag.from_symbol(cat)) + expect(Checksum.new(digest)).to eq(tag_spec.checksum) + end + end + + it "works with cellar" do + checksums = [ + { cellar: :any_skip_relocation, tag: :arm64_big_sur, digest: "deadbeef" * 8 }, + { cellar: :any, tag: :big_sur, digest: "faceb00c" * 8 }, + { cellar: "/usr/local/Cellar", tag: :catalina, digest: "baadf00d" * 8 }, + { cellar: Homebrew::DEFAULT_CELLAR, tag: :mojave, digest: "8badf00d" * 8 }, + ] + + checksums.each do |checksum| + bottle_spec.sha256(cellar: checksum[:cellar], checksum[:tag] => checksum[:digest]) + tag_spec = bottle_spec.tag_specification_for(Utils::Bottles::Tag.from_symbol(checksum[:tag])) + expect(Checksum.new(checksum[:digest])).to eq(tag_spec.checksum) + expect(checksum[:tag]).to eq(tag_spec.tag.to_sym) + checksum[:cellar] ||= Homebrew::DEFAULT_CELLAR + expect(checksum[:cellar]).to eq(tag_spec.cellar) + end + end + end + + describe "#compatible_locations?" do + it "checks if the bottle cellar is relocatable" do + expect(bottle_spec.compatible_locations?).to be false + end + end + + describe "#tag_to_cellar" do + it "returns the cellar for a tag" do + expect(bottle_spec.tag_to_cellar).to eq Utils::Bottles.tag.default_cellar + end + end + + %w[root_url rebuild].each do |method| + specify "##{method}" do + object = Object.new + bottle_spec.public_send(method, object) + expect(bottle_spec.public_send(method)).to eq(object) + end + end +end