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: GhAuthInvalid, GhAuthNeeded, GhIncompatible, InvalidAttestationError, MissingAttestationError
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)
- ATTESTATION_MAX_RETRIES =
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.
5
Class Method Summary collapse
-
.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil) ⇒ Hash{T.untyped => T.untyped}
private
Verifies the given bottle against a cryptographic attestation of build provenance.
-
.check_core_attestation(bottle) ⇒ Hash{T.untyped => T.untyped}
private
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.
-
.enabled? ⇒ Boolean
private
Returns whether attestation verification is enabled.
-
.gh_executable ⇒ Pathname
private
Returns a path to a suitable
gh
executable for attestation verification. -
.sort_formulae_for_install(formulae) ⇒ Array<Formula>
private
Prioritize installing
gh
first if it's in the formula list or check for the existence of thegh
executable elsewhere.
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_repository
, which is a String
that should be formatted as a GitHub owner/repository
.
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.
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 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'attestation.rb', line 129 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 an unnecessary subshell. credentials = GitHub::API.credentials raise GhAuthNeeded, "missing credentials" if credentials.blank? begin result = system_command!(gh_executable, args: cmd, env: { "GH_TOKEN" => credentials, "GH_HOST" => "github.com" }, secrets: [credentials], print_stderr: false, chdir: HOMEBREW_TEMP) rescue ErrorDuringExecution => e if e.status.exitstatus == 1 && e.stderr.include?("unknown command") raise GhIncompatible, "gh CLI is incompatible with attestations" end # Even if we have credentials, they may be invalid or malformed. if e.status.exitstatus == 4 || e.stderr.include?("HTTP 401: Bad credentials") raise GhAuthInvalid, "invalid credentials" end raise MissingAttestationError, "attestation not found: #{e}" if e.stderr.include?("HTTP 404: Not Found") 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 = if bottle.tag.to_sym == :all # :all-tagged bottles are created by `brew bottle --merge`, and are not directly # bound to their own filename (since they're created by deduplicating other filenames). # To verify these, we parse each attestation subject and look for one with a matching # formula (name, version), but not an exact tag match. # This is sound insofar as the signature has already been verified. However, # longer term, we should also directly attest to `:all`-tagged bottles. attestations.find do |a| actual_subject = a.dig("verificationResult", "statement", "subject", 0, "name") actual_subject.start_with? "#{bottle.filename.name}--#{bottle.filename.version}" end else attestations.find do |a| a.dig("verificationResult", "statement", "subject", 0, "name") == subject end end raise InvalidAttestationError, "no attestation matches subject: #{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.
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 |
# File 'attestation.rb', line 205 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 MissingAttestationError 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 = if EnvConfig.bottle_domain == HOMEBREW_BOTTLE_DEFAULT_DOMAIN Digest::SHA256.hexdigest(bottle.url) else # If our bottle is coming from a mirror, we need to recompute the expected # non-mirror URL to make the hash match. path, = Utils::Bottles.path_resolved_basename HOMEBREW_BOTTLE_DEFAULT_DOMAIN, bottle.name, bottle.resource.checksum, bottle.filename url = "#{HOMEBREW_BOTTLE_DEFAULT_DOMAIN}/#{path}" Digest::SHA256.hexdigest(url) end 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 = backfill_attestation.dig("verificationResult", "verifiedTimestamps", 0, "timestamp") raise InvalidAttestationError, "backfill attestation is missing verified timestamp" if .nil? if DateTime.parse() > BACKFILL_CUTOFF raise InvalidAttestationError, "backfill attestation post-dates cutoff" end end backfill_attestation rescue InvalidAttestationError @attestation_retry_count ||= T.let(Hash.new(0), T.nilable(T::Hash[Bottle, Integer])) raise if @attestation_retry_count[bottle] >= ATTESTATION_MAX_RETRIES sleep_time = 3 ** @attestation_retry_count[bottle] opoo "Failed to verify attestation. Retrying in #{sleep_time}s..." sleep sleep_time if ENV["HOMEBREW_TESTS"].blank? @attestation_retry_count[bottle] += 1 retry end |
.enabled? ⇒ Boolean
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 whether attestation verification is enabled.
65 66 67 68 69 70 71 72 73 |
# File 'attestation.rb', line 65 def self.enabled? return false if Homebrew::EnvConfig.no_verify_attestations? return true if Homebrew::EnvConfig.verify_attestations? return false if ENV.fetch("CI", false) return false if OS.unsupported_configuration? # Always check credentials last to avoid unnecessary credential extraction. (Homebrew::EnvConfig.developer? || Homebrew::EnvConfig.devcmdrun?) && GitHub::API.credentials.present? end |
.gh_executable ⇒ Pathname
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.
79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'attestation.rb', line 79 def self.gh_executable @gh_executable ||= T.let(nil, T.nilable(Pathname)) return @gh_executable if @gh_executable.present? # NOTE: We set HOMEBREW_NO_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. with_env(HOMEBREW_NO_VERIFY_ATTESTATIONS: "1") do @gh_executable = ensure_executable!("gh", reason: "verifying attestations", latest: true) end T.must(@gh_executable) end |
.sort_formulae_for_install(formulae) ⇒ Array<Formula>
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.
Prioritize installing gh
first if it's in the formula list
or check for the existence of the gh
executable elsewhere.
This ensures that a valid version of gh
is installed before
we use it to check the attestations of any other formulae we
want to install.
102 103 104 105 106 107 108 109 |
# File 'attestation.rb', line 102 def self.sort_formulae_for_install(formulae) if formulae.include?(Formula["gh"]) [Formula["gh"]] | formulae else Homebrew::Attestation.gh_executable formulae end end |