Merge pull request #17049 from trail-of-forks/ww/attestation

attestation: add initial attestation helpers, integrate into `brew install`
This commit is contained in:
Mike McQuaid 2024-04-12 15:52:51 +01:00 committed by GitHub
commit c683e011b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 395 additions and 54 deletions

View File

@ -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

View File

@ -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).",
},

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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