Module: Homebrew::Attestation Private

Extended by:
SystemCommand::Mixin
Defined in:
attestation.rb

This module is part of a private API. This module may only be used in the Homebrew/brew repository. Third parties should avoid using this module if possible, as it may be removed or changed without warning.

Defined Under Namespace

Classes: GhAuthNeeded, InvalidAttestationError

Constant Summary collapse

HOMEBREW_CORE_REPO =

This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.

"Homebrew/homebrew-core"
BACKFILL_REPO =

This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.

"trailofbits/homebrew-brew-verify"
BACKFILL_CUTOFF =

This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.

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.

T.let(DateTime.new(2024, 3, 14).freeze, DateTime)

Class Method Summary collapse

Methods included from SystemCommand::Mixin

system_command, system_command!

Class Method Details

.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil) ⇒ Hash{T.untyped => T.untyped}

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

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.

Parameters:

  • bottle (Bottle)
  • signing_repo (String)
  • signing_workflow (String, nil) (defaults to: nil)
  • subject (String, nil) (defaults to: nil)

Returns:

  • (Hash{T.untyped => T.untyped})

    the JSON-decoded response.

Raises:



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'attestation.rb', line 74

def self.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil)
  cmd = ["attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format",
         "json"]

  cmd += ["--cert-identity", signing_workflow] if signing_workflow.present?

  # Fail early if we have no credentials. The command below invariably
  # fails without them, so this saves us a network roundtrip before
  # presenting the user with the same error.
  credentials = GitHub::API.credentials
  raise GhAuthNeeded, "missing credentials" if credentials.blank?

  begin
    result = system_command!(gh_executable, args: cmd, env: { "GH_TOKEN" => credentials },
                            secrets: [credentials])
  rescue ErrorDuringExecution => e
    # Even if we have credentials, they may be invalid or malformed.
    raise GhAuthNeeded, "invalid credentials" if e.status.exitstatus == 4

    raise InvalidAttestationError, "attestation verification failed: #{e}"
  end

  begin
    attestations = JSON.parse(result.stdout)
  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

.check_core_attestation(bottle) ⇒ Hash{T.untyped => T.untyped}

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

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.

Parameters:

Returns:

  • (Hash{T.untyped => T.untyped})

    the JSON-decoded response

Raises:



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'attestation.rb', line 126

def self.check_core_attestation(bottle)
  begin
    # Ideally, we would also constrain the signing workflow here, but homebrew-core
    # currently uses multiple signing workflows to produce bottles
    # (e.g. `dispatch-build-bottle.yml`, `dispatch-rebottle.yml`, etc.).
    #
    # We could check each of these (1) explicitly (slow), (2) by generating a pattern
    # to pass into `--cert-identity-regex` (requires us to build up a Go-style regex),
    # or (3) by checking the resulting JSON for the expected signing workflow.
    #
    # Long term, we should probably either do (3) *or* switch to a single reusable
    # workflow, which would then be our sole identity. However, GitHub's
    # attestations currently do not include reusable workflow state by default.
    attestation = check_attestation bottle, HOMEBREW_CORE_REPO
    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}"

    # We don't pass in a signing workflow for backfill signatures because
    # some backfilled bottle signatures were signed from the 'backfill'
    # branch, and others from 'main' of trailofbits/homebrew-brew-verify
    # so the signing workflow is slightly different which causes some bottles to incorrectly
    # fail when checking their attestation. This shouldn't meaningfully affect security
    # because if somehow someone could generate false backfill attestations
    # from a different workflow we will still catch it because the
    # attestation would have been generated after our cutoff date.
    backfill_attestation = check_attestation bottle, BACKFILL_REPO, nil, 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

.gh_executablePathname

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

Returns a path to a suitable gh executable for attestation verification.

Returns:



47
48
49
50
51
52
53
54
# File 'attestation.rb', line 47

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