Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

attestation: add initial attestation helpers, integrate into brew install #17049

Merged
merged 15 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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.
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
#
# 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"
MikeMcQuaid marked this conversation as resolved.
Show resolved Hide resolved
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 @@

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

Check warning on line 1263 in Library/Homebrew/formula_installer.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/formula_installer.rb#L1263

Added line #L1263 was not covered by tests
rescue Homebrew::Attestation::InvalidAttestationError => e
raise CannotInstallFormulaError, <<~EOS

Check warning on line 1265 in Library/Homebrew/formula_installer.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/formula_installer.rb#L1265

Added line #L1265 was not covered by tests
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems weird no tests cover this, is this used anywhere right now?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests currently use a double for it, so I think that's what it doesn't get coverage. Want me to add some separate coverage for it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeh or just double lower in the stack (the owner/tag/rebuild) so it still gets called?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done with 1607d04 -- I went with a separate test since the more invasive double was going to require a lot of nesting that would make the other tests harder to read, but I can shoehorn it in if that's your preference 🙂

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I also had to move the BottleSpecification specs to their own file, since RuboCop was complaining about having two RSpec stanzas in bottle_spec.rb.)

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