-
-
Notifications
You must be signed in to change notification settings - Fork 10.2k
/
Copy pathattestation.rb
293 lines (252 loc) · 12.3 KB
/
attestation.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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
114
115
116
117
118
119
120
121
122
123
124
125
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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# typed: strict
# frozen_string_literal: true
require "date"
require "json"
require "utils/popen"
require "utils/github/api"
require "exceptions"
require "system_command"
module Homebrew
module Attestation
extend SystemCommand::Mixin
# @api private
HOMEBREW_CORE_REPO = "Homebrew/homebrew-core"
# @api private
BACKFILL_REPO = "trailofbits/homebrew-brew-verify"
# 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 the attestation was not found.
#
# @api private
class MissingAttestationError < RuntimeError; end
# Raised when attestation verification fails.
#
# @api private
class InvalidAttestationError < RuntimeError; end
# Raised if attestation verification cannot continue due to missing
# credentials.
#
# @api private
class GhAuthNeeded < RuntimeError; end
# Raised if attestation verification cannot continue due to invalid
# credentials.
#
# @api private
class GhAuthInvalid < RuntimeError; end
# Raised if attestation verification cannot continue due to `gh`
# being incompatible with attestations, typically because it's too old.
#
# @api private
class GhIncompatible < RuntimeError; end
# Returns whether attestation verification is enabled.
#
# @api private
sig { returns(T::Boolean) }
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
# Returns a path to a suitable `gh` executable for attestation verification.
#
# @api private
sig { returns(Pathname) }
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
# 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.
#
# @api private
sig { params(formulae: T::Array[Formula]).returns(T::Array[Formula]) }
def self.sort_formulae_for_install(formulae)
if formulae.include?(Formula["gh"])
[Formula["gh"]] | formulae
else
Homebrew::Attestation.gh_executable
formulae
end
end
# 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.
#
# @return [Hash] the JSON-decoded response.
# @raise [GhAuthNeeded] on any authentication failures
# @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 = ["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
ATTESTATION_CACHE_DIRECTORY = T.let((HOMEBREW_CACHE/"attestation").freeze, Pathname)
sig { params(bottle: Bottle).returns(Pathname) }
def self.cached_attestation_path(bottle)
ATTESTATION_CACHE_DIRECTORY/bottle.filename.attestation_json(bottle.resource.checksum.hexdigest)
end
ATTESTATION_MAX_RETRIES = 5
# 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 [GhAuthNeeded] on any authentication failures
# @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)
cached_attestation = cached_attestation_path(bottle)
if cached_attestation.exist?
begin
return JSON.parse(cached_attestation.read)
rescue JSON::ParserError
cached_attestation.unlink
end
end
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
ATTESTATION_CACHE_DIRECTORY.mkpath
cached_attestation.atomic_write attestation.to_json
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
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
ATTESTATION_CACHE_DIRECTORY.mkpath
cached_attestation.atomic_write backfill_attestation.to_json
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
end
end