Merge pull request #17049 from trail-of-forks/ww/attestation
attestation: add initial attestation helpers, integrate into `brew install`
This commit is contained in:
commit
c683e011b8
138
Library/Homebrew/attestation.rb
Normal file
138
Library/Homebrew/attestation.rb
Normal 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
|
||||
@ -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).",
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
153
Library/Homebrew/test/attestation_spec.rb
Normal file
153
Library/Homebrew/test/attestation_spec.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user