diff --git a/fixtures/vcr_cassettes/conformance/verify_signature_invalid.yml b/fixtures/vcr_cassettes/conformance/verify_signature_invalid.yml index 4964a53..fc2d7c6 100644 --- a/fixtures/vcr_cassettes/conformance/verify_signature_invalid.yml +++ b/fixtures/vcr_cassettes/conformance/verify_signature_invalid.yml @@ -110,4 +110,42 @@ http_interactions: 137\n\t\t\t}\n\t\t}\n\t},\n\t\"signatures\": [\n\t\t{\n\t\t\t\"keyid\": \"923bb39e60dd6fa2c31e6ea55473aa93b64dd4e53e16fbe42f6a207d3f97de2d\",\n\t\t\t\"sig\": \"304602210098c329ab4dab127270dd2f56150bdc1ea3c0bee78d6f231fcededa0704b5a37302210095ec5cb338ab53b3b6463c581d0ffd9134d23272572ac9ca69ad3b190041d5d8\"\n\t\t}\n\t]\n}" recorded_at: Thu, 25 Apr 2024 23:31:15 GMT +- request: + method: post + uri: https://rekor.sigstore.dev/api/v1/log/entries/retrieve/ + body: + encoding: UTF-8 + string: '{"entries":[{"spec":{"signature":{"content":"MGYCMQC4JASdu7Gx4GHFMauOoAQTb5gUYIO1d8ruB2yDemDA66KJcaMrEqBdSMKjl86c4cwCMQCOs2PROY/qEwg/Wsra+taofoTE3y31FD4Ef2TAIb2uAvXCp0U1JQdc1qCteRS/veM=","publicKey":{"content":"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUN4akNDQWt1Z0F3SUJBZ0lVRjQ3MHR4ZDFZOGErRE8vZ2ErTHdLMnBJeTJNd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJeE1UQTRNRFV5TkRJeVdoY05Nakl4TVRBNE1EVXpOREl5V2pBQU1IWXdFQVlICktvWkl6ajBDQVFZRks0RUVBQ0lEWWdBRWN5cC8wUUZJbkNZaWlod2d5ZUtHUlhhbEdmMmZWSzZTQlk0eDhTcmcKQy9EZE5XMHdxUjRTVkNIREpSU2pHR1JuRWtOaGpWUk82NGxZK3pCRWVBSkFialNoNkRkdVJ6QnNJQVRFRWdlaQpFSFJKVFB3enFaTEU1ejNYRnJHREtyeTNvNElCVFRDQ0FVa3dEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkCkpRUU1NQW9HQ0NzR0FRVUZCd01ETUIwR0ExVWREZ1FXQkJTOFRKbzIzd3BmRVVaaFlDLzhtT3N6V1JRSzB6QWYKQmdOVkhTTUVHREFXZ0JSeGhqQ21GSHhpYi9uMzF2UUZHbjlmLyt0dnJEQXFCZ05WSFJFQkFmOEVJREFlZ1J4aApiR1Y0TG1OaGJXVnliMjVBZEhKaGFXeHZabUpwZEhNdVkyOXRNQ2tHQ2lzR0FRUUJnNzh3QVFFRUcyaDBkSEJ6Ck9pOHZZV05qYjNWdWRITXVaMjl2WjJ4bExtTnZiVENCaWdZS0t3WUJCQUhXZVFJRUFnUjhCSG9BZUFCMkFDc3cKdk54b2lNbmk0ZGdtS1Y1MEgwZzVNWllDOHB3enkxNURRUDZ5cklaNkFBQUJoRld5V0tRQUFBUURBRWN3UlFJZwpWUzdvN1BmSXRHbHh4Y1Zwd2swa3lkMVBhUThhYW5PcEk3dE9ra0VnSDNBQ0lRRFN6bFhnY0NuQWlXRnVEZ3NmClpTR1FCWHFnaEFFWFRuaWxQZThFTTlZMVREQUtCZ2dxaGtqT1BRUURBd05wQURCbUFqRUE1dU4zZkRhN3BrUTYKZFRNS29yd2ZHMk9wcXdaRHZOVzNFKytUM3FWd3pNNVh3aEhVYWoxVytaQ25WdjU0TUkwOEFqRUE2aXJoY2gxSgplRWZ4VkMzV3RWZmtYbUNHQ3UxQU1mUkdFT08wdUpBYllsNE9HQ2NwRlVtV0hEZW1HZlVwYlJFVgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="}},"data":{"hash":{"algorithm":"sha256","value":"a0cfc71271d6e278e57cd332ff957c3f7043fdda354c4cbb190a30d56efa01bf"}}},"kind":"hashedrekord","apiVersion":"0.0.1"}]}' + headers: + Content-Type: + - application/json + Accept: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + User-Agent: + - Ruby + Host: + - rekor.sigstore.dev + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json + Vary: + - Origin + Date: + - Wed, 26 Jun 2024 18:15:44 GMT + Content-Length: + - '3' + Via: + - 1.1 google + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + body: + encoding: UTF-8 + string: "[]\n" + recorded_at: Thu, 25 Apr 2024 23:31:15 GMT recorded_with: VCR 6.2.0 diff --git a/lib/rubygems/commands/sigstore_verify_command.rb b/lib/rubygems/commands/sigstore_verify_command.rb index c41dc59..7413c8e 100644 --- a/lib/rubygems/commands/sigstore_verify_command.rb +++ b/lib/rubygems/commands/sigstore_verify_command.rb @@ -127,10 +127,11 @@ def collect_verification_state cert = options[:certificate] bundle = options[:bundle] - sig ||= File.dirname(file) + "/#{file}.sig" - cert ||= File.dirname(file) + "/#{file}.cert" + directory = File.dirname(file) - bundle = File.dirname(file) + "/#{file}.sigstore.json" if bundle.nil? + sig ||= File.join(directory, "#{file}.sig") + cert ||= File.join(directory, "#{file}.cert") + bundle = File.join(directory, "#{file}.sigstore.json") if bundle.nil? missing = [] @@ -148,6 +149,7 @@ def collect_verification_state input_map.each do |file, inputs| rekor_entry = nil + # TODO: replace verification materials with Sigstore::Verification::V1::Input materials = File.open(file, "rb") do |input| if inputs[:bundle] bundle_bytes = Gem.read_binary(inputs[:bundle]) diff --git a/lib/sigstore/internal/key.rb b/lib/sigstore/internal/key.rb index faa14a8..666e811 100644 --- a/lib/sigstore/internal/key.rb +++ b/lib/sigstore/internal/key.rb @@ -69,7 +69,10 @@ def initialize(...) raise ArgumentError, "key_type must be edcsa, given #{@key_type}" end - raise ArgumentError, "key must be an OpenSSL::PKey::EC" unless @key.is_a?(OpenSSL::PKey::EC) + unless @key.is_a?(OpenSSL::PKey::EC) + raise ArgumentError, + "key must be an OpenSSL::PKey::EC, is #{@key.inspect}" + end raise ArgumentError, "schema must be #{schema}" unless @schema == schema case @schema diff --git a/lib/sigstore/internal/x509.rb b/lib/sigstore/internal/x509.rb index 40977f7..54a0974 100644 --- a/lib/sigstore/internal/x509.rb +++ b/lib/sigstore/internal/x509.rb @@ -156,7 +156,8 @@ def initialize(extension) unless @extension.is_a?(OpenSSL::X509::Extension) && @oid == self.class.oid raise ArgumentError, - "Invalid extension: #{@extension} is not a #{@oid} (#{self.class})" + "Invalid extension: #{@extension.inspect} is not a #{@oid.inspect}" \ + "(#{self.class} / #{self.class.oid.inspect})" end @critical = false diff --git a/lib/sigstore/models.rb b/lib/sigstore/models.rb index 2104b30..bdead42 100644 --- a/lib/sigstore/models.rb +++ b/lib/sigstore/models.rb @@ -93,11 +93,11 @@ def rekor_entry? def find_rekor_entry(rekor_client) has_inclusion_promise = rekor_entry? && rekor_entry.inclusion_promise - has_inclusion_proof = rekor_entry? && rekor_entry.inclusion_proof && rekor_entry.inclusion_proof.checkpoint + has_inclusion_proof = rekor_entry? && !rekor_entry.inclusion_proof&.checkpoint.nil? logger.debug do "Looking for rekor entry, " \ - "has_inclusion_promise=#{!!has_inclusion_promise} has_inclusion_proof=#{!!has_inclusion_proof}" # rubocop:disable Style/DoubleNegation + "has_inclusion_promise=#{has_inclusion_promise} has_inclusion_proof=#{has_inclusion_proof}" end if signature diff --git a/lib/sigstore/verifier.rb b/lib/sigstore/verifier.rb index bf3bf1f..80dae59 100644 --- a/lib/sigstore/verifier.rb +++ b/lib/sigstore/verifier.rb @@ -48,11 +48,7 @@ def verify(materials:, policy:) # of the signature as the timestamped data. The Verifier MUST then extract a timestamp from the timestamping # response. If verification or timestamp parsing fails, the Verifier MUST abort. - extract_timestamp_from_verification_data(materials.timestamp_verification_data)&.each do |timestamp| - if timestamp < materials.certificate.not_before || timestamp > materials.certificate.not_after - return VerificationFailure.new("invalid signing cert: expired at time of timestamp (#{timestamp})") - end - end + timestamps = extract_timestamp_from_verification_data(materials.timestamp_verification_data) || [] # 2) # If the verification policy uses timestamps from the Transparency Service, the Verifier MUST verify the signature @@ -61,6 +57,27 @@ def verify(materials:, policy:) # The Verifier MUST then parse the integratedTime as a Unix timestamp (seconds since January 1, 1970 UTC). # If verification or timestamp parsing fails, the Verifier MUST abort. + begin + # TODO: should this instead be an input to the verify method? + # See https://docs.google.com/document/d/1kbhK2qyPPk8SLavHzYSDM8-Ueul9_oxIMVFuWMWKz0E/edit?disco=AAABQVV-gT0 + entry = materials.find_rekor_entry(@rekor_client) + rescue Sigstore::Error::MissingRekorEntry + return VerificationFailure.new("Rekor entry not found") + else + if entry.inclusion_proof&.checkpoint + Internal::Merkle.verify_merkle_inclusion(entry) + Rekor::Checkpoint.verify_checkpoint(@rekor_client, entry) + elsif !materials.offline + return VerificationFailure.new("Missing Rekor inclusion proof") + else + logger.warn "inclusion proof not present in bundle: skipping due to offline verification" + end + end + + Internal::SET.verify_set(client: @rekor_client, entry: entry) if entry.inclusion_promise + + timestamps << Time.at(entry.integrated_time).utc + # TODO: implement this step store = OpenSSL::X509::Store.new @@ -69,22 +86,41 @@ def verify(materials:, policy:) store.add_cert(cert.openssl) end - sign_date = materials.certificate.not_before - - store.time = sign_date - - store_ctx = OpenSSL::X509::StoreContext.new(store, materials.certificate.openssl) + # 3) + # The Verifier MUST perform certification path validation (RFC 5280 §6) of the certificate chain with the + # pre-distributed Fulcio root certificate(s) as a trust anchor, but with a fake “current time.” + # If a timestamp from the timestamping service is available, the Verifier MUST perform path validation using the + # timestamp from the Timestamping Service. If a timestamp from the Transparency Service is available, the Verifier + # MUST perform path validation using the timestamp from the Transparency Service. If both are available, the + # Verifier performs path validation twice. If either fails, verification fails. + chains = timestamps.map do |ts| + store_ctx = OpenSSL::X509::StoreContext.new(store, materials.certificate.openssl) + store_ctx.time = ts + + unless store_ctx.verify + return VerificationFailure.new( + "failed to validate certification from fulcio cert chain: #{store_ctx.error_string}" + ) + end - unless store_ctx.verify - return VerificationFailure.new( - "failed to validate certification from fulcio cert chain: #{store_ctx.error_string}" - ) + chain = store_ctx.chain || raise(Error::InvalidCertificate, "no valid cert chain found") + chain.shift # remove the cert itself + chain.map! { Internal::X509::Certificate.new(_1) } end - chain = store_ctx.chain || raise(Error::InvalidCertificate, "no valid cert chain found") - chain.shift # remove the cert itself - chain.map! { Internal::X509::Certificate.new(_1) } + chains.uniq! { |chain| chain.map(&:to_der) } + unless chains.size == 1 + raise "expected exactly one certificate chain, got #{chains.size} chains:\n" + + chains.map do |chain| + chain.map(&:to_text).join("\n") + end.join("\n\n") + end + # 4) + # Unless performing online verification (see §Alternative Workflows), the Verifier MUST extract the + # SignedCertificateTimestamp embedded in the leaf certificate, and verify it as in RFC 9162 §8.1.3, + # using the verification key from the Certificate Transparency Log. + chain = chains.first sct_list = materials.certificate .extension(Internal::X509::Extension::PrecertificateSignedCertificateTimestamps) .signed_certificate_timestamps @@ -100,6 +136,9 @@ def verify(materials:, policy:) return VerificationFailure.new("SCT verification failed") unless verified end + # 5) + # The Verifier MUST then check the certificate against the verification policy. + usage_ext = materials.certificate.extension(Internal::X509::Extension::KeyUsage) return VerificationFailure.new("Key usage is not of type `digital signature`") unless usage_ext.digital_signature @@ -111,6 +150,19 @@ def verify(materials:, policy:) policy_check = policy.verify(materials.certificate) return policy_check unless policy_check.verified? + # 6) + # By this point, the Verifier has already verified the signature by the Transparency Service (§Establishing a Time + # for the Signature). The Verifier MUST parse body: body is a base64-encoded JSON document with keys apiVersion + # and kind. The Verifier implementation contains a list of known Transparency Service formats (by apiVersion and + # kind); if no type is found, abort. The Verifier MUST parse body as the given type. + # + # Then, the Verifier MUST check the following; exactly how to do this will be specified by each type in Spec: + # Sigstore Registries (§Signature Metadata Formats): + # + # * The signature from the parsed body is the same as the provided signature. + # * The key or certificate from the parsed body is the same as in the input certificate. + # * The “subject” of the parsed body matches the artifact. + signing_key = materials.certificate.public_key unless materials.signature.nil? ^ materials.dsse_envelope.nil? @@ -139,23 +191,6 @@ def verify(materials:, policy:) end end - entry = materials.find_rekor_entry(@rekor_client) - if entry.inclusion_proof&.checkpoint - Internal::Merkle.verify_merkle_inclusion(entry) - Rekor::Checkpoint.verify_checkpoint(@rekor_client, entry) - elsif !materials.offline - return VerificationFailure.new("Missing Rekor inclusion proof") - else - warn "inclusion proof not present in bundle: skipping due to offline verification" - end - - Internal::SET.verify_set(client: @rekor_client, entry: entry) if entry.inclusion_promise - - integrated_time = Time.at(entry.integrated_time).utc - if integrated_time < materials.certificate.not_before || integrated_time > materials.certificate.not_after - return VerificationFailure.new("invalid signing cert: expired at time of Rekor entry") - end - VerificationSuccess.new end