Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Staple attestation to OCI image #19197

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions Library/Homebrew/dev-cmd/pr-upload.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class PrUpload < AbstractCommand
flag "--root-url-using=",
description: "Use the specified download strategy class for downloading the bottle's URL instead of " \
"Homebrew's default."
flag "--attestation-bundle=",
description: "Path of an attestation bundle to upload with the bottle. "\
"Currently only supported with GitHub Packages."

conflicts "--upload-only", "--keep-old"
conflicts "--upload-only", "--no-commit"
Expand All @@ -49,6 +52,10 @@ def run

bottles_hash = bottles_hash_from_json_files(json_files, args)

if args.attestation_bundle && !github_packages?(bottles_hash)
raise UsageError, "--attestation-bundle is only supported with GitHub Packages"
end

unless args.upload_only?
bottle_args = ["bottle", "--merge", "--write"]
bottle_args << "--verbose" if args.verbose?
Expand Down Expand Up @@ -110,9 +117,10 @@ def run
elsif github_packages?(bottles_hash)
github_packages = GitHubPackages.new
github_packages.upload_bottles(bottles_hash,
keep_old: args.keep_old?,
dry_run: args.dry_run?,
warn_on_error: args.warn_on_upload_failure?)
attestation_path: args.attestation_bundle,
keep_old: args.keep_old?,
dry_run: args.dry_run?,
warn_on_error: args.warn_on_upload_failure?)
else
odie "Service specified by root_url is not recognized"
end
Expand Down
84 changes: 67 additions & 17 deletions Library/Homebrew/github_packages.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

require "utils/curl"
require "utils/gzip"
require "base64"
require "json"
require "zlib"
require "extend/hash/keys"
Expand Down Expand Up @@ -41,18 +42,20 @@ class GitHubPackages

sig {
params(
bottles_hash: T::Hash[String, T.untyped],
keep_old: T::Boolean,
dry_run: T::Boolean,
warn_on_error: T::Boolean,
bottles_hash: T::Hash[String, T.untyped],
attestation_path: T.nilable(String),
keep_old: T::Boolean,
dry_run: T::Boolean,
warn_on_error: T::Boolean,
).void
}
def upload_bottles(bottles_hash, keep_old:, dry_run:, warn_on_error:)
def upload_bottles(bottles_hash, attestation_path:, keep_old:, dry_run:, warn_on_error:)
user = Homebrew::EnvConfig.github_packages_user
token = Homebrew::EnvConfig.github_packages_token

raise UsageError, "HOMEBREW_GITHUB_PACKAGES_USER is unset." if user.blank?
raise UsageError, "HOMEBREW_GITHUB_PACKAGES_TOKEN is unset." if token.blank?
raise UsageError, "Attestation path does not exist." if attestation_path && !File.exist?(attestation_path)

skopeo = ensure_executable!("skopeo", reason: "upload")

Expand All @@ -71,7 +74,7 @@ def upload_bottles(bottles_hash, keep_old:, dry_run:, warn_on_error:)
# rubocop:disable Style/CombinableLoops
bottles_hash.each do |formula_full_name, bottle_hash|
# Next, upload the bottles after checking them all.
upload_bottle(user, token, skopeo, formula_full_name, bottle_hash,
upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, attestation_path,
keep_old:, dry_run:, warn_on_error:)
end
# rubocop:enable Style/CombinableLoops
Expand Down Expand Up @@ -165,10 +168,7 @@ def load_schemas!
end

def schema_uri(basename, uris)
# The current `main` version has an invalid JSON schema.
# Going forward, this should probably be pinned to tags.
# We currently use features newer than the last one (v1.0.2).
url = "https://raw.githubusercontent.com/opencontainers/image-spec/170393e57ed656f7f81c3070bfa8c3346eaa0a5a/schema/#{basename}.json"
url = "https://raw.githubusercontent.com/opencontainers/image-spec/v1.1.0/schema/#{basename}.json"
out = Utils::Curl.curl_output(url).stdout
json = JSON.parse(out)

Expand Down Expand Up @@ -256,7 +256,7 @@ def preupload_check(user, token, skopeo, _formula_full_name, bottle_hash, keep_o
[formula_name, org, repo, version, rebuild, version_rebuild, image_name, image_uri, keep_old]
end

def upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, keep_old:, dry_run:, warn_on_error:)
def upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, attestation_path, keep_old:, dry_run:, warn_on_error:)
# We run the preupload check twice to prevent TOCTOU bugs.
result = preupload_check(user, token, skopeo, formula_full_name, bottle_hash,
keep_old:, dry_run:, warn_on_error:)
Expand Down Expand Up @@ -327,6 +327,23 @@ def upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, keep_old:
manifests = []
end

if attestation_path
attestation_bundle = JSON.load_file(attestation_path)
raise "Only DSSE attestations are supported!" unless attestation_bundle.key?("dsseEnvelope")
if attestation_bundle["dsseEnvelope"]["payloadType"] != "application/vnd.in-toto+json"
raise "Only in-toto DSSE envelopes are supported!"
end

attestation_bundle_sha256 = write_local_file(attestation_path, blobs)
attestation_media_type = attestation_bundle["mediaType"]

attestation_envelope_payload = JSON.parse(
Base64.strict_decode64(attestation_bundle["dsseEnvelope"]["payload"])
)

empty_config_sha256, empty_config_size = write_hash(blobs, {})
end

processed_image_refs = Set.new
manifests.each do |manifest|
processed_image_refs << manifest["annotations"]["org.opencontainers.image.ref.name"]
Expand All @@ -347,7 +364,7 @@ def upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, keep_old:
local_file = tag_hash["local_filename"]
odebug "Uploading #{local_file}"

tar_gz_sha256 = write_tar_gz(local_file, blobs)
tar_gz_sha256 = write_local_file(local_file, blobs)

tab = tag_hash["tab"]
architecture = TAB_ARCH_TO_PLATFORM_ARCHITECTURE[tab["arch"].presence || bottle_tag.arch.to_s]
Expand Down Expand Up @@ -407,6 +424,7 @@ def upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, keep_old:
"sh.brew.license" => license,
"sh.brew.tab" => tab.to_json,
"sh.brew.path_exec_files" => path_exec_files_string,
"sh.brew.attestation_bundle" => attestation_bundle_sha256,
}.compact_blank

# TODO: upload/add tag_hash["all_files"] somewhere.
Expand Down Expand Up @@ -439,6 +457,38 @@ def upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, keep_old:
validate_schema!(IMAGE_MANIFEST_SCHEMA_URI, image_manifest)
manifest_json_sha256, manifest_json_size = write_hash(blobs, image_manifest)

if attestation_path
attestation_annotations = {
"dev.sigstore.bundle.content" => "dsse-envelope",
"dev.sigstore.bundle.predicateType" => attestation_envelope_payload["predicateType"],
}
attestation_manifest = {
schemaVersion: 2,
mediaType: "application/vnd.oci.image.manifest.v1+json",
artifactType: attestation_media_type,
config: {
mediaType: "application/vnd.oci.empty.v1+json",
digest: "sha256:#{empty_config_sha256}",
size: empty_config_size,
},
layers: [{
mediaType: attestation_media_type,
digest: "sha256:#{attestation_bundle_sha256}",
size: File.size(attestation_path),
}],
subject: {
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: "sha256:#{manifest_json_sha256}",
size: manifest_json_size,
},
annotations: attestation_annotations,
}
validate_schema!(IMAGE_MANIFEST_SCHEMA_URI, attestation_manifest)
attestation_manifest_sha256, attestation_manifest_size = write_hash(blobs, attestation_manifest)

# TODO: what do we do with the manifest?
end

{
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: "sha256:#{manifest_json_sha256}",
Expand Down Expand Up @@ -481,11 +531,11 @@ def write_image_layout(root)
write_hash(root, image_layout, "oci-layout")
end

def write_tar_gz(local_file, blobs)
tar_gz_sha256 = Digest::SHA256.file(local_file)
.hexdigest
FileUtils.ln local_file, blobs/tar_gz_sha256, force: true
tar_gz_sha256
def write_local_file(local_file, blobs)
file_sha256 = Digest::SHA256.file(local_file)
.hexdigest
FileUtils.ln local_file, blobs/file_sha256, force: true
file_sha256
end

def write_image_config(platform_hash, tar_sha256, blobs)
Expand Down
Loading