-
-
Notifications
You must be signed in to change notification settings - Fork 9.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #17049 from trail-of-forks/ww/attestation
attestation: add initial attestation helpers, integrate into `brew install`
- Loading branch information
Showing
8 changed files
with
395 additions
and
54 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.