From 4c6c22b3c34a3ce82b58201c015fd866f815c9e1 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Wed, 10 Jun 2026 17:56:17 -0700 Subject: [PATCH 01/11] Update conformance suites to v0.0.29/v2.4.0 and add Rekor v2 verification Bump sigstore-conformance to v0.0.29 and tuf-conformance to v2.4.0. Fix issues surfaced by the newer suite: - Treat the OIDC nbf claim as optional (RFC 7519), which was rejecting beacon tokens that omit it and breaking all signing. - Reject message-signature bundles whose messageDigest hint does not match the artifact being verified. Add verification support for Rekor v2 (tiled) transparency log bundles: - Parse Ed25519 (PKIX_ED25519) log keys, and key the Rekor keyring by the trusted root's declared log id, since a Rekor v2 log id is a C2SP signed-note key hash over the log name rather than a digest of the key. - Verify hashedrekord 0.0.2 entries (including the hashedrekord-over-DSSE encoding) directly against the bundle's artifact, signature, and cert. - Rely on the Timestamping Service for signing time, as Rekor v2 entries carry no integrated time; require at least one trusted timestamp. - Ignore unknown witness cosignatures on checkpoints, requiring one valid signature from the log key. - Verify the RFC 3161 message imprint against the bundle signature and enforce the timestamp authority's validity window from the trusted root. Repoint the conformance unit tests to the reorganized bundle-verify/ asset layout, verifying offline against the vendored production trusted root. Mark only the remaining unsupported conformance cases as xfail: managed-key verification and signing to a Rekor v2 instance. Signed-off-by: Samuel Giddins --- .github/workflows/ci.yml | 15 ++- Rakefile | 22 +++- lib/sigstore/error.rb | 3 + lib/sigstore/internal/key.rb | 101 +++++++++++--- lib/sigstore/oidc.rb | 3 + lib/sigstore/rekor/checkpoint.rb | 24 +++- lib/sigstore/trusted_root.rb | 15 ++- lib/sigstore/verifier.rb | 204 +++++++++++++++++++++++++---- test/sigstore/conformance_test.rb | 107 +++++++++------ test/sigstore/trusted_root_test.rb | 6 +- test/sigstore/verifier_test.rb | 161 +++++++++++++++++++++++ 11 files changed, 566 insertions(+), 95 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1f10f9..667af29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,16 +86,21 @@ jobs: bundler-cache: true - name: Run the conformance tests - uses: sigstore/sigstore-conformance@d658ea74a060aeabae78f8a379167f219dc38c38 # v0.0.16 + uses: sigstore/sigstore-conformance@21533cde107c734ebc153c3e3a24d75fc9811a36 # v0.0.29 with: entrypoint: ${{ github.workspace }}/bin/conformance-entrypoint - xfail: "${{ matrix.ruby != 'head' && matrix.ruby != 'truffleruby-head' && matrix.ruby != '3.4' && matrix.ruby != '4.0' && 'test_verify_rejects_bad_tsa_timestamp' }}" + # In addition to the conditional TSA xfail, mark conformance cases for + # features sigstore-ruby does not yet support: managed-key (bring-your-own + # key) verification, and signing to a Rekor v2 instance. Verification of + # Rekor v2 bundles is supported and runs in the suite. The same set is reused + # for the staging step below via the YAML anchor. + xfail: &conformance_xfail "${{ matrix.ruby != 'head' && matrix.ruby != 'truffleruby-head' && matrix.ruby != '3.4' && matrix.ruby != '4.0' && 'test_verify_rejects_bad_tsa_timestamp' }} *-managed-key-happy-path] *-managed-key-and-trusted-root] test_sign_verify_rekor2" if: ${{ matrix.os }} == "ubuntu-latest" - name: Run the conformance tests against staging - uses: sigstore/sigstore-conformance@d658ea74a060aeabae78f8a379167f219dc38c38 # v0.0.16 + uses: sigstore/sigstore-conformance@21533cde107c734ebc153c3e3a24d75fc9811a36 # v0.0.29 with: entrypoint: ${{ github.workspace }}/bin/conformance-entrypoint - xfail: "${{ matrix.ruby != 'head' && matrix.ruby != 'truffleruby-head' && matrix.ruby != '3.4' && matrix.ruby != '4.0' && 'test_verify_rejects_bad_tsa_timestamp' }}" + xfail: *conformance_xfail environment: staging if: ${{ matrix.os }} == "ubuntu-latest" @@ -135,7 +140,7 @@ jobs: run: bin/rake bin/tuf-conformance-entrypoint.xfails - name: Run the TUF conformance tests - uses: theupdateframework/tuf-conformance@9bfc222a371e30ad5511eb17449f68f855fb9d8f # v2.3.0 + uses: theupdateframework/tuf-conformance@500c525c9ce287a472fd334fe8d885cace667d32 # v2.4.0 with: entrypoint: ${{ github.workspace }}/bin/tuf-conformance-entrypoint artifact-name: "test repositories ${{ matrix.ruby }} ${{ matrix.os }}" diff --git a/Rakefile b/Rakefile index 5588d00..eb4a1d6 100644 --- a/Rakefile +++ b/Rakefile @@ -25,7 +25,20 @@ task default: %i[test conformance_staging conformance conformance_tuf rubocop] require "openssl" # Checks for https://github.com/ruby/openssl/pull/770 -xfail = OpenSSL::X509::Store.new.instance_variable_defined?(:@time) ? "test_verify_rejects_bad_tsa_timestamp" : "" +tsa_xfail = OpenSSL::X509::Store.new.instance_variable_defined?(:@time) ? "test_verify_rejects_bad_tsa_timestamp" : "" + +# Conformance test cases that exercise features sigstore-ruby does not yet +# support: verification with a managed (bring-your-own) key, and signing to a +# Rekor v2 instance (verification of Rekor v2 bundles is supported). Patterns are +# fnmatch-ed against pytest node names; the trailing "]" anchors to the positive +# cases without matching their "_fail" siblings, which we already reject. +unsupported_conformance_xfails = %w( + *-managed-key-happy-path] + *-managed-key-and-trusted-root] + test_sign_verify_rekor2 +) + +xfail = ([tsa_xfail] + unsupported_conformance_xfails).reject(&:empty?).join(" ") desc "Run the conformance tests" task conformance: %w[conformance:setup] do @@ -59,7 +72,8 @@ end task :find_action_versions do # rubocop:disable Rake/Desc require "yaml" - gh = YAML.load_file(".github/workflows/ci.yml") + # ci.yml uses a YAML anchor (&conformance_xfail) to share the conformance xfail set. + gh = YAML.load_file(".github/workflows/ci.yml", aliases: true) actions = gh.fetch("jobs").flat_map { |_, job| job.fetch("steps", []).filter_map { |step| step.fetch("uses", nil) } } .uniq.map { |x| x.split("@", 2) } .group_by(&:first).transform_values { |v| v.map(&:last) } @@ -171,9 +185,11 @@ end namespace :tuf_conformance do file "bin/tuf-conformance-entrypoint.xfails" do |t| if RUBY_ENGINE == "jruby" + # jruby-openssl cannot verify RSASSA-PSS, so that key type still xfails. ed25519 + # verification is routed through java.security and passes, so it must not be listed + # here or pytest's strict xfail turns the unexpected pass into a failure. File.write(t.name, <<~TXT) test_keytype_and_scheme[rsa/rsassa-pss-sha256] - test_keytype_and_scheme[ed25519/ed25519] TXT else File.write(t.name, "") diff --git a/lib/sigstore/error.rb b/lib/sigstore/error.rb index 7f06fc5..ef576c2 100644 --- a/lib/sigstore/error.rb +++ b/lib/sigstore/error.rb @@ -24,12 +24,15 @@ class NoTrustedRoot < Error; end class NoBundle < Error; end class NoSignature < Error; end class InvalidKey < Error; end + class MissingLogId < Error; end class InvalidCheckpoint < Error; end class InvalidVerificationInput < Error; end class Signing < Error; end class InvalidIdentityToken < Error; end + class InvalidTimestamp < Error; end + class MissingRekorEntry < Error; end class InvalidRekorEntry < Error; end class FailedRekorLookup < Error; end diff --git a/lib/sigstore/internal/key.rb b/lib/sigstore/internal/key.rb index 2c36948..f61e798 100644 --- a/lib/sigstore/internal/key.rb +++ b/lib/sigstore/internal/key.rb @@ -21,7 +21,7 @@ module Internal class Key include Loggable - def self.from_key_details(key_details, key_bytes) + def self.from_key_details(key_details, key_bytes, key_id: nil) case key_details when Common::V1::PublicKeyDetails::PKIX_ECDSA_P256_SHA_256 key_type = "ecdsa" @@ -29,15 +29,34 @@ def self.from_key_details(key_details, key_bytes) when Common::V1::PublicKeyDetails::PKCS1_RSA_PKCS1V5 key_type = "rsa" key_schema = "rsa-pkcs1v15-sha256" + when Common::V1::PublicKeyDetails::PKIX_ED25519 + # Rekor v2 (tiled) logs sign checkpoints with an Ed25519 key. The raw_bytes + # are a DER-encoded SubjectPublicKeyInfo, not the bare 32-byte key. The log id + # is a C2SP signed-note key hash over the log's name, not a digest of the key + # bytes, so it cannot be recomputed from the key alone; the trusted root must + # declare it. Without it the key could never match a real entry, so refuse to + # register an unusable key. + unless key_id + raise Error::MissingLogId, + "Ed25519 (Rekor v2) key has no declared log id; the trusted root must provide log_id.key_id" + end + + return ED25519.new("ed25519", "ed25519", ED25519.public_key_from_spki_der(key_bytes), key_id:) else # Skip unrecognized key types instead of raising an error. # This allows the library to work with newer trusted roots that include - # key types we don't yet support (e.g., PKIX_ED25519 for Rekor v2). + # key types we don't yet support. logger.warn { "Skipping unrecognized key type: #{key_details}" } return nil end - read(key_type, key_schema, key_bytes, key_id: OpenSSL::Digest::SHA256.hexdigest(key_bytes)) + # The transparency log declares its own log id in the trusted root; prefer it so + # the keyring is keyed the same way entries reference the log. For ECDSA/RSA keys + # the log id is a plain SHA-256 of the key bytes, so it can be recomputed when the + # trusted root omits it. + key_id ||= OpenSSL::Digest::SHA256.hexdigest(key_bytes) + + read(key_type, key_schema, key_bytes, key_id:) end def self.read(key_type, schema, key_bytes, key_id: nil) @@ -141,8 +160,27 @@ def verify(_algo, signature, data) end class ED25519 < Key + # jruby-openssl cannot parse or verify Ed25519 keys, so on JRuby we hold a + # java.security.PublicKey instead of an OpenSSL::PKey and route loading and + # verification through the JDK (mirroring the java.security path in X509). + JAVA_ED25519 = RUBY_ENGINE == "jruby" + + # Load an Ed25519 public key from a DER-encoded SubjectPublicKeyInfo. + def self.public_key_from_spki_der(der) + if JAVA_ED25519 + spec = java.security.spec.X509EncodedKeySpec.new(der.to_java_bytes) + java.security.KeyFactory.getInstance("Ed25519").generatePublic(spec) + else + OpenSSL::PKey.read(der) + end + end + + # +raw+ is the bare 32-byte public key, not a SubjectPublicKeyInfo. def self.pkey_from_der(raw) - if OpenSSL::PKey.respond_to?(:new_raw_public_key) + if JAVA_ED25519 + # Wrap the raw key in the fixed Ed25519 SubjectPublicKeyInfo prefix. + public_key_from_spki_der(["302a300506032b6570032100"].pack("H*") + raw) + elsif OpenSSL::PKey.respond_to?(:new_raw_public_key) OpenSSL::PKey.new_raw_public_key("ed25519", raw) else pem = <<~PEM @@ -156,27 +194,54 @@ def self.pkey_from_der(raw) def initialize(...) super - unless @key_type == "ed25519" - raise ArgumentError, - "key_type must be ed25519, given #{@key_type}" - end - unless @key.is_a?(OpenSSL::PKey::PKey) && @key.oid == "ED25519" - raise ArgumentError, - "key must be an OpenSSL::PKey::PKey with oid ED25519, is #{@key.inspect}" - end - raise ArgumentError, "schema must be #{schema}" unless @schema == schema + raise ArgumentError, "key_type must be ed25519, given #{@key_type}" unless @key_type == "ed25519" - case @schema - when "ed25519" - # supported - else - raise ArgumentError, "Unsupported schema #{schema}" + if JAVA_ED25519 + unless @key.respond_to?(:getAlgorithm) && %w[Ed25519 EdDSA].include?(@key.getAlgorithm) + raise ArgumentError, "key must be a java Ed25519 PublicKey, is #{@key.inspect}" + end + elsif !(@key.is_a?(OpenSSL::PKey::PKey) && @key.oid == "ED25519") + raise ArgumentError, "key must be an OpenSSL::PKey::PKey with oid ED25519, is #{@key.inspect}" end + + raise ArgumentError, "Unsupported schema #{schema}" unless @schema == "ed25519" end def verify(_algo, signature, data) + return java_verify(signature, data) if JAVA_ED25519 + super(nil, signature, data) end + + # Ed25519 keys do not implement #to_der; the SubjectPublicKeyInfo DER is the + # public encoding. + def to_der + return String.from_java_bytes(@key.getEncoded).b if JAVA_ED25519 + + @key.public_to_der + end + + def public_to_der + to_der + end + + def to_pem + return super unless JAVA_ED25519 + + "-----BEGIN PUBLIC KEY-----\n#{Internal::Util.base64_encode(to_der)}\n-----END PUBLIC KEY-----\n" + end + + private + + def java_verify(signature, data) + sig = java.security.Signature.getInstance("Ed25519") + sig.initVerify(@key) + sig.update(data.to_java_bytes) + sig.verify(signature.to_java_bytes) + rescue java.security.GeneralSecurityException => e + logger.debug { "Ed25519 verification failed: #{e}" } + false + end end end end diff --git a/lib/sigstore/oidc.rb b/lib/sigstore/oidc.rb index 77918a9..a113e61 100644 --- a/lib/sigstore/oidc.rb +++ b/lib/sigstore/oidc.rb @@ -123,6 +123,9 @@ def validate_iat(iat, now, leeway) end def validate_nbf(nbf, now, leeway) + # The nbf ("not before") claim is optional (RFC 7519); skip validation when absent. + return if nbf.nil? + raise Error::InvalidIdentityToken, "nbf claim must be an integer" unless nbf.is_a?(Integer) raise Error::InvalidIdentityToken, "nbf claim is in the future" if nbf > now + leeway end diff --git a/lib/sigstore/rekor/checkpoint.rb b/lib/sigstore/rekor/checkpoint.rb index bc920e1..c38b814 100644 --- a/lib/sigstore/rekor/checkpoint.rb +++ b/lib/sigstore/rekor/checkpoint.rb @@ -65,15 +65,25 @@ def self.from_text(text) def verify(rekor_keyring, key_id) data = note.encode("utf-8") - signatures.each do |signature| - sig_hash = key_id[0, 4] - if signature.sig_hash != sig_hash - raise Error::InvalidCheckpoint, - "sig_hash hint #{signature.sig_hash.inspect} does not match key_id #{sig_hash.inspect}" - end - + key_hint = key_id[0, 4] + + # A checkpoint may also carry cosignatures from witnesses whose verification + # keys we do not yet know; their key hints differ from the log's, and the + # signed-note / Rekor v2 spec says to ignore those lines. We require that at + # least one signature made with the log's own key verifies. + candidates = signatures.select { |signature| signature.sig_hash == key_hint } + raise Error::InvalidCheckpoint, "no checkpoint signature matching the log key" if candidates.empty? + + # Keyring#verify raises KeyError when the log key id is not in the keyring (an + # untrusted or unknown log); treat that as a non-verifying candidate so it fails + # closed via the check below rather than escaping as an uncaught exception. + verified = candidates.any? do |signature| rekor_keyring.verify(key_id: key_id.unpack1("H*"), signature: signature.signature, data:) + rescue Error::InvalidSignature, KeyError + false end + + raise Error::InvalidCheckpoint, "no valid checkpoint signature from the log key" unless verified end end diff --git a/lib/sigstore/trusted_root.rb b/lib/sigstore/trusted_root.rb index a2b569c..753828f 100644 --- a/lib/sigstore/trusted_root.rb +++ b/lib/sigstore/trusted_root.rb @@ -46,8 +46,11 @@ def self.from_file(path) end def rekor_keys + # A trusted root may list more than one transparency log (e.g. a Rekor v1 + # instance alongside a Rekor v2 tiled log). The keyring selects the right + # key per entry by log id, so return all of them. keys = tlog_keys(tlogs).to_a - raise Error::InvalidBundle, "Did not find one Rekor key" if keys.size != 1 + raise Error::InvalidBundle, "Did not find any Rekor keys" if keys.empty? keys end @@ -87,7 +90,15 @@ def tlog_keys(tlogs) tlogs.each do |transparency_log_instance| key = transparency_log_instance.public_key - parsed_key = Internal::Key.from_key_details(key.key_details, key.raw_bytes) + # Verification keyring: include keys whose validity window has started, allowing + # already-expired keys so historical entries still verify, but skipping keys that + # are not yet valid. This mirrors the signing-path window check in #tlog_for_signing + # and sigstore-python's verify-purpose keyring (allow_expired: true). + next unless timerange_valid?(key.valid_for, allow_expired: true) + + declared_log_id = transparency_log_instance.log_id&.key_id + key_id = declared_log_id.unpack1("H*") if declared_log_id && !declared_log_id.empty? + parsed_key = Internal::Key.from_key_details(key.key_details, key.raw_bytes, key_id:) yield parsed_key if parsed_key end end diff --git a/lib/sigstore/verifier.rb b/lib/sigstore/verifier.rb index f55c652..ddb7737 100644 --- a/lib/sigstore/verifier.rb +++ b/lib/sigstore/verifier.rb @@ -67,7 +67,12 @@ def verify(input:, policy:, offline:) # 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. - timestamps = extract_timestamp_from_verification_data(materials.timestamp_verification_data) || [] + # Only resolve the data the timestamp binds to (and enforce its constraints) when + # there is a timestamp to verify; absent timestamp data there is nothing to bind. + timestamp_data = materials.timestamp_verification_data + timestamps = extract_timestamp_from_verification_data( + timestamp_data, timestamp_data && timestamped_data(bundle) + ) || [] # 2) # If the verification policy uses timestamps from the Transparency Service, the Verifier MUST verify the signature @@ -95,7 +100,16 @@ def verify(input:, policy:, offline:) Internal::SET.verify_set(keyring: @rekor_keyring, entry:) if entry.inclusion_promise - timestamps << Time.at(entry.integrated_time).utc + # Rekor v1 entries carry an integrated time signed by the log; Rekor v2 (tiled) + # entries do not, and rely on a Timestamping Service response for the signing time. + integrated_time = entry.integrated_time + timestamps << Time.at(integrated_time).utc if integrated_time&.positive? + + if timestamps.empty? + return VerificationFailure.new( + "no trusted signing time available (no timestamp authority response and no log integrated time)" + ) + end # 3) # The Verifier MUST perform certification path validation (RFC 5280 §6) of the certificate chain with the @@ -160,6 +174,13 @@ def verify(input:, policy:, offline:) case bundle.content when :message_signature + # The messageDigest is an unauthenticated hint, but when present it must be + # consistent with the artifact being verified. + message_digest = bundle.message_signature.message_digest + if message_digest && !message_digest.digest.empty? && message_digest.digest != input.hashed_input.digest + return VerificationFailure.new("message digest does not match the artifact") + end + verified = verify_raw(signing_key, bundle.message_signature.signature, input.hashed_input.digest) return VerificationFailure.new("Signature verification failed") unless verified when :dsse_envelope @@ -203,12 +224,9 @@ def verify_raw(public_key, signature, data) end def verify_dsse(dsse_envelope, public_key) - payload = dsse_envelope.payload - payload_type = dsse_envelope.payloadType signatures = dsse_envelope.signatures - pae = "DSSEv1 #{payload_type.bytesize} #{payload_type} " \ - "#{payload.bytesize} #{payload}".b + pae = dsse_pae(dsse_envelope) raise Error::InvalidBundle, "DSSEv1 envelope missing signatures" if signatures.empty? @@ -217,6 +235,12 @@ def verify_dsse(dsse_envelope, public_key) end end + def dsse_pae(dsse_envelope) + payload = dsse_envelope.payload + payload_type = dsse_envelope.payloadType + "DSSEv1 #{payload_type.bytesize} #{payload_type} #{payload.bytesize} #{payload}".b + end + def verify_in_toto(input, in_toto_payload) type = in_toto_payload["_type"] raise Error::InvalidBundle, "Expected in-toto statement, got #{type.inspect}" unless type == "https://in-toto.io/Statement/v1" @@ -349,7 +373,24 @@ def find_issuer_cert(chain) issuer end - def extract_timestamp_from_verification_data(data) + # The raw bytes a Timestamping Service response is expected to be computed over: the + # signature in the bundle (RFC 3161 message imprint covers these bytes). + def timestamped_data(bundle) + case bundle.content + when :message_signature + bundle.message_signature.signature + when :dsse_envelope + # The timestamp binds to a single signature; with more than one envelope + # signature it is ambiguous which one the imprint covers, so refuse to bind + # only the first (mirrors the v2 consistency path, which requires exactly one). + signatures = bundle.dsse_envelope.signatures + raise Error::InvalidBundle, "expected exactly one DSSE signature to bind a timestamp to" if signatures.size > 1 + + signatures.first&.sig + end + end + + def extract_timestamp_from_verification_data(data, signed_data) # TODO: allow requiring a verified timestamp unless data logger.debug { "no timestamp verification data" } @@ -365,6 +406,13 @@ def extract_timestamp_from_verification_data(data) return end + # The timestamp MUST be computed over the bundle signature (the RFC 3161 message + # imprint covers it). Without signature bytes to bind it to we cannot perform that + # binding, so fail closed rather than accept a timestamp over arbitrary data. + if signed_data.nil? || signed_data.empty? + raise Error::InvalidTimestamp, "no signature available to bind the timestamp to" + end + authorities = @timestamp_authorities.map do |ta| store = OpenSSL::X509::Store.new chain = ta.cert_chain.certificates.map do |cert| @@ -381,19 +429,37 @@ def extract_timestamp_from_verification_data(data) resp = OpenSSL::Timestamp::Response.new(ts.signed_timestamp) req = OpenSSL::Timestamp::Request.new - req.cert_requested = !resp.token.certificates.empty? - # TODO: verify the message imprint against the signature in the bundle + req.cert_requested = !(resp.token.certificates.nil? || resp.token.certificates.empty?) req.message_imprint = resp.token_info.message_imprint req.algorithm = resp.token_info.algorithm - req.policy_id = resp.token_info.policy_id - req.nonce = resp.token_info.nonce + req.policy_id = resp.token_info.policy_id if resp.token_info.policy_id + req.nonce = resp.token_info.nonce if resp.token_info.nonce req.version = resp.token_info.version - # TODO: verify the hashed message in the message imprint - # against the signature in the bundle + # The message imprint must cover the bundle's signature; otherwise the timestamp + # attests to unrelated data and must be rejected. + expected_imprint = + begin + OpenSSL::Digest.new(resp.token_info.algorithm).digest(signed_data) + rescue StandardError => e + raise Error::InvalidTimestamp, + "unsupported timestamp digest algorithm #{resp.token_info.algorithm.inspect}: #{e}" + end + unless resp.token_info.message_imprint == expected_imprint + raise Error::InvalidTimestamp, "timestamp message imprint does not match the bundle signature" + end + + verified = authorities.any? do |ta, chain, store| + # The timestamp must fall within the window the trusted root says this + # Timestamping Service was valid for, independent of cert-chain validity. + gen_time = resp.token_info.gen_time + valid_for = ta.valid_for + if valid_for + next false if valid_for.start && gen_time < valid_for.start.to_time + next false if valid_for.end && gen_time > valid_for.end.to_time + end - authorities.any? do |ta, chain, store| - store.time = resp.token_info.gen_time + store.time = gen_time resp.verify(req, store, chain) && (logger.debug do @@ -402,8 +468,9 @@ def extract_timestamp_from_verification_data(data) rescue OpenSSL::Timestamp::TimestampError => e logger.error { "timestamp verification failed (#{e})" } false - end || - raise(OpenSSL::Timestamp::TimestampError, "timestamp verification failed") + end + raise Error::InvalidTimestamp, "timestamp verification failed" unless verified + resp.token_info.gen_time end end @@ -420,6 +487,23 @@ def find_rekor_entry(bundle, hashed_input, offline:) "has_inclusion_promise=#{has_inclusion_promise} has_inclusion_proof=#{has_inclusion_proof}" end + # Rekor v2 (tiled) entries always ship an inclusion proof in the bundle and have no + # online retrieval API; detect them from the embedded entry and verify consistency + # against the bundle directly, rather than reconstructing a v1 canonicalized body. + if rekor_entry && (v2_body = rekor_v2_body(rekor_entry)) + # A v2 entry has no integrated time and no online retrieval API, so the only + # binding to the log is the Merkle inclusion proof and its checkpoint signature. + # Require them here so the v2 path can never reach the offline warn-and-skip + # branch in #verify (even though #validate_version! also enforces this for + # non-0.1 bundles): without a checkpoint there is nothing to verify against. + unless rekor_entry.inclusion_proof&.checkpoint + raise Error::InvalidBundle, "Rekor v2 entry must contain an inclusion proof with a checkpoint" + end + + verify_rekor_v2_entry_consistency(bundle, hashed_input, v2_body) + return rekor_entry + end + expected_entry = bundle.expected_tlog_entry(hashed_input) entry = if offline @@ -437,11 +521,7 @@ def find_rekor_entry(bundle, hashed_input, offline:) logger.debug { "Found rekor entry: #{entry}" } - actual_body = begin - JSON.parse(entry.canonicalized_body) - rescue JSON::ParserError - raise Error::InvalidRekorEntry, "invalid JSON in rekor entry canonicalized_body" - end + actual_body = parse_canonicalized_body(entry) if bundle.dsse_envelope? # since the hash is over the uncanonicalized envelope, we need to remove it # @@ -471,6 +551,86 @@ def find_rekor_entry(bundle, hashed_input, offline:) entry end + # Parsed canonicalized body if this is a Rekor v2 (hashedrekord 0.0.2) entry, else nil. + # A body that is not JSON returns nil so the v1 reconstruction path handles it. + def rekor_v2_body(entry) + body = parse_canonicalized_body(entry) + body if body.values_at("kind", "apiVersion") == ["hashedrekord", "0.0.2"] + rescue Error::InvalidRekorEntry + nil + end + + def parse_canonicalized_body(entry) + JSON.parse(entry.canonicalized_body) + rescue JSON::ParserError + raise Error::InvalidRekorEntry, "invalid JSON in rekor entry canonicalized_body" + end + + # Verify that a Rekor v2 hashedrekord (0.0.2) entry corresponds to the artifact, + # signature, and signing certificate in the bundle. The Merkle inclusion proof and + # checkpoint signature bind this body to the log; #find_rekor_entry has already + # confirmed both are present, and #verify performs that verification. Here we bind + # the body to the inputs we are verifying. + def verify_rekor_v2_entry_consistency(bundle, hashed_input, body) + spec = body.dig("spec", "hashedRekordV002") + raise Error::InvalidRekorEntry, "missing hashedRekordV002 spec" unless spec + + logged_algorithm = spec.dig("data", "algorithm") + logged_digest = decode_base64_field(spec.dig("data", "digest"), "data.digest") + logged_signature = decode_base64_field(spec.dig("signature", "content"), "signature.content") + logged_cert = decode_base64_field( + spec.dig("signature", "verifier", "x509Certificate", "rawBytes"), + "signature.verifier.x509Certificate.rawBytes" + ) + + unless logged_cert == bundle.leaf_certificate.to_der + raise Error::InvalidRekorEntry, "rekor entry certificate does not match the bundle certificate" + end + + expected_algorithm, expected_digest, expected_signature = + rekor_v2_expected_data_and_signature(bundle, hashed_input) + + unless logged_algorithm == expected_algorithm + raise Error::InvalidRekorEntry, + "rekor entry data algorithm #{logged_algorithm.inspect} does not match " \ + "expected #{expected_algorithm.inspect}" + end + unless logged_digest == expected_digest + raise Error::InvalidRekorEntry, "rekor entry data digest does not match the artifact" + end + return if logged_signature == expected_signature + + raise Error::InvalidRekorEntry, "rekor entry signature does not match the bundle signature" + end + + # The expected [data.algorithm, data.digest, signature] for the bundle, named to match + # the Rekor v2 `hashedRekordV002` spec fields (algorithm uses the proto enum name). + def rekor_v2_expected_data_and_signature(bundle, hashed_input) + case bundle.content + when :message_signature + [hashed_input.algorithm.name, hashed_input.digest, bundle.message_signature.signature] + when :dsse_envelope + # "hashedrekord-over-DSSE": the logged data is SHA2-256 of PAE(payloadType, payload) + # and the logged signature is the (single) DSSE envelope signature. + signatures = bundle.dsse_envelope.signatures + unless signatures.size == 1 + raise Error::InvalidRekorEntry, "expected exactly one DSSE signature for a Rekor v2 entry" + end + + ["SHA2_256", OpenSSL::Digest::SHA256.digest(dsse_pae(bundle.dsse_envelope)), signatures.first.sig] + else + raise Error::InvalidBundle, "expected either message_signature or dsse_envelope" + end + end + + def decode_base64_field(value, name) + raise Error::InvalidRekorEntry, "missing #{name} in rekor entry" unless value + + Internal::Util.base64_decode(value) + rescue ArgumentError + raise Error::InvalidRekorEntry, "invalid base64 in #{name} of rekor entry" + end + def diff_json(a, b) # rubocop:disable Naming/MethodParameterName return nil if a == b diff --git a/test/sigstore/conformance_test.rb b/test/sigstore/conformance_test.rb index 316f13a..e6efdf7 100644 --- a/test/sigstore/conformance_test.rb +++ b/test/sigstore/conformance_test.rb @@ -4,57 +4,90 @@ require "sigstore/cli" class Sigstore::ConformanceTest < Test::Unit::TestCase + # sigstore-conformance reorganized its fixtures under test/assets/bundle-verify//. + # Each case provides a bundle.sigstore.json (and optionally its own artifact and + # trusted_root.json); when no artifact is present the shared parent a.txt is used. + ASSETS = "test/sigstore-conformance/test/assets/bundle-verify" + IDENTITY = "https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon" \ + "/.github/workflows/extremely-dangerous-oidc-beacon.yml@refs/heads/main" + ISSUER = "https://token.actions.githubusercontent.com" + # These bundles embed historical (already-expired) signing certificates, so verification + # is anchored to the entry's integrated time rather than the wall clock. Verifying offline + # against the vendored production trusted root keeps the tests deterministic and network-free. + PROD_TRUSTED_ROOT = "data/_store/prod/trusted_root.json" + + def verify(case_name, trusted_root: PROD_TRUSTED_ROOT, artifact: "#{ASSETS}/a.txt") + Sigstore::CLI.start([ + "verify", + "--offline", + "--trusted-root", trusted_root, + "--certificate-identity", IDENTITY, + "--certificate-oidc-issuer", ISSUER, + "--bundle", "#{ASSETS}/#{case_name}/bundle.sigstore.json", + artifact + ]) + end + def test_verify_signature_invalid - VCR.use_cassette("conformance/verify_signature_invalid") do |cassette| - Timecop.freeze(cassette.originally_recorded_at || Time.now) do - capture_output do - e = assert_raise SystemExit do - Sigstore::CLI.start([ - "verify", - "--certificate-identity", "https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/.github/workflows/extremely-dangerous-oidc-beacon.yml@refs/heads/main", - "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com", - "--bundle", - "test/sigstore-conformance/test/assets/a.txt.invalid_signature.sigstore.json", - "test/sigstore-conformance/test/assets/a.txt" - ]) - end - assert_equal 1, e.status - end + capture_output do + e = assert_raise SystemExit do + verify("signature-mismatch_fail") end + assert_equal 1, e.status end end def test_verify_bundle_success - VCR.use_cassette("conformance/verify_bundle_success") do |cassette| - Timecop.freeze(cassette.originally_recorded_at || Time.now) do - capture_output do - assert_nothing_raised do - Sigstore::CLI.start([ - "verify", - "--bundle", "test/sigstore-conformance/test/assets/a.txt.good.sigstore.json", - "--certificate-identity", "https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/.github/workflows/extremely-dangerous-oidc-beacon.yml@refs/heads/main", - "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com", - "test/sigstore-conformance/test/assets/a.txt" - ]) - end - end + capture_output do + assert_nothing_raised do + verify("happy-path-v0.3") end end end def test_verify_dsse_bundle_with_trust_root + case_name = "intoto-with-custom-trust-root" capture_output do assert_nothing_raised do - Sigstore::CLI.start([ - "verify", - "--bundle", "test/sigstore-conformance/test/assets/d.txt.good.sigstore.json", - "--certificate-identity", "https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/.github/workflows/extremely-dangerous-oidc-beacon.yml@refs/heads/main", - "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com", - "--trusted-root", "test/sigstore-conformance/test/assets/trusted_root.d.json", - "--offline", - "test/sigstore-conformance/test/assets/d.txt" - ]) + verify(case_name, + trusted_root: "#{ASSETS}/#{case_name}/trusted_root.json", + artifact: "#{ASSETS}/#{case_name}/artifact") end end end + + # Drive the Rekor v2 (hashedrekord 0.0.2) path end-to-end, offline, against the + # per-case staging-derived trusted root that carries the v2 Ed25519 log key and the + # timestamp authority. v2 entries have no integrated time, so these bundles rely on + # the embedded TSA timestamp for the signing time. Each case ships its own + # trusted_root.json (see the case READMEs). + def verify_rekor2(case_name) + verify(case_name, trusted_root: "#{ASSETS}/#{case_name}/trusted_root.json") + end + + def test_verify_rekor2_message_signature_happy_path + capture_output do + assert_nothing_raised { verify_rekor2("rekor2-happy-path") } + end + end + + def test_verify_rekor2_dsse_happy_path + capture_output do + assert_nothing_raised { verify_rekor2("rekor2-dsse-happy-path") } + end + end + + def test_verify_rekor2_missing_inclusion_proof_fails + capture_output do + e = assert_raise(SystemExit) { verify_rekor2("rekor2-no-inclusion-proof_fail") } + assert_equal 1, e.status + end + end + + def test_verify_rekor2_missing_timestamp_fails + capture_output do + e = assert_raise(SystemExit) { verify_rekor2("rekor2-no-timestamp_fail") } + assert_equal 1, e.status + end + end end diff --git a/test/sigstore/trusted_root_test.rb b/test/sigstore/trusted_root_test.rb index c655d22..b134efe 100644 --- a/test/sigstore/trusted_root_test.rb +++ b/test/sigstore/trusted_root_test.rb @@ -9,9 +9,13 @@ def test_production Timecop.freeze(cassette.originally_recorded_at || Time.now) do production = Sigstore::TrustedRoot.production assert_equal "application/vnd.dev.sigstore.trustedroot+json;version=0.1", production.media_type + # The Ed25519 (Rekor v2) tlog key declares validFor.start 2025-09-23, after this + # cassette's recording time, so it is not yet valid and is excluded from the + # verification keyring; only the active ECDSA key is present. assert_equal(["MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9f\n" \ "AFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RY\n" \ - "tw==\n"], production.rekor_keys.map { [_1.to_der].pack("m") }) + "tw==\n"], + production.rekor_keys.map { [_1.to_der].pack("m") }) assert_equal(["MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy\n" \ "3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU\n" \ "7w==\n", diff --git a/test/sigstore/verifier_test.rb b/test/sigstore/verifier_test.rb index 151861f..aaf2916 100644 --- a/test/sigstore/verifier_test.rb +++ b/test/sigstore/verifier_test.rb @@ -2,6 +2,7 @@ require "test_helper" require "sigstore/verifier" +require "sigstore/models" class Sigstore::VerifierTest < Test::Unit::TestCase HEXDIGEST256 = "01234567" * 8 @@ -123,6 +124,166 @@ def test_verify_in_toto_wrong_type_raises end end + # --------------------------------------------------------------------------- + # Rekor v2 (hashedrekord 0.0.2) consistency checks. + # + # These drive the real message-signature happy-path bundle (which carries a v2 + # entry) so the leaf certificate, signature, and canonicalized body are genuine, + # then mutate copies of the parsed body to exercise each rejection branch of + # Verifier#verify_rekor_v2_entry_consistency offline (no network, no key checks). + # --------------------------------------------------------------------------- + + REKOR2_DIR = "test/sigstore-conformance/test/assets/bundle-verify/rekor2-happy-path" + REKOR2_ARTIFACT = "test/sigstore-conformance/test/assets/bundle-verify/a.txt" + + def rekor2_bundle + bytes = Gem.read_binary("#{REKOR2_DIR}/bundle.sigstore.json") + Sigstore::SBundle.new(Sigstore::Bundle::V1::Bundle.decode_json(bytes, registry: Sigstore::REGISTRY)) + end + + def rekor2_hashed_input + Sigstore::Common::V1::HashOutput.new.tap do |h| + h.algorithm = Sigstore::Common::V1::HashAlgorithm::SHA2_256 + h.digest = OpenSSL::Digest.new("SHA256").digest(Gem.read_binary(REKOR2_ARTIFACT)) + end + end + + # The genuine parsed v2 body for the happy-path bundle. + def rekor2_body(bundle = rekor2_bundle) + JSON.parse(bundle.verification_material.tlog_entries.first.canonicalized_body) + end + + def assert_rekor2_consistency_raises(message_match, bundle: rekor2_bundle) + body = rekor2_body(bundle) + yield body if block_given? + verifier = Sigstore::Verifier.allocate + error = assert_raise(Sigstore::Error::InvalidRekorEntry) do + verifier.send(:verify_rekor_v2_entry_consistency, bundle, rekor2_hashed_input, body) + end + assert_include error.message, message_match + end + + def test_rekor_v2_consistency_accepts_genuine_entry + verifier = Sigstore::Verifier.allocate + assert_nothing_raised do + verifier.send(:verify_rekor_v2_entry_consistency, rekor2_bundle, rekor2_hashed_input, rekor2_body) + end + end + + def test_rekor_v2_consistency_rejects_missing_spec + assert_rekor2_consistency_raises("missing hashedRekordV002 spec") do |body| + body["spec"].delete("hashedRekordV002") + end + end + + def test_rekor_v2_consistency_rejects_cert_mismatch + other_cert = Sigstore::Internal::Util.base64_encode("not the bundle certificate") + assert_rekor2_consistency_raises("certificate does not match") do |body| + body["spec"]["hashedRekordV002"]["signature"]["verifier"]["x509Certificate"]["rawBytes"] = other_cert + end + end + + def test_rekor_v2_consistency_rejects_algorithm_mismatch + assert_rekor2_consistency_raises("data algorithm") do |body| + body["spec"]["hashedRekordV002"]["data"]["algorithm"] = "SHA2_512" + end + end + + def test_rekor_v2_consistency_rejects_digest_mismatch + other_digest = Sigstore::Internal::Util.base64_encode("0" * 32) + assert_rekor2_consistency_raises("data digest does not match") do |body| + body["spec"]["hashedRekordV002"]["data"]["digest"] = other_digest + end + end + + def test_rekor_v2_consistency_rejects_signature_mismatch + other_sig = Sigstore::Internal::Util.base64_encode("not the bundle signature") + assert_rekor2_consistency_raises("signature does not match") do |body| + body["spec"]["hashedRekordV002"]["signature"]["content"] = other_sig + end + end + + def test_rekor_v2_consistency_rejects_invalid_base64_digest + assert_rekor2_consistency_raises("invalid base64 in data.digest") do |body| + body["spec"]["hashedRekordV002"]["data"]["digest"] = "!!!not base64!!!" + end + end + + def test_rekor_v2_consistency_rejects_missing_digest_field + assert_rekor2_consistency_raises("missing data.digest") do |body| + body["spec"]["hashedRekordV002"]["data"].delete("digest") + end + end + + def test_rekor_v2_expected_data_dsse_requires_single_signature + # The "exactly one DSSE signature" guard lives in the expected-data helper that the + # consistency check calls; drive it directly with a DSSE bundle carrying two sigs. + bundle = rekor2_dsse_bundle_with_two_signatures + verifier = Sigstore::Verifier.allocate + error = assert_raise(Sigstore::Error::InvalidRekorEntry) do + verifier.send(:rekor_v2_expected_data_and_signature, bundle, rekor2_hashed_input) + end + assert_include error.message, "exactly one DSSE signature" + end + + def rekor2_dsse_bundle_with_two_signatures + dir = "test/sigstore-conformance/test/assets/bundle-verify/rekor2-dsse-happy-path" + bytes = Gem.read_binary("#{dir}/bundle.sigstore.json") + bundle = Sigstore::Bundle::V1::Bundle.decode_json(bytes, registry: Sigstore::REGISTRY) + extra = bundle.dsse_envelope.signatures.first.class.new.tap { |s| s.sig = "second signature".b } + bundle.dsse_envelope.signatures = bundle.dsse_envelope.signatures + [extra] + Sigstore::SBundle.new(bundle) + end + + # --------------------------------------------------------------------------- + # rekor_v2_body: only hashedrekord 0.0.2 JSON bodies are treated as v2. + # --------------------------------------------------------------------------- + + FakeEntry = Struct.new(:canonicalized_body) + + def test_rekor_v2_body_returns_body_for_v2_entry + verifier = Sigstore::Verifier.allocate + body = verifier.send(:rekor_v2_body, rekor2_bundle.verification_material.tlog_entries.first) + assert_equal %w[hashedrekord 0.0.2], body.values_at("kind", "apiVersion") + end + + def test_rekor_v2_body_nil_for_non_json + verifier = Sigstore::Verifier.allocate + assert_nil verifier.send(:rekor_v2_body, FakeEntry.new("not json")) + end + + def test_rekor_v2_body_nil_for_non_v2_kind_version + verifier = Sigstore::Verifier.allocate + body = JSON.dump("kind" => "hashedrekord", "apiVersion" => "0.0.1") + assert_nil verifier.send(:rekor_v2_body, FakeEntry.new(body)) + end + + # --------------------------------------------------------------------------- + # decode_base64_field + # --------------------------------------------------------------------------- + + def test_decode_base64_field_decodes_valid_value + verifier = Sigstore::Verifier.allocate + encoded = Sigstore::Internal::Util.base64_encode("hello") + assert_equal "hello", verifier.send(:decode_base64_field, encoded, "field") + end + + def test_decode_base64_field_raises_on_missing + verifier = Sigstore::Verifier.allocate + error = assert_raise(Sigstore::Error::InvalidRekorEntry) do + verifier.send(:decode_base64_field, nil, "data.digest") + end + assert_include error.message, "missing data.digest" + end + + def test_decode_base64_field_raises_on_invalid_base64 + verifier = Sigstore::Verifier.allocate + error = assert_raise(Sigstore::Error::InvalidRekorEntry) do + verifier.send(:decode_base64_field, "!!!not base64!!!", "data.digest") + end + assert_include error.message, "invalid base64 in data.digest" + end + def test_pack_digitally_signed_precertificate verifier = Sigstore::Verifier.allocate [3, 255, 1024, 16_777_215].each do |precert_bytes_len| From bd99eae523bea72bf14067cf90e900db00a4c3d4 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Tue, 16 Jun 2026 16:20:47 -0700 Subject: [PATCH 02/11] Add Rekor v2 signing support (hashedrekord 0.0.2 + TSA + signing config) Signed-off-by: Samuel Giddins --- .github/workflows/ci.yml | 8 +- Rakefile | 9 +- cli/lib/sigstore/cli.rb | 10 +- lib/sigstore.rb | 1 + lib/sigstore/error.rb | 1 + lib/sigstore/rekor/client.rb | 33 ++++++ lib/sigstore/signer.rb | 114 +++++++++++++++++++- lib/sigstore/signing_config.rb | 154 +++++++++++++++++++++++++++ test/sigstore/signer_test.rb | 55 ++++++++++ test/sigstore/signing_config_test.rb | 142 ++++++++++++++++++++++++ 10 files changed, 512 insertions(+), 15 deletions(-) create mode 100644 lib/sigstore/signing_config.rb create mode 100644 test/sigstore/signer_test.rb create mode 100644 test/sigstore/signing_config_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 667af29..443787a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,10 +91,10 @@ jobs: entrypoint: ${{ github.workspace }}/bin/conformance-entrypoint # In addition to the conditional TSA xfail, mark conformance cases for # features sigstore-ruby does not yet support: managed-key (bring-your-own - # key) verification, and signing to a Rekor v2 instance. Verification of - # Rekor v2 bundles is supported and runs in the suite. The same set is reused - # for the staging step below via the YAML anchor. - xfail: &conformance_xfail "${{ matrix.ruby != 'head' && matrix.ruby != 'truffleruby-head' && matrix.ruby != '3.4' && matrix.ruby != '4.0' && 'test_verify_rejects_bad_tsa_timestamp' }} *-managed-key-happy-path] *-managed-key-and-trusted-root] test_sign_verify_rekor2" + # key) verification. Signing to and verification of Rekor v2 instances is + # supported and runs in the suite. The same set is reused for the staging + # step below via the YAML anchor. + xfail: &conformance_xfail "${{ matrix.ruby != 'head' && matrix.ruby != 'truffleruby-head' && matrix.ruby != '3.4' && matrix.ruby != '4.0' && 'test_verify_rejects_bad_tsa_timestamp' }} *-managed-key-happy-path] *-managed-key-and-trusted-root]" if: ${{ matrix.os }} == "ubuntu-latest" - name: Run the conformance tests against staging uses: sigstore/sigstore-conformance@21533cde107c734ebc153c3e3a24d75fc9811a36 # v0.0.29 diff --git a/Rakefile b/Rakefile index eb4a1d6..096ab9d 100644 --- a/Rakefile +++ b/Rakefile @@ -28,14 +28,13 @@ require "openssl" tsa_xfail = OpenSSL::X509::Store.new.instance_variable_defined?(:@time) ? "test_verify_rejects_bad_tsa_timestamp" : "" # Conformance test cases that exercise features sigstore-ruby does not yet -# support: verification with a managed (bring-your-own) key, and signing to a -# Rekor v2 instance (verification of Rekor v2 bundles is supported). Patterns are -# fnmatch-ed against pytest node names; the trailing "]" anchors to the positive -# cases without matching their "_fail" siblings, which we already reject. +# support: verification with a managed (bring-your-own) key. Signing to (and +# verification of) Rekor v2 instances is supported. Patterns are fnmatch-ed +# against pytest node names; the trailing "]" anchors to the positive cases +# without matching their "_fail" siblings, which we already reject. unsupported_conformance_xfails = %w( *-managed-key-happy-path] *-managed-key-and-trusted-root] - test_sign_verify_rekor2 ) xfail = ([tsa_xfail] + unsupported_conformance_xfails).reject(&:empty?).join(" ") diff --git a/cli/lib/sigstore/cli.rb b/cli/lib/sigstore/cli.rb index ef3c1b3..a0b77bc 100644 --- a/cli/lib/sigstore/cli.rb +++ b/cli/lib/sigstore/cli.rb @@ -84,6 +84,7 @@ def verify(*files) option :signature, type: :string, desc: "Path to write the signature to" option :certificate, type: :string, desc: "Path to the public certificate" option :trusted_root, type: :string, desc: "Path to the trusted root" + option :signing_config, type: :string, desc: "Path to the signing config" option :update_trusted_root, type: :boolean, desc: "Update the trusted root", default: true def sign(file) self.options = options.merge(identity_token: IdToken.detect_credential).freeze if options[:identity_token].nil? @@ -95,7 +96,8 @@ def sign(file) contents = File.binread(file) bundle = Sigstore::Signer.new( jwt: options[:identity_token], - trusted_root: + trusted_root:, + signing_config: ).sign(contents) File.binwrite(options[:bundle], bundle.to_json) if options[:bundle] @@ -192,6 +194,12 @@ def trusted_root end end + def signing_config + return unless options[:signing_config] + + Sigstore::SigningConfig.from_file(options[:signing_config]) + end + def collect_verification_state(files) if (options[:certificate] || options[:signature] || options[:bundle]) && files.size > 1 raise Thor::InvocationError, "Too many files specified: #{files.inspect}" diff --git a/lib/sigstore.rb b/lib/sigstore.rb index 2ab45c8..d62626e 100644 --- a/lib/sigstore.rb +++ b/lib/sigstore.rb @@ -43,5 +43,6 @@ def logger end end +require_relative "sigstore/signing_config" require_relative "sigstore/verifier" require_relative "sigstore/signer" diff --git a/lib/sigstore/error.rb b/lib/sigstore/error.rb index ef576c2..d0df2ab 100644 --- a/lib/sigstore/error.rb +++ b/lib/sigstore/error.rb @@ -30,6 +30,7 @@ class InvalidVerificationInput < Error; end class Signing < Error; end class InvalidIdentityToken < Error; end + class InvalidSigningConfig < Error; end class InvalidTimestamp < Error; end diff --git a/lib/sigstore/rekor/client.rb b/lib/sigstore/rekor/client.rb index 8f5ba40..ec2a8c1 100644 --- a/lib/sigstore/rekor/client.rb +++ b/lib/sigstore/rekor/client.rb @@ -44,6 +44,39 @@ def log end end + # Client for the Rekor v2 (tiled / rekor-tiles) API. Unlike v1, creating an + # entry returns a fully-formed protobuf TransparencyLogEntry (including the + # inclusion proof and checkpoint) directly, so there is no separate retrieval + # step. See https://github.com/sigstore/rekor-tiles/blob/main/CLIENTS.md + class V2Client + def initialize(url:) + @url = URI.join("#{url.chomp("/")}/", "api/v2/") + + net = defined?(Gem::Net) ? Gem::Net : Net + @session = net::HTTP.new(@url.host, @url.port) + @session.use_ssl = true + end + + # Submit a proposed entry (a CreateEntryRequest, as a Hash) and return the + # integrated V1::TransparencyLogEntry. The log only responds once the entry + # has been included, so this call can take a while. + def create_entry(payload) + resp = @session.post2(URI.join(@url, "log/entries").path, payload.to_json, + { "Content-Type" => "application/json", "Accept" => "application/json", + "User-Agent" => Sigstore::USER_AGENT }) + + unless %w[200 201].include?(resp.code) + raise Error::FailedRekorPost, + "#{resp.code} #{resp.message.inspect}\n#{JSON.pretty_generate(payload)}\n#{resp.body}" + end + unless resp.content_type == "application/json" + raise Error::FailedRekorPost, "Unexpected content type: #{resp.content_type.inspect}" + end + + V1::TransparencyLogEntry.decode_json(resp.body, registry: REGISTRY) + end + end + class Log def initialize(url, session:) @url = url diff --git a/lib/sigstore/signer.rb b/lib/sigstore/signer.rb index c66e11d..daae11e 100644 --- a/lib/sigstore/signer.rb +++ b/lib/sigstore/signer.rb @@ -26,9 +26,10 @@ module Sigstore class Signer include Loggable - def initialize(jwt:, trusted_root:) + def initialize(jwt:, trusted_root:, signing_config: nil) @identity_token = OIDC::IdentityToken.new(jwt) @trusted_root = trusted_root + @signing_config = signing_config @verifier = Verifier.for_trust_root(trust_root: @trusted_root) end @@ -104,8 +105,14 @@ def generate_csr(keypair) } end + def fulcio_url + return @signing_config.fulcio.url if @signing_config + + @trusted_root.certificate_authority_for_signing.uri + end + def fetch_cert(csr) - uri = URI.parse @trusted_root.certificate_authority_for_signing.uri + uri = URI.parse fulcio_url uri = URI.join(uri, "api/v2/signingCert") resp = Net::HTTP.post( uri, @@ -188,13 +195,57 @@ def sign_payload(payload, key) key.sign("SHA256", payload) end - # TODO: implement - def submit_signature_hash_to_timstamping_service(_signature) + def submit_signature_hash_to_timstamping_service(signature) # The Signer sends a hash of the signature as the messageImprint in a TimeStampReq to the Timestamping Service and # receives a TimeStampResp including a `TimeStampToken`. # The signer MUST verify the TimeStampToken against the payload and Timestamping Service root certificate. + # + # Timestamping is driven by the signing config: a Rekor v2 entry carries no + # integrated time, so a TSA timestamp is the only trusted signing time. The + # returned tokens are verified below in #verify (against the trusted root). + tsa_urls = @signing_config&.tsa_urls || [] + return nil if tsa_urls.empty? + + timestamps = tsa_urls.map { |url| request_timestamp(url, signature) } - nil + Bundle::V1::TimestampVerificationData.new.tap do |data| + data.rfc3161_timestamps = timestamps + end + end + + def request_timestamp(url, signature) + # The message imprint is a hash of the signature (RFC 3161 §2.4.1); the + # Verifier binds the timestamp back to the bundle signature via this imprint. + req = OpenSSL::Timestamp::Request.new + req.algorithm = "SHA256" + req.message_imprint = OpenSSL::Digest::SHA256.digest(signature) + req.cert_requested = true + req.version = 1 + req.nonce = OpenSSL::BN.rand(64) + + uri = URI.parse(url) + resp = Net::HTTP.post( + uri, req.to_der, + { "Content-Type" => "application/timestamp-query", "User-Agent" => Sigstore::USER_AGENT } + ) + unless resp.code == "200" + raise Error::InvalidTimestamp, "TSA #{url} returned #{resp.code} #{resp.message}\n#{resp.body}" + end + + # Parse the response to fail fast on a malformed token and confirm the TSA + # actually granted a timestamp (PKIStatus GRANTED / GRANTED_WITH_MODS); a token + # is only present for those statuses. Cryptographic verification of the token is + # deferred to the verification step. + response = OpenSSL::Timestamp::Response.new(resp.body) + unless [OpenSSL::Timestamp::Response::GRANTED, + OpenSSL::Timestamp::Response::GRANTED_WITH_MODS].include?(response.status) + raise Error::InvalidTimestamp, + "TSA #{url} did not grant a timestamp (status #{response.status}): #{response.status_text.inspect}" + end + + Common::V1::RFC3161SignedTimestamp.new.tap do |ts| + ts.signed_timestamp = resp.body + end end def build_proposed_hashed_rekord_entry(signature, cert, hashed_input) @@ -238,6 +289,13 @@ def submit_signed_metadata_to_transparency_service(signature, cert, hashed_input # The signing metadata might contain additional, application-specific metadata according to the format used. # The Signer then canonically encodes the metadata (according to the chosen format). + # A Rekor v2 (tiled) instance speaks a different API and a different entry + # format (hashedrekord 0.0.2). When the signing config selects one, submit + # there; otherwise fall back to the v1 hashedrekord flow. + if @signing_config && @signing_config.tlog.major_api_version == 2 + return submit_rekor_v2_entry(signature, cert, hashed_input) + end + # TODO: allow configuring the entry kind? proposed_entry = build_proposed_hashed_rekord_entry(signature, cert, hashed_input) @@ -248,6 +306,52 @@ def submit_signed_metadata_to_transparency_service(signature, cert, hashed_input @verifier.rekor_client.log.entries.post(proposed_entry) end + def submit_rekor_v2_entry(signature, cert, hashed_input) + tlog_url = @signing_config.tlog.url + logger.info { "Submitting to #{tlog_url} (Rekor v2)" } + + request = build_create_entry_request(signature, cert, hashed_input) + Rekor::V2Client.new(url: tlog_url).create_entry(request) + end + + # A Rekor v2 CreateEntryRequest for a hashedrekord 0.0.2 entry. The request + # carries only the prehash digest and the signature + verifier; the log fills + # in the data.algorithm (derived from the verifier key details) itself. + def build_create_entry_request(signature, cert, hashed_input) + { + "hashedRekordRequestV002" => { + "digest" => Internal::Util.base64_encode(hashed_input.digest), + "signature" => { + "content" => Internal::Util.base64_encode(signature), + "verifier" => { + "x509Certificate" => { + "rawBytes" => Internal::Util.base64_encode(cert.to_der) + }, + "keyDetails" => key_details(cert) + } + } + } + } + end + + # The Sigstore PublicKeyDetails enum name for the leaf certificate's key, + # used as the Rekor v2 verifier key_details. Only the algorithms this signer + # can produce are mapped. + def key_details(cert) + public_key = cert.openssl.public_key + unless public_key.is_a?(OpenSSL::PKey::EC) + raise Error::Signing, "unsupported signing key type: #{public_key.class}" + end + + case public_key.group.curve_name + when "prime256v1" then "PKIX_ECDSA_P256_SHA_256" + when "secp384r1" then "PKIX_ECDSA_P384_SHA_384" + when "secp521r1" then "PKIX_ECDSA_P521_SHA_512" + else + raise Error::Signing, "unsupported EC curve: #{public_key.group.curve_name}" + end + end + def verify(artifact, bundle) verification_input = Verification::V1::Input.new verification_input.bundle = bundle diff --git a/lib/sigstore/signing_config.rb b/lib/sigstore/signing_config.rb new file mode 100644 index 0000000..47b943d --- /dev/null +++ b/lib/sigstore/signing_config.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +# Copyright 2024 The Sigstore Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "json" +require "time" + +require_relative "error" + +module Sigstore + # Parses a SigningConfig (signingconfig.v0.2) document and selects the + # services (Fulcio CA, OIDC provider, Rekor transparency log, Timestamping + # Authority) a client should use to sign. + # + # The protobuf-specs gem does not yet ship the v0.2 SigningConfig message + # (only the obsolete v0.1 flat-URL form), so this parses the JSON directly. + # The service-selection algorithm mirrors sigstore-python's + # SigningConfig._get_valid_services: per kind, keep services whose major API + # version is supported and whose validity window covers now, collapse to one + # service per operator (highest supported version), then apply the + # ServiceConfiguration selector (ANY/EXACT/ALL). + class SigningConfig + MEDIA_TYPE = "application/vnd.dev.sigstore.signingconfig.v0.2+json" + + REKOR_VERSIONS = [1, 2].freeze + TSA_VERSIONS = [1].freeze + FULCIO_VERSIONS = [1].freeze + OIDC_VERSIONS = [1].freeze + + Service = Struct.new(:url, :major_api_version, :valid_for, :operator) + + def self.from_file(path) + from_json(Gem.read_binary(path)) + end + + def self.from_json(contents) + new(JSON.parse(contents)) + end + + def initialize(raw) + media_type = raw["mediaType"] + unless media_type == MEDIA_TYPE + raise Error::InvalidSigningConfig, "unsupported signing config format: #{media_type.inspect}" + end + + @fulcios = select_services(raw["caUrls"], FULCIO_VERSIONS, nil) + raise Error::InvalidSigningConfig, "No valid Fulcio CA found in signing config" if @fulcios.empty? + + @oidcs = select_services(raw["oidcUrls"], OIDC_VERSIONS, nil) + + @tlogs = select_services(raw["rekorTlogUrls"], REKOR_VERSIONS, raw["rekorTlogConfig"]) + raise Error::InvalidSigningConfig, "No valid Rekor transparency log found in signing config" if @tlogs.empty? + + @tsas = select_services(raw["tsaUrls"], TSA_VERSIONS, raw["tsaConfig"]) + end + + # The Rekor transparency log to submit the signing metadata to. + def tlog + @tlogs.first + end + + def fulcio + @fulcios.first + end + + def oidc_url + @oidcs.first&.url + end + + # The Timestamping Authority URLs to request RFC 3161 timestamps from. + def tsa_urls + @tsas.map(&:url) + end + + private + + def select_services(services, supported_versions, config) + services = parse_services(services) + + by_operator = Hash.new { |h, k| h[k] = [] } + services.each do |service| + next unless supported_versions.include?(service.major_api_version) + next unless timerange_valid?(service.valid_for) + + by_operator[service.operator] << service + end + + # One service per operator, preferring the highest supported version. + result = by_operator.values.map do |op_services| + op_services.max_by(&:major_api_version) + end + + selector = config && config["selector"] + return result if selector.nil? || selector == "ALL" + + if selector == "EXACT" + count = exact_count(config) + # EXACT means exactly `count` services must remain after filtering and + # per-operator collapsing; neither too few nor too many is acceptable. + unless result.size == count + raise Error::InvalidSigningConfig, "Expected #{count} services in signing config, found #{result.size}" + end + + return result + end + + result.first(1) + end + + def exact_count(config) + Integer(config["count"]) + rescue TypeError, ArgumentError + raise Error::InvalidSigningConfig, + "EXACT selector requires an integer count, got #{config["count"].inspect}" + end + + def parse_services(services) + Array(services).map do |service| + valid_for = service["validFor"] + Service.new( + url: service.fetch("url"), + major_api_version: service["majorApiVersion"] || 0, + valid_for: valid_for && { + start: valid_for["start"] && Time.iso8601(valid_for["start"]), + end: valid_for["end"] && Time.iso8601(valid_for["end"]) + }, + operator: service["operator"] || "" + ) + end + end + + def timerange_valid?(period) + return true unless period + + now = Time.now.utc + return false if period[:start] && now < period[:start] + return false if period[:end] && now > period[:end] + + true + end + end +end diff --git a/test/sigstore/signer_test.rb b/test/sigstore/signer_test.rb new file mode 100644 index 0000000..b2558df --- /dev/null +++ b/test/sigstore/signer_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "test_helper" +require "sigstore/signer" + +class Sigstore::SignerTest < Test::Unit::TestCase + TSA_URL = "https://tsa.example/api/v1/timestamp" + + # A real, granted RFC 3161 timestamp token taken from the rekor2 happy-path bundle. + def granted_timestamp_der + bundle = JSON.parse( + Gem.read_binary("test/sigstore-conformance/test/assets/bundle-verify/rekor2-happy-path/bundle.sigstore.json") + ) + encoded = bundle.dig("verificationMaterial", "timestampVerificationData", "rfc3161Timestamps", 0, + "signedTimestamp") + encoded.unpack1("m") + end + + # A minimal TimeStampResp whose PKIStatus is `status` and which carries no token. + def status_only_timestamp_der(status) + OpenSSL::ASN1::Sequence.new( + [OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(status)])] + ).to_der + end + + def stub_tsa(body) + stub_request(:post, TSA_URL) + .to_return(status: 200, body:, headers: { "Content-Type" => "application/timestamp-reply" }) + end + + def test_request_timestamp_accepts_granted_response + stub_tsa(granted_timestamp_der) + signer = Sigstore::Signer.allocate + ts = signer.send(:request_timestamp, TSA_URL, "signature".b) + assert_kind_of Sigstore::Common::V1::RFC3161SignedTimestamp, ts + assert_equal granted_timestamp_der, ts.signed_timestamp + end + + def test_request_timestamp_rejects_non_granted_status + stub_tsa(status_only_timestamp_der(OpenSSL::Timestamp::Response::REJECTION)) + signer = Sigstore::Signer.allocate + error = assert_raise(Sigstore::Error::InvalidTimestamp) do + signer.send(:request_timestamp, TSA_URL, "signature".b) + end + assert_include error.message, "did not grant a timestamp" + end + + def test_request_timestamp_rejects_malformed_response + stub_tsa("not a valid DER timestamp response") + signer = Sigstore::Signer.allocate + assert_raise do + signer.send(:request_timestamp, TSA_URL, "signature".b) + end + end +end diff --git a/test/sigstore/signing_config_test.rb b/test/sigstore/signing_config_test.rb new file mode 100644 index 0000000..355c3e7 --- /dev/null +++ b/test/sigstore/signing_config_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "test_helper" +require "sigstore/signing_config" + +class Sigstore::SigningConfigTest < Test::Unit::TestCase + # A v0.2 signing config modeled on the staging config: several Rekor tlog + # services (a current v2, two retired v2s, and a v1) plus single Fulcio/OIDC/TSA + # entries. Service selection should pick the currently-valid, highest-version + # service per operator. + STAGING_LIKE = { + "mediaType" => "application/vnd.dev.sigstore.signingconfig.v0.2+json", + "caUrls" => [ + { "url" => "https://fulcio.example", "majorApiVersion" => 1, + "validFor" => { "start" => "2022-04-14T21:38:40Z" }, "operator" => "example.dev" } + ], + "oidcUrls" => [ + { "url" => "https://oauth2.example/auth", "majorApiVersion" => 1, + "validFor" => { "start" => "2025-04-16T00:00:00Z" }, "operator" => "example.dev" } + ], + "rekorTlogUrls" => [ + { "url" => "https://log-alpha3.example", "majorApiVersion" => 2, + "validFor" => { "start" => "2025-09-22T11:00:00Z" }, "operator" => "example.dev" }, + { "url" => "https://log-alpha1.example", "majorApiVersion" => 2, + "validFor" => { "start" => "2025-05-07T12:00:00Z", "end" => "2025-08-20T07:24:08Z" }, + "operator" => "example.dev" }, + { "url" => "https://rekor-v1.example", "majorApiVersion" => 1, + "validFor" => { "start" => "2021-01-12T11:53:27Z" }, "operator" => "example.dev" } + ], + "tsaUrls" => [ + { "url" => "https://tsa.example/api/v1/timestamp", "majorApiVersion" => 1, + "validFor" => { "start" => "2025-04-09T00:00:00Z" }, "operator" => "example.dev" } + ], + "rekorTlogConfig" => { "selector" => "ANY" }, + "tsaConfig" => { "selector" => "ANY" } + }.freeze + + def config(raw = STAGING_LIKE) + Sigstore::SigningConfig.from_json(JSON.dump(raw)) + end + + def test_rejects_unknown_media_type + raw = STAGING_LIKE.merge("mediaType" => "application/vnd.dev.sigstore.signingconfig.v0.1+json") + error = assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } + assert_include error.message, "unsupported signing config format" + end + + def test_any_selector_prefers_current_highest_version_tlog + Timecop.freeze(Time.utc(2026, 1, 1)) do + tlog = config.tlog + assert_equal "https://log-alpha3.example", tlog.url + assert_equal 2, tlog.major_api_version + end + end + + def test_selects_fulcio_oidc_and_tsa + Timecop.freeze(Time.utc(2026, 1, 1)) do + sc = config + assert_equal "https://fulcio.example", sc.fulcio.url + assert_equal "https://oauth2.example/auth", sc.oidc_url + assert_equal ["https://tsa.example/api/v1/timestamp"], sc.tsa_urls + end + end + + def test_falls_back_to_v1_tlog_before_v2_window_opens + # Before the v2 services' validity windows, only the v1 log is valid. + Timecop.freeze(Time.utc(2024, 1, 1)) do + tlog = config.tlog + assert_equal "https://rekor-v1.example", tlog.url + assert_equal 1, tlog.major_api_version + end + end + + def test_raises_when_no_valid_tlog + raw = STAGING_LIKE.merge( + "rekorTlogUrls" => [ + { "url" => "https://log.example", "majorApiVersion" => 2, + "validFor" => { "start" => "2999-01-01T00:00:00Z" }, "operator" => "example.dev" } + ] + ) + Timecop.freeze(Time.utc(2026, 1, 1)) do + assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } + end + end + + def test_exact_selector_requires_count + raw = STAGING_LIKE.merge("rekorTlogConfig" => { "selector" => "EXACT", "count" => 2 }) + Timecop.freeze(Time.utc(2026, 1, 1)) do + # Only one service survives per-operator collapsing, so EXACT count=2 fails. + assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } + end + end + + def test_exact_selector_missing_count_raises_invalid_signing_config + raw = STAGING_LIKE.merge("rekorTlogConfig" => { "selector" => "EXACT" }) + Timecop.freeze(Time.utc(2026, 1, 1)) do + error = assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } + assert_include error.message, "EXACT selector requires an integer count" + end + end + + def test_exact_selector_non_numeric_count_raises_invalid_signing_config + raw = STAGING_LIKE.merge("rekorTlogConfig" => { "selector" => "EXACT", "count" => "two" }) + Timecop.freeze(Time.utc(2026, 1, 1)) do + error = assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } + assert_include error.message, "EXACT selector requires an integer count" + end + end + + def test_exact_selector_rejects_more_services_than_count + # Two distinct operators each contribute one valid v2 tlog, so the result has two + # services; EXACT count=1 must reject the over-match rather than truncating to one. + raw = STAGING_LIKE.merge( + "rekorTlogUrls" => [ + { "url" => "https://log-a.example", "majorApiVersion" => 2, + "validFor" => { "start" => "2025-01-01T00:00:00Z" }, "operator" => "a.dev" }, + { "url" => "https://log-b.example", "majorApiVersion" => 2, + "validFor" => { "start" => "2025-01-01T00:00:00Z" }, "operator" => "b.dev" } + ], + "rekorTlogConfig" => { "selector" => "EXACT", "count" => 1 } + ) + Timecop.freeze(Time.utc(2026, 1, 1)) do + error = assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } + assert_include error.message, "Expected 1 services in signing config, found 2" + end + end + + def test_exact_selector_accepts_matching_count + raw = STAGING_LIKE.merge( + "rekorTlogUrls" => [ + { "url" => "https://log-a.example", "majorApiVersion" => 2, + "validFor" => { "start" => "2025-01-01T00:00:00Z" }, "operator" => "a.dev" }, + { "url" => "https://log-b.example", "majorApiVersion" => 2, + "validFor" => { "start" => "2025-01-01T00:00:00Z" }, "operator" => "b.dev" } + ], + "rekorTlogConfig" => { "selector" => "EXACT", "count" => 2 } + ) + Timecop.freeze(Time.utc(2026, 1, 1)) do + assert_nothing_raised { config(raw) } + end + end +end From a23fa3d3bfff7f1e52e72d4506cdaf10e151b4f7 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Wed, 17 Jun 2026 13:39:25 -0700 Subject: [PATCH 03/11] Update vendored trusted roots Signed-off-by: Samuel Giddins --- data/_store/prod/root.json | 82 +++++++++-------------- data/_store/prod/trusted_root.json | 38 +++++++---- data/_store/staging/root.json | 8 +-- data/_store/staging/trusted_root.json | 94 +++++++++++++++++++++++++-- 4 files changed, 145 insertions(+), 77 deletions(-) diff --git a/data/_store/prod/root.json b/data/_store/prod/root.json index 3f18ee7..55115df 100644 --- a/data/_store/prod/root.json +++ b/data/_store/prod/root.json @@ -1,98 +1,74 @@ { "signatures": [ - { - "keyid": "6f260089d5923daf20166ca657c543af618346ab971884a99962b01988bbe0c3", - "sig": "30460221008ab1f6f17d4f9e6d7dcf1c88912b6b53cc10388644ae1f09bc37a082cd06003e022100e145ef4c7b782d4e8107b53437e669d0476892ce999903ae33d14448366996e7" - }, { "keyid": "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", - "sig": "3045022100c768b2f86da99569019c160a081da54ae36c34c0a3120d3cb69b53b7d113758e02204f671518f617b20d46537fae6c3b63bae8913f4f1962156105cc4f019ac35c6a" + "sig": "3045022100ea2f374f409810e2db950749d9cfed09a15b6a5e25f3d5ffd0799459d7bee167022028d3acdde6dbd5034cfad222d31b41090ee21894e2c46cb8974198ab0377db44" }, { "keyid": "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", - "sig": "3045022100b4434e6995d368d23e74759acd0cb9013c83a5d3511f0f997ec54c456ae4350a022015b0e265d182d2b61dc74e155d98b3c3fbe564ba05286aa14c8df02c9b756516" + "sig": "304402207ebb24e3237e470691d7875903a7754d0ef2ae7e7b5024a7888c9a38a52deecd02206ed5ad1c6f4fab46995843ab6b23f9420c5a4cf6ce1cb2cb2a6fc2e87e2ef3e1" }, { "keyid": "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", - "sig": "304502210082c58411d989eb9f861410857d42381590ec9424dbdaa51e78ed13515431904e0220118185da6a6c2947131c17797e2bb7620ce26e5f301d1ceac5f2a7e58f9dcf2e" + "sig": "304602210089d9dfd8e106cc958088a4da3c8cf7254ab6f65a9647d37ada730ef4763c5163022100d882ee744615be79861e214e1eeb9e1eddf6a1e203a201b4c5d03f5224d71d16" }, { "keyid": "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", - "sig": "3046022100c78513854cae9c32eaa6b88e18912f48006c2757a258f917312caba75948eb9e022100d9e1b4ce0adfe9fd2e2148d7fa27a2f40ba1122bd69da7612d8d1776b013c91d" - }, - { - "keyid": "fdfa83a07b5a83589b87ded41f77f39d232ad91f7cce52868dacd06ba089849f", - "sig": "3045022056483a2d5d9ea9cec6e11eadfb33c484b614298faca15acf1c431b11ed7f734c022100d0c1d726af92a87e4e66459ca5adf38a05b44e1f94318423f954bae8bca5bb2e" - }, - { - "keyid": "e2f59acb9488519407e18cbfc9329510be03c04aca9929d2f0301343fec85523", - "sig": "3046022100d004de88024c32dc5653a9f4843cfc5215427048ad9600d2cf9c969e6edff3d2022100d9ebb798f5fc66af10899dece014a8628ccf3c5402cd4a4270207472f8f6e712" - }, - { - "keyid": "3c344aa068fd4cc4e87dc50b612c02431fbc771e95003993683a2b0bf260cf0e", - "sig": "3046022100b7b09996c45ca2d4b05603e56baefa29718a0b71147cf8c6e66349baa61477df022100c4da80c717b4fa7bba0fd5c72da8a0499358b01358b2309f41d1456ea1e7e1d9" - }, - { - "keyid": "ec81669734e017996c5b85f3d02c3de1dd4637a152019fe1af125d2f9368b95e", - "sig": "3046022100be9782c30744e411a82fa85b5138d601ce148bc19258aec64e7ec24478f38812022100caef63dcaf1a4b9a500d3bd0e3f164ec18f1b63d7a9460d9acab1066db0f016d" + "sig": "304502210088bd4b88e83f586ce568d27d04214c4ab3fd1894178ef015303d56afa939205302205538ebab93876abb9075ad77114bff28a0d79a7cc229b534a0c5ced5526b48e7" }, { - "keyid": "1e1d65ce98b10addad4764febf7dda2d0436b3d3a3893579c0dddaea20e54849", - "sig": "30450220746ec3f8534ce55531d0d01ff64964ef440d1e7d2c4c142409b8e9769f1ada6f022100e3b929fcd93ea18feaa0825887a7210489879a66780c07a83f4bd46e2f09ab3b" + "keyid": "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632", + "sig": "3045022100f35b07e938d4949caf82e69e86cc9db3b69b6dbc6740c1f343d06893f996fbeb022001e847d816259a96a49e42779a2350dab97b71c8ae7e26b2380c6fa7f58131b3" } ], "signed": { "_type": "root", "consistent_snapshot": true, - "expires": "2025-02-19T08:04:32Z", + "expires": "2026-11-20T13:58:18Z", "keys": { - "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06": { + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5": { "keyid_hash_algorithms": [ "sha256", "sha512" ], "keytype": "ecdsa", "keyval": { - "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzBzVOmHCPojMVLSI364WiiV8NPrD\n6IgRxVliskz/v+y3JER5mcVGcONliDcWMC5J2lfHmjPNPhb4H7xm8LzfSA==\n-----END PUBLIC KEY-----\n" + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWRiGr5+j+3J5SsH+Ztr5nE2H2wO7\nBV+nO3s93gLca18qTOzHY1oWyAGDykMSsGTUBSt9D+An0KfKsD2mfSM42Q==\n-----END PUBLIC KEY-----\n" }, "scheme": "ecdsa-sha2-nistp256", - "x-tuf-on-ci-keyowner": "@santiagotorres" + "x-tuf-on-ci-online-uri": "gcpkms:projects/sigstore-root-signing/locations/global/keyRings/root/cryptoKeys/timestamp/cryptoKeyVersions/1" }, - "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222": { - "keyid_hash_algorithms": [ - "sha256", - "sha512" - ], + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632": { "keytype": "ecdsa", "keyval": { - "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEinikSsAQmYkNeH5eYq/CnIzLaacO\nxlSaawQDOwqKy/tCqxq5xxPSJc21K4WIhs9GyOkKfzueY3GILzcMJZ4cWw==\n-----END PUBLIC KEY-----\n" + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMxpPOJCIZ5otG4106fGJseEQi3V9\npkMYQ4uyV9Tj1M7WHXIyLG+jkfvuG0glQ1JZbRZZBV3gAR4sojdGHISeow==\n-----END PUBLIC KEY-----\n" }, "scheme": "ecdsa-sha2-nistp256", - "x-tuf-on-ci-keyowner": "@bobcallaway" + "x-tuf-on-ci-keyowner": "@lance" }, - "6f260089d5923daf20166ca657c543af618346ab971884a99962b01988bbe0c3": { + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06": { "keyid_hash_algorithms": [ "sha256", "sha512" ], "keytype": "ecdsa", "keyval": { - "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEy8XKsmhBYDI8Jc0GwzBxeKax0cm5\nSTKEU65HPFunUn41sT8pi0FjM4IkHz/YUmwmLUO0Wt7lxhj6BkLIK4qYAw==\n-----END PUBLIC KEY-----\n" + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzBzVOmHCPojMVLSI364WiiV8NPrD\n6IgRxVliskz/v+y3JER5mcVGcONliDcWMC5J2lfHmjPNPhb4H7xm8LzfSA==\n-----END PUBLIC KEY-----\n" }, "scheme": "ecdsa-sha2-nistp256", - "x-tuf-on-ci-keyowner": "@dlorenc" + "x-tuf-on-ci-keyowner": "@santiagotorres" }, - "7247f0dbad85b147e1863bade761243cc785dcb7aa410e7105dd3d2b61a36d2c": { + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222": { "keyid_hash_algorithms": [ "sha256", "sha512" ], "keytype": "ecdsa", "keyval": { - "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWRiGr5+j+3J5SsH+Ztr5nE2H2wO7\nBV+nO3s93gLca18qTOzHY1oWyAGDykMSsGTUBSt9D+An0KfKsD2mfSM42Q==\n-----END PUBLIC KEY-----\n" + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEinikSsAQmYkNeH5eYq/CnIzLaacO\nxlSaawQDOwqKy/tCqxq5xxPSJc21K4WIhs9GyOkKfzueY3GILzcMJZ4cWw==\n-----END PUBLIC KEY-----\n" }, "scheme": "ecdsa-sha2-nistp256", - "x-tuf-on-ci-online-uri": "gcpkms://projects/sigstore-root-signing/locations/global/keyRings/root/cryptoKeys/timestamp" + "x-tuf-on-ci-keyowner": "@bobcallaway" }, "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70": { "keyid_hash_algorithms": [ @@ -122,17 +98,17 @@ "roles": { "root": { "keyids": [ - "6f260089d5923daf20166ca657c543af618346ab971884a99962b01988bbe0c3", "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", - "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70" + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632" ], "threshold": 3 }, "snapshot": { "keyids": [ - "7247f0dbad85b147e1863bade761243cc785dcb7aa410e7105dd3d2b61a36d2c" + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5" ], "threshold": 1, "x-tuf-on-ci-expiry-period": 3650, @@ -140,26 +116,26 @@ }, "targets": { "keyids": [ - "6f260089d5923daf20166ca657c543af618346ab971884a99962b01988bbe0c3", "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", - "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70" + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632" ], "threshold": 3 }, "timestamp": { "keyids": [ - "7247f0dbad85b147e1863bade761243cc785dcb7aa410e7105dd3d2b61a36d2c" + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5" ], "threshold": 1, "x-tuf-on-ci-expiry-period": 7, - "x-tuf-on-ci-signing-period": 4 + "x-tuf-on-ci-signing-period": 6 } }, "spec_version": "1.0", - "version": 10, - "x-tuf-on-ci-expiry-period": 182, - "x-tuf-on-ci-signing-period": 31 + "version": 15, + "x-tuf-on-ci-expiry-period": 197, + "x-tuf-on-ci-signing-period": 46 } } \ No newline at end of file diff --git a/data/_store/prod/trusted_root.json b/data/_store/prod/trusted_root.json index 5ed6281..effb0a1 100644 --- a/data/_store/prod/trusted_root.json +++ b/data/_store/prod/trusted_root.json @@ -8,12 +8,26 @@ "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", "keyDetails": "PKIX_ECDSA_P256_SHA_256", "validFor": { - "start": "2021-01-12T11:53:27.000Z" + "start": "2021-01-12T11:53:27Z" } }, "logId": { "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } } ], "certificateAuthorities": [ @@ -31,7 +45,7 @@ ] }, "validFor": { - "start": "2021-03-07T03:20:29.000Z", + "start": "2021-03-07T03:20:29Z", "end": "2022-12-31T23:59:59.999Z" } }, @@ -52,7 +66,7 @@ ] }, "validFor": { - "start": "2022-04-13T20:06:15.000Z" + "start": "2022-04-13T20:06:15Z" } } ], @@ -64,7 +78,7 @@ "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", "keyDetails": "PKIX_ECDSA_P256_SHA_256", "validFor": { - "start": "2021-03-14T00:00:00.000Z", + "start": "2021-03-14T00:00:00Z", "end": "2022-10-31T23:59:59.999Z" } }, @@ -79,7 +93,7 @@ "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", "keyDetails": "PKIX_ECDSA_P256_SHA_256", "validFor": { - "start": "2022-10-20T00:00:00.000Z" + "start": "2022-10-20T00:00:00Z" } }, "logId": { @@ -90,24 +104,22 @@ "timestampAuthorities": [ { "subject": { - "organization": "GitHub, Inc.", - "commonName": "Internal Services Root" + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", "certChain": { "certificates": [ { - "rawBytes": "MIIB3DCCAWKgAwIBAgIUchkNsH36Xa04b1LqIc+qr9DVecMwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTIzMDQxNDAwMDAwMFoXDTI0MDQxMzAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUD5ZNbSqYMd6r8qpOOEX9ibGnZT9GsuXOhr/f8U9FJugBGExKYp40OULS0erjZW7xV9xV52NnJf5OeDq4e5ZKqNWMFQwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMIMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUaW1RudOgVt0leqY0WKYbuPr47wAwCgYIKoZIzj0EAwMDaAAwZQIwbUH9HvD4ejCZJOWQnqAlkqURllvu9M8+VqLbiRK+zSfZCZwsiljRn8MQQRSkXEE5AjEAg+VxqtojfVfu8DhzzhCx9GKETbJHb19iV72mMKUbDAFmzZ6bQ8b54Zb8tidy5aWe" - }, - { - "rawBytes": "MIICEDCCAZWgAwIBAgIUX8ZO5QXP7vN4dMQ5e9sU3nub8OgwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDQxNDAwMDAwMFoXDTI4MDQxMjAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEvMLY/dTVbvIJYANAuszEwJnQE1llftynyMKIMhh48HmqbVr5ygybzsLRLVKbBWOdZ21aeJz+gZiytZetqcyF9WlER5NEMf6JV7ZNojQpxHq4RHGoGSceQv/qvTiZxEDKo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaW1RudOgVt0leqY0WKYbuPr47wAwHwYDVR0jBBgwFoAU9NYYlobnAG4c0/qjxyH/lq/wz+QwCgYIKoZIzj0EAwMDaQAwZgIxAK1B185ygCrIYFlIs3GjswjnwSMG6LY8woLVdakKDZxVa8f8cqMs1DhcxJ0+09w95QIxAO+tBzZk7vjUJ9iJgD4R6ZWTxQWKqNm74jO99o+o9sv4FI/SZTZTFyMn0IJEHdNmyA==" + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" }, { - "rawBytes": "MIIB9DCCAXqgAwIBAgIUa/JAkdUjK4JUwsqtaiRJGWhqLSowCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDQxNDAwMDAwMFoXDTMzMDQxMTAwMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEf9jFAXxz4kx68AHRMOkFBhflDcMTvzaXz4x/FCcXjJ/1qEKon/qPIGnaURskDtyNbNDOpeJTDDFqt48iMPrnzpx6IZwqemfUJN4xBEZfza+pYt/iyod+9tZr20RRWSv/o0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQU9NYYlobnAG4c0/qjxyH/lq/wz+QwCgYIKoZIzj0EAwMDaAAwZQIxALZLZ8BgRXzKxLMMN9VIlO+e4hrBnNBgF7tz7Hnrowv2NetZErIACKFymBlvWDvtMAIwZO+ki6ssQ1bsZo98O8mEAf2NZ7iiCgDDU0Vwjeco6zyeh0zBTs9/7gV6AHNQ53xD" + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" } ] }, "validFor": { - "start": "2023-04-14T00:00:00.000Z" + "start": "2025-07-04T00:00:00Z" } } ] diff --git a/data/_store/staging/root.json b/data/_store/staging/root.json index f00afe8..f68bebe 100644 --- a/data/_store/staging/root.json +++ b/data/_store/staging/root.json @@ -2,11 +2,11 @@ "signatures": [ { "keyid": "aa61e09f6af7662ac686cf0c6364079f63d3e7a86836684eeced93eace3acd81", - "sig": "304502204d5d01c2ae4b846cc6d29d7c5676f5d99ea464a69bd464fef16a5d0cdd4a616d022100bf73b2b11b68bf7a7047480bf0d5961a3a40c524f64a82e2c90f59d4083e498e" + "sig": "304502201419a6f003291be4cd00c110a00e9d9b152a78181ed3c54edf9d22a79938276f022100f751058c6c3eb6aecf277db367b4858d3612abd1ad96c40fd71ebbd26c944055" }, { "keyid": "61f9609d2655b346fcebccd66b509d5828168d5e447110e261f0bcc8553624bc", - "sig": "3044022005a8e904d484b7f4c3bac53ed6babeee303f6308f81f9ea29a7a1f6ad51068c20220641303f1e5ab14b151525c63ca95b35df64ffc905c8883f96cbee703ed45a2df" + "sig": "3045022007313e0afd5f282a1383a4f8f7a150e266a059420e1036eee222ce0053e36a67022100dfb05a4d1e6be2f43ce30f030783ff7a747cda5d84f341fd03b06dff32fba5bd" }, { "keyid": "9471fbda95411d10109e467ad526082d15f14a38de54ea2ada9687ab39d8e237", @@ -20,7 +20,7 @@ "signed": { "_type": "root", "consistent_snapshot": true, - "expires": "2025-03-07T07:44:40Z", + "expires": "2026-10-16T19:39:43Z", "keys": { "0374a9e18a20a2103736cb4277e2fdd7f8453642c7d9eaf4ad8aee9cf2d47bb5": { "keytype": "ecdsa", @@ -100,7 +100,7 @@ } }, "spec_version": "1.0", - "version": 10, + "version": 14, "x-tuf-on-ci-expiry-period": 182, "x-tuf-on-ci-signing-period": 35 } diff --git a/data/_store/staging/trusted_root.json b/data/_store/staging/trusted_root.json index 35b9ba3..7cec954 100644 --- a/data/_store/staging/trusted_root.json +++ b/data/_store/staging/trusted_root.json @@ -8,12 +8,56 @@ "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEDODRU688UYGuy54mNUlaEBiQdTE9nYLr0lg6RXowI/QV/RE1azBn4Eg5/2uTOMbhB1/gfcHzijzFi9Tk+g1Prg==", "keyDetails": "PKIX_ECDSA_P256_SHA_256", "validFor": { - "start": "2021-01-12T11:53:27.000Z" + "start": "2021-01-12T11:53:27Z" } }, "logId": { "keyId": "0y8wo8MtY5wrdiIFohx7sHeI5oKDpK5vQhGHI6G+pJY=" } + }, + { + "baseUrl": "https://log2025-alpha1.rekor.sigstage.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAPn+AREHoBaZ7wgS1zBqpxmLSGnyhxXj4lFxSdWVB8o8=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-04-16T00:00:00Z", + "end": "2025-09-04T00:00:00Z" + } + }, + "logId": { + "keyId": "8w1amZ2S5mJIQkQmPxdMuOrL/oJkvFg9MnQXmeOCXck=" + } + }, + { + "baseUrl": "https://log2025-alpha2.rekor.sigstage.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAkrA8Ou2FtN7kYXCP/lpvF8vQrvh4nj+91+PWOGGzfGc=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-08-08T00:00:00Z", + "end": "2025-09-28T00:00:00Z" + } + }, + "logId": { + "keyId": "KfSiSX2iRLyhK62SUVL47vVcqqRx/RAewpKJm8IdZTo=" + } + }, + { + "baseUrl": "https://log2025-alpha3.rekor.sigstage.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAlD3dVc8yaP25mPtT/sJ59D3LLxGBgW/qYrM6x6KmOqk=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-22T00:00:00Z" + } + }, + "logId": { + "keyId": "09OnDKEw7/hpZiYVPoTRzRbglHk0sylsUovegnRUlJY=" + } } ], "certificateAuthorities": [ @@ -34,7 +78,7 @@ ] }, "validFor": { - "start": "2022-04-14T21:38:40.000Z" + "start": "2022-04-14T21:38:40Z" } } ], @@ -46,8 +90,8 @@ "rawBytes": "MIICCgKCAgEA27A2MPQXm0I0v7/Ly5BIauDjRZF5Jor9vU+QheoE2UIIsZHcyYq3slHzSSHy2lLj1ZD2d91CtJ492ZXqnBmsr4TwZ9jQ05tW2mGIRI8u2DqN8LpuNYZGz/f9SZrjhQQmUttqWmtu3UoLfKz6NbNXUnoo+NhZFcFRLXJ8VporVhuiAmL7zqT53cXR3yQfFPCUDeGnRksnlhVIAJc3AHZZSHQJ8DEXMhh35TVv2nYhTI3rID7GwjXXw4ocz7RGDD37ky6p39Tl5NB71gT1eSqhZhGHEYHIPXraEBd5+3w9qIuLWlp5Ej/K6Mu4ELioXKCUimCbwy+Cs8UhHFlqcyg4AysOHJwIadXIa8LsY51jnVSGrGOEBZevopmQPNPtyfFY3dmXSS+6Z3RD2Gd6oDnNGJzpSyEk410Ag5uvNDfYzJLCWX9tU8lIxNwdFYmIwpd89HijyRyoGnoJ3entd63cvKfuuix5r+GHyKp1Xm1L5j5AWM6P+z0xigwkiXnt+adexAl1J9wdDxv/pUFEESRF4DG8DFGVtbdH6aR1A5/vD4krO4tC1QYUSeyL5Mvsw8WRqIFHcXtgybtxylljvNcGMV1KXQC8UFDmpGZVDSHx6v3e/BHMrZ7gjoCCfVMZ/cFcQi0W2AIHPYEMH/C95J2r4XbHMRdYXpovpOoT5Ca78gsCAwEAAQ==", "keyDetails": "PKCS1_RSA_PKCS1V5", "validFor": { - "start": "2021-03-14T00:00:00.000Z", - "end": "2022-07-31T00:00:00.000Z" + "start": "2021-03-14T00:00:00Z", + "end": "2022-07-31T00:00:00Z" } }, "logId": { @@ -61,8 +105,8 @@ "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEh99xuRi6slBFd8VUJoK/rLigy4bYeSYWO/fE6Br7r0D8NpMI94+A63LR/WvLxpUUGBpY8IJA3iU2telag5CRpA==", "keyDetails": "PKIX_ECDSA_P256_SHA_256", "validFor": { - "start": "2022-07-01T00:00:00.000Z", - "end": "2022-07-31T00:00:00.000Z" + "start": "2022-07-01T00:00:00Z", + "end": "2022-07-31T00:00:00Z" } }, "logId": { @@ -76,12 +120,48 @@ "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8gEDKNme8AnXuPBgHjrtXdS6miHqc24CRblNEOFpiJRngeq8Ko73Y+K18yRYVf1DXD4AVLwvKyzdNdl5n0jUSQ==", "keyDetails": "PKIX_ECDSA_P256_SHA_256", "validFor": { - "start": "2022-07-01T00:00:00.000Z" + "start": "2022-07-01T00:00:00Z" } }, "logId": { "keyId": "KzC83GiIyeLh2CYpXnQfSDkxlgLynDPLXkNA/rKshno=" } + }, + { + "baseUrl": "https://log2026-1.ctfe.sigstage.dev", + "hashAlgorithm":"SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEv8+Fp+klTMlOd0FU+eekotPzlaF9orvv9ZgdLXq5+MmoGThLNigXIapXjW0lujsU6+ZHKZ6UPzSuz+V8YxLoQw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2026-01-14T00:00:00Z" + } + }, + "logId": { + "keyId": "PmBxU3RuGJLgkLI2sUl2Jy9ntE1vks5vdxFKtyKgprY=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstage.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICDzCCAZagAwIBAgIUCjWhBmHV4kFzxomWp/J98n4DfKcwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTAzMjgwOTE0MDZaFw0zNTAzMjYwODE0MDZaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEx1v5F3HpD9egHuknpBFlRz7QBRDJu4aeVzt9zJLRY0lvmx1lF7WBM2c9AN8ZGPQsmDqHlJN2R/7+RxLkvlLzkc19IOx38t7mGGEcB7agUDdCF/Ky3RTLSK0Xo/0AgHQdo2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFKj8ZPYo3i7mO3NPVIxSxOGc3VOlMB8GA1UdIwQYMBaAFDsgRlletTJNRzDObmPuc3RH8gR9MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2cAMGQCMESvVS6GGtF33+J19TfwENWJXjRv4i0/HQFwLUSkX6TfV7g0nG8VnqNHJLvEpAtOjQIwUD3uywTXorQP1DgbV09rF9Yen+CEqs/iEpieJWPst280SSOZ5Na+dyPVk9/8SFk6" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUCPExEFKiQh0dP4sp5ltmSYSSkFUwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTAzMjgwOTE0MDZaFw0zNTAzMjYwODE0MDZaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATt0tIDWyo4ARfL9BaSo0W5bJQEbKJTU/u7llvdjSI5aTkOAJa8tixn2+LEfPG4dMFdsMPtsIuU1qn2OqFiuMk6vHv/c+az25RQVY1oo50iMb0jIL3N4FgwhPFpZnCbQPOjRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQ7IEZZXrUyTUcwzm5j7nN0R/IEfTAKBggqhkjOPQQDAwNpADBmAjEA2MI1VXgbf3dUOSc95hSRypBKOab18eh2xzQtxUsHvWeY+1iFgyMluUuNR6taoSmFAjEA31m2czguZhKYX+4JSKu5pRYhBTXAd8KKQ3xdPRX/qCaLvT2qJAEQ1YQM3EJRrtI7" + } + ] + }, + "validFor": { + "start": "2025-04-09T00:00:00Z" + } } ] } From 66cfa4129f454b0314f86fcc40aabc00abc9e96d Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Thu, 18 Jun 2026 13:58:28 -0700 Subject: [PATCH 04/11] Add managed-key (bring-your-own-key) verification Support verifying bundles whose verificationMaterial is a public key hint rather than a Fulcio certificate. The verifying key is supplied out-of-band via a new `--key` flag; certificate-path validation, SCT verification, and identity-policy checks are skipped, while the signature and its binding to the Rekor entry are still verified against the supplied key. Signed-off-by: Samuel Giddins --- cli/lib/sigstore/cli.rb | 34 ++++++-- lib/sigstore/models.rb | 45 ++++++++--- lib/sigstore/policy.rb | 8 ++ lib/sigstore/verifier.rb | 125 ++++++++++++++++++++---------- test/sigstore/cli_test.rb | 26 +++++++ test/sigstore/conformance_test.rb | 40 ++++++++++ 6 files changed, 218 insertions(+), 60 deletions(-) create mode 100644 test/sigstore/cli_test.rb diff --git a/cli/lib/sigstore/cli.rb b/cli/lib/sigstore/cli.rb index a0b77bc..01fc754 100644 --- a/cli/lib/sigstore/cli.rb +++ b/cli/lib/sigstore/cli.rb @@ -48,21 +48,29 @@ def initialize(*) option :certificate, type: :string, desc: "Path to the public certificate" option :certificate_identity, type: :string, desc: "The identity of the certificate" option :certificate_oidc_issuer, type: :string, desc: "The OIDC issuer of the certificate" + option :key, type: :string, desc: "Path to a PEM public key for managed-key (bring-your-own-key) verification" option :offline, type: :boolean, desc: "Do not fetch the latest timestamp from the Rekor server" option :bundle, type: :string, desc: "Path to the signed bundle" option :trusted_root, type: :string, desc: "Path to the trusted root" option :update_trusted_root, type: :boolean, desc: "Update the trusted root", default: true exclusive :bundle, :signature exclusive :bundle, :certificate + exclusive :key, :certificate + exclusive :key, :certificate_identity def verify(*files) verifier, files_with_materials = collect_verification_state(files) - policy = Sigstore::Policy::Identity.new( - identity: options[:certificate_identity], - issuer: options[:certificate_oidc_issuer] - ) + key = load_verification_key + policy = if key + Sigstore::Policy::UnsafeNoOp.new + else + Sigstore::Policy::Identity.new( + identity: options[:certificate_identity], + issuer: options[:certificate_oidc_issuer] + ) + end verified = files_with_materials.all? do |file, input| - result = verifier.verify(input:, policy:, offline: options[:offline]) + result = verifier.verify(input:, policy:, offline: options[:offline], key:) if result.verified? say "OK: #{file}" @@ -116,7 +124,11 @@ def display(*files) say "--- Bundle #{file} ---" say "Media Type: #{bundle.media_type}" - say bundle.leaf_certificate.to_text + if bundle.key_based? + say "Public Key (hint): #{bundle.signing_key_hint}" + else + say bundle.leaf_certificate.to_text + end case bundle.content when :message_signature @@ -200,6 +212,16 @@ def signing_config Sigstore::SigningConfig.from_file(options[:signing_config]) end + def load_verification_key + return unless options[:key] + + raise Thor::InvocationError, "Key file not found: #{options[:key]}" unless File.exist?(options[:key]) + + OpenSSL::PKey.read(Gem.read_binary(options[:key])) + rescue OpenSSL::OpenSSLError => e + raise Error::InvalidKey, "could not parse verification key #{options[:key].inspect}: #{e.message}" + end + def collect_verification_state(files) if (options[:certificate] || options[:signature] || options[:bundle]) && files.size > 1 raise Thor::InvocationError, "Too many files specified: #{files.inspect}" diff --git a/lib/sigstore/models.rb b/lib/sigstore/models.rb index 890ba22..52c5165 100644 --- a/lib/sigstore/models.rb +++ b/lib/sigstore/models.rb @@ -118,7 +118,14 @@ def initialize(*) end class SBundle < DelegateClass(Bundle::V1::Bundle) - attr_reader :bundle_type, :leaf_certificate + attr_reader :bundle_type, :leaf_certificate, :signing_key_hint + + # A bundle whose signing identity is a bare public key (a "managed" / + # bring-your-own-key signature) rather than a Fulcio-issued certificate. + # The key itself is supplied out-of-band; the bundle only carries a hint. + def key_based? + !@signing_key_hint.nil? + end def initialize(*) super @@ -138,11 +145,24 @@ def self.for_cert_bytes_and_signature(cert_bytes, signature) new(bundle) end - def expected_tlog_entry(hashed_input) + # +verifier_pem+ is the PEM of the public key the entry should be bound to. For + # certificate bundles it defaults to the leaf certificate's PEM; for managed-key + # bundles the caller passes the supplied key's SubjectPublicKeyInfo PEM. + def expected_tlog_entry(hashed_input, verifier_pem: leaf_certificate&.to_pem) case content when :message_signature - expected_hashed_rekord_tlog_entry(hashed_input) + expected_hashed_rekord_tlog_entry(hashed_input, verifier_pem) when :dsse_envelope + # The DSSE-v1 (Rekor v1) expected-entry builders embed the signing leaf + # certificate. Key-based (managed-key) DSSE is only supported on Rekor v2, whose + # consistency check runs before this method; reaching here without a leaf + # certificate means a key-based bundle carries a v1 DSSE entry, which we cannot + # build an expected entry for. Fail cleanly rather than dereferencing a nil cert. + if leaf_certificate.nil? + raise Error::InvalidBundle, + "key-based DSSE bundles are only supported with Rekor v2 entries" + end + rekor_entry = verification_material.tlog_entries.first canonicalized_body = begin JSON.parse(rekor_entry.canonicalized_body) @@ -152,9 +172,9 @@ def expected_tlog_entry(hashed_input) case kind_version = canonicalized_body.values_at("kind", "apiVersion") when %w[dsse 0.0.1] - expected_dsse_0_0_1_tlog_entry + expected_dsse_0_0_1_tlog_entry(verifier_pem) when %w[intoto 0.0.2] - expected_intoto_0_0_2_tlog_entry + expected_intoto_0_0_2_tlog_entry(verifier_pem) else raise Error::InvalidRekorEntry, "Unhandled rekor entry kind/version: #{kind_version.inspect}" end @@ -193,7 +213,10 @@ def validate_version! case verification_material.content when :public_key - raise Error::Unimplemented, "public_key content of bundle" + # Managed key: the verifying key is provided out-of-band; the bundle only + # carries a hint identifying it. There is no certificate to anchor. + @signing_key_hint = verification_material.public_key.hint + return when :x509_certificate_chain certs = verification_material.x509_certificate_chain.certificates.map do |cert| Internal::X509::Certificate.read(cert.raw_bytes) @@ -211,13 +234,13 @@ def validate_version! raise Error::InvalidBundle, "expected certificate to be leaf" unless @leaf_certificate.leaf? end - def expected_hashed_rekord_tlog_entry(hashed_input) + def expected_hashed_rekord_tlog_entry(hashed_input, verifier_pem) { "spec" => { "signature" => { "content" => Internal::Util.base64_encode(message_signature.signature), "publicKey" => { - "content" => Internal::Util.base64_encode(leaf_certificate.to_pem) + "content" => Internal::Util.base64_encode(verifier_pem) } }, "data" => { @@ -232,7 +255,7 @@ def expected_hashed_rekord_tlog_entry(hashed_input) } end - def expected_intoto_0_0_2_tlog_entry + def expected_intoto_0_0_2_tlog_entry(_verifier_pem) { "apiVersion" => "0.0.2", "kind" => "intoto", @@ -263,7 +286,7 @@ def expected_intoto_0_0_2_tlog_entry } end - def expected_dsse_0_0_1_tlog_entry + def expected_dsse_0_0_1_tlog_entry(verifier_pem) { "apiVersion" => "0.0.1", "kind" => "dsse", @@ -276,7 +299,7 @@ def expected_dsse_0_0_1_tlog_entry dsse_envelope.signatures.map do |sig| { "signature" => Internal::Util.base64_encode(sig.sig), - "verifier" => Internal::Util.base64_encode(leaf_certificate.to_pem) + "verifier" => Internal::Util.base64_encode(verifier_pem) } end } diff --git a/lib/sigstore/policy.rb b/lib/sigstore/policy.rb index f72155d..79e84ab 100644 --- a/lib/sigstore/policy.rb +++ b/lib/sigstore/policy.rb @@ -87,6 +87,14 @@ def verify(cert) end end + # No identity to check: used for managed-key (bring-your-own-key) verification, + # where trust comes from the supplied key rather than a certificate identity. + class UnsafeNoOp + def verify(_cert) + VerificationSuccess.new + end + end + class Identity def initialize(identity:, issuer:) @identity = identity diff --git a/lib/sigstore/verifier.rb b/lib/sigstore/verifier.rb index ddb7737..a982b2d 100644 --- a/lib/sigstore/verifier.rb +++ b/lib/sigstore/verifier.rb @@ -15,6 +15,7 @@ # limitations under the License. require_relative "trusted_root" +require_relative "policy" require_relative "internal/keyring" require_relative "internal/merkle" require_relative "internal/set" @@ -54,13 +55,24 @@ def self.staging(trust_root: TrustedRoot.staging) for_trust_root(trust_root:) end - def verify(input:, policy:, offline:) + # +key+ is an out-of-band public key (an OpenSSL::PKey) used to verify "managed" + # (bring-your-own-key) bundles, which carry a public key hint instead of a Fulcio + # certificate. It must be supplied for, and only for, such bundles. + def verify(input:, policy:, offline:, key: nil) # First, establish a time for the signature. This timestamp is required to validate the certificate chain, # so this step comes first. bundle = input.sbundle materials = bundle.verification_material + if bundle.key_based? + unless key + return VerificationFailure.new("bundle is signed with a managed key but no verifying key was provided") + end + elsif key + return VerificationFailure.new("a verifying key was provided but the bundle contains a signing certificate") + end + # 1) # If the verification policy uses the Timestamping Service, the Verifier MUST verify the timestamping response # using the Timestamping Service root key material, as described in Spec: Timestamping Service, with the raw bytes @@ -84,7 +96,7 @@ def verify(input:, policy:, offline:) 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 = find_rekor_entry(bundle, input.hashed_input, offline:) + entry = find_rekor_entry(bundle, input.hashed_input, offline:, signing_key: key) rescue Sigstore::Error::MissingRekorEntry return VerificationFailure.new("Rekor entry not found") else @@ -118,44 +130,60 @@ def verify(input:, policy:, offline:) # 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. + # + # Steps 3-5 are certificate-specific: a managed-key bundle has no certificate to do + # path validation, SCT verification, or identity-policy checks against, so they are + # skipped. The signature (and its binding to the Rekor entry) is still verified below + # against the supplied key. - chains = timestamps.map do |ts| - chain, err = Internal::X509.validate_chain(@fulcio_cert_chains, bundle.leaf_certificate, ts) - return err if err - - chain + if bundle.key_based? && !policy.is_a?(Policy::UnsafeNoOp) + logger.warn do + "ignoring identity policy #{policy.class} for managed-key bundle: managed-key " \ + "verification trusts the supplied key, not a certificate identity" + end end - 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 + unless bundle.key_based? + chains = timestamps.map do |ts| + chain, err = Internal::X509.validate_chain(@fulcio_cert_chains, bundle.leaf_certificate, ts) + return err if err - # 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 - if (result = verify_scts(bundle.leaf_certificate, chain)) && !result.verified? - return result - end + chain + end + + 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 - # 5) - # The Verifier MUST then check the certificate against the verification policy. + # 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 + if (result = verify_scts(bundle.leaf_certificate, chain)) && !result.verified? + return result + end - usage_ext = bundle.leaf_certificate.extension(Internal::X509::Extension::KeyUsage) - return VerificationFailure.new("Key usage is not of type `digital signature`") unless usage_ext.digital_signature + # 5) + # The Verifier MUST then check the certificate against the verification policy. - extended_key_usage = bundle.leaf_certificate.extension(Internal::X509::Extension::ExtendedKeyUsage) - unless extended_key_usage.code_signing? - return VerificationFailure.new("Extended key usage is not of type `code signing`") - end + usage_ext = bundle.leaf_certificate.extension(Internal::X509::Extension::KeyUsage) + unless usage_ext.digital_signature + return VerificationFailure.new("Key usage is not of type `digital signature`") + end - policy_check = policy.verify(bundle.leaf_certificate) - return policy_check unless policy_check.verified? + extended_key_usage = bundle.leaf_certificate.extension(Internal::X509::Extension::ExtendedKeyUsage) + unless extended_key_usage.code_signing? + return VerificationFailure.new("Extended key usage is not of type `code signing`") + end + + policy_check = policy.verify(bundle.leaf_certificate) + return policy_check unless policy_check.verified? + end # 6) # By this point, the Verifier has already verified the signature by the Transparency Service (§Establishing a Time @@ -170,7 +198,7 @@ def verify(input:, policy:, offline:) # * 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 = bundle.leaf_certificate.public_key + signing_key = bundle.key_based? ? key : bundle.leaf_certificate.public_key case bundle.content when :message_signature @@ -475,7 +503,7 @@ def extract_timestamp_from_verification_data(data, signed_data) end end - def find_rekor_entry(bundle, hashed_input, offline:) + def find_rekor_entry(bundle, hashed_input, offline:, signing_key: nil) raise Error::InvalidBundle, "multiple tlog entries" if bundle.verification_material.tlog_entries.size > 1 rekor_entry = bundle.verification_material.tlog_entries&.first @@ -500,11 +528,12 @@ def find_rekor_entry(bundle, hashed_input, offline:) raise Error::InvalidBundle, "Rekor v2 entry must contain an inclusion proof with a checkpoint" end - verify_rekor_v2_entry_consistency(bundle, hashed_input, v2_body) + verify_rekor_v2_entry_consistency(bundle, hashed_input, v2_body, signing_key:) return rekor_entry end - expected_entry = bundle.expected_tlog_entry(hashed_input) + verifier_pem = signing_key&.public_to_pem || bundle.leaf_certificate&.to_pem + expected_entry = bundle.expected_tlog_entry(hashed_input, verifier_pem:) entry = if offline logger.debug { "Offline verification, skipping rekor" } @@ -571,20 +600,30 @@ def parse_canonicalized_body(entry) # checkpoint signature bind this body to the log; #find_rekor_entry has already # confirmed both are present, and #verify performs that verification. Here we bind # the body to the inputs we are verifying. - def verify_rekor_v2_entry_consistency(bundle, hashed_input, body) + def verify_rekor_v2_entry_consistency(bundle, hashed_input, body, signing_key: nil) spec = body.dig("spec", "hashedRekordV002") raise Error::InvalidRekorEntry, "missing hashedRekordV002 spec" unless spec logged_algorithm = spec.dig("data", "algorithm") logged_digest = decode_base64_field(spec.dig("data", "digest"), "data.digest") logged_signature = decode_base64_field(spec.dig("signature", "content"), "signature.content") - logged_cert = decode_base64_field( - spec.dig("signature", "verifier", "x509Certificate", "rawBytes"), - "signature.verifier.x509Certificate.rawBytes" - ) - unless logged_cert == bundle.leaf_certificate.to_der - raise Error::InvalidRekorEntry, "rekor entry certificate does not match the bundle certificate" + if bundle.key_based? + logged_key = decode_base64_field( + spec.dig("signature", "verifier", "publicKey", "rawBytes"), + "signature.verifier.publicKey.rawBytes" + ) + unless logged_key == signing_key.public_to_der + raise Error::InvalidRekorEntry, "rekor entry public key does not match the supplied key" + end + else + logged_cert = decode_base64_field( + spec.dig("signature", "verifier", "x509Certificate", "rawBytes"), + "signature.verifier.x509Certificate.rawBytes" + ) + unless logged_cert == bundle.leaf_certificate.to_der + raise Error::InvalidRekorEntry, "rekor entry certificate does not match the bundle certificate" + end end expected_algorithm, expected_digest, expected_signature = diff --git a/test/sigstore/cli_test.rb b/test/sigstore/cli_test.rb new file mode 100644 index 0000000..bf7cff3 --- /dev/null +++ b/test/sigstore/cli_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "test_helper" +require "sigstore/cli" + +class Sigstore::CLITest < Test::Unit::TestCase + ASSETS = "test/sigstore-conformance/test/assets/bundle-verify" + + def test_display_managed_key_bundle + bundle = "#{ASSETS}/managed-key-happy-path/bundle.sigstore.json" + out, = capture_output do + assert_nothing_raised { Sigstore::CLI.start(["display", bundle]) } + end + # A managed-key bundle has no certificate; display should report the public key + # hint rather than crashing on a missing leaf certificate. + assert_match(%r{Public Key.*TLMsSDfG3ajPsWge\+z/vX5T/zluXnmvbkTkwLIV68Tk=}m, out) + end + + def test_display_certificate_bundle + bundle = "#{ASSETS}/happy-path-v0.3/bundle.sigstore.json" + out, = capture_output do + assert_nothing_raised { Sigstore::CLI.start(["display", bundle]) } + end + assert_match(/Certificate:/, out) + end +end diff --git a/test/sigstore/conformance_test.rb b/test/sigstore/conformance_test.rb index e6efdf7..82573f2 100644 --- a/test/sigstore/conformance_test.rb +++ b/test/sigstore/conformance_test.rb @@ -90,4 +90,44 @@ def test_verify_rekor2_missing_timestamp_fails assert_equal 1, e.status end end + + # Managed-key ("bring your own key") verification: the bundle carries a public key + # hint instead of a Fulcio certificate, and the verifying key is supplied out-of-band + # via --key. There is no identity to check, so certificate-path/SCT/identity steps are + # skipped; the signature and the Rekor entry are still bound to the supplied key. + def verify_key(case_name, trusted_root: PROD_TRUSTED_ROOT, key: "#{ASSETS}/#{case_name}/key.pub") + args = ["verify", "--offline", "--trusted-root", trusted_root, + "--bundle", "#{ASSETS}/#{case_name}/bundle.sigstore.json", "#{ASSETS}/a.txt"] + args.push("--key", key) if key + Sigstore::CLI.start(args) + end + + def test_verify_managed_key_happy_path + capture_output do + assert_nothing_raised { verify_key("managed-key-happy-path") } + end + end + + def test_verify_managed_key_with_trusted_root + case_name = "managed-key-and-trusted-root" + capture_output do + assert_nothing_raised do + verify_key(case_name, trusted_root: "#{ASSETS}/#{case_name}/trusted_root.json") + end + end + end + + def test_verify_managed_key_no_key_fails + capture_output do + e = assert_raise(SystemExit) { verify_key("managed-key-no-key_fail", key: nil) } + assert_equal 1, e.status + end + end + + def test_verify_managed_key_wrong_key_fails + capture_output do + e = assert_raise(SystemExit) { verify_key("managed-key-wrong-key_fail") } + assert_equal 1, e.status + end + end end From 9a6c13f81ce1a24ece6d0520455420a0f4d1584e Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Fri, 19 Jun 2026 16:55:27 -0500 Subject: [PATCH 05/11] Update to protobug 0.2.0 / sigstore protobuf-specs v0.5.1 Bump the protobug_sigstore_protos dependency from ~> 0.1.0 to ~> 0.2.0, which pulls in protobuf-specs v0.5.1. Take advantage of two additions the new specs ship: - SigningConfig v0.2 is now a compiled message, so signing_config.rb decodes the document through Sigstore::TrustRoot::V1::SigningConfig instead of parsing the JSON by hand. Only the service-selection algorithm (per-operator collapse + ANY/EXACT/ALL selector) remains in Ruby. An unset EXACT count now defaults to the proto zero value, which the selector logic rejects as invalid. - Artifact gained a typed artifact_digest oneof variant. VerificationInput now accepts it alongside the raw-bytes and sha256: URI forms, extracted into a testable VerificationInput.hashed_input_for. Signed-off-by: Samuel Giddins --- Gemfile.lock | 30 +++++----- lib/sigstore/models.rb | 25 ++++++-- lib/sigstore/signing_config.rb | 85 ++++++++++++---------------- sigstore.gemspec | 2 +- test/sigstore/models_test.rb | 48 ++++++++++++++++ test/sigstore/signing_config_test.rb | 6 +- 6 files changed, 124 insertions(+), 72 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c531d0f..32c4163 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,7 +4,7 @@ PATH sigstore (0.2.3) logger net-http - protobug_sigstore_protos (~> 0.1.0) + protobug_sigstore_protos (~> 0.2.0) uri PATH @@ -48,16 +48,16 @@ GEM racc power_assert (2.0.5) prism (1.9.0) - protobug (0.1.0) - protobug_googleapis_field_behavior_protos (0.1.0) - protobug (= 0.1.0) - protobug_well_known_protos (= 0.1.0) - protobug_sigstore_protos (0.1.0) - protobug (= 0.1.0) - protobug_googleapis_field_behavior_protos (= 0.1.0) - protobug_well_known_protos (= 0.1.0) - protobug_well_known_protos (0.1.0) - protobug (= 0.1.0) + protobug (0.2.0) + protobug_googleapis_field_behavior_protos (0.2.0) + protobug (= 0.2.0) + protobug_well_known_protos (= 0.2.0) + protobug_sigstore_protos (0.2.0) + protobug (= 0.2.0) + protobug_googleapis_field_behavior_protos (= 0.2.0) + protobug_well_known_protos (= 0.2.0) + protobug_well_known_protos (0.2.0) + protobug (= 0.2.0) public_suffix (6.0.1) racc (1.8.1) racc (1.8.1-java) @@ -153,10 +153,10 @@ CHECKSUMS parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357 power_assert (2.0.5) sha256=63b511b85bb8ea57336d25156864498644f5bbf028699ceda27949e0125bc323 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 - protobug (0.1.0) sha256=5bf1356cedf99dcf311890743b78f5e602f62ca703e574764337f1996b746bf2 - protobug_googleapis_field_behavior_protos (0.1.0) sha256=db48ef6a5913b2355b4a6931ab400a9e3e995fb48499977a3ad0be6365f9e265 - protobug_sigstore_protos (0.1.0) sha256=4ad1eebaf6454131b6f432dda50ad0e513773613474b92470847614a5acacce1 - protobug_well_known_protos (0.1.0) sha256=356757f562453bb34a28f12e8e9fa357346cca35a6807a549837c3fe256bb5b3 + protobug (0.2.0) sha256=eb154bdbe2a3afe796711e0c16e89b2ed59efee0f172a10b634400471299b3f2 + protobug_googleapis_field_behavior_protos (0.2.0) sha256=5d4ddcdcfd8616a74ac100de893e3768feee631579a777e23498d3a0ba45b893 + protobug_sigstore_protos (0.2.0) sha256=b094cbb2ceadc9ffe6e34ec217613dd7a8e78b6411bf4404bd686f7343514e25 + protobug_well_known_protos (0.2.0) sha256=89dd4965ed80ed9355b575e9243e7762487de581a8e2b34a97af6bef60fe3b3f public_suffix (6.0.1) sha256=61d44e1cab5cbbbe5b31068481cf16976dd0dc1b6b07bd95617ef8c5e3e00c6f racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f racc (1.8.1-java) sha256=54f2e6d1e1b91c154013277d986f52a90e5ececbe91465d29172e49342732b98 diff --git a/lib/sigstore/models.rb b/lib/sigstore/models.rb index 52c5165..7cb3841 100644 --- a/lib/sigstore/models.rb +++ b/lib/sigstore/models.rb @@ -92,6 +92,16 @@ def initialize(*) raise Error::InvalidVerificationInput, "bundle with message_signature requires an artifact" end + @hashed_input = self.class.hashed_input_for(artifact) + + freeze + end + + # Derive the SHA2-256 HashOutput the verifier checks signatures and Rekor + # entries against, from any of the Artifact oneof variants: the raw bytes + # (:artifact), a "sha256:"-prefixed URI (:artifact_uri), or a typed digest + # (:artifact_digest, protobuf-specs v0.5.1+). + def self.hashed_input_for(artifact) case artifact.data when :artifact_uri unless artifact.artifact_uri.start_with?("sha256:") @@ -99,21 +109,28 @@ def initialize(*) "artifact_uri must be prefixed with 'sha256:'" end - @hashed_input = Common::V1::HashOutput.new.tap do |hash_output| + Common::V1::HashOutput.new.tap do |hash_output| hash_output.algorithm = Common::V1::HashAlgorithm::SHA2_256 hexdigest = artifact.artifact_uri.split(":", 2).last hash_output.digest = Internal::Util.hex_decode(hexdigest) end when :artifact - @hashed_input = Common::V1::HashOutput.new.tap do |hash_output| + Common::V1::HashOutput.new.tap do |hash_output| hash_output.algorithm = Common::V1::HashAlgorithm::SHA2_256 hash_output.digest = OpenSSL::Digest.new("SHA256").update(artifact.artifact).digest end + when :artifact_digest + # The rest of the pipeline (message-signature and Rekor digest checks) + # operates on SHA2-256, so reject other algorithms. + artifact.artifact_digest.tap do |hash_output| + unless hash_output.algorithm == Common::V1::HashAlgorithm::SHA2_256 + raise Error::InvalidVerificationInput, + "unsupported artifact digest algorithm: #{hash_output.algorithm}" + end + end else raise Error::InvalidVerificationInput, "Unsupported artifact data: #{artifact.data}" end - - freeze end end diff --git a/lib/sigstore/signing_config.rb b/lib/sigstore/signing_config.rb index 47b943d..031b1da 100644 --- a/lib/sigstore/signing_config.rb +++ b/lib/sigstore/signing_config.rb @@ -14,8 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -require "json" -require "time" +require "protobug_sigstore_protos" require_relative "error" @@ -24,46 +23,50 @@ module Sigstore # services (Fulcio CA, OIDC provider, Rekor transparency log, Timestamping # Authority) a client should use to sign. # - # The protobuf-specs gem does not yet ship the v0.2 SigningConfig message - # (only the obsolete v0.1 flat-URL form), so this parses the JSON directly. - # The service-selection algorithm mirrors sigstore-python's - # SigningConfig._get_valid_services: per kind, keep services whose major API - # version is supported and whose validity window covers now, collapse to one - # service per operator (highest supported version), then apply the - # ServiceConfiguration selector (ANY/EXACT/ALL). + # The document is decoded through the protobuf-specs SigningConfig message + # (shipped since protobug_sigstore_protos 0.2.0). The service-selection + # algorithm mirrors sigstore-python's SigningConfig._get_valid_services: per + # kind, keep services whose major API version is supported and whose validity + # window covers now, collapse to one service per operator (highest supported + # version), then apply the ServiceConfiguration selector (ANY/EXACT/ALL). class SigningConfig MEDIA_TYPE = "application/vnd.dev.sigstore.signingconfig.v0.2+json" + REGISTRY = Protobug::Registry.new do |registry| + Sigstore::TrustRoot::V1.register_sigstore_trustroot_protos(registry) + end + + Selector = Sigstore::TrustRoot::V1::ServiceSelector + REKOR_VERSIONS = [1, 2].freeze TSA_VERSIONS = [1].freeze FULCIO_VERSIONS = [1].freeze OIDC_VERSIONS = [1].freeze - Service = Struct.new(:url, :major_api_version, :valid_for, :operator) - def self.from_file(path) from_json(Gem.read_binary(path)) end def self.from_json(contents) - new(JSON.parse(contents)) + new(Sigstore::TrustRoot::V1::SigningConfig.decode_json(contents, registry: REGISTRY)) + rescue Protobug::Error => e + raise Error::InvalidSigningConfig, "invalid signing config: #{e.message}" end - def initialize(raw) - media_type = raw["mediaType"] - unless media_type == MEDIA_TYPE - raise Error::InvalidSigningConfig, "unsupported signing config format: #{media_type.inspect}" + def initialize(config) + unless config.media_type == MEDIA_TYPE + raise Error::InvalidSigningConfig, "unsupported signing config format: #{config.media_type.inspect}" end - @fulcios = select_services(raw["caUrls"], FULCIO_VERSIONS, nil) + @fulcios = select_services(config.ca_urls, FULCIO_VERSIONS, nil) raise Error::InvalidSigningConfig, "No valid Fulcio CA found in signing config" if @fulcios.empty? - @oidcs = select_services(raw["oidcUrls"], OIDC_VERSIONS, nil) + @oidcs = select_services(config.oidc_urls, OIDC_VERSIONS, nil) - @tlogs = select_services(raw["rekorTlogUrls"], REKOR_VERSIONS, raw["rekorTlogConfig"]) + @tlogs = select_services(config.rekor_tlog_urls, REKOR_VERSIONS, config.rekor_tlog_config) raise Error::InvalidSigningConfig, "No valid Rekor transparency log found in signing config" if @tlogs.empty? - @tsas = select_services(raw["tsaUrls"], TSA_VERSIONS, raw["tsaConfig"]) + @tsas = select_services(config.tsa_urls, TSA_VERSIONS, config.tsa_config) end # The Rekor transparency log to submit the signing metadata to. @@ -87,8 +90,6 @@ def tsa_urls private def select_services(services, supported_versions, config) - services = parse_services(services) - by_operator = Hash.new { |h, k| h[k] = [] } services.each do |service| next unless supported_versions.include?(service.major_api_version) @@ -102,11 +103,17 @@ def select_services(services, supported_versions, config) op_services.max_by(&:major_api_version) end - selector = config && config["selector"] - return result if selector.nil? || selector == "ALL" + # An absent ServiceConfiguration (or the ALL selector) imposes no count + # constraint, so every per-operator service is returned. + return result if config.nil? || config.selector == Selector::ALL + + if config.selector == Selector::EXACT + count = config.count + unless count.is_a?(Integer) && count.positive? + raise Error::InvalidSigningConfig, + "EXACT selector requires a positive integer count, got #{count.inspect}" + end - if selector == "EXACT" - count = exact_count(config) # EXACT means exactly `count` services must remain after filtering and # per-operator collapsing; neither too few nor too many is acceptable. unless result.size == count @@ -119,34 +126,12 @@ def select_services(services, supported_versions, config) result.first(1) end - def exact_count(config) - Integer(config["count"]) - rescue TypeError, ArgumentError - raise Error::InvalidSigningConfig, - "EXACT selector requires an integer count, got #{config["count"].inspect}" - end - - def parse_services(services) - Array(services).map do |service| - valid_for = service["validFor"] - Service.new( - url: service.fetch("url"), - major_api_version: service["majorApiVersion"] || 0, - valid_for: valid_for && { - start: valid_for["start"] && Time.iso8601(valid_for["start"]), - end: valid_for["end"] && Time.iso8601(valid_for["end"]) - }, - operator: service["operator"] || "" - ) - end - end - def timerange_valid?(period) return true unless period now = Time.now.utc - return false if period[:start] && now < period[:start] - return false if period[:end] && now > period[:end] + return false if period.start && now < period.start.to_time + return false if period.end && now > period.end.to_time true end diff --git a/sigstore.gemspec b/sigstore.gemspec index d4c33fe..1e422dd 100644 --- a/sigstore.gemspec +++ b/sigstore.gemspec @@ -29,7 +29,7 @@ Gem::Specification.new do |spec| spec.add_dependency "logger" spec.add_dependency "net-http" - spec.add_dependency "protobug_sigstore_protos", "~> 0.1.0" + spec.add_dependency "protobug_sigstore_protos", "~> 0.2.0" spec.add_dependency "uri" spec.metadata["rubygems_mfa_required"] = "true" diff --git a/test/sigstore/models_test.rb b/test/sigstore/models_test.rb index 3dc9848..0cf1b3e 100644 --- a/test/sigstore/models_test.rb +++ b/test/sigstore/models_test.rb @@ -38,4 +38,52 @@ def test_verification_input_bundle_missing_verification_material e = assert_raise(Sigstore::Error::InvalidBundle) { Sigstore::VerificationInput.new(verification_input) } assert_equal("bundle requires verification material", e.message) end + + DIGEST = OpenSSL::Digest.new("SHA256").update("hello world").digest + + def test_from_raw_artifact + artifact = Sigstore::Verification::V1::Artifact.new.tap { _1.artifact = "hello world" } + hashed = Sigstore::VerificationInput.hashed_input_for(artifact) + assert_equal Sigstore::Common::V1::HashAlgorithm::SHA2_256, hashed.algorithm + assert_equal DIGEST, hashed.digest + end + + def test_from_artifact_uri + artifact = Sigstore::Verification::V1::Artifact.new.tap do |a| + a.artifact_uri = "sha256:#{DIGEST.unpack1("H*")}" + end + assert_equal DIGEST, Sigstore::VerificationInput.hashed_input_for(artifact).digest + end + + def test_from_artifact_uri_rejects_non_sha256 + artifact = Sigstore::Verification::V1::Artifact.new.tap { _1.artifact_uri = "sha512:abcd" } + e = assert_raise(Sigstore::Error::InvalidVerificationInput) do + Sigstore::VerificationInput.hashed_input_for(artifact) + end + assert_include e.message, "must be prefixed with 'sha256:'" + end + + # protobuf-specs v0.5.1 added the typed artifact_digest oneof variant. + def test_from_artifact_digest + artifact = Sigstore::Verification::V1::Artifact.new.tap do |a| + a.artifact_digest = Sigstore::Common::V1::HashOutput.new.tap do |h| + h.algorithm = Sigstore::Common::V1::HashAlgorithm::SHA2_256 + h.digest = DIGEST + end + end + assert_equal DIGEST, Sigstore::VerificationInput.hashed_input_for(artifact).digest + end + + def test_from_artifact_digest_rejects_non_sha256 + artifact = Sigstore::Verification::V1::Artifact.new.tap do |a| + a.artifact_digest = Sigstore::Common::V1::HashOutput.new.tap do |h| + h.algorithm = Sigstore::Common::V1::HashAlgorithm::SHA2_512 + h.digest = "x" + end + end + e = assert_raise(Sigstore::Error::InvalidVerificationInput) do + Sigstore::VerificationInput.hashed_input_for(artifact) + end + assert_include e.message, "unsupported artifact digest algorithm" + end end diff --git a/test/sigstore/signing_config_test.rb b/test/sigstore/signing_config_test.rb index 355c3e7..cf2d6d1 100644 --- a/test/sigstore/signing_config_test.rb +++ b/test/sigstore/signing_config_test.rb @@ -92,18 +92,20 @@ def test_exact_selector_requires_count end def test_exact_selector_missing_count_raises_invalid_signing_config + # The proto defaults an unset count to 0, which the spec forbids for EXACT. raw = STAGING_LIKE.merge("rekorTlogConfig" => { "selector" => "EXACT" }) Timecop.freeze(Time.utc(2026, 1, 1)) do error = assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } - assert_include error.message, "EXACT selector requires an integer count" + assert_include error.message, "EXACT selector requires a positive integer count" end end def test_exact_selector_non_numeric_count_raises_invalid_signing_config + # A non-integer count is rejected when decoding the uint32 proto field. raw = STAGING_LIKE.merge("rekorTlogConfig" => { "selector" => "EXACT", "count" => "two" }) Timecop.freeze(Time.utc(2026, 1, 1)) do error = assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } - assert_include error.message, "EXACT selector requires an integer count" + assert_include error.message, "invalid signing config" end end From 5be92542289002e5c1b93e0ece44bb787d467ca2 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Sat, 20 Jun 2026 13:50:04 -0500 Subject: [PATCH 06/11] Add DSSE/in-toto signing and clear stale conformance xfails Implement signing an in-toto statement as a DSSE envelope, the last conformance signing case sigstore-ruby did not support. `sign-bundle --in-toto` now signs the file's DSSE Pre-Authentication Encoding, wraps it in an io.intoto.Envelope, and submits the transparency-log entry as a Rekor v1 `dsse` 0.0.1 entry (or, against a Rekor v2 instance, a hashedrekord over the PAE digest, matching the verification side). The bundle carries the envelope rather than a message signature, and is self-verified against the statement's first subject digest. Drop the managed-key xfails from the Rakefile and ci.yml: managed-key verification has been supported since the previous commit, so those cases now pass and were failing CI as strict XPASS. The full conformance suite now passes with no xfails beyond the conditional TSA timestamp case. Signed-off-by: Samuel Giddins --- .github/workflows/ci.yml | 16 +-- Rakefile | 47 +++++--- cli/lib/sigstore/cli.rb | 13 ++- lib/sigstore/internal/util.rb | 7 ++ lib/sigstore/signer.rb | 175 +++++++++++++++++++++++------- lib/sigstore/verifier.rb | 4 +- test/sigstore/cli_test.rb | 18 +++ test/sigstore/conformance_test.rb | 8 ++ test/sigstore/signer_test.rb | 49 +++++++++ 9 files changed, 274 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 443787a..e8d502e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,22 +85,24 @@ jobs: ruby-version: ${{ matrix.ruby }} bundler-cache: true + # The xfail set depends on the OpenSSL build's behavior (ruby/openssl#770), so it is + # computed in the Rakefile (conformance_xfails) and shared with local `rake + # conformance` runs rather than duplicated as a Ruby-version guess here. + - name: Compute the conformance xfails for this Ruby/OpenSSL build + id: conformance_xfails + run: echo "xfail=$(bundle exec rake conformance_xfails)" >> "$GITHUB_OUTPUT" + - name: Run the conformance tests uses: sigstore/sigstore-conformance@21533cde107c734ebc153c3e3a24d75fc9811a36 # v0.0.29 with: entrypoint: ${{ github.workspace }}/bin/conformance-entrypoint - # In addition to the conditional TSA xfail, mark conformance cases for - # features sigstore-ruby does not yet support: managed-key (bring-your-own - # key) verification. Signing to and verification of Rekor v2 instances is - # supported and runs in the suite. The same set is reused for the staging - # step below via the YAML anchor. - xfail: &conformance_xfail "${{ matrix.ruby != 'head' && matrix.ruby != 'truffleruby-head' && matrix.ruby != '3.4' && matrix.ruby != '4.0' && 'test_verify_rejects_bad_tsa_timestamp' }} *-managed-key-happy-path] *-managed-key-and-trusted-root]" + xfail: ${{ steps.conformance_xfails.outputs.xfail }} if: ${{ matrix.os }} == "ubuntu-latest" - name: Run the conformance tests against staging uses: sigstore/sigstore-conformance@21533cde107c734ebc153c3e3a24d75fc9811a36 # v0.0.29 with: entrypoint: ${{ github.workspace }}/bin/conformance-entrypoint - xfail: *conformance_xfail + xfail: ${{ steps.conformance_xfails.outputs.xfail }} environment: staging if: ${{ matrix.os }} == "ubuntu-latest" diff --git a/Rakefile b/Rakefile index 096ab9d..ea580d9 100644 --- a/Rakefile +++ b/Rakefile @@ -24,20 +24,39 @@ RuboCop::RakeTask.new task default: %i[test conformance_staging conformance conformance_tuf rubocop] require "openssl" -# Checks for https://github.com/ruby/openssl/pull/770 -tsa_xfail = OpenSSL::X509::Store.new.instance_variable_defined?(:@time) ? "test_verify_rejects_bad_tsa_timestamp" : "" - -# Conformance test cases that exercise features sigstore-ruby does not yet -# support: verification with a managed (bring-your-own) key. Signing to (and -# verification of) Rekor v2 instances is supported. Patterns are fnmatch-ed -# against pytest node names; the trailing "]" anchors to the positive cases -# without matching their "_fail" siblings, which we already reject. -unsupported_conformance_xfails = %w( - *-managed-key-happy-path] - *-managed-key-and-trusted-root] -) +# On OpenSSL builds with a broken X509::Store#time (ruby/openssl#770) RFC 3161 timestamps +# cannot be verified. Rekor v2 (tiled) entries have no integrated time, so every positive +# v2 case — and the v2 sign+verify roundtrip — fails closed there, and the negative TSA +# cases that depend on the timestamp check no longer reject. Patched builds verify them, +# so these xfails are conditional on the actual (broken) behavior rather than on the Ruby +# version. Negative rekor2 *_fail cases are intentionally absent: they still reject (for +# lack of trusted time) and would XPASS under pytest's strict xfail. +xfail = + if OpenSSL::X509::Store.new.instance_variable_defined?(:@time) + %w[ + test_verify_rejects_bad_tsa_timestamp + *rekor2-happy-path* + *rekor2-dsse-happy-path* + *rekor2-checkpoint-cosigned* + *rekor2-checkpoint-two-sigs-cosigned* + *rekor2-checkpoint-multiple-cosigs* + *rekor2-checkpoint-origin-not-first* + *rekor2-checkpoint-two-sigs-from-origin* + *rekor2-timestamp-with-embedded-cert* + *rekor2-timestamp-with-expired-cert-chain* + *rekor2-timestamp-without-embedded-cert* + *intoto-tsa-timestamp-outside-cert-validity_fail* + *bundle-with-sct-with-extensions* + test_sign_verify_rekor2 + ].join(" ") + else + "" + end -xfail = ([tsa_xfail] + unsupported_conformance_xfails).reject(&:empty?).join(" ") +desc "Print the conformance xfail patterns for the current Ruby/OpenSSL build" +task :conformance_xfails do + print xfail +end desc "Run the conformance tests" task conformance: %w[conformance:setup] do @@ -71,7 +90,7 @@ end task :find_action_versions do # rubocop:disable Rake/Desc require "yaml" - # ci.yml uses a YAML anchor (&conformance_xfail) to share the conformance xfail set. + # Enable aliases in case the workflow uses YAML anchors. gh = YAML.load_file(".github/workflows/ci.yml", aliases: true) actions = gh.fetch("jobs").flat_map { |_, job| job.fetch("steps", []).filter_map { |step| step.fetch("uses", nil) } } .uniq.map { |x| x.split("@", 2) } diff --git a/cli/lib/sigstore/cli.rb b/cli/lib/sigstore/cli.rb index 01fc754..11d4912 100644 --- a/cli/lib/sigstore/cli.rb +++ b/cli/lib/sigstore/cli.rb @@ -94,7 +94,15 @@ def verify(*files) option :trusted_root, type: :string, desc: "Path to the trusted root" option :signing_config, type: :string, desc: "Path to the signing config" option :update_trusted_root, type: :boolean, desc: "Update the trusted root", default: true + option :in_toto, type: :boolean, desc: "Sign the file as an in-toto statement in a DSSE envelope" def sign(file) + # A DSSE bundle carries an envelope, not a message signature, so the + # detached --signature/--certificate outputs of the message-signature flow + # do not apply. + if options[:in_toto] && (options[:signature] || options[:certificate]) + raise Thor::InvocationError, "--in-toto cannot be combined with --signature or --certificate" + end + self.options = options.merge(identity_token: IdToken.detect_credential).freeze if options[:identity_token].nil? unless options[:identity_token] raise Error::InvalidIdentityToken, @@ -102,11 +110,12 @@ def sign(file) end contents = File.binread(file) - bundle = Sigstore::Signer.new( + signer = Sigstore::Signer.new( jwt: options[:identity_token], trusted_root:, signing_config: - ).sign(contents) + ) + bundle = options[:in_toto] ? signer.sign_dsse(contents) : signer.sign(contents) File.binwrite(options[:bundle], bundle.to_json) if options[:bundle] if options[:signature] diff --git a/lib/sigstore/internal/util.rb b/lib/sigstore/internal/util.rb index f237d52..559e0cf 100644 --- a/lib/sigstore/internal/util.rb +++ b/lib/sigstore/internal/util.rb @@ -47,6 +47,13 @@ def base64_encode(string) def base64_decode(string) string.unpack1("m0") end + + # The DSSE Pre-Authentication Encoding over a payload and its type, as + # defined by the DSSE v1 spec. This is the byte string that is signed (and + # verified) for a DSSE envelope. + def dsse_pae(payload_type, payload) + "DSSEv1 #{payload_type.bytesize} #{payload_type} #{payload.bytesize} #{payload}".b + end end end end diff --git a/lib/sigstore/signer.rb b/lib/sigstore/signer.rb index daae11e..b4f481b 100644 --- a/lib/sigstore/signer.rb +++ b/lib/sigstore/signer.rb @@ -34,25 +34,20 @@ def initialize(jwt:, trusted_root:, signing_config: nil) @verifier = Verifier.for_trust_root(trust_root: @trusted_root) end + IN_TOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json" + def sign(payload) - # 2) generate a keypair - keypair = generate_keypair - # 3) generate a CreateSigningCertificateRequest - csr = generate_csr(keypair) - # 4) get a cert chain from fulcio - leaf = fetch_cert(csr) - # 5) verify returned cert chain - verify_chain(leaf) - # 6) sign the payload + keypair, leaf = issue_signing_certificate + + # Sign the payload, then submit a hash of the signature to the TSA and the + # signing metadata (a hashedrekord) to the transparency log. signature = sign_payload(payload, keypair) - # 7) send hash of signature to timestamping service timestamp_verification_data = submit_signature_hash_to_timstamping_service(signature) - # 8) submit signed metadata to transparency service + hashed_input = Common::V1::HashOutput.new hashed_input.algorithm = Common::V1::HashAlgorithm::SHA2_256 hashed_input.digest = OpenSSL::Digest("SHA256").digest(payload) - tlog_entry = submit_signed_metadata_to_transparency_service(signature, leaf, hashed_input) - # 9) perform verification + tlog_entry = submit_message_signature_entry(signature, leaf, hashed_input) bundle = collect_bundle(leaf, [tlog_entry], timestamp_verification_data, hashed_input, signature) verify(payload, bundle) @@ -60,8 +55,39 @@ def sign(payload) bundle end + # Sign +payload+ (an in-toto statement) as a DSSE envelope, returning a + # bundle whose content is the envelope rather than a message signature. + def sign_dsse(payload, payload_type: IN_TOTO_PAYLOAD_TYPE) + keypair, leaf = issue_signing_certificate + + # DSSE signs the Pre-Authentication Encoding of the payload, not the + # payload itself. + pae = Internal::Util.dsse_pae(payload_type, payload) + signature = sign_payload(pae, keypair) + envelope = build_dsse_envelope(payload, payload_type, signature) + + timestamp_verification_data = submit_signature_hash_to_timstamping_service(signature) + tlog_entry = submit_dsse_entry(envelope, leaf, pae) + + bundle = collect_dsse_bundle(leaf, [tlog_entry], timestamp_verification_data, envelope) + verify_dsse_statement(payload, bundle) + + bundle + end + private + # Generate an ephemeral keypair, obtain a Fulcio signing certificate bound to + # the identity token, and verify the returned chain. Shared by all signing + # flows; returns [keypair, leaf_certificate]. + def issue_signing_certificate + keypair = generate_keypair + csr = generate_csr(keypair) + leaf = fetch_cert(csr) + verify_chain(leaf) + [keypair, leaf] + end + def generate_keypair # maybe allow configuring? key = OpenSSL::PKey::EC.generate("prime256v1") @@ -277,23 +303,26 @@ def build_proposed_hashed_rekord_entry(signature, cert, hashed_input) } end - def submit_signed_metadata_to_transparency_service(signature, cert, hashed_input) - # The Signer chooses a format for signing metadata; this format MUST be in the supportedMetadataFormats in the - # Transparency Service configuration. The Signer prepares signing metadata containing at a minimum: - # * The signature. - # * The payload (possibly pre-hashed; if so, the entry also includes the identifier of the hash algorithm). - # * Verification material (signing certificate or verification key). - # * If the verification material is a certificate, the client SHOULD upload only the signing certificate and - # SHOULD NOT upload the CA certificate chain. - # - # The signing metadata might contain additional, application-specific metadata according to the format used. - # The Signer then canonically encodes the metadata (according to the chosen format). - + # Submit a hashedrekord entry for a message signature: the prehash digest of + # the artifact, signed. + # + # The Signer chooses a format for signing metadata; this format MUST be in the supportedMetadataFormats in the + # Transparency Service configuration. The Signer prepares signing metadata containing at a minimum: + # * The signature. + # * The payload (possibly pre-hashed; if so, the entry also includes the identifier of the hash algorithm). + # * Verification material (signing certificate or verification key). + # * If the verification material is a certificate, the client SHOULD upload only the signing certificate and + # SHOULD NOT upload the CA certificate chain. + # + # The signing metadata might contain additional, application-specific metadata according to the format used. + # The Signer then canonically encodes the metadata (according to the chosen format). + def submit_message_signature_entry(signature, cert, hashed_input) # A Rekor v2 (tiled) instance speaks a different API and a different entry # format (hashedrekord 0.0.2). When the signing config selects one, submit # there; otherwise fall back to the v1 hashedrekord flow. - if @signing_config && @signing_config.tlog.major_api_version == 2 - return submit_rekor_v2_entry(signature, cert, hashed_input) + if rekor_v2? + request = build_create_entry_request(hashed_input.digest, signature, cert) + return submit_rekor_v2_entry(request) end # TODO: allow configuring the entry kind? @@ -306,21 +335,42 @@ def submit_signed_metadata_to_transparency_service(signature, cert, hashed_input @verifier.rekor_client.log.entries.post(proposed_entry) end - def submit_rekor_v2_entry(signature, cert, hashed_input) + # Submit a transparency log entry for a DSSE envelope. Rekor v1 stores a + # native `dsse` entry; Rekor v2 stores a hashedrekord over the DSSE PAE (the + # signed bytes), since v2 only supports the hashedrekord type. + def submit_dsse_entry(envelope, cert, pae) + if rekor_v2? + digest = OpenSSL::Digest::SHA256.digest(pae) + request = build_create_entry_request(digest, envelope.signatures.first.sig, cert) + return submit_rekor_v2_entry(request) + end + + proposed_entry = build_proposed_dsse_entry(envelope, cert) + + ctlog = @trusted_root.tlog_for_signing + logger.info { "Submitting to #{ctlog.base_url}" } + + @verifier.rekor_client.log.entries.post(proposed_entry) + end + + def rekor_v2? + @signing_config && @signing_config.tlog.major_api_version == 2 + end + + def submit_rekor_v2_entry(request) tlog_url = @signing_config.tlog.url logger.info { "Submitting to #{tlog_url} (Rekor v2)" } - request = build_create_entry_request(signature, cert, hashed_input) Rekor::V2Client.new(url: tlog_url).create_entry(request) end # A Rekor v2 CreateEntryRequest for a hashedrekord 0.0.2 entry. The request # carries only the prehash digest and the signature + verifier; the log fills # in the data.algorithm (derived from the verifier key details) itself. - def build_create_entry_request(signature, cert, hashed_input) + def build_create_entry_request(digest, signature, cert) { "hashedRekordRequestV002" => { - "digest" => Internal::Util.base64_encode(hashed_input.digest), + "digest" => Internal::Util.base64_encode(digest), "signature" => { "content" => Internal::Util.base64_encode(signature), "verifier" => { @@ -334,6 +384,22 @@ def build_create_entry_request(signature, cert, hashed_input) } end + # A Rekor v1 `dsse` 0.0.1 proposed entry: the full DSSE envelope (as JSON) + # plus the signing certificate as a verifier. + def build_proposed_dsse_entry(envelope, cert) + { + "apiVersion" => "0.0.1", + "kind" => "dsse", + "spec" => { + "proposedContent" => { + # Rekor expects the envelope as a JSON string nested in the request. + "envelope" => envelope.to_json, + "verifiers" => [Internal::Util.base64_encode(cert.to_pem)] + } + } + } + end + # The Sigstore PublicKeyDetails enum name for the leaf certificate's key, # used as the Rekor v2 verifier key_details. Only the algorithms this signer # can produce are mapped. @@ -353,10 +419,26 @@ def key_details(cert) end def verify(artifact, bundle) + input = Verification::V1::Artifact.new.tap { |a| a.artifact = artifact } + verify_bundle(input, bundle) + end + + # Self-verify a freshly-signed DSSE bundle. The in-toto statement's subject + # is matched against an artifact, so verify against the first subject's + # SHA2-256 digest (the only artifact reference available at signing time). + def verify_dsse_statement(statement, bundle) + statement = JSON.parse(statement) + subject = Array(statement["subject"]).find { |s| s.dig("digest", "sha256") } + raise Error::Signing, "in-toto statement has no sha256 subject to verify against" unless subject + + input = Verification::V1::Artifact.new.tap { |a| a.artifact_uri = "sha256:#{subject.dig("digest", "sha256")}" } + verify_bundle(input, bundle) + end + + def verify_bundle(artifact, bundle) verification_input = Verification::V1::Input.new verification_input.bundle = bundle - verification_input.artifact = Verification::V1::Artifact.new - verification_input.artifact.artifact = artifact + verification_input.artifact = artifact result = @verifier.verify( input: VerificationInput.new(verification_input), @@ -371,6 +453,21 @@ def expected_identity end def collect_bundle(leaf_certificate, tlog_entries, timestamp_verification_data, hashed_input, signature) + collect_base_bundle(leaf_certificate, tlog_entries, timestamp_verification_data).tap do |bundle| + bundle.message_signature = Sigstore::Common::V1::MessageSignature.new.tap do |ms| + ms.message_digest = hashed_input + ms.signature = signature + end + end + end + + def collect_dsse_bundle(leaf_certificate, tlog_entries, timestamp_verification_data, envelope) + collect_base_bundle(leaf_certificate, tlog_entries, timestamp_verification_data).tap do |bundle| + bundle.dsse_envelope = envelope + end + end + + def collect_base_bundle(leaf_certificate, tlog_entries, timestamp_verification_data) bundle = Bundle::V1::Bundle.new bundle.media_type = BundleType::BUNDLE_0_3.media_type bundle.verification_material = Bundle::V1::VerificationMaterial.new @@ -378,11 +475,15 @@ def collect_bundle(leaf_certificate, tlog_entries, timestamp_verification_data, bundle.verification_material.certificate.raw_bytes = leaf_certificate.to_der bundle.verification_material.tlog_entries = tlog_entries bundle.verification_material.timestamp_verification_data = timestamp_verification_data - bundle.message_signature = Sigstore::Common::V1::MessageSignature.new.tap do |ms| - ms.message_digest = hashed_input - ms.signature = signature - end bundle end + + def build_dsse_envelope(payload, payload_type, signature) + DSSE::Envelope.new.tap do |envelope| + envelope.payload = payload + envelope.payloadType = payload_type + envelope.signatures = [DSSE::Signature.new.tap { |s| s.sig = signature }] + end + end end end diff --git a/lib/sigstore/verifier.rb b/lib/sigstore/verifier.rb index a982b2d..7e5e8b3 100644 --- a/lib/sigstore/verifier.rb +++ b/lib/sigstore/verifier.rb @@ -264,9 +264,7 @@ def verify_dsse(dsse_envelope, public_key) end def dsse_pae(dsse_envelope) - payload = dsse_envelope.payload - payload_type = dsse_envelope.payloadType - "DSSEv1 #{payload_type.bytesize} #{payload_type} #{payload.bytesize} #{payload}".b + Internal::Util.dsse_pae(dsse_envelope.payloadType, dsse_envelope.payload) end def verify_in_toto(input, in_toto_payload) diff --git a/test/sigstore/cli_test.rb b/test/sigstore/cli_test.rb index bf7cff3..5330227 100644 --- a/test/sigstore/cli_test.rb +++ b/test/sigstore/cli_test.rb @@ -17,10 +17,28 @@ def test_display_managed_key_bundle end def test_display_certificate_bundle + # jruby-openssl cannot parse one of the leaf certificate's extensions (raises + # ExtensionError "unknown tag 13"), so displaying the cert is unsupported there. + omit_if(RUBY_ENGINE == "jruby", "jruby-openssl cannot parse the certificate's extensions") bundle = "#{ASSETS}/happy-path-v0.3/bundle.sigstore.json" out, = capture_output do assert_nothing_raised { Sigstore::CLI.start(["display", bundle]) } end assert_match(/Certificate:/, out) end + + # --in-toto produces a DSSE bundle, which has no message signature, so the + # detached --signature/--certificate outputs are rejected up front (before any + # token detection or network calls). + data("signature" => "--signature", "certificate" => "--certificate") + def test_sign_in_toto_rejects_detached_output_flags(flag) + # exit_on_failure? turns the Thor::InvocationError into a printed message + exit. + # The flag validation runs before the file is read, so the path need not exist. + _, err = capture_output do + assert_raise(SystemExit) do + Sigstore::CLI.start(["sign", "--in-toto", flag, "out", "statement.json"]) + end + end + assert_include err, "--in-toto cannot be combined with --signature or --certificate" + end end diff --git a/test/sigstore/conformance_test.rb b/test/sigstore/conformance_test.rb index 82573f2..74e76e4 100644 --- a/test/sigstore/conformance_test.rb +++ b/test/sigstore/conformance_test.rb @@ -16,6 +16,12 @@ class Sigstore::ConformanceTest < Test::Unit::TestCase # against the vendored production trusted root keeps the tests deterministic and network-free. PROD_TRUSTED_ROOT = "data/_store/prod/trusted_root.json" + # OpenSSL builds with a broken X509::Store#time (ruby/openssl#770) cannot verify RFC 3161 + # timestamps. Rekor v2 entries have no integrated time, so a v2 bundle has no trusted + # signing time on those builds and cannot be verified at all — the happy-path cases below + # are skipped there, matching the verifier's fail-closed behavior. + BROKEN_STORE_TIME = OpenSSL::X509::Store.new.instance_variable_defined?(:@time) + def verify(case_name, trusted_root: PROD_TRUSTED_ROOT, artifact: "#{ASSETS}/a.txt") Sigstore::CLI.start([ "verify", @@ -66,12 +72,14 @@ def verify_rekor2(case_name) end def test_verify_rekor2_message_signature_happy_path + omit_if(BROKEN_STORE_TIME, "openssl #{OpenSSL::VERSION} cannot verify TSA timestamps (ruby/openssl#770)") capture_output do assert_nothing_raised { verify_rekor2("rekor2-happy-path") } end end def test_verify_rekor2_dsse_happy_path + omit_if(BROKEN_STORE_TIME, "openssl #{OpenSSL::VERSION} cannot verify TSA timestamps (ruby/openssl#770)") capture_output do assert_nothing_raised { verify_rekor2("rekor2-dsse-happy-path") } end diff --git a/test/sigstore/signer_test.rb b/test/sigstore/signer_test.rb index b2558df..48fca7f 100644 --- a/test/sigstore/signer_test.rb +++ b/test/sigstore/signer_test.rb @@ -29,6 +29,7 @@ def stub_tsa(body) end def test_request_timestamp_accepts_granted_response + omit_if(!defined?(OpenSSL::Timestamp), "OpenSSL::Timestamp is unavailable (e.g. JRuby)") stub_tsa(granted_timestamp_der) signer = Sigstore::Signer.allocate ts = signer.send(:request_timestamp, TSA_URL, "signature".b) @@ -37,6 +38,7 @@ def test_request_timestamp_accepts_granted_response end def test_request_timestamp_rejects_non_granted_status + omit_if(!defined?(OpenSSL::Timestamp), "OpenSSL::Timestamp is unavailable (e.g. JRuby)") stub_tsa(status_only_timestamp_der(OpenSSL::Timestamp::Response::REJECTION)) signer = Sigstore::Signer.allocate error = assert_raise(Sigstore::Error::InvalidTimestamp) do @@ -46,10 +48,57 @@ def test_request_timestamp_rejects_non_granted_status end def test_request_timestamp_rejects_malformed_response + omit_if(!defined?(OpenSSL::Timestamp), "OpenSSL::Timestamp is unavailable (e.g. JRuby)") stub_tsa("not a valid DER timestamp response") signer = Sigstore::Signer.allocate assert_raise do signer.send(:request_timestamp, TSA_URL, "signature".b) end end + + STATEMENT = '{"_type":"https://in-toto.io/Statement/v1","subject":[]}' + + def leaf_certificate + bundle = JSON.parse( + Gem.read_binary("test/sigstore-conformance/test/assets/bundle-verify/rekor2-happy-path/bundle.sigstore.json") + ) + raw = bundle.dig("verificationMaterial", "certificate", "rawBytes").unpack1("m") + Sigstore::Internal::X509::Certificate.read(raw) + end + + def test_build_dsse_envelope + signer = Sigstore::Signer.allocate + envelope = signer.send(:build_dsse_envelope, STATEMENT, Sigstore::Signer::IN_TOTO_PAYLOAD_TYPE, "RAWSIG".b) + + assert_equal STATEMENT, envelope.payload + assert_equal "application/vnd.in-toto+json", envelope.payloadType + assert_equal ["RAWSIG"], envelope.signatures.map(&:sig) + end + + def test_build_proposed_dsse_entry_is_a_v1_dsse_entry + signer = Sigstore::Signer.allocate + envelope = signer.send(:build_dsse_envelope, STATEMENT, Sigstore::Signer::IN_TOTO_PAYLOAD_TYPE, "RAWSIG".b) + entry = signer.send(:build_proposed_dsse_entry, envelope, leaf_certificate) + + assert_equal "dsse", entry["kind"] + assert_equal "0.0.1", entry["apiVersion"] + # The envelope is nested as a JSON string whose payload round-trips. + nested = JSON.parse(entry.dig("spec", "proposedContent", "envelope")) + assert_equal STATEMENT, nested["payload"].unpack1("m0") + verifiers = entry.dig("spec", "proposedContent", "verifiers") + assert_equal([leaf_certificate.to_pem], verifiers.map { |v| v.unpack1("m0") }) + end + + # Rekor v2 stores a DSSE envelope as a hashedrekord over the PAE digest. + def test_dsse_rekor_v2_request_digests_the_pae + signer = Sigstore::Signer.allocate + pae = Sigstore::Internal::Util.dsse_pae(Sigstore::Signer::IN_TOTO_PAYLOAD_TYPE, STATEMENT) + request = signer.send(:build_create_entry_request, OpenSSL::Digest::SHA256.digest(pae), "RAWSIG".b, + leaf_certificate) + + spec = request.fetch("hashedRekordRequestV002") + assert_equal OpenSSL::Digest::SHA256.digest(pae), spec.fetch("digest").unpack1("m0") + assert_equal "RAWSIG", spec.dig("signature", "content").unpack1("m0") + assert_equal "PKIX_ECDSA_P256_SHA_256", spec.dig("signature", "verifier", "keyDetails") + end end From e9b2a29b56b2ead19ba2f9633147d6861b95c804 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Sat, 20 Jun 2026 14:43:20 -0500 Subject: [PATCH 07/11] Use in-toto attestation protos and default the signing config from TUF Add the protobug_in_toto_attestation_protos dependency and decode the in-toto statement through InTotoAttestation::V1::Statement in the DSSE self-verification path, instead of parsing the JSON by hand. Default the signing config from TUF when none is given. SigningConfig gains production/staging/from_tuf (mirroring TrustedRoot) backed by a new TrustUpdater#signing_config_path that fetches the signing_config.v0.2.json target, returning nil when the repository publishes none or when offline. The CLI's sign command now falls back to this published config instead of nil, so signing targets whatever Rekor the instance's config selects -- Rekor v2 where the config lists a valid v2 service -- and picks up the instance's TSA. With no published config (or offline) the Signer still uses the legacy v1 flow from the trusted root. Both TrustedRoot and SigningConfig also gain a from_tuf_updater entry point that builds from an already-refreshed updater, and the CLI shares one TrustUpdater between them so signing refreshes the repository once rather than twice. Signed-off-by: Samuel Giddins --- Gemfile.lock | 5 +++ cli/lib/sigstore/cli.rb | 24 ++++++++---- lib/sigstore/signer.rb | 12 ++++-- lib/sigstore/signing_config.rb | 58 ++++++++++++++++++++++++---- lib/sigstore/trusted_root.rb | 9 ++++- lib/sigstore/tuf.rb | 13 +++++++ sigstore.gemspec | 1 + test/sigstore/signing_config_test.rb | 39 ++++++++++++++++--- 8 files changed, 136 insertions(+), 25 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 32c4163..f8d8a9a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ PATH sigstore (0.2.3) logger net-http + protobug_in_toto_attestation_protos (~> 0.2.0) protobug_sigstore_protos (~> 0.2.0) uri @@ -52,6 +53,9 @@ GEM protobug_googleapis_field_behavior_protos (0.2.0) protobug (= 0.2.0) protobug_well_known_protos (= 0.2.0) + protobug_in_toto_attestation_protos (0.2.0) + protobug (= 0.2.0) + protobug_well_known_protos (= 0.2.0) protobug_sigstore_protos (0.2.0) protobug (= 0.2.0) protobug_googleapis_field_behavior_protos (= 0.2.0) @@ -155,6 +159,7 @@ CHECKSUMS prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 protobug (0.2.0) sha256=eb154bdbe2a3afe796711e0c16e89b2ed59efee0f172a10b634400471299b3f2 protobug_googleapis_field_behavior_protos (0.2.0) sha256=5d4ddcdcfd8616a74ac100de893e3768feee631579a777e23498d3a0ba45b893 + protobug_in_toto_attestation_protos (0.2.0) sha256=64317c2bb5efe8494ecd0da6990f3c027e7af58bf12c34d6dbd1aa43fd87f731 protobug_sigstore_protos (0.2.0) sha256=b094cbb2ceadc9ffe6e34ec217613dd7a8e78b6411bf4404bd686f7343514e25 protobug_well_known_protos (0.2.0) sha256=89dd4965ed80ed9355b575e9243e7762487de581a8e2b34a97af6bef60fe3b3f public_suffix (6.0.1) sha256=61d44e1cab5cbbbe5b31068481cf16976dd0dc1b6b07bd95617ef8c5e3e00c6f diff --git a/cli/lib/sigstore/cli.rb b/cli/lib/sigstore/cli.rb index 11d4912..b9d38b5 100644 --- a/cli/lib/sigstore/cli.rb +++ b/cli/lib/sigstore/cli.rb @@ -208,17 +208,27 @@ def refresh def trusted_root return Sigstore::TrustedRoot.from_file(options[:trusted_root]) if options[:trusted_root] - if options[:staging] - Sigstore::TrustedRoot.staging(offline: !options[:update_trusted_root]) - else - Sigstore::TrustedRoot.production(offline: !options[:update_trusted_root]) - end + Sigstore::TrustedRoot.from_tuf_updater(tuf_updater) end def signing_config - return unless options[:signing_config] + return Sigstore::SigningConfig.from_file(options[:signing_config]) if options[:signing_config] + + # With no explicit config, fall back to the one the Sigstore instance + # publishes via TUF, so signing targets the current Rekor (v2 where the + # config selects it). Returns nil offline or when none is published, in + # which case the Signer uses the legacy v1 flow from the trusted root. + Sigstore::SigningConfig.from_tuf_updater(tuf_updater) + end - Sigstore::SigningConfig.from_file(options[:signing_config]) + # One refreshed TUF updater per command invocation, shared by the trusted + # root and signing config so signing refreshes the repository only once. + def tuf_updater + @tuf_updater ||= begin + url = options[:staging] ? Sigstore::TUF::STAGING_TUF_URL : Sigstore::TUF::DEFAULT_TUF_URL + offline = !options[:update_trusted_root] + Sigstore::TUF::TrustUpdater.new(url, offline).tap { _1.refresh unless offline } + end end def load_verification_key diff --git a/lib/sigstore/signer.rb b/lib/sigstore/signer.rb index b4f481b..1440740 100644 --- a/lib/sigstore/signer.rb +++ b/lib/sigstore/signer.rb @@ -14,6 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +require "protobug_in_toto_attestation_protos" + require_relative "internal/util" require_relative "internal/x509" require_relative "models" @@ -26,6 +28,10 @@ module Sigstore class Signer include Loggable + STATEMENT_REGISTRY = Protobug::Registry.new do |registry| + InTotoAttestation::V1.register_statement_protos(registry) + end + def initialize(jwt:, trusted_root:, signing_config: nil) @identity_token = OIDC::IdentityToken.new(jwt) @trusted_root = trusted_root @@ -427,11 +433,11 @@ def verify(artifact, bundle) # is matched against an artifact, so verify against the first subject's # SHA2-256 digest (the only artifact reference available at signing time). def verify_dsse_statement(statement, bundle) - statement = JSON.parse(statement) - subject = Array(statement["subject"]).find { |s| s.dig("digest", "sha256") } + statement = InTotoAttestation::V1::Statement.decode_json(statement, registry: STATEMENT_REGISTRY) + subject = statement.subject.find { |s| s.digest["sha256"] } raise Error::Signing, "in-toto statement has no sha256 subject to verify against" unless subject - input = Verification::V1::Artifact.new.tap { |a| a.artifact_uri = "sha256:#{subject.dig("digest", "sha256")}" } + input = Verification::V1::Artifact.new.tap { |a| a.artifact_uri = "sha256:#{subject.digest["sha256"]}" } verify_bundle(input, bundle) end diff --git a/lib/sigstore/signing_config.rb b/lib/sigstore/signing_config.rb index 031b1da..c7d10ac 100644 --- a/lib/sigstore/signing_config.rb +++ b/lib/sigstore/signing_config.rb @@ -14,9 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +require "openssl" require "protobug_sigstore_protos" require_relative "error" +require_relative "tuf" module Sigstore # Parses a SigningConfig (signingconfig.v0.2) document and selects the @@ -43,6 +45,30 @@ class SigningConfig FULCIO_VERSIONS = [1].freeze OIDC_VERSIONS = [1].freeze + # The signing config published by the public-good (or staging) Sigstore + # instance via TUF, or nil if the repository does not publish one. Mirrors + # TrustedRoot.production/.staging. + def self.production(offline: false) + from_tuf(TUF::DEFAULT_TUF_URL, offline) + end + + def self.staging(offline: false) + from_tuf(TUF::STAGING_TUF_URL, offline) + end + + def self.from_tuf(url, offline) + updater = TUF::TrustUpdater.new(url, offline) + updater.refresh unless offline + from_tuf_updater(updater) + end + + # Build from an already-refreshed TrustUpdater, so a caller that also needs + # the trusted root can share one updater (and one refresh). + def self.from_tuf_updater(updater) + path = updater.signing_config_path + path && from_file(path) + end + def self.from_file(path) from_json(Gem.read_binary(path)) end @@ -63,7 +89,8 @@ def initialize(config) @oidcs = select_services(config.oidc_urls, OIDC_VERSIONS, nil) - @tlogs = select_services(config.rekor_tlog_urls, REKOR_VERSIONS, config.rekor_tlog_config) + @tlogs = select_services(config.rekor_tlog_urls, REKOR_VERSIONS, config.rekor_tlog_config, + prefer_version: preferred_rekor_major_version) raise Error::InvalidSigningConfig, "No valid Rekor transparency log found in signing config" if @tlogs.empty? @tsas = select_services(config.tsa_urls, TSA_VERSIONS, config.tsa_config) @@ -89,7 +116,18 @@ def tsa_urls private - def select_services(services, supported_versions, config) + # On OpenSSL builds with a broken X509::Store#time (ruby/openssl#770) an RFC 3161 + # timestamp cannot be verified, and Rekor v2 entries carry no integrated time, so a + # v2 bundle could never be verified there (even the signer's own self-verification + # would fail). Prefer a Rekor v1 log in that case, when the signing config offers one, + # so signing still produces a verifiable bundle. + def preferred_rekor_major_version + return nil unless OpenSSL::X509::Store.new.instance_variable_defined?(:@time) + + 1 + end + + def select_services(services, supported_versions, config, prefer_version: nil) by_operator = Hash.new { |h, k| h[k] = [] } services.each do |service| next unless supported_versions.include?(service.major_api_version) @@ -98,9 +136,11 @@ def select_services(services, supported_versions, config) by_operator[service.operator] << service end - # One service per operator, preferring the highest supported version. + # One service per operator. Normally prefer the highest supported version; when + # +prefer_version+ is supplied and an operator offers it, pick that version instead. result = by_operator.values.map do |op_services| - op_services.max_by(&:major_api_version) + (prefer_version && op_services.find { |s| s.major_api_version == prefer_version }) || + op_services.max_by(&:major_api_version) end # An absent ServiceConfiguration (or the ALL selector) imposes no count @@ -114,13 +154,15 @@ def select_services(services, supported_versions, config) "EXACT selector requires a positive integer count, got #{count.inspect}" end - # EXACT means exactly `count` services must remain after filtering and - # per-operator collapsing; neither too few nor too many is acceptable. - unless result.size == count + # EXACT means at least `count` services must remain after filtering and + # per-operator collapsing; select the first `count` of them. Fewer than + # `count` is a misconfiguration; more is acceptable (mirrors + # sigstore-python's SigningConfig._get_valid_services). + if result.size < count raise Error::InvalidSigningConfig, "Expected #{count} services in signing config, found #{result.size}" end - return result + return result.first(count) end result.first(1) diff --git a/lib/sigstore/trusted_root.rb b/lib/sigstore/trusted_root.rb index 753828f..61d55d3 100644 --- a/lib/sigstore/trusted_root.rb +++ b/lib/sigstore/trusted_root.rb @@ -36,8 +36,13 @@ def self.staging(offline: false) end def self.from_tuf(url, offline) - path = TUF::TrustUpdater.new(url, offline).tap { _1.refresh unless offline }.trusted_root_path - from_file(path) + from_tuf_updater(TUF::TrustUpdater.new(url, offline).tap { _1.refresh unless offline }) + end + + # Build from an already-refreshed TrustUpdater, so a caller that also needs + # the signing config can share one updater (and one refresh). + def self.from_tuf_updater(updater) + from_file(updater.trusted_root_path) end def self.from_file(path) diff --git a/lib/sigstore/tuf.rb b/lib/sigstore/tuf.rb index 7e978dd..5eae7ae 100644 --- a/lib/sigstore/tuf.rb +++ b/lib/sigstore/tuf.rb @@ -120,6 +120,19 @@ def trusted_root_path path end + # Path to the TUF-distributed v0.2 signing config, or nil if the repository + # does not publish one (older repositories, or offline mode where it is not + # vendored). Signing always requires network access, so there is no cached + # offline fallback as there is for the trusted root. + def signing_config_path + return unless @updater + + info = @updater.get_targetinfo("signing_config.v0.2.json") + return unless info + + @updater.find_cached_target(info) || @updater.download_target(info) + end + def refresh raise ArgumentError, "Offline mode: cannot refresh" if @offline || !@updater diff --git a/sigstore.gemspec b/sigstore.gemspec index 1e422dd..eab59fb 100644 --- a/sigstore.gemspec +++ b/sigstore.gemspec @@ -29,6 +29,7 @@ Gem::Specification.new do |spec| spec.add_dependency "logger" spec.add_dependency "net-http" + spec.add_dependency "protobug_in_toto_attestation_protos", "~> 0.2.0" spec.add_dependency "protobug_sigstore_protos", "~> 0.2.0" spec.add_dependency "uri" diff --git a/test/sigstore/signing_config_test.rb b/test/sigstore/signing_config_test.rb index cf2d6d1..a400839 100644 --- a/test/sigstore/signing_config_test.rb +++ b/test/sigstore/signing_config_test.rb @@ -39,6 +39,13 @@ def config(raw = STAGING_LIKE) Sigstore::SigningConfig.from_json(JSON.dump(raw)) end + # Offline there is no vendored signing config (unlike the trusted root), so the + # TUF helpers return nil and the caller falls back to the legacy v1 flow. + data("production" => :production, "staging" => :staging) + def test_from_tuf_returns_nil_offline(method) + assert_nil Sigstore::SigningConfig.public_send(method, offline: true) + end + def test_rejects_unknown_media_type raw = STAGING_LIKE.merge("mediaType" => "application/vnd.dev.sigstore.signingconfig.v0.1+json") error = assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } @@ -48,8 +55,15 @@ def test_rejects_unknown_media_type def test_any_selector_prefers_current_highest_version_tlog Timecop.freeze(Time.utc(2026, 1, 1)) do tlog = config.tlog - assert_equal "https://log-alpha3.example", tlog.url - assert_equal 2, tlog.major_api_version + if OpenSSL::X509::Store.new.instance_variable_defined?(:@time) + # OpenSSL builds that cannot verify RFC 3161 timestamps (ruby/openssl#770) can't use + # Rekor v2 entries — they have no integrated time — so the config prefers the v1 log. + assert_equal "https://rekor-v1.example", tlog.url + assert_equal 1, tlog.major_api_version + else + assert_equal "https://log-alpha3.example", tlog.url + assert_equal 2, tlog.major_api_version + end end end @@ -109,9 +123,10 @@ def test_exact_selector_non_numeric_count_raises_invalid_signing_config end end - def test_exact_selector_rejects_more_services_than_count + def test_exact_selector_accepts_more_services_than_count # Two distinct operators each contribute one valid v2 tlog, so the result has two - # services; EXACT count=1 must reject the over-match rather than truncating to one. + # services; EXACT count=1 means "at least one available", so selection succeeds and + # returns the first service rather than rejecting the over-supply. raw = STAGING_LIKE.merge( "rekorTlogUrls" => [ { "url" => "https://log-a.example", "majorApiVersion" => 2, @@ -121,9 +136,23 @@ def test_exact_selector_rejects_more_services_than_count ], "rekorTlogConfig" => { "selector" => "EXACT", "count" => 1 } ) + Timecop.freeze(Time.utc(2026, 1, 1)) do + assert_equal "https://log-a.example", config(raw).tlog.url + end + end + + def test_exact_selector_rejects_fewer_services_than_count + # Only one operator contributes a valid tlog, so EXACT count=2 cannot be satisfied. + raw = STAGING_LIKE.merge( + "rekorTlogUrls" => [ + { "url" => "https://log-a.example", "majorApiVersion" => 2, + "validFor" => { "start" => "2025-01-01T00:00:00Z" }, "operator" => "a.dev" } + ], + "rekorTlogConfig" => { "selector" => "EXACT", "count" => 2 } + ) Timecop.freeze(Time.utc(2026, 1, 1)) do error = assert_raise(Sigstore::Error::InvalidSigningConfig) { config(raw) } - assert_include error.message, "Expected 1 services in signing config, found 2" + assert_include error.message, "Expected 2 services in signing config, found 1" end end From c9a71b93d345a6813d0bce8952e3b55ffcc0394c Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Sat, 20 Jun 2026 15:56:42 -0500 Subject: [PATCH 08/11] Bump version to 0.3.0 and update changelog Backfill CHANGELOG entries for 0.2.0-0.2.3 and add a 0.3.0 entry covering Rekor v2, DSSE/in-toto signing, managed-key verification, TSA signing, and the protobuf-specs v0.5.1 upgrade. Signed-off-by: Samuel Giddins --- CHANGELOG.md | 63 +++++++++++++++++++++++++++++++++++++++++ Gemfile.lock | 10 +++---- bin/smoketest | 6 +++- lib/sigstore/version.rb | 2 +- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81463de..30dcd6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,66 @@ +## [0.3.0] - 2026-06-20 + +### Added + +- Rekor v2 (tiled transparency log) verification and signing, using the + `hashedrekord` 0.0.2 entry type. +- DSSE / in-toto attestation signing (`Signer#sign_dsse`). +- Managed-key (bring-your-own-key) verification via `Verifier#verify(key:)`, + for bundles that carry a public-key hint instead of a Fulcio certificate. +- Timestamping Authority (TSA / RFC 3161) timestamp signing, and defaulting the + signing configuration from TUF. + +### Changed + +- Updated to sigstore protobuf-specs v0.5.1 / protobug 0.2.0, and added the + `protobug_in_toto_attestation_protos` dependency. The `protobug_sigstore_protos` + constraint is now `~> 0.2.0`. +- Updated conformance suites to sigstore-conformance v0.0.29 and + tuf-conformance v2.4.0. + +### Security + +- Enforce that an RFC 3161 timestamp's message imprint covers the bundle + signature, and that the timestamp's `gen_time` falls within the Timestamping + Authority's validity window. Verification now fails closed when no trusted + signing time (TSA response or log integrated time) is available. +- A managed-key DSSE bundle carried as a Rekor v1 entry now fails with a clear + error instead of raising `NoMethodError`. + +## [0.2.3] - 2026-03-10 + +### Security + +- Fix in-toto statement verification (GHSA-mhg6-2q2v-9h2c). + +### Changed + +- Accept extensions for SCTs. +- Set a library-specific `User-Agent` header on outbound HTTP requests. + +## [0.2.2] - 2025-10-24 + +### Changed + +- Require Ruby >= 3.2. +- Re-implement missing JRuby functionality atop `java.security`. +- Ensure `kind_version` is set on transparency log entries after signing. +- Skip unrecognized keys when parsing. +- Enable smoke tests for fork PRs using the public OIDC beacon. + +## [0.2.1] - 2024-11-19 + +- Fix the release automation (gem push paths; split the RubyGems release to a + matrix). + +## [0.2.0] - 2024-11-18 + +### Changed + +- Extract the CLI into a separate gem that can be published independently. +- Improve compatibility with the sigstore-js mock server. +- Improve error handling. + ## [0.1.1] - 2024-10-18 - Fix release automation diff --git a/Gemfile.lock b/Gemfile.lock index f8d8a9a..d505b65 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - sigstore (0.2.3) + sigstore (0.3.0) logger net-http protobug_in_toto_attestation_protos (~> 0.2.0) @@ -11,8 +11,8 @@ PATH PATH remote: cli specs: - sigstore-cli (0.2.3) - sigstore (= 0.2.3) + sigstore-cli (0.3.0) + sigstore (= 0.3.0) thor GEM @@ -174,8 +174,8 @@ CHECKSUMS rubocop-performance (1.23.1) sha256=f22f86a795f5e6a6180aac2c6fc172534b173a068d6ed3396d6460523e051b82 rubocop-rake (0.6.0) sha256=56b6f22189af4b33d4f4e490a555c09f1281b02f4d48c3a61f6e8fe5f401d8db ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 - sigstore (0.2.3) - sigstore-cli (0.2.3) + sigstore (0.3.0) + sigstore-cli (0.3.0) simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.12.3) sha256=4b1aad33259ffba8b29c6876c12db70e5750cb9df829486e4c6e5da4fa0aa07b simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 diff --git a/bin/smoketest b/bin/smoketest index aac680e..4cbad10 100755 --- a/bin/smoketest +++ b/bin/smoketest @@ -10,7 +10,11 @@ include FileUtils # rubocop:disable Style/MixinUsage raise(StandardError, "Usage: #{$PROGRAM_NAME} ") if ARGV.empty? -dists = ARGV +# Install the base library gem before any gem that depends on it. sigstore-cli pins +# an exact sigstore version that is not yet published when smoketesting a release, so +# `gem install sigstore-cli-x.y.z.gem` would otherwise fail to resolve the dependency +# unless the local sigstore-x.y.z.gem is installed first. +dists = ARGV.sort_by { |d| File.basename(d).start_with?("sigstore-cli") ? 1 : 0 } mkdir_p %w[smoketest-gem-home smoketest-artifacts] at_exit { rm_rf "smoketest-gem-home" } diff --git a/lib/sigstore/version.rb b/lib/sigstore/version.rb index 82692ec..db00083 100644 --- a/lib/sigstore/version.rb +++ b/lib/sigstore/version.rb @@ -15,6 +15,6 @@ # limitations under the License. module Sigstore - VERSION = "0.2.3" + VERSION = "0.3.0" USER_AGENT = "sigstore-ruby/#{VERSION}".freeze end From 8ebedadcea3219051aa472e7f04a4211488005f3 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Sat, 20 Jun 2026 22:31:28 -0500 Subject: [PATCH 09/11] Replace the deprecated public OIDC beacon in the smoketest The extremely-dangerous-public-oidc-beacon action is deprecated and already fails to publish a usable token ("Current token expires too early"), breaking the CI smoketest. Fetch sigstore-conformance's longer-lived public test token directly instead; it is issued by a Google service account, so make the smoketest's expected certificate OIDC issuer configurable (defaulting to GitHub Actions for the ambient identity used when releasing). Signed-off-by: Samuel Giddins --- .github/workflows/ci.yml | 10 ++++++++-- bin/smoketest | 9 ++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8d502e..8f975e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,15 +179,21 @@ jobs: id: list-gems run: | echo "gems=$(find pkg -type f -name '*.gem' -print0 | xargs -0 jq --compact-output --null-input --args '[$ARGS.positional[]]')" >> $GITHUB_OUTPUT + # The smoketest job runs with no id-token permission (so it works on fork PRs), so it + # can't use ambient GitHub OIDC. It signs with sigstore-conformance's public test token + # instead. This replaces the deprecated extremely-dangerous-public-oidc-beacon action; + # the token now comes from a Google service account (issuer https://accounts.google.com). + # (The release workflow signs with the real ambient GitHub identity, not this token.) - name: Fetch testing OIDC token - uses: sigstore-conformance/extremely-dangerous-public-oidc-beacon@4a8befcc16064dac9e97f210948d226e5c869bdc # v1.0.0 + run: curl -sSfL --retry 3 https://storage.googleapis.com/sigstore-conformance-testing-token/untrusted-testing-token.txt -o oidc-token.txt - name: Run the smoketest run: | ./bin/smoketest ${BUILT_GEMS} env: BUILT_GEMS: ${{ join(fromJson(steps.list-gems.outputs.gems), ' ') }} OIDC_TOKEN_FILE: ./oidc-token.txt - SIGSTORE_CERT_IDENTITY: https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/.github/workflows/extremely-dangerous-oidc-beacon.yml@refs/heads/main + SIGSTORE_CERT_IDENTITY: untrusted-sa@sigstore-conformance.iam.gserviceaccount.com + SIGSTORE_CERT_OIDC_ISSUER: https://accounts.google.com all-tests-pass: if: always() diff --git a/bin/smoketest b/bin/smoketest index 4cbad10..482570c 100755 --- a/bin/smoketest +++ b/bin/smoketest @@ -26,8 +26,11 @@ env = { "BUNDLE_GEMFILE" => "smoketest-gem-home/Gemfile" } -# Get cert identity from environment +# Get the expected signing identity and its OIDC issuer from the environment. The issuer +# defaults to GitHub Actions (the ambient identity used when releasing); CI overrides it +# when signing with a test token from a different issuer. cert_identity = ENV.fetch("SIGSTORE_CERT_IDENTITY") +cert_oidc_issuer = ENV.fetch("SIGSTORE_CERT_OIDC_ISSUER", "https://token.actions.githubusercontent.com") # Read OIDC token from file if available oidc_token_file = ENV.fetch("OIDC_TOKEN_FILE", nil) @@ -61,14 +64,14 @@ dists.each do |dist| "verify", "--signature=smoketest-artifacts/#{File.basename(dist)}.sig", "--certificate=smoketest-artifacts/#{File.basename(dist)}.crt", - "--certificate-oidc-issuer=https://token.actions.githubusercontent.com", + "--certificate-oidc-issuer=#{cert_oidc_issuer}", "--certificate-identity=#{cert_identity}", dist, exception: true) sh(env, File.expand_path("sigstore-cli", __dir__), "verify", "--bundle=smoketest-artifacts/#{File.basename(dist)}.sigstore.json", - "--certificate-oidc-issuer=https://token.actions.githubusercontent.com", + "--certificate-oidc-issuer=#{cert_oidc_issuer}", "--certificate-identity=#{cert_identity}", dist, exception: true) From cf6c27c3e3474abdf8ad6b04ff581f5f16b420ef Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Sat, 20 Jun 2026 23:17:46 -0500 Subject: [PATCH 10/11] Support Ruby 4.x and JRuby on the verification path Three fixes surfaced by the ruby-head and jruby-head CI legs: - Pass +verifier_pem+ to Bundle#expected_tlog_entry positionally instead of as a keyword argument. JRuby 4.0 (jruby-head) mis-binds the keyword through the bundle's DelegateClass and raises ArgumentError; the helpers it forwards to are already positional, so this is also more consistent. - On JRuby, compute the precertificate TBS via the manual ASN.1 path rather than OpenSSL::X509::Certificate#tbs_bytes. jruby-openssl's tbs_bytes does not honor removing the SCT extension, producing the wrong TBS and breaking SCT verification. - Bump the bundled Bundler to 4.0.14. 2.6.9 crashes during 'bundle install' on Ruby 4.1 (ruby-head) with 'uninitialized constant Pathname::SEPARATOR_PAT'; 4.0.14 requires Ruby >= 3.2 and installs the existing lockfile unchanged. Signed-off-by: Samuel Giddins --- Gemfile.lock | 2 +- lib/sigstore/internal/x509.rb | 6 +++++- lib/sigstore/models.rb | 2 +- lib/sigstore/verifier.rb | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d505b65..f04e803 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -188,4 +188,4 @@ CHECKSUMS webmock (3.25.1) sha256=ab9d5d9353bcbe6322c83e1c60a7103988efc7b67cd72ffb9012629c3d396323 BUNDLED WITH - 2.6.9 + 4.0.14 diff --git a/lib/sigstore/internal/x509.rb b/lib/sigstore/internal/x509.rb index 53bf427..7eb6715 100644 --- a/lib/sigstore/internal/x509.rb +++ b/lib/sigstore/internal/x509.rb @@ -111,7 +111,11 @@ def self.read(certificate_bytes) end def tbs_certificate_der - if openssl.respond_to?(:tbs_bytes) + # jruby-openssl's X509::Certificate#tbs_bytes does not honor removing the + # precertificate SCT extension (it re-encodes the original extensions), yielding + # the wrong TBS and breaking SCT verification. Fall through to the manual ASN.1 + # path on JRuby, which manipulates the DER directly and is engine-independent. + if openssl.respond_to?(:tbs_bytes) && RUBY_ENGINE != "jruby" cert = openssl.dup short_name = Extension::PrecertificateSignedCertificateTimestamps.oid.short_name cert.extensions = cert.extensions.reject! do |ext| diff --git a/lib/sigstore/models.rb b/lib/sigstore/models.rb index 7cb3841..3ca0acc 100644 --- a/lib/sigstore/models.rb +++ b/lib/sigstore/models.rb @@ -165,7 +165,7 @@ def self.for_cert_bytes_and_signature(cert_bytes, signature) # +verifier_pem+ is the PEM of the public key the entry should be bound to. For # certificate bundles it defaults to the leaf certificate's PEM; for managed-key # bundles the caller passes the supplied key's SubjectPublicKeyInfo PEM. - def expected_tlog_entry(hashed_input, verifier_pem: leaf_certificate&.to_pem) + def expected_tlog_entry(hashed_input, verifier_pem = leaf_certificate&.to_pem) case content when :message_signature expected_hashed_rekord_tlog_entry(hashed_input, verifier_pem) diff --git a/lib/sigstore/verifier.rb b/lib/sigstore/verifier.rb index 7e5e8b3..5a3c0c8 100644 --- a/lib/sigstore/verifier.rb +++ b/lib/sigstore/verifier.rb @@ -531,7 +531,7 @@ def find_rekor_entry(bundle, hashed_input, offline:, signing_key: nil) end verifier_pem = signing_key&.public_to_pem || bundle.leaf_certificate&.to_pem - expected_entry = bundle.expected_tlog_entry(hashed_input, verifier_pem:) + expected_entry = bundle.expected_tlog_entry(hashed_input, verifier_pem) entry = if offline logger.debug { "Offline verification, skipping rekor" } From 6f93150b90d8bf34e291671d1567d67cdfb361a6 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Thu, 25 Jun 2026 09:02:21 -0700 Subject: [PATCH 11/11] Gate the TUF RSASSA-PSS xfail on verify_pss support The jruby-head TUF conformance leg failed with XPASS(strict) on test_keytype_and_scheme[rsa/rsassa-pss-sha256]. The xfail set keyed the RSASSA-PSS case on RUBY_ENGINE == "jruby", but the library actually gates PSS verification on OpenSSL::PKey::RSA#verify_pss (internal/key.rb). Stable jruby's jruby-openssl lacks the method so the case fails as expected, while jruby-head's newer jruby-openssl provides it, so verification succeeds and pytest's strict xfail turns the unexpected pass into a job failure. Gate the xfail on the same capability instead of the engine name: emit it only when verify_pss is undefined. This drops the jruby special-case entirely and self-corrects whenever stable jruby gains the method. Signed-off-by: Samuel Giddins --- Rakefile | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/Rakefile b/Rakefile index ea580d9..f1e080f 100644 --- a/Rakefile +++ b/Rakefile @@ -2,6 +2,7 @@ require "bundler/gem_tasks" require "rake/testtask" +require "openssl" directory "pkg" namespace "cli" do @@ -202,16 +203,13 @@ end namespace :tuf_conformance do file "bin/tuf-conformance-entrypoint.xfails" do |t| - if RUBY_ENGINE == "jruby" - # jruby-openssl cannot verify RSASSA-PSS, so that key type still xfails. ed25519 - # verification is routed through java.security and passes, so it must not be listed - # here or pytest's strict xfail turns the unexpected pass into a failure. - File.write(t.name, <<~TXT) - test_keytype_and_scheme[rsa/rsassa-pss-sha256] - TXT - else - File.write(t.name, "") - end + # RSASSA-PSS verification requires OpenSSL::PKey::RSA#verify_pss (see + # internal/key.rb); without it the rsa/rsassa-pss-sha256 key type xfails. + # Gate on the capability rather than the engine: older jruby-openssl lacks + # the method (stable jruby), while jruby-head and CRuby have it, so this + # self-corrects and avoids pytest's strict xfail turning a pass into a failure. + xfails = OpenSSL::PKey::RSA.method_defined?(:verify_pss) ? "" : "test_keytype_and_scheme[rsa/rsassa-pss-sha256]\n" + File.write(t.name, xfails) end file "test/tuf-conformance/env/pyvenv.cfg" => :tuf_conformance do sh "make", "dev", chdir: "test/tuf-conformance"