Skip to content

Commit

Permalink
Merge pull request #17049 from trail-of-forks/ww/attestation
Browse files Browse the repository at this point in the history
attestation: add initial attestation helpers, integrate into `brew install`
  • Loading branch information
MikeMcQuaid committed Apr 12, 2024
2 parents b16112f + 1607d04 commit c683e01
Show file tree
Hide file tree
Showing 8 changed files with 395 additions and 54 deletions.
138 changes: 138 additions & 0 deletions Library/Homebrew/attestation.rb
Original file line number Diff line number Diff line change
@@ -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: <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.
#
# @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
5 changes: 5 additions & 0 deletions Library/Homebrew/env_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,11 @@ module EnvConfig
"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).",
},
Expand Down
20 changes: 20 additions & 0 deletions Library/Homebrew/formula_installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
require "deprecate_disable"
require "unlink"
require "service"
require "attestation"

# Installer for a formula.
#
Expand Down Expand Up @@ -1256,6 +1257,25 @@ def downloader

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
Expand Down
5 changes: 5 additions & 0 deletions Library/Homebrew/software_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,11 @@ def tab_attributes
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)
Expand Down
3 changes: 3 additions & 0 deletions Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

153 changes: 153 additions & 0 deletions Library/Homebrew/test/attestation_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit c683e01

Please sign in to comment.