diff --git a/Rakefile b/Rakefile index 2026fe1..542b6f2 100644 --- a/Rakefile +++ b/Rakefile @@ -12,12 +12,12 @@ require "rubocop/rake_task" RuboCop::RakeTask.new -task default: %i[test conformance conformance_staging rubocop] +task default: %i[test conformance conformance_staging conformance_tuf rubocop] desc "Run the conformance tests" task conformance: %w[conformance:setup] do sh({ "GHA_SIGSTORE_CONFORMANCE_XFAIL" => - "test_verify_trust_root_with_invalid_ct_keys test_verify_dsse_bundle_with_trust_root" }, + "test_verify_dsse_bundle_with_trust_root" }, File.expand_path("test/sigstore-conformance/env/bin/pytest"), "test", "--entrypoint=#{File.join(__dir__, "bin", "conformance-entrypoint")}", "--skip-signing", chdir: "test/sigstore-conformance") @@ -26,29 +26,96 @@ end desc "Run the conformance tests against staging" task conformance_staging: %w[conformance:setup] do sh({ "GHA_SIGSTORE_CONFORMANCE_XFAIL" => - "test_verify_trust_root_with_invalid_ct_keys test_verify_dsse_bundle_with_trust_root" }, + "test_verify_dsse_bundle_with_trust_root" }, File.expand_path("test/sigstore-conformance/env/bin/pytest"), "test", "--entrypoint=#{File.join(__dir__, "bin", "conformance-entrypoint")}", "--skip-signing", "--staging", chdir: "test/sigstore-conformance") end +desc "Run the TUF conformance tests" +task conformance_tuf: %w[tuf_conformance:setup] do + sh("test/tuf-conformance/env/bin/tuf-conformance", "bin/tuf-conformance-entrypoint") +end + namespace :conformance do - file "test/sigstore-conformance/.git/config" do - rm_rf "test/sigstore-conformance" - sh "git", "clone", "https://github.com/sigstore/sigstore-conformance", chdir: "test" + file "test/sigstore-conformance/env/pyvenv.cfg" => :sigstore_conformance do + sh "make", "dev", chdir: "test/sigstore-conformance" end - file "test/sigstore-conformance/.git/HEAD" => %w[test/sigstore-conformance/.git/config] do - sh "git", "checkout", "main", chdir: "test/sigstore-conformance" + task setup: "test/sigstore-conformance/env/pyvenv.cfg" # rubocop:disable Rake/Desc +end + +task test: %w[sigstore_conformance] + +require "open3" + +class GitRepo < Rake::Task + attr_accessor :path, :url, :commit + + include FileUtils + + def initialize(*) + super + + @actions << method(:clone_repo) + @actions << method(:checkout) end - file "test/sigstore-conformance/.git/rake-version" => %w[test/sigstore-conformance/.git/HEAD] do - sh "git", "describe", "--tags", "--always", chdir: "test/sigstore-conformance", - out: "test/sigstore-conformance/.git/rake-version" + + def needed? + !correct_remote? || !correct_commit? end - file "test/sigstore-conformance/env/pyvenv.cfg" => "test/sigstore-conformance/.git/rake-version" do - sh "make", "dev", chdir: "test/sigstore-conformance" + + def correct_remote? + return false unless File.directory?(@path) + + out, status = Open3.capture2(*%w[git remote get-url origin], chdir: path) + status.success? && out.strip == url end - task setup: "test/sigstore-conformance/env/pyvenv.cfg" # rubocop:disable Rake/Desc + + def correct_commit? + head, status = Open3.capture2(*%w[git rev-parse HEAD], chdir: path) + head.strip! + return true if status.success? && head == commit + + desired, status = Open3.capture2(*%w[git rev-parse], "#{commit}^{commit}", chdir: path) + desired.strip! + status.success? && desired == head + end + + def clone_repo(_, _) + return if correct_remote? + + rm_rf path + sh "git", "clone", url, path + end + + def checkout(_, _) + return if correct_commit? + + sh "git", "-C", path, "switch", "--detach", commit do |ok, _| + unless ok + sh "git", "-C", path, "fetch", "origin", commit + sh "git", "-C", path, "switch", "--detach", commit + end + end + end +end + +GitRepo.define_task(:sigstore_conformance).tap do |task| + task.path = "test/sigstore-conformance" + task.url = "https://github.com/sigstore/sigstore-conformance.git" + task.commit = "0a0196b" end -task test: %w[test/sigstore-conformance/.git/rake-version] +GitRepo.define_task(:tuf_conformance).tap do |task| + task.path = "test/tuf-conformance" + task.url = "https://github.com/jku/tuf-conformance.git" + task.commit = "3072fdb346ce27210e5125b30c6626a9f6b34fc0" +end + +namespace :tuf_conformance do + file "test/tuf-conformance/env/pyvenv.cfg" => :tuf_conformance do + sh "make", "dev", chdir: "test/tuf-conformance" + end + task setup: "test/tuf-conformance/env/pyvenv.cfg" # rubocop:disable Rake/Desc +end diff --git a/lib/rubygems/commands/sigstore_verify_command.rb b/lib/rubygems/commands/sigstore_verify_command.rb index e7c08e7..8d4c2d6 100644 --- a/lib/rubygems/commands/sigstore_verify_command.rb +++ b/lib/rubygems/commands/sigstore_verify_command.rb @@ -31,6 +31,10 @@ def initialize add_option("--rekor-url URL", "URL of the Rekor server") do |url, options| options[:rekor_url] = url end + + add_option("--[no-]offline", "Do not fetch the latest timestamp from the Rekor server") do |offline, options| + options[:offline] = offline + end end def execute diff --git a/lib/sigstore/models.rb b/lib/sigstore/models.rb index 037cec5..05e9620 100644 --- a/lib/sigstore/models.rb +++ b/lib/sigstore/models.rb @@ -58,16 +58,16 @@ def self.from_media_type(media_type) keyword_init: true) do # @implements VerificationMaterials - def initialize(input:, cert_pem:, offline: false, **kwargs) + def initialize(input:, cert_pem:, **kwargs) input_bytes = input.read digest = OpenSSL::Digest.new("SHA256") digest.update(input_bytes) hashed_input = digest certificate = OpenSSL::X509::Certificate.new(cert_pem) - raise ArgumentError, "offline verification requires a rekor entry" if offline && !rekor_entry - super(hashed_input: hashed_input, certificate: certificate, input_bytes: input_bytes, offline: offline, **kwargs) + + raise ArgumentError, "offline verification requires a rekor entry" if offline && !rekor_entry? end def rekor_entry? @@ -124,9 +124,10 @@ def find_rekor_entry(rekor_client) def self.from_bundle(input:, bundle:, offline:) media_type = BundleType.from_media_type(bundle.media_type) - if media_type == BundleType::BUNDLE_0_3 + case media_type + when BundleType::BUNDLE_0_3 leaf_cert = OpenSSL::X509::Certificate.new(bundle.verification_material.certificate.raw_bytes) - else + when BundleType::BUNDLE_0_1, BundleType::BUNDLE_0_2 certs = bundle.verification_material.x509_certificate_chain.certificates.map do |cert| OpenSSL::X509::Certificate.new(cert.raw_bytes) end @@ -138,9 +139,11 @@ def self.from_bundle(input:, bundle:, offline:) certs.each do |cert| raise "Root CA in chain" if cert_is_root_ca?(cert) end + else + raise "Unsupported bundle format: #{media_type}" end - raise "DSSE not yet supported" if bundle.dsse_envelope + raise "DSSE envelope verification not yet supported" if bundle.dsse_envelope raise "bundle missing message signature" unless bundle.message_signature signature = bundle.message_signature.signature @@ -151,7 +154,7 @@ def self.from_bundle(input:, bundle:, offline:) tlog_entry = tlog_entries.first if media_type == BundleType::BUNDLE_0_1 - raise unless tlog_entry.inclusion_promise + raise "bundle v0.1 requires an inclusion promise" unless tlog_entry.inclusion_promise if tlog_entry.inclusion_proof && !tlog_entry.inclusion_proof.checkpoint.envelope raise "0.1 bundle contains an inclusion proof without checkpoint" end diff --git a/lib/sigstore/trusted_root.rb b/lib/sigstore/trusted_root.rb index 772b09a..e3c4611 100644 --- a/lib/sigstore/trusted_root.rb +++ b/lib/sigstore/trusted_root.rb @@ -26,14 +26,14 @@ def self.from_file(path) def rekor_keys keys = tlog_keys(tlogs).to_a - raise "Did not find one active Rekor key" if keys.size != 1 + raise "Did not find one Rekor key" if keys.size != 1 keys end def ctfe_keys keys = tlog_keys(ctlogs).to_a - raise "Did not find one active CT key" if keys.size != 1 + raise "Did not find any CTFE keys" if keys.empty? keys end @@ -51,8 +51,6 @@ def tlog_keys(tlogs) return enum_for(__method__, tlogs) unless block_given? tlogs.each do |key| - next unless timerange_valid?(key.public_key.valid_for, allow_expired: false) - key_bytes = key.public_key.raw_bytes yield key_bytes if key_bytes end diff --git a/lib/sigstore/verifier.rb b/lib/sigstore/verifier.rb index 0b007cc..33ae503 100644 --- a/lib/sigstore/verifier.rb +++ b/lib/sigstore/verifier.rb @@ -9,7 +9,7 @@ module Sigstore class Verifier def initialize(rekor_client:, fulcio_cert_chain:) @rekor_client = rekor_client - @fulcio_cert_chain = fulcio_cert_chain + @fulcio_cert_chain = fulcio_cert_chain.map { |cert| OpenSSL::X509::Certificate.new(cert) } end def self.production(trust_root: TrustedRoot.production) @@ -33,7 +33,22 @@ def verify(materials:, policy:) store_ctx = OpenSSL::X509::StoreContext.new(store, cert_ossl) - store_ctx.verify + unless store_ctx.verify + return VerificationFailure.new( + "failed to validate certification from fulcio cert chain: #{store_ctx.error_string}" + ) + end + + chain = store_ctx.chain || raise + chain.drop(1) + + _sct = precertificate_signed_certificate_timestamps(materials.certificate)[0] + # verify_sct( + # sct, + # materials.certificate, + # chain, + # @rekor_client._ct_keyring + # ) usage_ext = materials.certificate.find_extension("keyUsage") unless usage_ext.value == "Digital Signature" @@ -77,5 +92,39 @@ def verify(materials:, policy:) VerificationSuccess.new end + + private + + def precertificate_signed_certificate_timestamps(certificate) + # this is cursed. can't always find_extension(oid) because #oid can return a string or an OID + oid = OpenSSL::X509::Extension.new("1.3.6.1.4.1.11129.2.4.2", "").oid + precert_scts_extension = certificate.find_extension(oid) + unless precert_scts_extension + raise "No PrecertificateSignedCertificateTimestamps (#{oid.inspect}) found for the certificate #{certificate.extensions.join("\n")}" + end + + # TODO: parse the extension properly + # https://github.com/pierky/sct-verify/blob/master/sct-verify.py + + os1 = OpenSSL::ASN1.decode(precert_scts_extension.value_der) + + len = os1.value.unpack1("n") + string = os1.value[2..] + raise "os1: len=#{len} #{os1.value.inspect}" unless string && string.size == len + + len = string.unpack1("n") + string = string[2..] + raise "os1: len=#{len} #{string.inspect}" unless string && string.size == len + + sct_version, sct_log_id, sct_timestamp, sct_extensions_len, sct_signature_alg_hash, + sct_signature_alg_sign, sct_signature_len, sct_signature_bytes = string.unpack("Ca32QnCCna*") + raise "sct extensions not supported" unless sct_extensions_len.zero? + unless sct_signature_bytes.bytesize == sct_signature_len + raise "sct_signature_bytes: #{sct_signature_bytes.inspect} sct_signature_len: #{sct_signature_len}" + end + + # TODO: parse the SCT properly + [nil] + end end end diff --git a/test/.gitignore b/test/.gitignore index 437c852..e67cc72 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -1,2 +1,3 @@ +/tuf-conformance/ /sigstore-conformance/ /conformance_invocations diff --git a/test/sigstore/trusted_root_test.rb b/test/sigstore/trusted_root_test.rb index 8fc223d..f4129db 100644 --- a/test/sigstore/trusted_root_test.rb +++ b/test/sigstore/trusted_root_test.rb @@ -12,7 +12,10 @@ def test_production assert_equal ["MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9f\n" \ "AFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RY\n" \ "tw==\n"], production.rekor_keys.map { [_1].pack("m") } - assert_equal ["MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEc\n" \ + assert_equal ["MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy\n" \ + "3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU\n" \ + "7w==\n", + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEc\n" \ "YXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9F\n" \ "yw==\n"], production.ctfe_keys.map { [_1].pack("m") } assert_equal "-----BEGIN CERTIFICATE-----\n" \