diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1f10f9..8f975e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,17 +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@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: ${{ steps.conformance_xfails.outputs.xfail }} 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: ${{ steps.conformance_xfails.outputs.xfail }} environment: staging if: ${{ matrix.os }} == "ubuntu-latest" @@ -135,7 +142,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 }}" @@ -172,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/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 c531d0f..f04e803 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,17 +1,18 @@ PATH remote: . specs: - sigstore (0.2.3) + sigstore (0.3.0) logger net-http - protobug_sigstore_protos (~> 0.1.0) + protobug_in_toto_attestation_protos (~> 0.2.0) + protobug_sigstore_protos (~> 0.2.0) uri PATH remote: cli specs: - sigstore-cli (0.2.3) - sigstore (= 0.2.3) + sigstore-cli (0.3.0) + sigstore (= 0.3.0) thor GEM @@ -48,16 +49,19 @@ 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_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) + 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 +157,11 @@ 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_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 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f racc (1.8.1-java) sha256=54f2e6d1e1b91c154013277d986f52a90e5ececbe91465d29172e49342732b98 @@ -169,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 @@ -183,4 +188,4 @@ CHECKSUMS webmock (3.25.1) sha256=ab9d5d9353bcbe6322c83e1c60a7103988efc7b67cd72ffb9012629c3d396323 BUNDLED WITH - 2.6.9 + 4.0.14 diff --git a/Rakefile b/Rakefile index 5588d00..08270b7 100644 --- a/Rakefile +++ b/Rakefile @@ -2,6 +2,7 @@ require "bundler/gem_tasks" require "rake/testtask" +require "openssl" directory "pkg" namespace "cli" do @@ -23,9 +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 -xfail = OpenSSL::X509::Store.new.instance_variable_defined?(:@time) ? "test_verify_rejects_bad_tsa_timestamp" : "" +# 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 + +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 @@ -59,7 +90,8 @@ end task :find_action_versions do # rubocop:disable Rake/Desc require "yaml" - gh = YAML.load_file(".github/workflows/ci.yml") + # 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) } .group_by(&:first).transform_values { |v| v.map(&:last) } @@ -170,14 +202,13 @@ end namespace :tuf_conformance do file "bin/tuf-conformance-entrypoint.xfails" do |t| - if RUBY_ENGINE == "jruby" - 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, "") - 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" diff --git a/bin/smoketest b/bin/smoketest index aac680e..482570c 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" } @@ -22,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) @@ -57,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) diff --git a/cli/lib/sigstore/cli.rb b/cli/lib/sigstore/cli.rb index ef3c1b3..b9d38b5 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}" @@ -84,8 +92,17 @@ 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 + 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, @@ -93,10 +110,12 @@ def sign(file) end contents = File.binread(file) - bundle = Sigstore::Signer.new( + signer = Sigstore::Signer.new( jwt: options[:identity_token], - trusted_root: - ).sign(contents) + trusted_root:, + signing_config: + ) + bundle = options[:in_toto] ? signer.sign_dsse(contents) : signer.sign(contents) File.binwrite(options[:bundle], bundle.to_json) if options[:bundle] if options[:signature] @@ -114,7 +133,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 @@ -185,13 +208,39 @@ 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]) + Sigstore::TrustedRoot.from_tuf_updater(tuf_updater) + end + + def 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 + + # 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 + 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/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" + } } ] } 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 7f06fc5..d0df2ab 100644 --- a/lib/sigstore/error.rb +++ b/lib/sigstore/error.rb @@ -24,11 +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 InvalidSigningConfig < Error; end + + class InvalidTimestamp < Error; end class MissingRekorEntry < Error; end class InvalidRekorEntry < 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/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/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 890ba22..3ca0acc 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,26 +109,40 @@ 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 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 +162,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 +189,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 +230,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 +251,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 +272,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 +303,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 +316,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/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/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/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/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..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,32 +28,32 @@ module Sigstore class Signer include Loggable - def initialize(jwt:, trusted_root:) + 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 + @signing_config = signing_config @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) @@ -59,8 +61,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") @@ -104,8 +137,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 +227,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) @@ -226,17 +309,27 @@ 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 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? proposed_entry = build_proposed_hashed_rekord_entry(signature, cert, hashed_input) @@ -248,11 +341,110 @@ def submit_signed_metadata_to_transparency_service(signature, cert, hashed_input @verifier.rekor_client.log.entries.post(proposed_entry) end + # 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)" } + + 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(digest, signature, cert) + { + "hashedRekordRequestV002" => { + "digest" => Internal::Util.base64_encode(digest), + "signature" => { + "content" => Internal::Util.base64_encode(signature), + "verifier" => { + "x509Certificate" => { + "rawBytes" => Internal::Util.base64_encode(cert.to_der) + }, + "keyDetails" => key_details(cert) + } + } + } + } + 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. + 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) + 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 = 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.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), @@ -267,6 +459,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 @@ -274,11 +481,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/signing_config.rb b/lib/sigstore/signing_config.rb new file mode 100644 index 0000000..c7d10ac --- /dev/null +++ b/lib/sigstore/signing_config.rb @@ -0,0 +1,181 @@ +# 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 "openssl" +require "protobug_sigstore_protos" + +require_relative "error" +require_relative "tuf" + +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 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 + + # 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 + + def self.from_json(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(config) + unless config.media_type == MEDIA_TYPE + raise Error::InvalidSigningConfig, "unsupported signing config format: #{config.media_type.inspect}" + end + + @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(config.oidc_urls, OIDC_VERSIONS, nil) + + @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) + 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 + + # 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) + next unless timerange_valid?(service.valid_for) + + by_operator[service.operator] << service + end + + # 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| + (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 + # 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 + + # 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.first(count) + end + + result.first(1) + end + + def timerange_valid?(period) + return true unless period + + now = Time.now.utc + return false if period.start && now < period.start.to_time + return false if period.end && now > period.end.to_time + + true + end + end +end diff --git a/lib/sigstore/trusted_root.rb b/lib/sigstore/trusted_root.rb index a2b569c..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) @@ -46,8 +51,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 +95,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/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/lib/sigstore/verifier.rb b/lib/sigstore/verifier.rb index f55c652..5a3c0c8 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,20 +55,36 @@ 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 # 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 @@ -79,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 @@ -95,7 +112,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 @@ -104,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 - # 5) - # The Verifier MUST then check the certificate against the verification policy. + 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 - 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 + # 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 - 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 + # 5) + # The Verifier MUST then check the certificate against the verification policy. + + 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 + + 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? + 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 @@ -156,10 +198,17 @@ 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 + # 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 +252,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 +263,10 @@ def verify_dsse(dsse_envelope, public_key) end end + def dsse_pae(dsse_envelope) + Internal::Util.dsse_pae(dsse_envelope.payloadType, dsse_envelope.payload) + 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 +399,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 +432,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 +455,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,13 +494,14 @@ 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 - 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 @@ -420,7 +513,25 @@ def find_rekor_entry(bundle, hashed_input, offline:) "has_inclusion_promise=#{has_inclusion_promise} has_inclusion_proof=#{has_inclusion_proof}" end - expected_entry = bundle.expected_tlog_entry(hashed_input) + # 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, signing_key:) + return rekor_entry + end + + 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" } @@ -437,11 +548,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 +578,96 @@ 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, 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") + + 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 = + 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/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 diff --git a/sigstore.gemspec b/sigstore.gemspec index d4c33fe..eab59fb 100644 --- a/sigstore.gemspec +++ b/sigstore.gemspec @@ -29,7 +29,8 @@ 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_in_toto_attestation_protos", "~> 0.2.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/cli_test.rb b/test/sigstore/cli_test.rb new file mode 100644 index 0000000..5330227 --- /dev/null +++ b/test/sigstore/cli_test.rb @@ -0,0 +1,44 @@ +# 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 + # 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 316f13a..74e76e4 100644 --- a/test/sigstore/conformance_test.rb +++ b/test/sigstore/conformance_test.rb @@ -4,57 +4,138 @@ 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" + + # 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", + "--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 + 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 + 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 + + # 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 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/signer_test.rb b/test/sigstore/signer_test.rb new file mode 100644 index 0000000..48fca7f --- /dev/null +++ b/test/sigstore/signer_test.rb @@ -0,0 +1,104 @@ +# 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 + 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) + 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 + 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 + 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 + 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 diff --git a/test/sigstore/signing_config_test.rb b/test/sigstore/signing_config_test.rb new file mode 100644 index 0000000..a400839 --- /dev/null +++ b/test/sigstore/signing_config_test.rb @@ -0,0 +1,173 @@ +# 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 + + # 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) } + 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 + 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 + + 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 + # 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 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, "invalid signing config" + end + end + + 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 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, + "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 + 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 2 services in signing config, found 1" + 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 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|