| 
									
										
										
										
											2024-04-09 11:03:41 -04:00
										 |  |  | # typed: strict | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  | # frozen_string_literal: true | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | require "date" | 
					
						
							|  |  |  | require "json" | 
					
						
							|  |  |  | require "utils/popen" | 
					
						
							|  |  |  | require "exceptions" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | module Homebrew | 
					
						
							|  |  |  |   module Attestation | 
					
						
							| 
									
										
										
										
											2024-04-08 16:22:57 -04:00
										 |  |  |     # @api private | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |     HOMEBREW_CORE_REPO = "Homebrew/homebrew-core" | 
					
						
							| 
									
										
										
										
											2024-04-08 16:22:57 -04:00
										 |  |  |     # @api private | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |     HOMEBREW_CORE_CI_URI = "https://github.com/Homebrew/homebrew-core/.github/workflows/publish-commit-bottles.yml@refs/heads/master" | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-04-08 16:22:57 -04:00
										 |  |  |     # @api private | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |     BACKFILL_REPO = "trailofbits/homebrew-brew-verify" | 
					
						
							| 
									
										
										
										
											2024-04-08 16:22:57 -04:00
										 |  |  |     # @api private | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |     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. | 
					
						
							| 
									
										
										
										
											2024-04-09 10:50:49 -04:00
										 |  |  |     # | 
					
						
							|  |  |  |     # 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. | 
					
						
							|  |  |  |     # | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |     # @api private | 
					
						
							| 
									
										
										
										
											2024-04-09 11:03:41 -04:00
										 |  |  |     BACKFILL_CUTOFF = T.let(DateTime.new(2024, 3, 14).freeze, DateTime) | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-04-09 10:52:48 -04:00
										 |  |  |     # Raised when attestation verification fails. | 
					
						
							|  |  |  |     # | 
					
						
							|  |  |  |     # @api private | 
					
						
							|  |  |  |     class InvalidAttestationError < RuntimeError; end | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-04-09 10:48:17 -04:00
										 |  |  |     # Returns a path to a suitable `gh` executable for attestation verification. | 
					
						
							|  |  |  |     # | 
					
						
							|  |  |  |     # @api private | 
					
						
							| 
									
										
										
										
											2024-04-09 11:03:41 -04:00
										 |  |  |     sig { returns(Pathname) } | 
					
						
							| 
									
										
										
										
											2024-04-09 10:45:44 -04:00
										 |  |  |     def self.gh_executable | 
					
						
							| 
									
										
										
										
											2024-04-09 10:48:17 -04:00
										 |  |  |       # 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. | 
					
						
							| 
									
										
										
										
											2024-04-09 11:03:41 -04:00
										 |  |  |       @gh_executable ||= T.let(with_env("HOMEBREW_VERIFY_ATTESTATIONS" => nil) do | 
					
						
							| 
									
										
										
										
											2024-04-09 10:45:44 -04:00
										 |  |  |         ensure_executable!("gh") | 
					
						
							| 
									
										
										
										
											2024-04-09 11:03:41 -04:00
										 |  |  |       end, T.nilable(Pathname)) | 
					
						
							| 
									
										
										
										
											2024-04-09 10:45:44 -04:00
										 |  |  |     end | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |     # 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. | 
					
						
							| 
									
										
										
										
											2024-04-08 16:22:57 -04:00
										 |  |  |     # @raise [InvalidAttestationError] on any verification failures | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |     # | 
					
						
							|  |  |  |     # @api private | 
					
						
							| 
									
										
										
										
											2024-04-09 11:03:41 -04:00
										 |  |  |     sig { | 
					
						
							|  |  |  |       params(bottle: Bottle, signing_repo: String, | 
					
						
							| 
									
										
										
										
											2024-04-11 16:44:57 -04:00
										 |  |  |              signing_workflow: T.nilable(String), subject: T.nilable(String)).returns(T::Hash[T.untyped, T.untyped]) | 
					
						
							| 
									
										
										
										
											2024-04-09 11:03:41 -04:00
										 |  |  |     } | 
					
						
							| 
									
										
										
										
											2024-04-11 16:44:57 -04:00
										 |  |  |     def self.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil) | 
					
						
							| 
									
										
										
										
											2024-04-09 10:45:44 -04:00
										 |  |  |       cmd = [gh_executable, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", | 
					
						
							|  |  |  |              "json"] | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-04-09 10:18:08 -04:00
										 |  |  |       cmd += ["--cert-identity", signing_workflow] if signing_workflow.present? | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  | 
 | 
					
						
							|  |  |  |       begin | 
					
						
							|  |  |  |         output = Utils.safe_popen_read(*cmd) | 
					
						
							|  |  |  |       rescue ErrorDuringExecution => e | 
					
						
							|  |  |  |         raise InvalidAttestationError, "attestation verification failed: #{e}" | 
					
						
							|  |  |  |       end | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       begin | 
					
						
							| 
									
										
										
										
											2024-04-11 13:39:13 -04:00
										 |  |  |         attestations = JSON.parse(output) | 
					
						
							| 
									
										
										
										
											2024-04-08 16:21:31 -04:00
										 |  |  |       rescue JSON::ParserError | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |         raise InvalidAttestationError, "attestation verification returned malformed JSON" | 
					
						
							|  |  |  |       end | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-04-11 13:39:13 -04:00
										 |  |  |       # `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. | 
					
						
							| 
									
										
										
										
											2024-04-11 16:44:57 -04:00
										 |  |  |       subject = bottle.filename.to_s if subject.blank? | 
					
						
							| 
									
										
										
										
											2024-04-11 13:39:13 -04:00
										 |  |  |       attestation = attestations.find do |a| | 
					
						
							| 
									
										
										
										
											2024-04-11 16:44:57 -04:00
										 |  |  |         a.dig("verificationResult", "statement", "subject", 0, "name") == subject | 
					
						
							| 
									
										
										
										
											2024-04-11 13:39:13 -04:00
										 |  |  |       end | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       raise InvalidAttestationError, "no attestation matches subject" if attestation.blank? | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-04-11 13:39:13 -04:00
										 |  |  |       attestation | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |     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. | 
					
						
							| 
									
										
										
										
											2024-04-08 16:22:57 -04:00
										 |  |  |     # | 
					
						
							|  |  |  |     # @return [Hash] the JSON-decoded response | 
					
						
							|  |  |  |     # @raise [InvalidAttestationError] on any verification failures | 
					
						
							|  |  |  |     # | 
					
						
							|  |  |  |     # @api private | 
					
						
							| 
									
										
										
										
											2024-04-09 11:03:41 -04:00
										 |  |  |     sig { params(bottle: Bottle).returns(T::Hash[T.untyped, T.untyped]) } | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |     def self.check_core_attestation(bottle) | 
					
						
							|  |  |  |       begin | 
					
						
							| 
									
										
										
										
											2024-04-10 17:57:01 -04:00
										 |  |  |         attestation = check_attestation bottle, HOMEBREW_CORE_REPO, HOMEBREW_CORE_CI_URI | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |         return attestation | 
					
						
							|  |  |  |       rescue InvalidAttestationError | 
					
						
							| 
									
										
										
										
											2024-04-09 10:18:08 -04:00
										 |  |  |         odebug "falling back on backfilled attestation for #{bottle}" | 
					
						
							| 
									
										
										
										
											2024-04-11 16:44:57 -04:00
										 |  |  | 
 | 
					
						
							|  |  |  |         # 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 | 
					
						
							| 
									
										
										
										
											2024-04-11 13:39:13 -04:00
										 |  |  |         timestamp = backfill_attestation.dig("verificationResult", "verifiedTimestamps", | 
					
						
							| 
									
										
										
										
											2024-04-08 16:18:15 -04:00
										 |  |  |                                              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 |