diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..ca7bb0c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,42 @@ +name: Test + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + ruby-version: + - '2.6' + - '2.7' + - '3.0' + - '3.1' + + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + - name: Run Tests + run: bundle exec rake + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.6 + bundler-cache: true + - name: Run Lint + run: bundle exec rubocop diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml deleted file mode 100644 index 37a0f04..0000000 --- a/.github/workflows/verify.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Verify - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - license-check: - name: license boilerplate check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-go@v2 - with: - go-version: '1.16' - - name: Install addlicense - run: go install github.com/google/addlicense@latest - - name: Check license headers - run: | - set -e - addlicense -l apache -c 'The Sigstore Authors' -v -ignore *.yml -ignore *.yaml -ignore Gemfile * - git diff --exit-code diff --git a/.gitignore b/.gitignore index b4c4028..05ccabe 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,9 @@ /tmp/ /vendor/ /certs/ -*.gem +/*.gem + +.byebug_history # rspec failure tracking .rspec_status @@ -21,3 +23,5 @@ data.tar.gz.sig metadata.gz metadata.gz.sig ruby-sigstore-*.gem + +rekor-cli diff --git a/.rspec b/.rspec deleted file mode 100644 index 34c5164..0000000 --- a/.rspec +++ /dev/null @@ -1,3 +0,0 @@ ---format documentation ---color ---require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..0457076 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,153 @@ +require: rubocop-performance + +AllCops: + DisabledByDefault: true + Exclude: + - 'bundler/**/*' + - 'lib/rubygems/resolver/molinillo/**/*' + - 'pkg/**/*' + - 'vendor/bundle/**/*' + - 'tmp/**/*' + TargetRubyVersion: 2.3 + +Layout/AccessModifierIndentation: + Enabled: true + +Layout/ArrayAlignment: + Enabled: true + +Layout/BlockAlignment: + Enabled: true + +Layout/CaseIndentation: + Enabled: true + +Layout/ClosingParenthesisIndentation: + Enabled: true + +Layout/CommentIndentation: + Enabled: true + +Layout/ElseAlignment: + Enabled: true + +Layout/EmptyLinesAroundAccessModifier: + Enabled: true + +# Force Unix line endings. +Layout/EndOfLine: + Enabled: true + EnforcedStyle: lf + +Layout/EmptyLines: + Enabled: true + +Layout/EmptyLinesAroundClassBody: + Enabled: true + +Layout/EmptyLinesAroundMethodBody: + Enabled: true + +Layout/ExtraSpacing: + Enabled: true + +Layout/FirstHashElementIndentation: + Enabled: true + EnforcedStyle: consistent + +Layout/FirstArrayElementIndentation: + Enabled: true + EnforcedStyle: consistent + +Layout/IndentationConsistency: + Enabled: true + +Layout/IndentationWidth: + Enabled: true + +Layout/LeadingEmptyLines: + Enabled: true + +Layout/SpaceAroundOperators: + Enabled: true + +Layout/SpaceInsideBlockBraces: + Enabled: true + SpaceBeforeBlockParameters: false + +Layout/SpaceInsideParens: + Enabled: true + +Layout/TrailingEmptyLines: + Enabled: true + +Layout/TrailingWhitespace: + Enabled: true + +Lint/DuplicateMethods: + Enabled: true + +Lint/ParenthesesAsGroupedExpression: + Enabled: true + +Layout/EndAlignment: + Enabled: true + +Naming/HeredocDelimiterCase: + Enabled: true + +Naming/HeredocDelimiterNaming: + Enabled: true + ForbiddenDelimiters: + - ^RB$ + +Performance/StartWith: + Enabled: true + +Performance/StringReplacement: + Enabled: true + +Security/Open: + Enabled: true + +Style/Encoding: + Enabled: true + Exclude: + - test/rubygems/specifications/foo-0.0.1-x86-mswin32.gemspec + +Style/EvalWithLocation: + Enabled: true + +Style/IfInsideElse: + Enabled: false + +Style/MethodCallWithoutArgsParentheses: + Enabled: true + +Style/MethodDefParentheses: + Enabled: true + +Style/MultilineIfThen: + Enabled: true + +Style/MutableConstant: + Enabled: true + +Style/NilComparison: + Enabled: true + +Style/BlockDelimiters: + Enabled: true + +Style/PercentLiteralDelimiters: + Enabled: true + +# Having these make it easier to *not* forget to add one when adding a new +# value and you can simply copy the previous line. +Style/TrailingCommaInArrayLiteral: + Enabled: true + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInHashLiteral: + Enabled: true + EnforcedStyleForMultiline: comma diff --git a/DECISIONLOG b/DECISIONLOG new file mode 100644 index 0000000..498228c --- /dev/null +++ b/DECISIONLOG @@ -0,0 +1,3 @@ +# 2021-12-15 + +We decided to keep a decision log to capture key resolutions and make them available for future reference. diff --git a/Gemfile b/Gemfile index b20f148..86ea282 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,20 @@ gem "faraday_middleware", "~> 1.0.0" gem "oa-openid", "~> 0.0.2" gem "omniauth-openid", "~> 2.0.1" gem "ruby-openid-apps-discovery", "~> 1.2.0" -gem "rake", "~> 12.0" -gem "rspec", "~> 3.0" -gem "json-jwt", "~> 1.13.0" \ No newline at end of file +gem "json-jwt", "~> 1.13.0" +gem 'net-smtp', require: false + +group :development do + gem "rubocop", "~> 0.80.1" + gem "rubocop-performance", "~> 1.5.2" + gem "rake", "~> 12.0" +end + +group :test do + gem "test-unit", "~> 3.0" + gem "webmock", "~> 3.0" +end + +group :development, :test do + gem "byebug" +end diff --git a/Gemfile.lock b/Gemfile.lock index 8806f3e..8284ec4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,15 +8,15 @@ PATH launchy (~> 2.5) oa-openid (~> 0.0.2) omniauth-openid (~> 2.0.1) - openid_connect (~> 1.2, >= 1.2.0) + openid_connect (~> 1.3, >= 1.3.0) ruby-openid-apps-discovery (~> 1.2.0) GEM remote: https://rubygems.org/ specs: - activemodel (6.1.3.1) - activesupport (= 6.1.3.1) - activesupport (6.1.3.1) + activemodel (6.1.4.1) + activesupport (= 6.1.4.1) + activesupport (6.1.4.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -25,14 +25,18 @@ GEM addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) aes_key_wrap (1.1.0) + ast (2.4.2) attr_required (1.0.1) - bindata (2.4.8) - concurrent-ruby (1.1.8) + bindata (2.4.10) + byebug (11.1.3) + concurrent-ruby (1.1.9) config (3.1.0) deep_merge (~> 1.2, >= 1.2.1) dry-validation (~> 1.0, >= 1.0.0) + crack (0.4.5) + rexml deep_merge (1.2.1) - diff-lcs (1.4.4) + digest (3.1.0) dry-configurable (0.12.1) concurrent-ruby (~> 1.0) dry-core (~> 0.5, >= 0.5.0) @@ -78,10 +82,13 @@ GEM faraday-net_http_persistent (1.1.0) faraday_middleware (1.0.0) faraday (~> 1.0) + hashdiff (1.0.1) hashie (4.1.0) httpclient (2.8.3) - i18n (1.8.9) + i18n (1.8.11) concurrent-ruby (~> 1.0) + io-wait (0.2.1) + jaro_winkler (1.5.4) json-jwt (1.13.0) activesupport (>= 4.2) aes_key_wrap @@ -90,9 +97,16 @@ GEM addressable (~> 2.7) mail (2.7.1) mini_mime (>= 0.1.1) - mini_mime (1.1.0) + mini_mime (1.1.2) minitest (5.14.4) multipart-post (2.1.1) + net-protocol (0.1.2) + io-wait + timeout + net-smtp (0.3.1) + digest + net-protocol + timeout oa-core (0.0.3) rack oa-openid (0.0.2) @@ -105,7 +119,7 @@ GEM omniauth-openid (2.0.1) omniauth (>= 1.0, < 3.0) rack-openid (~> 1.4.0) - openid_connect (1.2.0) + openid_connect (1.3.0) activemodel attr_required (>= 1.0.0) json-jwt (>= 1.5.0) @@ -115,12 +129,16 @@ GEM validate_email validate_url webfinger (>= 1.0.1) + parallel (1.21.0) + parser (3.0.2.0) + ast (~> 2.4.1) + power_assert (2.0.1) pp (0.2.0) prettyprint prettyprint (0.1.0) public_suffix (4.0.6) rack (2.2.3) - rack-oauth2 (1.16.0) + rack-oauth2 (1.19.0) activesupport attr_required httpclient @@ -131,56 +149,69 @@ GEM ruby-openid (>= 2.1.8) rack-protection (2.1.0) rack + rainbow (3.0.0) rake (12.3.3) - rspec (3.10.0) - rspec-core (~> 3.10.0) - rspec-expectations (~> 3.10.0) - rspec-mocks (~> 3.10.0) - rspec-core (3.10.1) - rspec-support (~> 3.10.0) - rspec-expectations (3.10.1) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-mocks (3.10.2) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-support (3.10.2) + rexml (3.2.5) + rubocop (0.80.1) + jaro_winkler (~> 1.5.1) + parallel (~> 1.10) + parser (>= 2.7.0.1) + rainbow (>= 2.2.2, < 4.0) + rexml + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-performance (1.5.2) + rubocop (>= 0.71.0) ruby-openid (2.9.2) ruby-openid-apps-discovery (1.2.0) ruby-openid (>= 2.1.7) + ruby-progressbar (1.11.0) ruby2_keywords (0.0.4) - swd (1.2.0) + swd (1.3.0) activesupport (>= 3) attr_required (>= 0.0.5) httpclient (>= 2.4) + test-unit (3.5.0) + power_assert + timeout (0.2.0) tzinfo (2.0.4) concurrent-ruby (~> 1.0) + unicode-display_width (1.6.1) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) validate_url (1.0.13) activemodel (>= 3.0.0) public_suffix - webfinger (1.1.0) + webfinger (1.2.0) activesupport httpclient (>= 2.4) - zeitwerk (2.4.2) + webmock (3.13.0) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + zeitwerk (2.5.1) PLATFORMS ruby x86_64-linux DEPENDENCIES + byebug config (~> 3.1.0) faraday_middleware (~> 1.0.0) json-jwt (~> 1.13.0) + net-smtp oa-openid (~> 0.0.2) omniauth-openid (~> 2.0.1) pp (= 0.2.0) rake (~> 12.0) - rspec (~> 3.0) + rubocop (~> 0.80.1) + rubocop-performance (~> 1.5.2) ruby-openid-apps-discovery (~> 1.2.0) ruby-sigstore! + test-unit (~> 3.0) + webmock (~> 3.0) BUNDLED WITH - 2.2.16 + 2.2.28 diff --git a/README.md b/README.md index 488365a..ee979a3 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,10 @@ > :warning: Still under developement, not ready for production use yet! +> :information_source: This is a temporary fork of [sigstore/ruby-sigstore](https://github.com/sigstore/ruby-sigstore). This version abandons the [existing gem signing flow](https://ruby-doc.org/stdlib-3.0.3/libdoc/rubygems/rdoc/Gem/Security.html) in favor of a keyless gem signature that we store in the [Rekor](https://docs.sigstore.dev/rekor/overview) transparency log. + This rubygems plugin enables both developers to sign gem files and users to verify the origin -of a gem. It wraps around the main gem command to allow a level of seamless intergration with +of a gem. It wraps around the main gem command to allow a level of seamless integration with gem build and install operations. ## Installation @@ -26,11 +28,20 @@ Or install it yourself as: ### Sign an existing gem file -`gem sign foo.gem` +`gem signatures --sign foo.gem` + +### Identity Tokens + +In automated environments, gem also supports directly using OIDC Identity Tokens from specific issuers. +These can be supplied on the command line with the `--identity-token` flag. + +```shell +$ gem signatures --sign --identity-token=$(gcloud auth print-identity-token) +``` ### Verify an existing gem file -`gem verify foo.gem` +`gem signatures --verify foo.gem` ### Build and sign a gem @@ -38,17 +49,18 @@ Or install it yourself as: ### Install and verify a gem -`gem install foo --verify` - -### Install a gem without verification - -`gem install foo --noverify` +`gem install foo --verify-signatures` ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). +To build this gem, run `gem build ruby-sigstore`. To install it, run `gem install -l GEM`, e.g. `gem install -l ruby-sigstore-0.1.0.gem`. + +To test or debug the plugin after making changes, try this: +```shell +gem uninstall ruby-sigstore && gem build ruby-sigstore && gem install -l ruby-sigstore-0.1.0.gem +``` ## Contributing diff --git a/Rakefile b/Rakefile index 2871261..69b8bc6 100644 --- a/Rakefile +++ b/Rakefile @@ -12,9 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -require "bundler/gem_tasks" -require "rspec/core/rake_task" +require 'rake/testtask' -RSpec::Core::RakeTask.new(:spec) +Rake::TestTask.new do |t| + t.libs << "lib" + t.libs << "test" -task :default => :spec + t.test_files = FileList['test/**/test_*.rb'] +end + +task :default => :test diff --git a/lib/rubygems/commands/build_command_extend.rb b/lib/rubygems/commands/build_command_extend.rb new file mode 100644 index 0000000..401a060 --- /dev/null +++ b/lib/rubygems/commands/build_command_extend.rb @@ -0,0 +1,48 @@ +# Copyright 2021 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 'digest' +require 'fileutils' +require 'openssl' +require 'rubygems/package' +require 'rubygems/command_manager' +require 'rubygems/sigstore' + +# override the generic gem build command to lay our own --sign option on top +b = Gem::CommandManager.instance[:build] +b.add_option("--[no-]sign", "Sign gem with sigstore.") do |value, options| + Gem::Sigstore.options[:sign] = true +end + +b.add_option("--identity-token", String, + "Provide a static token for automated environments") do |value, options| + Gem::Sigstore.options[:identity_token] = value +end + +class Gem::Commands::BuildCommand + alias_method :original_execute, :execute + def execute + if Gem::Sigstore.options[:sign] + gemfile = Gem::Sigstore::Gemfile.new(get_one_gem_name) + gem_signer = Gem::Sigstore::GemSigner.new( + gemfile: gemfile, + config: Gem::Sigstore::Config.read, + identity_token: Gem::Sigstore.options[:identity_token], + ) + # Run the gem build process only if openid auth was successful (original_execute) + rekor_entry = gem_signer.run { original_execute } + say rekor_entry + end + end +end diff --git a/lib/rubygems/sigstore/verify_extend.rb b/lib/rubygems/commands/install_command_extend.rb similarity index 61% rename from lib/rubygems/sigstore/verify_extend.rb rename to lib/rubygems/commands/install_command_extend.rb index 8bc5ee5..5628a89 100644 --- a/lib/rubygems/sigstore/verify_extend.rb +++ b/lib/rubygems/commands/install_command_extend.rb @@ -13,14 +13,12 @@ # limitations under the License. require 'rubygems/command_manager' -require "rubygems/sigstore/config" -require 'rubygems/sigstore/options' - -Gem::CommandManager.instance.register_command :verify +require "rubygems/user_interaction" +require 'rubygems/sigstore' # gem install hooks i = Gem::CommandManager.instance[:install] -i.add_option("--[no-]verify", +i.add_option("--[no-]verify-signatures", 'Verifies a local gem has been signed via sigstore.' + 'This helps to ensure the gem has not been tampered with in transit.') do |value, options| Gem::Sigstore.options[:verify] = value @@ -28,10 +26,23 @@ Gem.pre_install do |installer| begin - if (Gem::Sigstore.options[:verify]) - puts "verify called" + verify = Gem::Sigstore.options[:verify] || Gem::SigningPolicy.verify_gem_install? + if verify + # A locally installed gem will sometimes not have a reference to the .gem file + if (package = installer.package) + gem_path = package.gem.path + + installer.say "Verifying #{gem_path}" + + gemfile = Gem::Sigstore::Gemfile.new(gem_path) + verifier = Gem::Sigstore::GemVerifier.new( + gemfile: gemfile, + config: Gem::Sigstore::Config.read + ) + verifier.run + end end - rescue Gem::SigstoreException => ex + rescue StandardError => ex installer.alert_error(ex.message) installer.terminate_interaction(1) end diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb deleted file mode 100644 index 81d2424..0000000 --- a/lib/rubygems/commands/sign_command.rb +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2021 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 'rubygems/command' -require "rubygems/sigstore/config" -require "rubygems/sigstore/crypto" -require "rubygems/sigstore/http_client" -require "rubygems/sigstore/openid" - -require 'json/jwt' -require "launchy" -require "openid_connect" -require "socket" - -class Gem::Commands::SignCommand < Gem::Command - def initialize - super "sign", "Sign a gem" - end - - def arguments # :nodoc: - "GEMNAME name of gem to sign" - end - - def defaults_str # :nodoc: - "" - end - - def usage # :nodoc: - "gem sign GEMNAME" - end - - def execute - config = SigStoreConfig.new().config - priv_key, pub_key = Crypto.new().generate_keys - proof, access_token = OpenIDHandler.new(priv_key).get_token - cert_response = HttpClient.new().get_cert(access_token, proof, pub_key, config.fulcio_host) - puts cert_response - end -end diff --git a/lib/rubygems/commands/signatures_command.rb b/lib/rubygems/commands/signatures_command.rb new file mode 100644 index 0000000..0b77d24 --- /dev/null +++ b/lib/rubygems/commands/signatures_command.rb @@ -0,0 +1,112 @@ +# Copyright 2021 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 'rubygems/command' +require 'rubygems/sigstore' + +require 'json/jwt' +require 'launchy' +require 'openid_connect' +require 'socket' + +class Gem::Commands::SignaturesCommand < Gem::Command + SIGNING_OPTIONS = [:sign, :verify].freeze + + def initialize + super "signatures", "Create and verify gem signatures" + + add_option("-s", "--[no-]sign", "Sign the gem(s)") do |value, options| + options[:sign] = value + end + + add_option("-v", "--[no-]verify", "Verify gem signatures") do |value, options| + options[:verify] = value + end + + add_option("--identity-token TOKEN", String, + "Provide a static token for signing in automated environments") do |value, options| + options[:identity_token] = value + end + end + + def arguments # :nodoc: + "GEMNAME name of gem to sign or verify" + end + + def defaults_str # :nodoc: + "" + end + + # def usage # :nodoc: + # "gem signatures GEMNAME" + # end + + def execute + gem_path = get_one_gem_name + raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(gem_path) + raise Gem::CommandLineError, "#{gem_path} is not a valid gem" unless is_a_gem?(gem_path) + + gemfile = Gem::Sigstore::Gemfile.new(gem_path) + + sign(gemfile) if options[:sign] + verify(gemfile) if verify_signatures? + end + + private + + def is_a_gem?(file) + begin + Gem::Package.new(file).verify + rescue Gem::Package::FormatError + return false + end + end + + def sign(gemfile) + rekor_entry = Gem::Sigstore::GemSigner.new( + gemfile: gemfile, + config: Gem::Sigstore::Config.read, + identity_token: options[:identity_token], + ).run + + say log_entry_url(rekor_entry) + end + + def verify_signatures? + if options.key?(:verify) + options[:verify] + else + default_verify_behavior + end + end + + def default_verify_behavior + # Only verify signatures if there are no other signature-related options present. + options.slice(*SIGNING_OPTIONS).empty? + end + + def verify(gemfile) + say "Verifying #{gemfile.path}" + + verifier = Gem::Sigstore::GemVerifier.new( + gemfile: gemfile, + config: Gem::Sigstore::Config.read + ) + verifier.run + end + + def log_entry_url(rekor_entry) + "#{Gem::Sigstore::Config.read.rekor_host}/api/v1/log/entries/#{rekor_entry.keys.first}" + end +end diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb deleted file mode 100644 index 82af112..0000000 --- a/lib/rubygems/commands/verify_command.rb +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright 2021 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. - -class Gem::Commands::VerifyCommand < Gem::Command - def initialize - super 'verify', "Opens the gem's documentation" - add_option('--fulcio-host HOST', 'Fulcio host') do |value, options| - options[:host] = value - end - end - - def execute - puts "verify" - end -end \ No newline at end of file diff --git a/lib/rubygems/sigstore.rb b/lib/rubygems/sigstore.rb new file mode 100644 index 0000000..b1cdf7f --- /dev/null +++ b/lib/rubygems/sigstore.rb @@ -0,0 +1,18 @@ +module Gem::Sigstore +end + +require 'rubygems/sigstore/cert_chain' +require 'rubygems/sigstore/cert_extensions' +require 'rubygems/sigstore/cert_provider' +require 'rubygems/sigstore/config' +require 'rubygems/sigstore/pkey' +require 'rubygems/sigstore/file_signer' +require 'rubygems/sigstore/fulcio_api' +require 'rubygems/sigstore/gem_signer' +require 'rubygems/sigstore/gem_verifier' +require 'rubygems/sigstore/gemfile' +require 'rubygems/sigstore/openid' +require 'rubygems/sigstore/options' +require 'rubygems/sigstore/rekor' +require 'rubygems/sigstore/signing_policy' +require 'rubygems/sigstore/version' diff --git a/lib/rubygems/sigstore/cert_chain.rb b/lib/rubygems/sigstore/cert_chain.rb new file mode 100644 index 0000000..7324259 --- /dev/null +++ b/lib/rubygems/sigstore/cert_chain.rb @@ -0,0 +1,41 @@ +require "open-uri" +require "rubygems/sigstore/cert_extensions" + +class Gem::Sigstore::CertChain + PATTERN = /-----BEGIN CERTIFICATE-----(?:.|\n)+?-----END CERTIFICATE-----/.freeze + + def initialize(cert_pem) + @cert_pem = cert_pem + end + + def certificates + @certificates ||= build_chain + end + + def signing_cert + certificates.last + end + + def root_cert + certificates.first + end + + private + + def build_chain + deserialize.tap do |chain| + while chain.first&.issuing_certificate_uri do + chain.prepend(chain.first.issuing_certificate) + end + end + end + + def deserialize + return [] unless @cert_pem + @cert_pem.scan(PATTERN).map do |cert| + cert = OpenSSL::X509::Certificate.new(cert) + cert.extend(Gem::Sigstore::CertExtensions) + cert + end + end +end diff --git a/lib/rubygems/sigstore/cert_extensions.rb b/lib/rubygems/sigstore/cert_extensions.rb new file mode 100644 index 0000000..c746859 --- /dev/null +++ b/lib/rubygems/sigstore/cert_extensions.rb @@ -0,0 +1,36 @@ +module Gem::Sigstore::CertExtensions + def extension(oid) + extensions_hash[oid] + end + + def issuing_certificate_uri + return @issuing_certificate_uri if defined?(@issuing_certificate_uri) + @issuing_certificate_uri ||= begin + aia = extension("authorityInfoAccess") + aia.match(/http\S+/).to_s if aia.present? + end + end + + def issuing_certificate + if issuing_certificate_uri.empty? + raise "unsupported authorityInfoAccess value #{extension("authorityInfoAccess")}" + end + + cert_pem = URI.open(issuing_certificate_uri).read + issuer = OpenSSL::X509::Certificate.new(cert_pem) + issuer.extend(Gem::Sigstore::CertExtensions) + issuer + end + + def subject_alt_name + extension("subjectAltName")&.delete_prefix("email:") + end + + private + + def extensions_hash + @extensions_hash ||= extensions.each_with_object({}) do |ext, hash| + hash[ext.oid] = ext.value + end + end +end diff --git a/lib/rubygems/sigstore/cert_provider.rb b/lib/rubygems/sigstore/cert_provider.rb new file mode 100644 index 0000000..d165652 --- /dev/null +++ b/lib/rubygems/sigstore/cert_provider.rb @@ -0,0 +1,19 @@ +class Gem::Sigstore::CertProvider + def initialize(config:, pkey:, oidp:) + @config = config + @pkey = pkey + @oidp = oidp + end + + def run + fulcio_api.create(pkey.public_key.to_der) + end + + private + + attr_reader :config, :pkey, :oidp + + def fulcio_api + Gem::Sigstore::FulcioApi.new(oidp: oidp, host: config.fulcio_host) + end +end diff --git a/lib/rubygems/sigstore/config.rb b/lib/rubygems/sigstore/config.rb index 9e5698d..d3f25a0 100644 --- a/lib/rubygems/sigstore/config.rb +++ b/lib/rubygems/sigstore/config.rb @@ -14,18 +14,27 @@ require 'config' -class SigStoreConfig - def initialize; end - def config - Config.setup do |config| - config.use_env = true - config.env_prefix = 'sigstore' - config.env_separator = '_' - end - settings_file = Config.setting_files( +class Gem::Sigstore::Config + class << self + def read + ::Config.load_and_set_settings(settings_file) + end + + private + + def setup + ::Config.setup do |config| + config.use_env = true + config.env_prefix = 'sigstore' + config.env_separator = '_' + end + end + + def settings_file + ::Config.setting_files( File.expand_path('../../../../', __FILE__), 'development' # TODO: Get this from gemspec - ) - return Config.load_and_set_settings(settings_file) + ) end + end end diff --git a/lib/rubygems/sigstore/crypto.rb b/lib/rubygems/sigstore/crypto.rb deleted file mode 100644 index a1efb2b..0000000 --- a/lib/rubygems/sigstore/crypto.rb +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2021 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 'base64' -require 'openssl' - -class Crypto - def initialize; end - - def generate_keys - key = OpenSSL::PKey::RSA.generate(2048) - pkey = key.public_key - return [key, pkey, Base64.encode64(pkey.to_der)] - end - - def sign_proof(priv_key, email) - proof = priv_key.sign(OpenSSL::Digest::SHA256.new, email) - return Base64.encode64(proof) - end -end - -# class Crypto -# def initialize; end - -# def generate_keys -# key = OpenSSL::PKey::EC.new('prime256v1').generate_key -# pkey = OpenSSL::PKey::EC.new(key.public_key.group) -# pkey.public_key = key.public_key -# return [key, pkey, Base64.encode64(pkey.to_der)] -# end - -# def sign_proof(priv_key, email) -# proof = priv_key.sign(OpenSSL::Digest::SHA256.new, email) -# return Base64.encode64(proof) -# end -# end - diff --git a/lib/rubygems/sigstore/file_signer.rb b/lib/rubygems/sigstore/file_signer.rb new file mode 100644 index 0000000..738908d --- /dev/null +++ b/lib/rubygems/sigstore/file_signer.rb @@ -0,0 +1,24 @@ +class Gem::Sigstore::FileSigner + Data = Struct.new(:digest, :signature, :raw) + + def initialize(file:, pkey:, transparency_log:, cert:) + @pkey = pkey + @file = file + @transparency_log = transparency_log + @cert = cert + end + + def run + @transparency_log.create(@cert, data) + end + + private + + def data + @data ||= Data.new(@file.digest, signature, @file.content) + end + + def signature + @signature ||= @pkey.private_key.sign @file.digest, @file.content + end +end diff --git a/lib/rubygems/sigstore/fulcio_api.rb b/lib/rubygems/sigstore/fulcio_api.rb new file mode 100644 index 0000000..02aa59a --- /dev/null +++ b/lib/rubygems/sigstore/fulcio_api.rb @@ -0,0 +1,40 @@ +require "faraday_middleware" +require "openssl" + +class Gem::Sigstore::FulcioApi + def initialize(host:, oidp:) + @host = host + @oidp = oidp + end + + def create(pub_key) + response = connection.post("/api/v1/signingCert", { + publicKey: { + content: Base64.encode64(pub_key), + algorithm: "ecdsa", + }, + signedEmailAddress: Base64.encode64(oidp.proof), + } + ) + + unless response.status == 201 + raise "Unexpected response from POST api/v1/signingCert:\n #{response.body}" + end + + response.body + end + + private + + attr_reader :host, :oidp + + def connection + Faraday.new do |request| + request.authorization :Bearer, oidp.token.to_s + request.url_prefix = host + request.request :json + request.response :json, content_type: /json/ + request.adapter :net_http + end + end +end diff --git a/lib/rubygems/sigstore/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb new file mode 100644 index 0000000..3834aa8 --- /dev/null +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -0,0 +1,58 @@ +require "rubygems/user_interaction" +require "rubygems/sigstore/pkey" +require "rubygems/sigstore/cert_provider" +require "rubygems/sigstore/file_signer" +require "rubygems/sigstore/rekor" + +class Gem::Sigstore::GemSigner + include Gem::UserInteraction + + Data = Struct.new(:digest, :signature, :raw) + + def initialize(gemfile:, config:, identity_token: nil) + @gemfile = gemfile + @config = config + @identity_token = identity_token + end + + def run + cert = cert_provider.run + + yield if block_given? + + say "Fulcio certificate chain" + say cert + say + say "Sending gem digest, signature & certificate chain to transparency log." + + gemfile_signer(cert).run + end + + private + + attr_reader :gemfile, :config, :identity_token + + def cert_provider + Gem::Sigstore::CertProvider.new(config: config, pkey: pkey, oidp: oidp) + end + + def pkey + @pkey ||= Gem::Sigstore::PKey.new + end + + def oidp + @oidp ||= if identity_token + Gem::Sigstore::OpenID::Static.new(pkey.private_key, identity_token) + else + Gem::Sigstore::OpenID::Dynamic.new(pkey.private_key) + end + end + + def gemfile_signer(cert) + Gem::Sigstore::FileSigner.new(file: gemfile, pkey: pkey, transparency_log: rekor_api, cert: cert) + end + + def rekor_api + Gem::Sigstore::Rekor::Api.new(host: config.rekor_host) + end +end diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb new file mode 100644 index 0000000..1cdcde5 --- /dev/null +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -0,0 +1,61 @@ +require "rubygems/user_interaction" +require "rubygems/sigstore/rekor" + +class Gem::Sigstore::GemVerifier + include Gem::UserInteraction + + Data = Struct.new(:digest, :signature, :raw) + + def initialize(gemfile:, config:) + @gemfile = gemfile + @config = config + end + + def run + rekor_api = Gem::Sigstore::Rekor::Api.new(host: config.rekor_host) + log_entries = rekor_api.where(data_digest: gemfile.digest) + rekords = log_entries.select {|entry| %i[rekord hashedrekord].include?(entry.kind) } + + valid_signature_rekords = rekords.select {|rekord| valid_signature?(rekord, gemfile) } + + if valid_signature_rekords.empty? + say "No valid signatures found for digest #{gemfile.digest}" + else + say ":noice:" + print_signers(valid_signature_rekords) + end + end + + private + + attr_reader :gemfile, :config + + def valid_signature?(rekord, gemfile) + public_key = rekord.signer_public_key + digest = gemfile.digest + signature = rekord.signature + content = gemfile.content + + public_key.verify(digest, signature, content) + end + + def print_signers(rekords) + maintainers, others = rekords.map(&:signer_email).uniq.partition do |email| + gemfile.maintainer?(email) + end + + unless maintainers.empty? + say "Signed by maintainer#{maintainers.size == 1 ? '' : 's'}: #{email_list(maintainers)}" + end + + unless others.empty? + say "Signed by non-maintainer#{others.size == 1 ? '' : 's'}: #{email_list(others)}" + end + end + + def email_list(emails) + return emails.first if emails.size == 1 + + emails[0...-1].join(", ") + " and #{emails.last}" + end +end diff --git a/lib/rubygems/sigstore/gemfile.rb b/lib/rubygems/sigstore/gemfile.rb new file mode 100644 index 0000000..50e6775 --- /dev/null +++ b/lib/rubygems/sigstore/gemfile.rb @@ -0,0 +1,51 @@ +require 'openssl' +require 'rubygems/package' +require 'digest' +require 'fileutils' + +class Gem::Sigstore::Gemfile + class << self + def find_gemspec(glob = "*.gemspec") + gemspecs = Dir.glob(glob).sort + + if gemspecs.size > 1 + alert_error "Multiple gemspecs found: #{gemspecs}, please specify one" + terminate_interaction(1) + end + + new(gemspecs.first) + end + end + + def initialize(path) + @path = path + end + + def path + @path + end + + def content + @content ||= File.read(path) + end + + def digest + @digest ||= OpenSSL::Digest::SHA256.new(content) + end + + def package + @package ||= Gem::Package.new(path) + end + + def spec + package.spec + end + + def maintainer?(email) + maintainers.include?(email) + end + + def maintainers + Array(spec.email) + end +end diff --git a/lib/rubygems/sigstore/http_client.rb b/lib/rubygems/sigstore/http_client.rb deleted file mode 100644 index 2de4071..0000000 --- a/lib/rubygems/sigstore/http_client.rb +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright 2021 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 "faraday_middleware" -require "openssl" - -class HttpClient - def initialize; end - def get_cert(id_token, proof, pub_key, fulcio_host) - connection = Faraday.new do |request| - request.authorization :Bearer, id_token.to_s - request.url_prefix = fulcio_host - request.request :json - request.response :json, content_type: /json/ - request.adapter :net_http - end - fulcio_response = connection.post("/api/v1/signingCert", { publicKey: { content: pub_key, algorithm: "ecdsa" }, signedEmailAddress: proof}) - return fulcio_response.body - end - def submit_rekor(pub_key, data_digest, data_signature, certPEM, data_raw, rekor_host) - connection = Faraday.new do |request| - # request.authorization :Bearer, id_token.to_s - request.url_prefix = rekor_host - request.request :json - request.response :json, content_type: /json/ - request.adapter :net_http - end - - rekor_response = connection.post("/api/v1/log/entries", - { - kind: "rekord", - apiVersion: "0.0.1", - spec: { - signature: { - format: "x509", - content: Base64.encode64(data_signature), - publicKey: { - content: Base64.encode64(pub_key.to_pem) - } - }, - data: { - content: data_raw, - hash: { - algorithm: "sha256", - value: data_digest - } - } - } - }) - return rekor_response.body - end -end diff --git a/lib/rubygems/sigstore/openid.rb b/lib/rubygems/sigstore/openid.rb index 2c3da9a..61604a4 100644 --- a/lib/rubygems/sigstore/openid.rb +++ b/lib/rubygems/sigstore/openid.rb @@ -1,182 +1,9 @@ -# Copyright 2021 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 "rubygems/sigstore/config" -require "rubygems/sigstore/crypto" - -require 'base64' -require 'cgi' -require 'digest' -require 'json/jwt' -require "launchy" -require "openid_connect" - -class OpenIDHandler - def initialize(priv_key) - @priv_key = priv_key - end - - def get_token() - config = SigStoreConfig.new().config - session = {} - session[:state] = SecureRandom.hex(16) - session[:nonce] = SecureRandom.hex(16) - oidc_discovery = OpenIDConnect::Discovery::Provider::Config.discover! config.oidc_issuer - - # oidc_discovery gem doesn't support code_challenge_methods yet, so we will just blindly include - pkce = generate_pkce - - # If development env, used a fixed port - if config.development == true - server = TCPServer.new 5678 - server_addr = "5678" - else - server = TCPServer.new 0 - server_addr = server.addr[1].to_s - end - - webserv = Thread.new do - response = "You may close this browser" - response_code = "200 OK" - connection = server.accept - while (input = connection.gets) - begin - # VERB PATH HTTP/1.1 - http_req = input.split(' ') - if http_req.length() != 3 - raise "invalid HTTP request received on callback" - end - params = CGI.parse(URI.parse(http_req[1]).query) - if params["code"].length() != 1 or params["state"].length() != 1 - raise "multiple values for code or state returned in callback; unable to process" - end - Thread.current[:code] = params["code"][0] - Thread.current[:state] = params["state"][0] - rescue StandardError => e - response = "Error processing request: #{e.message}" - response_code = "400 Bad Request" - end - connection.print "HTTP/1.1 #{response_code}\r\n" + - "Content-Type: text/plain\r\n" + - "Content-Length: #{response.bytesize}\r\n" + - "Connection: close\r\n" - connection.print "\r\n" - connection.print response - connection.close - if response_code != "200 OK" - raise response - end - break - end - ensure - server.close - end - - webserv.abort_on_exception = true - - client = OpenIDConnect::Client.new( - authorization_endpoint: oidc_discovery.authorization_endpoint, - identifier: config.oidc_client, - redirect_uri: "http://localhost:" + server_addr, - secret: config.oidc_secret, - token_endpoint: oidc_discovery.token_endpoint, - ) - - authorization_uri = client.authorization_uri( - scope: ["openid", :email], - state: session[:state], - nonce: session[:nonce], - code_challenge_method: pkce[:method], - code_challenge: pkce[:challenge], - ) - - begin - Launchy.open(authorization_uri) - rescue - # NOTE: ignore any exception, as the URL is printed above and may be - # opened manually - puts "Cannot open browser automatically, please click on the link below:" - puts "" - puts authorization_uri - end - - webserv.join - - # check state == webserv[:state] - if webserv[:state] != session[:state] - abort 'Invalid state value received from OIDC Provider' - end - - client.authorization_code = webserv[:code] - access_token = client.access_token!({code_verifier: pkce[:value]}) - - provider_public_keys = oidc_discovery.jwks - - token = verify_token(access_token, provider_public_keys, config, session[:nonce]) - - proof = Crypto.new().sign_proof(@priv_key, token["email"]) - return proof, access_token - end - - private - - def generate_pkce() - pkce = {} - pkce[:method] = "S256" - # generate 43 <= x <= 128 character random string; the length below will generate a 2x hex length string - pkce[:value] = SecureRandom.hex(24) - # compute SHA256 hash and base64-urlencode hash - pkce[:challenge] = Base64.urlsafe_encode64(Digest::SHA256.digest(pkce[:value]), padding:false) - return pkce +module Gem + module Sigstore + module OpenID end + end +end - def verify_token(access_token, public_keys, config, nonce) - begin - decoded_access_token = JSON::JWT.decode(access_token.to_s,public_keys) - rescue JSON::JWS::VerificationFailed => e - abort 'JWT Verification Failed: ' + e.to_s - else #success - token = JSON.parse(decoded_access_token.to_json) - end - - # verify issuer matches - if token["iss"] != config.oidc_issuer - abort 'Mismatched issuer in OIDC ID Token' - end - - # verify it was intended for me - if token["aud"] != config.oidc_client - abort 'OIDC ID Token was not intended for this use' - end - - # verify token has not expired (iat < now <= exp) - now = Time.now.to_i - if token["iat"] > now or now > token["exp"] - abort 'OIDC ID Token is expired' - end - - # verify nonce if present in token - if token.key?("nonce") and token["nonce"] != nonce - abort 'OIDC ID Token has incorrect nonce value' - end - - # ensure that the OIDC provider has verified the email address - # note: this may have happened some time in the past - if token["email_verified"] != true - abort 'Email address in OIDC token has not been verified by provider' - end - - return token - end -end \ No newline at end of file +require "rubygems/sigstore/openid/dynamic" +require "rubygems/sigstore/openid/static" diff --git a/lib/rubygems/sigstore/openid/dynamic.rb b/lib/rubygems/sigstore/openid/dynamic.rb new file mode 100644 index 0000000..efba306 --- /dev/null +++ b/lib/rubygems/sigstore/openid/dynamic.rb @@ -0,0 +1,202 @@ +# Copyright 2021 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 'rubygems/sigstore/config' +require 'rubygems/sigstore/pkey' + +require 'base64' +require 'cgi' +require 'digest' +require 'json/jwt' +require "launchy" +require "openid_connect" + +class Gem::Sigstore::OpenID::Dynamic + include Gem::UserInteraction + + def initialize(priv_key) + @priv_key = priv_key + end + + def proof + get_token unless defined?(@proof) + @proof + end + + def token + get_token unless defined?(@token) + @token.to_s + end + + private + + def get_token + config = Gem::Sigstore::Config.read + session = {} + session[:state] = SecureRandom.hex(16) + session[:nonce] = SecureRandom.hex(16) + oidc_discovery = OpenIDConnect::Discovery::Provider::Config.discover! config.oidc_issuer + + # oidc_discovery gem doesn't support code_challenge_methods yet, so we will just blindly include + pkce = generate_pkce + + # If development env, used a fixed port + if config.development == true || ENV["SIGSTORE_TEST"] + server = TCPServer.new 5678 + server_addr = "5678" + else + server = TCPServer.new 0 + server_addr = server.addr[1].to_s + end + + webserv = Thread.new do + begin + response = "You may close this browser" + response_code = "200 OK" + connection = server.accept + while (input = connection.gets) + begin + # VERB PATH HTTP/1.1 + http_req = input.split(' ') + if http_req.length != 3 + raise "invalid HTTP request received on callback" + end + params = CGI.parse(URI.parse(http_req[1]).query) + if params["code"].length != 1 or params["state"].length != 1 + raise "multiple values for code or state returned in callback; unable to process" + end + Thread.current[:code] = params["code"][0] + Thread.current[:state] = params["state"][0] + rescue StandardError => e + response = "Error processing request: #{e.message}" + response_code = "400 Bad Request" + end + connection.print "HTTP/1.1 #{response_code}\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: #{response.bytesize}\r\n" + + "Connection: close\r\n" + connection.print "\r\n" + connection.print response + connection.close + if response_code != "200 OK" + raise response + end + break + end + ensure + server.close + end + end + + webserv.abort_on_exception = true + redirect_uri = "http://localhost:" + server_addr + + client = OpenIDConnect::Client.new( + authorization_endpoint: oidc_discovery.authorization_endpoint, + identifier: config.oidc_client, + redirect_uri: redirect_uri, + secret: config.oidc_secret, + token_endpoint: oidc_discovery.token_endpoint, + ) + + authorization_uri = client.authorization_uri( + scope: ["openid", :email], + state: session[:state], + nonce: session[:nonce], + code_challenge_method: pkce[:method], + code_challenge: pkce[:challenge], + ) + + begin + if ENV["SIGSTORE_TEST"] + Faraday.get("#{redirect_uri}/?code=DUMMY&state=#{session[:state]}") + else + Launchy.open(authorization_uri) + end + rescue + # NOTE: ignore any exception, as the URL is printed above and may be + # opened manually + say "Cannot open browser automatically, please click on the link below:" + say "" + say authorization_uri + end + + webserv.join + + # check state == webserv[:state] + if webserv[:state] != session[:state] + abort 'Invalid state value received from OIDC Provider' + end + + client.authorization_code = webserv[:code] + access_token = client.access_token!({code_verifier: pkce[:value]}) + + provider_public_keys = oidc_discovery.jwks + + token = verify_token(access_token, provider_public_keys, config, session[:nonce]) + + pkey = Gem::Sigstore::PKey.new(private_key: @priv_key) + @proof = pkey.sign_proof(token["email"]) + @token = access_token + end + + def generate_pkce + pkce = {} + pkce[:method] = "S256" + # generate 43 <= x <= 128 character random string; the length below will generate a 2x hex length string + pkce[:value] = SecureRandom.hex(24) + # compute SHA256 hash and base64-urlencode hash + pkce[:challenge] = Base64.urlsafe_encode64(Digest::SHA256.digest(pkce[:value]), padding:false) + pkce + end + + def verify_token(access_token, public_keys, config, nonce) + begin + decoded_access_token = JSON::JWT.decode(access_token.to_s,public_keys) + rescue JSON::JWS::VerificationFailed => e + abort 'JWT Verification Failed: ' + e.to_s + else #success + token = JSON.parse(decoded_access_token.to_json) + end + + # verify issuer matches + if token["iss"] != config.oidc_issuer + abort 'Mismatched issuer in OIDC ID Token' + end + + # verify it was intended for me + if token["aud"] != config.oidc_client + abort 'OIDC ID Token was not intended for this use' + end + + # verify token has not expired (iat < now <= exp) + now = Time.now.to_i + if token["iat"] > now or now > token["exp"] + abort 'OIDC ID Token is expired' + end + + # verify nonce if present in token + if token.key?("nonce") and token["nonce"] != nonce + abort 'OIDC ID Token has incorrect nonce value' + end + + # ensure that the OIDC provider has verified the email address + # note: this may have happened some time in the past + if token["email_verified"] != true + abort 'Email address in OIDC token has not been verified by provider' + end + + token + end +end diff --git a/lib/rubygems/sigstore/openid/static.rb b/lib/rubygems/sigstore/openid/static.rb new file mode 100644 index 0000000..d376d0c --- /dev/null +++ b/lib/rubygems/sigstore/openid/static.rb @@ -0,0 +1,73 @@ +class Gem::Sigstore::OpenID::Static + def initialize(priv_key, token) + @priv_key = priv_key + @unparsed_token = token + end + + # https://www.youtube.com/watch?v=ZsgA77j5LyY + def proof + @proof ||= create_proof + end + + def token + parse_token unless defined?(@token) + @token ||= @unparsed_token.to_s + end + + private + + def create_proof + pkey.sign_proof(subject) + end + + def pkey + @pkey ||= Gem::Sigstore::PKey.new(private_key: @priv_key) + end + + def parsed_token + @parsed_token ||= parse_token + end + + def parse_token + begin + decoded_access_token = JSON::JWT.decode(@unparsed_token.to_s, public_keys) + JSON.parse(decoded_access_token.to_json) + rescue JSON::JWS::VerificationFailed => e + abort 'JWT Verification Failed: ' + e.to_s + end + end + + def subject + return email if email + + if parsed_token["subject"].empty? + abort 'No subject found in claims' + end + + parsed_token["subject"] + end + + def email + return unless parsed_token["email"] + + # ensure that the OIDC provider has verified the email address + # note: this may have happened some time in the past + unless parsed_token["email_verified"] + abort 'Email address in OIDC token was not verified by identity provider' + end + + parsed_token["email"] + end + + def public_keys + @public_keys ||= oidc_discovery.jwks + end + + def oidc_discovery + OpenIDConnect::Discovery::Provider::Config.discover! config.oidc_issuer + end + + def config + Gem::Sigstore::Config.read + end +end diff --git a/lib/rubygems/sigstore/options.rb b/lib/rubygems/sigstore/options.rb index 8083f49..1fa055c 100644 --- a/lib/rubygems/sigstore/options.rb +++ b/lib/rubygems/sigstore/options.rb @@ -13,9 +13,10 @@ # limitations under the License. module Gem::Sigstore - private - def self.options - @options ||= {} - @options - end + private + + def self.options + @options ||= {} + @options + end end diff --git a/spec/ruby/sigstore_spec.rb b/lib/rubygems/sigstore/pkey.rb similarity index 62% rename from spec/ruby/sigstore_spec.rb rename to lib/rubygems/sigstore/pkey.rb index da681da..301ffd8 100644 --- a/spec/ruby/sigstore_spec.rb +++ b/lib/rubygems/sigstore/pkey.rb @@ -12,12 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -RSpec.describe Ruby::Sigstore do - it "has a version number" do - expect(Ruby::Sigstore::VERSION).not_to be nil +require 'base64' +require 'openssl' + +class Gem::Sigstore::PKey + def initialize(private_key: nil) + @private_key = private_key if private_key + end + + def sign_proof(email) + private_key.sign(OpenSSL::Digest::SHA256.new, email) + end + + def public_key + private_key.public_key end - it "does something useful" do - expect(false).to eq(true) + def private_key + @private_key ||= OpenSSL::PKey::RSA.generate(2048) end end diff --git a/lib/rubygems/sigstore/rekor.rb b/lib/rubygems/sigstore/rekor.rb new file mode 100644 index 0000000..42bd3b8 --- /dev/null +++ b/lib/rubygems/sigstore/rekor.rb @@ -0,0 +1,8 @@ +module Gem::Sigstore::Rekor +end + +require "rubygems/sigstore/rekor/api" +require "rubygems/sigstore/rekor/hashed_rekord" +require "rubygems/sigstore/rekor/log_entry" +require "rubygems/sigstore/rekor/rekord" +require "rubygems/sigstore/rekor/signature" diff --git a/lib/rubygems/sigstore/rekor/api.rb b/lib/rubygems/sigstore/rekor/api.rb new file mode 100644 index 0000000..2e23da8 --- /dev/null +++ b/lib/rubygems/sigstore/rekor/api.rb @@ -0,0 +1,86 @@ +require "faraday_middleware" +require "openssl" +require "rubygems/sigstore/rekor/log_entry" + +class Gem::Sigstore::Rekor::Api + def initialize(host:) + @host = host + end + + def create(cert_chain, data) + response = connection.post("/api/v1/log/entries", + { + kind: "hashedrekord", + apiVersion: "0.0.1", + spec: { + signature: { + format: "x509", + content: Base64.encode64(data.signature), + publicKey: { + content: Base64.encode64(cert_chain), + }, + }, + data: { + hash: { + algorithm: "sha256", + value: data.digest, + }, + }, + }, + } + ) + + unless response.status == 201 + raise "Unexpected response from POST api/v1/log/entries:\n #{response.body}" + end + + response.body + end + + def where(data_digest:) + log_entry_uuids = find_log_entry_uuids_by_digest(data_digest) + + return [] if log_entry_uuids.empty? + + find_log_entries_by_uuid(log_entry_uuids).reduce({}, :merge).map do |uuid, entry| + Gem::Sigstore::Rekor::LogEntry.from(uuid, entry) + end + end + + private + + def connection + # rekor uses a self signed certificate which failes the ssl check + Faraday.new(ssl: { verify: false }) do |request| + # request.authorization :Bearer, id_token.to_s + request.url_prefix = @host + request.request :json + request.response :json, content_type: /json/ + request.adapter :net_http + end + end + + def find_log_entry_uuids_by_digest(digest) + index_response = connection.post("/api/v1/index/retrieve", + { + hash: "sha256:#{digest}", + } + ) + + unless index_response.status == 200 + raise "Unexpected response from POST /api/v1/index/retrieve:\n #{index_response}" + end + + index_response.body + end + + def find_log_entries_by_uuid(uuids) + log_entries_response = connection.post("api/v1/log/entries/retrieve", entryUUIDs: uuids) + + unless log_entries_response.status == 200 + raise "Unexpected response from POST api/v1/log/entries/retrieve:\n #{log_entries_response}" + end + + log_entries_response.body + end +end diff --git a/lib/rubygems/sigstore/rekor/hashed_rekord.rb b/lib/rubygems/sigstore/rekor/hashed_rekord.rb new file mode 100644 index 0000000..93e5f59 --- /dev/null +++ b/lib/rubygems/sigstore/rekor/hashed_rekord.rb @@ -0,0 +1,6 @@ +require "rubygems/sigstore/rekor/log_entry" +require "rubygems/sigstore/rekor/signature" + +class Gem::Sigstore::Rekor::HashedRekord < Gem::Sigstore::Rekor::LogEntry + include Gem::Sigstore::Rekor::Signature +end diff --git a/lib/rubygems/sigstore/rekor/log_entry.rb b/lib/rubygems/sigstore/rekor/log_entry.rb new file mode 100644 index 0000000..842563b --- /dev/null +++ b/lib/rubygems/sigstore/rekor/log_entry.rb @@ -0,0 +1,31 @@ +class Gem::Sigstore::Rekor::LogEntry + def self.from(uuid, entry) + body = encoded_body_to_hash(entry["body"]) + + case body["kind"] + when "hashedrekord" + Gem::Sigstore::Rekor::HashedRekord.new(uuid, entry) + when "rekord" + Gem::Sigstore::Rekor::Rekord.new(uuid, entry) + else + new(uuid, entry) + end + end + + def self.encoded_body_to_hash(body) + JSON.parse(Base64.decode64(body)) + end + + attr_reader :uuid, :attestation, :body, :integrated_time + + def initialize(uuid, entry) + @uuid = uuid + @attestation = entry["attestation"] + @body = Gem::Sigstore::Rekor::LogEntry.encoded_body_to_hash(entry["body"]) + @integrated_time = entry["integratedTime"] + end + + def kind + body["kind"]&.to_sym || :log_entry + end +end diff --git a/lib/rubygems/sigstore/rekor/rekord.rb b/lib/rubygems/sigstore/rekor/rekord.rb new file mode 100644 index 0000000..b86281a --- /dev/null +++ b/lib/rubygems/sigstore/rekor/rekord.rb @@ -0,0 +1,6 @@ +require "rubygems/sigstore/rekor/log_entry" +require "rubygems/sigstore/rekor/signature" + +class Gem::Sigstore::Rekor::Rekord < Gem::Sigstore::Rekor::LogEntry + include Gem::Sigstore::Rekor::Signature +end diff --git a/lib/rubygems/sigstore/rekor/signature.rb b/lib/rubygems/sigstore/rekor/signature.rb new file mode 100644 index 0000000..42bbb07 --- /dev/null +++ b/lib/rubygems/sigstore/rekor/signature.rb @@ -0,0 +1,33 @@ +require "rubygems/sigstore/cert_chain" + +module Gem::Sigstore::Rekor::Signature + def signature + @signature ||= begin + signature = Base64.decode64(body.dig("spec", "signature", "content")) + raise "Expecting a signature in #{body}" unless signature + signature + end + end + + def cert_chain + Gem::Sigstore::CertChain.new(cert) + end + + def signer_email + cert_chain.signing_cert.subject_alt_name + end + + def signer_public_key + cert_chain.signing_cert.public_key + end + + private + + def cert + @cert ||= begin + cert = Base64.decode64(body.dig("spec", "signature", "publicKey", "content")) + raise "Expecting a publicKey in #{body}" unless cert + cert + end + end +end diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb deleted file mode 100644 index c32869c..0000000 --- a/lib/rubygems/sigstore/sign_extend.rb +++ /dev/null @@ -1,139 +0,0 @@ -# Copyright 2021 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 'digest' -require 'fileutils' -require 'open3' -require 'openssl' -require 'rubygems/package' -require 'rubygems/command_manager' -require "rubygems/sigstore/config" -require 'rubygems/sigstore/options' -require "rubygems/sigstore/crypto" -require "rubygems/sigstore/http_client" -require "rubygems/sigstore/openid" - -Gem::CommandManager.instance.register_command :sign - -def find_gemspec(glob = "*.gemspec") - gemspecs = Dir.glob(glob).sort - - if gemspecs.size > 1 - alert_error "Multiple gemspecs found: #{gemspecs}, please specify one" - terminate_interaction(1) - end - - gemspecs.first -end - -# overde the generic gem build command to lay are own --sign option on top -b = Gem::CommandManager.instance[:build] -b.add_option("--sign", "Sign gem with sigstore.") do |value, options| - Gem::Sigstore.options[:sign] = true -end - -class Gem::Commands::BuildCommand - alias_method :original_execute, :execute - def execute - - config = SigStoreConfig.new().config - - if Gem::Sigstore.options[:sign] - config = SigStoreConfig.new().config - priv_key, pub_key, enc_pub_key = Crypto.new().generate_keys - proof, access_token = OpenIDHandler.new(priv_key).get_token - puts "" - cert_response = HttpClient.new().get_cert(access_token, proof, enc_pub_key, config.fulcio_host) - certPEM, rootPem = cert_response.split(/\n{2,}/) - - Dir.mkdir("certs") unless File.exists?("certs") - File.write('certs/sigstore.pem', "#{certPEM}\n", nil , mode: 'w+') - - puts "Received fulcio signing certicate: certs/sigstore.pem" - puts "" - - # Run the gem build process (original_execute) - original_execute - - # Find the gemspec file for the project - gemspec_file = find_gemspec() - spec = Gem::Specification::load(gemspec_file) - - # Unwrap files for signing - File.open("#{spec.full_name}.gem", "rb") do |file| - Gem::Package::TarReader.new(file) do |tar| - tar.each do |entry| - if entry.file? - FileUtils.mkdir_p(File.dirname(entry.full_name)) - File.open(entry.full_name, "wb") do |f| - f.write(entry.read) - end - File.chmod(entry.header.mode, entry.full_name) - end - end - end - end - - puts "" - puts " Updating #{spec.full_name}.gem with signed materials" - - checksums_file = File.read('checksums.yaml.gz') - checksums_digest = OpenSSL::Digest::SHA256.new(checksums_file) - checksums_signature = priv_key.sign checksums_digest, checksums_file - File.open('checksums.yaml.gz.sig', 'wb') do |f| - f.write(checksums_signature) - end - - metadata_file = File.read('metadata.gz') - metadata_digest = OpenSSL::Digest::SHA256.new(metadata_file) - metadata_signature = priv_key.sign metadata_digest, metadata_file - File.open('metadata.gz.sig', 'wb') do |f| - f.write(metadata_signature) - end - - data_file = File.read('data.tar.gz') - data_digest = OpenSSL::Digest::SHA256.new(data_file) - data_signature = priv_key.sign data_digest, data_file - File.open('data.tar.gz.sig', 'wb') do |f| - f.write(data_signature) - end - - gem_files = ["data.tar.gz", "data.tar.gz.sig", "metadata.gz", "metadata.gz.sig", "checksums.yaml.gz", "checksums.yaml.gz.sig"] - - File.open("#{spec.full_name}_signed.gem", 'wb') do |file| - Gem::Package::TarWriter.new(file) do |tar| - gem_files.each { |file| - tar.add_file_simple(File.basename(file), 0o666, File.size(file)) do |io| - File.open(file, 'rb') { |f| io.write(f.read) } - end - } - end - end - - puts "" - puts " sigstore signing operation complete" - puts "" - puts " sending signing manifests to rekor.." - puts "" - rekor_response = HttpClient.new().submit_rekor(pub_key, data_digest, data_signature, certPEM, Base64.encode64(data_file), config.rekor_host) - print " rekor response: " - puts rekor_response - #clean up - Open3.popen3("rm data.tar.gz data.tar.gz.sig metadata.gz metadata.gz.sig checksums.yaml.gz checksums.yaml.gz.sig") do |stdin, stdout, stderr, thread| - puts stdout.read.chomp - end - puts "signed file: #{spec.full_name}_signed.gem" - end - end -end diff --git a/lib/rubygems/sigstore/signing_policy.rb b/lib/rubygems/sigstore/signing_policy.rb new file mode 100644 index 0000000..69a546e --- /dev/null +++ b/lib/rubygems/sigstore/signing_policy.rb @@ -0,0 +1,29 @@ +class Gem::SigningPolicy + class << self + NONE = "DOUBLEPLUSUNHIGH".freeze + LOW = "LOW".freeze + MEDIUM = "MEDIUM".freeze + HIGH = "HIGH".freeze + + def verify_gem_install? + security_policy >= 1 + end + + private + + def security_policy + case ENV["GEM_SIGNING_POLICY"] + when NONE + 0 + when LOW + 1 + when MEDIUM + 2 + when HIGH + 3 + else + 0 + end + end + end +end diff --git a/lib/rubygems/sigstore/version.rb b/lib/rubygems/sigstore/version.rb index 5150ddf..24b2ab6 100644 --- a/lib/rubygems/sigstore/version.rb +++ b/lib/rubygems/sigstore/version.rb @@ -14,6 +14,6 @@ module Ruby module Sigstore - VERSION = "0.1.0" + VERSION = "0.1.0".freeze end end diff --git a/lib/rubygems_plugin.rb b/lib/rubygems_plugin.rb index 18e681c..086691d 100644 --- a/lib/rubygems_plugin.rb +++ b/lib/rubygems_plugin.rb @@ -13,12 +13,13 @@ # limitations under the License. require 'rubygems/command_manager' -require 'rubygems/sigstore/sign_extend' -require 'rubygems/sigstore/verify_extend' +require 'rubygems/sigstore' +require 'rubygems/commands/signatures_command' +require 'rubygems/commands/build_command_extend' +require 'rubygems/commands/install_command_extend' -Gem::CommandManager.instance.register_command :sign -Gem::CommandManager.instance.register_command :verify +Gem::CommandManager.instance.register_command :signatures -[:sign, :verify, :build, :install].each do |cmd_name| - cmd = Gem::CommandManager.instance[cmd_name] +[:signatures, :build, :install].each do |cmd_name| + cmd = Gem::CommandManager.instance[cmd_name] end diff --git a/ruby-sigstore.gemspec b/ruby-sigstore.gemspec index da5bd29..ff1f911 100644 --- a/ruby-sigstore.gemspec +++ b/ruby-sigstore.gemspec @@ -20,8 +20,8 @@ Gem::Specification.new do |spec| spec.authors = ["Sigstore Community"] spec.email = ["lhinds@redhat.com"] - spec.summary = %q{Sigstore signing client.} - spec.description = %q{Sigstore} + spec.summary = %q(Sigstore signing client.) + spec.description = %q(Sigstore) spec.homepage = "https://github.com/sigstore/ruby-sigstore" spec.license = "MIT" spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") @@ -31,20 +31,19 @@ Gem::Specification.new do |spec| spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/sigstore/ruby-sigstore" spec.metadata["changelog_uri"] = "https://github.com/sigstore/ruby-sigstore/CHANGELOG.md" - spec.cert_chain = ['certs/sigstore.pem'] - + spec.cert_chain = ['certs/sigstore.pem'] # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. - spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do + `git ls-files -z`.split("\x0").reject {|f| f.match(%r{^(test|spec|features)/}) } end spec.bindir = "exe" - spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.executables = spec.files.grep(%r{^exe/}) {|f| File.basename(f) } spec.require_paths = ["lib"] spec.add_development_dependency "pp", "0.2.0" - spec.add_runtime_dependency "openid_connect", "~> 1.2", ">= 1.2.0" + spec.add_runtime_dependency "openid_connect", "~> 1.3", ">= 1.3.0" spec.add_runtime_dependency "oa-openid", "~> 0.0.2" spec.add_runtime_dependency "omniauth-openid", "~> 2.0.1" spec.add_runtime_dependency "ruby-openid-apps-discovery", "~> 1.2.0" diff --git a/settings.yml b/settings.yml index a37bc20..d99a534 100644 --- a/settings.yml +++ b/settings.yml @@ -1,5 +1,5 @@ fulcio_host: "https://fulcio.sigstore.dev" -rekor_host: "https://api.rekor.dev" +rekor_host: "https://rekor.sigstore.dev" oidc_issuer: "https://oauth2.sigstore.dev/auth" oidc_client: "sigstore" oidc_secret: "" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index d3788ab..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright 2021 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 "bundler/setup" -require "ruby/sigstore" - -RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" - - # Disable RSpec exposing methods globally on `Module` and `main` - config.disable_monkey_patching! - - config.expect_with :rspec do |c| - c.syntax = :expect - end -end diff --git a/test/hello-world.gem b/test/fixtures/gems/hello-world.gem similarity index 100% rename from test/hello-world.gem rename to test/fixtures/gems/hello-world.gem diff --git a/test/fixtures/not_a_gem b/test/fixtures/not_a_gem new file mode 100644 index 0000000..d01e191 --- /dev/null +++ b/test/fixtures/not_a_gem @@ -0,0 +1 @@ +This is used to test logic which determines whether a given file is a gem. \ No newline at end of file diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..f5820dd --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,52 @@ +require 'test/unit' +require 'webmock/test_unit' +require 'rubygems/mock_gem_ui' +require 'json/jwt' +require 'byebug' + +require 'rubygems/sigstore' + +require 'support/webmock_helper' +require 'support/url_helper' +require 'support/sigstore_auth_helper' +require 'support/fulcio_helper' +require 'support/rekor_helper' + +WebMock.disable_net_connect!(allow_localhost: true) + +module Gem + ## + # Sets the default user interaction to a MockGemUi. + + module DefaultUserInteraction + @ui = Gem::MockGemUi.new + end + + class Gem::TestCase < Test::Unit::TestCase + include Gem::DefaultUserInteraction + + BASE64_ENCODED_PATTERN = /[a-zA-Z0-9\+\/=\\]/.freeze + + def setup + @back_ui = Gem::DefaultUserInteraction.ui + @ui = Gem::MockGemUi.new + # This needs to be a new instance since we call use_ui(@ui) when we want to capture output + Gem::DefaultUserInteraction.ui = Gem::MockGemUi.new + + ENV["SIGSTORE_TEST"] = "1" + end + + def teardown + @back_ui.close + ENV.delete("SIGSTORE_TEST") + end + + def gem_path(name) + File.join("test", "fixtures", "gems", name) + end + + def gem_digest(path) + OpenSSL::Digest::SHA256.new(File.read(path)).to_s + end + end +end diff --git a/test/support/fulcio_helper.rb b/test/support/fulcio_helper.rb new file mode 100644 index 0000000..8a09012 --- /dev/null +++ b/test/support/fulcio_helper.rb @@ -0,0 +1,91 @@ +module FulcioHelper + include UrlHelper + include SigstoreAuthHelper + + FULCIO_BASE_URL = 'https://fulcio.sigstore.dev'.freeze + FULCIO_FAKE_CA_BASE_URL = 'http://ca.example.org.org'.freeze + + def fulcio_api_url(*path, **kwargs) + url_regex(FULCIO_BASE_URL, 'api', 'v1', path, **kwargs) + end + + def fulcio_signing_cert_url + fulcio_api_url('signingCert') + end + + def stub_fulcio_create_signing_cert(headers: {}, body: {}, returning: {}) + stub_request(:post, fulcio_signing_cert_url) + .with( + headers: { + accept: '*/*', + authorization: "Bearer #{access_token}", + content_type: 'application/json', + }.merge(headers), + body: hash_including( + { + publicKey: hash_including({ + content: BASE64_ENCODED_PATTERN, + algorithm: "ecdsa", + }), + signedEmailAddress: BASE64_ENCODED_PATTERN, + }.merge(body) + ), + ) + .to_return do |request| + { + status: 201, + headers: {}, + body: build_fulcio_cert_chain(signing_cert_key(request)).join, + }.merge(returning) + end + end + + def build_fulcio_cert_chain(public_signing_key, signing_cert_options: {}) + ef = OpenSSL::X509::ExtensionFactory.new + + issuing_key = OpenSSL::PKey::RSA.new(1024) + issuer_subject = "/O=sigstore.dev/CN=sigstore" + + issuing_cert = OpenSSL::X509::Certificate.new + issuing_cert.subject = issuing_cert.issuer = OpenSSL::X509::Name.parse(issuer_subject) + issuing_cert.not_before = Time.now + issuing_cert.not_after = Time.now + 10.years + issuing_cert.public_key = issuing_key.public_key + issuing_cert.serial = 0x0 + issuing_cert.version = 2 + issuing_cert.add_extension(ef.create_extension("basicConstraints","CA:FALSE",true)) + issuing_cert.add_extension(ef.create_extension("keyUsage","keyCertSign, cRLSign", true)) + + issuing_cert.sign(issuing_key, OpenSSL::Digest.new("SHA256")) + + options = default_signing_cert_options.merge(signing_cert_options) + + signing_cert = OpenSSL::X509::Certificate.new + signing_cert.issuer = OpenSSL::X509::Name.parse(issuer_subject) + signing_cert.not_before = options[:not_before] + signing_cert.not_after = options[:not_before] + 10.minutes + signing_cert.public_key = public_signing_key + signing_cert.serial = 0x0 + signing_cert.version = 2 + signing_cert.add_extension(ef.create_extension("basicConstraints","CA:TRUE",true)) + signing_cert.add_extension(ef.create_extension("keyUsage","digitalSignature", true)) + signing_cert.add_extension(ef.create_extension("extendedKeyUsage","codeSigning", true)) + signing_cert.add_extension(ef.create_extension("authorityInfoAccess","caIssuers;URI:#{FULCIO_FAKE_CA_BASE_URL}/ca.crt", true)) + signing_cert.add_extension(ef.create_extension("subjectAltName","email:#{options[:email]}", true)) + signing_cert.sign(issuing_key, OpenSSL::Digest.new("SHA256")) + + [issuing_cert, signing_cert].map(&:to_pem) + end + + def default_signing_cert_options + { + not_before: Time.now, + email: "someone@example.org", + } + end + + def signing_cert_key(request) + key_contents = Base64.decode64(JSON.parse(request.body).dig("publicKey", "content")) + OpenSSL::PKey.read(key_contents) + end +end diff --git a/test/support/rekor_helper.rb b/test/support/rekor_helper.rb new file mode 100644 index 0000000..d74ce45 --- /dev/null +++ b/test/support/rekor_helper.rb @@ -0,0 +1,176 @@ +require 'rubygems/sigstore/gemfile' +require 'rubygems/sigstore/pkey' + +module RekorHelper + include UrlHelper + include FulcioHelper + + REKOR_BASE_URL = 'https://rekor.sigstore.dev'.freeze + + def rekor_api_url(*path, **kwargs) + url_regex(REKOR_BASE_URL, 'api', 'v1', path, **kwargs) + end + + def rekor_log_entries_url + rekor_api_url('log', 'entries') + end + + def stub_rekor_create_rekord(gem_path: @gem_path, body: {}, returning: {}) + gem = Gem::Sigstore::Gemfile.new(gem_path) + + stub_request(:post, rekor_log_entries_url) + .with( + headers: { + content_type: 'application/json', + }, + body: hash_including( + { + kind: "hashedrekord", + apiVersion: "0.0.1", + spec: hash_including({ + signature: hash_including({ + format: "x509", + content: BASE64_ENCODED_PATTERN, + publicKey: hash_including({ + content: BASE64_ENCODED_PATTERN, + }), + }), + data: hash_including({ + hash: hash_including({ + algorithm: "sha256", + value: gem.digest, + }), + }), + }), + }.merge(body) # deep_merge is incompatible with nested hash_including() + ) + ) + .to_return_json( + build_rekord_entry(returning[:body] || {}), + { + status: 201, + } + ) + end + + def build_rekord_entry(options) + { + dummy_entry_uuid: { + body: "dummy rekord body", + integratedTime: 1637154947, + logID: "dummy rekord logID", + logIndex: 864991, + verification: { + signedEntryTimestamp: "dummy timestamp signature", + }, + }, + }.deep_merge(options) + end + + def rekor_index_retrieve_url + rekor_api_url('index', 'retrieve') + end + + def stub_rekor_search_index_by_digest(gem_path: @gem_path, body: {}, returning: nil) + gem = Gem::Sigstore::Gemfile.new(gem_path) + + stub_request(:post, rekor_index_retrieve_url) + .with( + headers: { + content_type: 'application/json', + }, + body: { + hash: "sha256:#{gem.digest}", + }, + ) + .to_return_json(returning || ["dummy_entry_uuid"]) + end + + def rekor_log_entries_retrieve_url + rekor_api_url('log', 'entries', 'retrieve') + end + + def stub_rekor_get_rekords_by_uuid( + uuids: ["dummy_entry_uuid"], + returning: { + "dummy_entry_uuid" => { + log_entry_options: {}, + rekord_options: {}, + cert_options: {}, + gem_path: @gem_path, + }, + } + ) + stub_request(:post, rekor_log_entries_retrieve_url) + .with( + body: { + entryUUIDs: uuids, + } + ) + .to_return_json( + returning.map do |uuid, options| + build_rekord_log_entry( + uuid: uuid, + **options + ) + end + ) + end + + def build_rekord_log_entry(uuid:, log_entry_options: {}, rekord_options: {}, cert_options: {}, gem_path: @gem_path) + { + uuid => { + body: Base64.encode64(build_rekord(rekord_options, cert_options, gem_path).to_json), + integratedTime: 1637154947, + logID: "dummy rekord logID", + logIndex: 864991, + verification: { + signedEntryTimestamp: "dummy timestamp signature", + }, + }, + }.deep_merge(log_entry_options) + end + + def build_rekord(rekord_options, cert_options, gem_path) + gem = Gem::Sigstore::Gemfile.new(gem_path) + pkey = Gem::Sigstore::PKey.new + cert_chain = build_fulcio_cert_chain(pkey.public_key, signing_cert_options: cert_options) + stub_get_ca_certificate(certificate: cert_chain.first) + + { + "apiVersion": "0.0.1", + "kind": "rekord", + "spec": { + "data": { + "hash": { + "algorithm": "sha256", + "value": gem.digest, + }, + }, + "signature": { + "content": Base64.encode64(pkey.private_key.sign(OpenSSL::Digest.new('SHA256'), gem.content)), + "format": "x509", + "publicKey": { + "content": Base64.encode64(cert_chain.last), + }, + }, + }, + }.deep_merge(rekord_options) + end + + def ca_authority_url(*path, **kwargs) + url_regex(FULCIO_FAKE_CA_BASE_URL, path, **kwargs) + end + + def stub_get_ca_certificate(certificate:, returning: {}) + stub_request(:get, ca_authority_url('ca.crt')) + .to_return( + { + headers: { + content_type: "application/octet-stream", + }, + body: certificate, + }.merge(returning) + ) + end +end diff --git a/test/support/sigstore_auth_helper.rb b/test/support/sigstore_auth_helper.rb new file mode 100644 index 0000000..2d9d6d4 --- /dev/null +++ b/test/support/sigstore_auth_helper.rb @@ -0,0 +1,112 @@ +module SigstoreAuthHelper + include UrlHelper + + SIGSTORE_OAUTH2_BASE_URL = 'https://oauth2.sigstore.dev/'.freeze + + def sigstore_auth_url(*path, **kwargs) + url_regex(SIGSTORE_OAUTH2_BASE_URL, 'auth', path, **kwargs) + end + + # OpenID config + + def sigstore_auth_openid_config_url + sigstore_auth_url('.well-known', 'openid-configuration') + end + + def stub_sigstore_auth_get_openid_config(returning: {}) + stub_request(:get, sigstore_auth_openid_config_url) + .to_return_json(build_sigstore_auth_openid_config(returning)) + end + + def build_sigstore_auth_openid_config(options) + { + issuer: "https://oauth2.sigstore.dev/auth", + authorization_endpoint: "https://oauth2.sigstore.dev/auth/auth", + token_endpoint: "https://oauth2.sigstore.dev/auth/token", + jwks_uri: "https://oauth2.sigstore.dev/auth/keys", + userinfo_endpoint: "https://oauth2.sigstore.dev/auth/userinfo", + device_authorization_endpoint: "https://oauth2.sigstore.dev/auth/device/code", + grant_types_supported: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + code_challenge_methods_supported: ["S256", "plain"], + scopes_supported: ["openid", "email", "groups", "profile", "offline_access"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], + claims_supported: ["iss", "sub", "aud", "iat", "exp", "email", "email_verified", "locale", "name", "preferred_username", "at_hash"], + }.merge(options) + end + + # Access token + + def sigstore_auth_token_url + sigstore_auth_url('token') + end + + def stub_sigstore_auth_create_token(headers: {}, body: {}, returning: {}) + stub_request(:post, sigstore_auth_url('token')) + .with( + headers: { + authorization: 'Basic c2lnc3RvcmU6', #u: sigstore, no password. From settings.yml + content_type: 'application/x-www-form-urlencoded', + }.merge(headers), + body: hash_including( + { + grant_type: "authorization_code", + code: "DUMMY", + code_verifier: /[a-z0-9]+/, + }.merge(body) + ), + ) + .to_return_json(build_sigstore_auth_access_token(returning)) + end + + def build_sigstore_auth_access_token(options) + { + access_token: access_token, + token_type: "bearer", + expires_in: 59, + id_token: "", + }.merge(options) + end + + # JSON web keys + + def sigstore_auth_keys_url + sigstore_auth_url('keys') + end + + def stub_sigstore_auth_get_keys(returning: {}) + stub_request(:get, sigstore_auth_keys_url) + .to_return_json(build_sigstore_auth_keys(returning)) + end + + def build_sigstore_auth_keys(options) + { + keys: [access_token_jwk], + }.merge(options) + end + + def access_token + @access_token ||= begin + claim = { + iss: "https://oauth2.sigstore.dev/auth", + aud: "sigstore", + exp: 1.minute.from_now, + iat: Time.now, + email: "someone@example.org", + email_verified: true, + } + jws = JSON::JWT.new(claim).sign(access_token_jwk, :RS256) + jws.to_s + end + end + + def access_token_jwk + @access_token_jwk ||= JSON::JWK.new(access_token_pkey, kid: "dummy_kid", use: "sig") + end + + def access_token_pkey + @access_token_pkey ||= OpenSSL::PKey::RSA.generate(1024) + end +end diff --git a/test/support/url_helper.rb b/test/support/url_helper.rb new file mode 100644 index 0000000..bdcf4cd --- /dev/null +++ b/test/support/url_helper.rb @@ -0,0 +1,22 @@ +module UrlHelper + BASE64_ENCODED_PATTERN = /[a-zA-Z0-9\+\/=\\]/.freeze + + def url_regex(base, *path, **kwargs) + escape = lambda {|v| v.is_a?(String) ? Regexp.escape(v) : v.to_s } + + params = [base, path] + .flatten + .map {|param| escape.call(param) } + + url = File.join(params) + kwargs = kwargs.delete_if {|_, v| v.blank? } + unless kwargs.blank? + url += Regexp.escape('?') + query = kwargs + .map {|k, v| [escape.call(k), escape.call(v)].join('=') } + .join('&') + url += query + end + /^#{url}$/ + end +end diff --git a/test/support/webmock_helper.rb b/test/support/webmock_helper.rb new file mode 100644 index 0000000..2fda92c --- /dev/null +++ b/test/support/webmock_helper.rb @@ -0,0 +1,9 @@ +module WebMock + class RequestStub + def to_return_json(hash = {}, options = {}) + options[:body] = hash.to_json + result = options.merge(headers: { "Content-Type" => "application/json" }) + to_return(result) + end + end +end diff --git a/test/test_signatures_command.rb b/test/test_signatures_command.rb new file mode 100644 index 0000000..8f4f7bc --- /dev/null +++ b/test/test_signatures_command.rb @@ -0,0 +1,186 @@ +require 'helper' +require "rubygems/commands/signatures_command" + +class TestSignaturesCommand < Gem::TestCase + include SigstoreAuthHelper + include FulcioHelper + include RekorHelper + + def setup + super + + @gem_path = gem_path("hello-world.gem") + @cmd = Gem::Commands::SignaturesCommand.new + end + + def test_no_options + @cmd.handle_options %W[#{@gem_path}] + stub_rekor_search_index_by_digest(returning: []) + + use_ui @ui do + @cmd.execute + end + + output = @ui.output.split "\n" + assert_equal "Verifying #{@gem_path}", output.shift + assert_match /No valid signatures found for digest/, output.shift + assert_equal [], output + end + + def test_sign + @cmd.handle_options %W[--sign #{@gem_path}] + stub_signing + + use_ui @ui do + @cmd.execute + end + + output = @ui.output.split "\n" + assert_equal "Fulcio certificate chain", output.shift + assert_certificate(output) # root certificate + assert_certificate(output) # leaf certificate + assert_empty output.shift + assert_equal "Sending gem digest, signature & certificate chain to transparency log.", output.shift + assert_equal "https://rekor.sigstore.dev/api/v1/log/entries/dummy_entry_uuid", output.shift + assert_equal [], output + end + + def test_static_sign + @cmd.handle_options %W[--sign --identity-token #{access_token} #{@gem_path}] + stub_signing + + use_ui @ui do + @cmd.execute + end + + output = @ui.output.split "\n" + assert_equal "Fulcio certificate chain", output.shift + assert_certificate(output) # root certificate + assert_certificate(output) # leaf certificate + assert_empty output.shift + assert_equal "Sending gem digest, signature & certificate chain to transparency log.", output.shift + assert_equal "https://rekor.sigstore.dev/api/v1/log/entries/dummy_entry_uuid", output.shift + assert_equal [], output + end + + def test_verify_unsigned_gem + @cmd.handle_options %W[--verify #{@gem_path}] + stub_rekor_search_index_by_digest(returning: []) + + use_ui @ui do + @cmd.execute + end + + output = @ui.output.split "\n" + assert_equal "Verifying #{@gem_path}", output.shift + assert_match /No valid signatures found for digest/, output.shift + assert_equal [], output + end + + def test_one_non_maintainer_signature + @cmd.handle_options %W[--verify #{@gem_path}] + stub_rekor_search_index_by_digest + stub_rekor_get_rekords_by_uuid + + use_ui @ui do + @cmd.execute + end + + output = @ui.output.split "\n" + assert_equal "Verifying #{@gem_path}", output.shift + assert_equal ":noice:", output.shift + assert_equal "Signed by non-maintainer: someone@example.org", output.shift + assert_equal [], output + end + + def test_maintainer_signature_and_non_maintainer_signature + @cmd.handle_options %W[--verify #{@gem_path}] + uuids = ["maintainer_entry_uuid", "dummy_entry_uuid"] + stub_rekor_search_index_by_digest(returning: uuids) + stub_rekor_get_rekords_by_uuid( + uuids: uuids, + returning: { + uuids.first => { + cert_options: { + email: "rubygems.org@n13.org", # email set in the spec for hello-world.gem + }, + }, + uuids.last => {}, + } + ) + + use_ui @ui do + @cmd.execute + end + + output = @ui.output.split "\n" + assert_equal "Verifying #{@gem_path}", output.shift + assert_equal ":noice:", output.shift + assert_equal "Signed by maintainer: rubygems.org@n13.org", output.shift + assert_equal "Signed by non-maintainer: someone@example.org", output.shift + assert_equal [], output + end + + def test_sign_and_verify + @cmd.handle_options %W[--sign --verify #{@gem_path}] + stub_signing + stub_rekor_search_index_by_digest + stub_rekor_get_rekords_by_uuid + + use_ui @ui do + @cmd.execute + end + + output = @ui.output.split "\n" + assert_equal "Fulcio certificate chain", output.shift + assert_certificate(output) # root certificate + assert_certificate(output) # leaf certificate + assert_empty output.shift + assert_equal "Sending gem digest, signature & certificate chain to transparency log.", output.shift + assert_equal "https://rekor.sigstore.dev/api/v1/log/entries/dummy_entry_uuid", output.shift + assert_equal "Verifying #{@gem_path}", output.shift + assert_equal ":noice:", output.shift + assert_equal "Signed by non-maintainer: someone@example.org", output.shift + assert_equal [], output + end + + def test_nonexistent_file + @cmd.handle_options %W[not_a_file] + + use_ui @ui do + e = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_equal "not_a_file is not a file", e.message + end + end + + def test_rejects_files_that_are_not_gems + @cmd.handle_options %W[./test/fixtures/not_a_gem] + + use_ui @ui do + e = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_equal "./test/fixtures/not_a_gem is not a valid gem", e.message + end + end + + def assert_certificate(output) + assert_equal "-----BEGIN CERTIFICATE-----", output.shift + assert_match BASE64_ENCODED_PATTERN, output.shift until output.first == "-----END CERTIFICATE-----" + assert_equal "-----END CERTIFICATE-----", output.shift + end + + def stub_signing(gems: [@gem_path]) + stub_sigstore_auth_get_openid_config + stub_sigstore_auth_create_token + stub_sigstore_auth_get_keys + stub_fulcio_create_signing_cert + gems.each do |gem| + stub_rekor_create_rekord(gem_path: gem) + end + end +end