From 013e1862562c4c89ba90d9971c3592dc4205bd1a Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Thu, 28 Oct 2021 10:44:34 -0700 Subject: [PATCH 01/56] fix connection to rekor --- lib/rubygems/sigstore/http_client.rb | 8 +++++--- settings.yml | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/rubygems/sigstore/http_client.rb b/lib/rubygems/sigstore/http_client.rb index 2de4071..26b3b0d 100644 --- a/lib/rubygems/sigstore/http_client.rb +++ b/lib/rubygems/sigstore/http_client.rb @@ -18,7 +18,8 @@ class HttpClient def initialize; end def get_cert(id_token, proof, pub_key, fulcio_host) - connection = Faraday.new do |request| + # rekor uses a self signed certificate which failes the ssl check + connection = Faraday.new(ssl: { verify: false }) do |request| request.authorization :Bearer, id_token.to_s request.url_prefix = fulcio_host request.request :json @@ -29,7 +30,8 @@ def get_cert(id_token, proof, pub_key, fulcio_host) return fulcio_response.body end def submit_rekor(pub_key, data_digest, data_signature, certPEM, data_raw, rekor_host) - connection = Faraday.new do |request| + # rekor uses a self signed certificate which failes the ssl check + connection = Faraday.new(ssl: { verify: false }) do |request| # request.authorization :Bearer, id_token.to_s request.url_prefix = rekor_host request.request :json @@ -37,7 +39,7 @@ def submit_rekor(pub_key, data_digest, data_signature, certPEM, data_raw, rekor_ request.adapter :net_http end - rekor_response = connection.post("/api/v1/log/entries", + rekor_response = connection.post("/api/v1/log/entries", { kind: "rekord", apiVersion: "0.0.1", 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: "" From a53f3a593bf37ad33782b39671847247ad61cd8b Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Thu, 28 Oct 2021 21:39:52 -0700 Subject: [PATCH 02/56] add rubocop --- Gemfile | 11 ++++++++--- Gemfile.lock | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index b20f148..9a5b0c0 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,11 @@ 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" + +group :development do + gem "rubocop", "~> 0.80.1" + gem "rubocop-performance", "~> 1.5.2" + gem "rake", "~> 12.0" + gem "rspec", "~> 3.0" +end diff --git a/Gemfile.lock b/Gemfile.lock index 8806f3e..357208f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,6 +25,7 @@ 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) @@ -82,6 +83,7 @@ GEM httpclient (2.8.3) i18n (1.8.9) concurrent-ruby (~> 1.0) + jaro_winkler (1.5.4) json-jwt (1.13.0) activesupport (>= 4.2) aes_key_wrap @@ -115,6 +117,9 @@ GEM validate_email validate_url webfinger (>= 1.0.1) + parallel (1.21.0) + parser (3.0.2.0) + ast (~> 2.4.1) pp (0.2.0) prettyprint prettyprint (0.1.0) @@ -131,7 +136,9 @@ GEM ruby-openid (>= 2.1.8) rack-protection (2.1.0) rack + rainbow (3.0.0) rake (12.3.3) + rexml (3.2.5) rspec (3.10.0) rspec-core (~> 3.10.0) rspec-expectations (~> 3.10.0) @@ -145,9 +152,20 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.10.0) rspec-support (3.10.2) + 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) activesupport (>= 3) @@ -155,6 +173,7 @@ GEM httpclient (>= 2.4) 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) @@ -179,6 +198,8 @@ DEPENDENCIES 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! From a5eb3a52365af5e7c109baf1db4f5a054057a560 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Thu, 28 Oct 2021 21:40:40 -0700 Subject: [PATCH 03/56] add rubygems rubocop file --- .rubocop.yml | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 .rubocop.yml diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..940cc26 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,152 @@ +require: rubocop-performance + +AllCops: + DisabledByDefault: true + Exclude: + - 'bundler/**/*' + - 'lib/rubygems/resolver/molinillo/**/*' + - 'pkg/**/*' + - '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 From f64477ccf4a8c57cce31116a22eb67db3780e908 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Thu, 28 Oct 2021 21:42:01 -0700 Subject: [PATCH 04/56] apply rubocop changes --- lib/rubygems/commands/sign_command.rb | 6 +- lib/rubygems/commands/verify_command.rb | 2 +- lib/rubygems/sigstore/config.rb | 18 +- lib/rubygems/sigstore/crypto.rb | 29 ++- lib/rubygems/sigstore/http_client.rb | 86 ++++---- lib/rubygems/sigstore/openid.rb | 278 ++++++++++++------------ lib/rubygems/sigstore/options.rb | 11 +- lib/rubygems/sigstore/sign_extend.rb | 175 ++++++++------- lib/rubygems/sigstore/version.rb | 2 +- lib/rubygems_plugin.rb | 2 +- ruby-sigstore.gemspec | 13 +- 11 files changed, 311 insertions(+), 311 deletions(-) diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index 81d2424..cf2644d 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -41,10 +41,10 @@ def usage # :nodoc: end def execute - config = SigStoreConfig.new().config - priv_key, pub_key = Crypto.new().generate_keys + 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) + 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/verify_command.rb b/lib/rubygems/commands/verify_command.rb index 82af112..52a9c29 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -23,4 +23,4 @@ def initialize def execute puts "verify" end -end \ No newline at end of file +end diff --git a/lib/rubygems/sigstore/config.rb b/lib/rubygems/sigstore/config.rb index 9e5698d..eed9e66 100644 --- a/lib/rubygems/sigstore/config.rb +++ b/lib/rubygems/sigstore/config.rb @@ -15,17 +15,17 @@ 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 + 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( 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 index a1efb2b..e47b4f0 100644 --- a/lib/rubygems/sigstore/crypto.rb +++ b/lib/rubygems/sigstore/crypto.rb @@ -15,22 +15,22 @@ 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 +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 +# class Crypto # def initialize; end # def generate_keys @@ -45,4 +45,3 @@ def sign_proof(priv_key, email) # return Base64.encode64(proof) # end # end - diff --git a/lib/rubygems/sigstore/http_client.rb b/lib/rubygems/sigstore/http_client.rb index 26b3b0d..18fdaa1 100644 --- a/lib/rubygems/sigstore/http_client.rb +++ b/lib/rubygems/sigstore/http_client.rb @@ -16,50 +16,50 @@ require "openssl" class HttpClient - def initialize; end - def get_cert(id_token, proof, pub_key, fulcio_host) - # rekor uses a self signed certificate which failes the ssl check - connection = Faraday.new(ssl: { verify: false }) 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 + def initialize; end + def get_cert(id_token, proof, pub_key, fulcio_host) + # rekor uses a self signed certificate which failes the ssl check + connection = Faraday.new(ssl: { verify: false }) 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) + # rekor uses a self signed certificate which failes the ssl check + connection = Faraday.new(ssl: { verify: false }) 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 - def submit_rekor(pub_key, data_digest, data_signature, certPEM, data_raw, rekor_host) - # rekor uses a self signed certificate which failes the ssl check - connection = Faraday.new(ssl: { verify: false }) 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) - } + 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 + }, + 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..6dd8155 100644 --- a/lib/rubygems/sigstore/openid.rb +++ b/lib/rubygems/sigstore/openid.rb @@ -22,161 +22,163 @@ require "launchy" require "openid_connect" -class OpenIDHandler - def initialize(priv_key) - @priv_key = priv_key +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 - 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 + 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 - 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 + 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.join + 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 - # check state == webserv[:state] - if webserv[:state] != session[:state] - abort 'Invalid state value received from OIDC Provider' - end + webserv.join - client.authorization_code = webserv[:code] - access_token = client.access_token!({code_verifier: pkce[:value]}) + # check state == webserv[:state] + if webserv[:state] != session[:state] + abort 'Invalid state value received from OIDC Provider' + end - provider_public_keys = oidc_discovery.jwks + client.authorization_code = webserv[:code] + access_token = client.access_token!({code_verifier: pkce[:value]}) - token = verify_token(access_token, provider_public_keys, config, session[:nonce]) + provider_public_keys = oidc_discovery.jwks - proof = Crypto.new().sign_proof(@priv_key, token["email"]) - return proof, access_token - end + token = verify_token(access_token, provider_public_keys, config, session[:nonce]) - private + proof = Crypto.new.sign_proof(@priv_key, token["email"]) + return proof, 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) - return pkce - end + private - 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 + 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 + end - # verify issuer matches - if token["iss"] != config.oidc_issuer - abort 'Mismatched issuer in OIDC ID Token' - 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 it was intended for me - if token["aud"] != config.oidc_client - abort 'OIDC ID Token was not intended for this use' - end + # verify issuer matches + if token["iss"] != config.oidc_issuer + abort 'Mismatched issuer in OIDC ID Token' + 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 it was intended for me + if token["aud"] != config.oidc_client + abort 'OIDC ID Token was not intended for this use' + end - # verify nonce if present in token - if token.key?("nonce") and token["nonce"] != nonce - abort 'OIDC ID Token has incorrect nonce value' - 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 - # 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 + # verify nonce if present in token + if token.key?("nonce") and token["nonce"] != nonce + abort 'OIDC ID Token has incorrect nonce value' + end - return token + # 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 -end \ No newline at end of file + + return token + 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/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb index c32869c..c677565 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/sigstore/sign_extend.rb @@ -27,14 +27,14 @@ Gem::CommandManager.instance.register_command :sign def find_gemspec(glob = "*.gemspec") - gemspecs = Dir.glob(glob).sort + gemspecs = Dir.glob(glob).sort - if gemspecs.size > 1 - alert_error "Multiple gemspecs found: #{gemspecs}, please specify one" - terminate_interaction(1) - end + if gemspecs.size > 1 + alert_error "Multiple gemspecs found: #{gemspecs}, please specify one" + terminate_interaction(1) + end - gemspecs.first + gemspecs.first end # overde the generic gem build command to lay are own --sign option on top @@ -46,94 +46,93 @@ def find_gemspec(glob = "*.gemspec") class Gem::Commands::BuildCommand alias_method :original_execute, :execute def execute - - config = SigStoreConfig.new().config + 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 + 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 - - 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 + + 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 do|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 + + 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/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..1356b99 100644 --- a/lib/rubygems_plugin.rb +++ b/lib/rubygems_plugin.rb @@ -20,5 +20,5 @@ Gem::CommandManager.instance.register_command :verify [:sign, :verify, :build, :install].each do |cmd_name| - cmd = Gem::CommandManager.instance[cmd_name] + cmd = Gem::CommandManager.instance[cmd_name] end diff --git a/ruby-sigstore.gemspec b/ruby-sigstore.gemspec index da5bd29..44c5b33 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,16 +31,15 @@ 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" From 9a093882a530431ad3c526d5cbdc7c909c647afd Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Thu, 28 Oct 2021 22:15:37 -0700 Subject: [PATCH 05/56] sign gem --- lib/rubygems/sigstore/http_client.rb | 5 ++- lib/rubygems/sigstore/sign_extend.rb | 63 ++++------------------------ 2 files changed, 12 insertions(+), 56 deletions(-) diff --git a/lib/rubygems/sigstore/http_client.rb b/lib/rubygems/sigstore/http_client.rb index 18fdaa1..a0dd192 100644 --- a/lib/rubygems/sigstore/http_client.rb +++ b/lib/rubygems/sigstore/http_client.rb @@ -16,7 +16,9 @@ require "openssl" class HttpClient - def initialize; end + def initialize + end + def get_cert(id_token, proof, pub_key, fulcio_host) # rekor uses a self signed certificate which failes the ssl check connection = Faraday.new(ssl: { verify: false }) do |request| @@ -29,6 +31,7 @@ def get_cert(id_token, proof, pub_key, fulcio_host) 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) # rekor uses a self signed certificate which failes the ssl check connection = Faraday.new(ssl: { verify: false }) do |request| diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb index c677565..9ea6732 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/sigstore/sign_extend.rb @@ -69,69 +69,22 @@ def execute 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 do|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 - end + gem_file_path = "#{spec.full_name}.gem" + gem_file_sig_path = "#{gem_file_path}.sig" + gem_file = File.read(gem_file_path) + gem_file_digest = OpenSSL::Digest::SHA256.new(gem_file) + gem_file_signature = priv_key.sign gem_file_digest, gem_file 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) + + rekor_response = HttpClient.new.submit_rekor(pub_key, gem_file_digest, gem_file_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 From 8a7d2ad73cbf189fbfb2ab9dad669a07c739f557 Mon Sep 17 00:00:00 2001 From: Roch Lefebvre Date: Fri, 29 Oct 2021 13:25:04 -0400 Subject: [PATCH 06/56] Restore default SSL peer verification for connections to fulcio (#4) Co-authored-by: Frederik Dudzik <5946811+doodzik@users.noreply.github.com> --- lib/rubygems/sigstore/http_client.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/rubygems/sigstore/http_client.rb b/lib/rubygems/sigstore/http_client.rb index 18fdaa1..c8b8f50 100644 --- a/lib/rubygems/sigstore/http_client.rb +++ b/lib/rubygems/sigstore/http_client.rb @@ -18,8 +18,7 @@ class HttpClient def initialize; end def get_cert(id_token, proof, pub_key, fulcio_host) - # rekor uses a self signed certificate which failes the ssl check - connection = Faraday.new(ssl: { verify: false }) do |request| + connection = Faraday.new do |request| request.authorization :Bearer, id_token.to_s request.url_prefix = fulcio_host request.request :json @@ -38,7 +37,6 @@ def submit_rekor(pub_key, data_digest, data_signature, certPEM, data_raw, rekor_ request.response :json, content_type: /json/ request.adapter :net_http end - rekor_response = connection.post("/api/v1/log/entries", { kind: "rekord", From 6bb57afc1631a6396f7c62454d6493f179ebe57d Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Fri, 29 Oct 2021 10:43:32 -0700 Subject: [PATCH 07/56] push cert chain to rekord --- lib/rubygems/sigstore/http_client.rb | 4 ++-- lib/rubygems/sigstore/sign_extend.rb | 29 +++++++++++----------------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/lib/rubygems/sigstore/http_client.rb b/lib/rubygems/sigstore/http_client.rb index a0dd192..38771f9 100644 --- a/lib/rubygems/sigstore/http_client.rb +++ b/lib/rubygems/sigstore/http_client.rb @@ -32,7 +32,7 @@ def get_cert(id_token, proof, pub_key, fulcio_host) return fulcio_response.body end - def submit_rekor(pub_key, data_digest, data_signature, certPEM, data_raw, rekor_host) + def submit_rekor(cert_chain, data_digest, data_signature, certPEM, data_raw, rekor_host) # rekor uses a self signed certificate which failes the ssl check connection = Faraday.new(ssl: { verify: false }) do |request| # request.authorization :Bearer, id_token.to_s @@ -51,7 +51,7 @@ def submit_rekor(pub_key, data_digest, data_signature, certPEM, data_raw, rekor_ format: "x509", content: Base64.encode64(data_signature), publicKey: { - content: Base64.encode64(pub_key.to_pem), + content: Base64.encode64(cert_chain), }, }, data: { diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb index 9ea6732..f2c9d2f 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/sigstore/sign_extend.rb @@ -50,17 +50,11 @@ def execute if Gem::Sigstore.options[:sign] config = SigStoreConfig.new.config - priv_key, pub_key, enc_pub_key = Crypto.new.generate_keys + 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 "" + certPEM, _rootPem = cert_response.split(/\n{2,}/) # Run the gem build process (original_execute) original_execute @@ -70,22 +64,21 @@ def execute spec = Gem::Specification::load(gemspec_file) gem_file_path = "#{spec.full_name}.gem" - gem_file_sig_path = "#{gem_file_path}.sig" gem_file = File.read(gem_file_path) gem_file_digest = OpenSSL::Digest::SHA256.new(gem_file) gem_file_signature = priv_key.sign gem_file_digest, gem_file - puts "" - puts " sigstore signing operation complete" - puts "" - puts " sending signing manifests to rekor.." - puts "" + content = <<~CONTENT + + sigstore signing operation complete." - rekor_response = HttpClient.new.submit_rekor(pub_key, gem_file_digest, gem_file_signature, certPEM, Base64.encode64(data_file), config.rekor_host) - print " rekor response: " - puts rekor_response + sending signiture & certificate chain to rekor." + CONTENT + puts content - puts "signed file: #{spec.full_name}_signed.gem" + rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file_digest, gem_file_signature, certPEM, Base64.encode64(gem_file), config.rekor_host) + puts "rekor response: " + pp rekor_response end end end From b8f637acb7f26c5680fa47dd279c235d7087bb1e Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Fri, 29 Oct 2021 17:39:27 -0400 Subject: [PATCH 08/56] super terrible `gem verify` implementation --- lib/rubygems/commands/sign_command.rb | 25 ++++++++- lib/rubygems/commands/verify_command.rb | 75 ++++++++++++++++++++++++- lib/rubygems/sigstore/http_client.rb | 67 +++++++++++++++------- 3 files changed, 143 insertions(+), 24 deletions(-) diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index cf2644d..5be39f8 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -42,9 +42,28 @@ def usage # :nodoc: def execute config = SigStoreConfig.new.config - priv_key, pub_key = Crypto.new.generate_keys + priv_key, _pub_key, enc_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 + cert_response = HttpClient.new.get_cert(access_token, proof, enc_pub_key, config.fulcio_host) + puts "Fulcio cert chain" + print cert_response + puts "" + + gem_file_path = get_one_gem_name + gem_file = File.read(gem_file_path) + gem_file_digest = OpenSSL::Digest::SHA256.new(gem_file) + gem_file_signature = priv_key.sign gem_file_digest, gem_file + + content = <<~CONTENT + + sigstore signing operation complete." + + sending signiture & certificate chain to rekor." + CONTENT + puts content + + rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file_digest, gem_file_signature, nil, Base64.encode64(gem_file), config.rekor_host) + puts "rekor response: " + pp rekor_response end end diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb index 52a9c29..da2429e 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -12,15 +12,86 @@ # 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| + add_option('--rekor-host HOST', 'Rekor host') do |value, options| options[:host] = value end end def execute - puts "verify" + gem_path = get_one_gem_name + puts "verify \"#{gem_path}\"" + + raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(gem_path) + contents = File.read(gem_path) + digest = OpenSSL::Digest::SHA256.new(contents) + + config = SigStoreConfig.new.config + + entries = HttpClient.new.get_rekor_entries(digest, config.rekor_host) + rekord_hashes = entries.map { |entry| rekord_from_entry(entry.values.first) } + + rekord = rekord_hashes.find { |rekord| valid_signature?(rekord, digest, contents) } + + if rekord + puts ":noice:, signed by #{signer_email(rekord)}" + else + puts "not :noice: thxkbye" + end + end + + private + + def rekord_from_entry(entry_hash) + JSON.parse(Base64.decode64(entry_hash["body"])) + end + + def valid_signature?(rekord_hash, digest, contents) + # { + # "apiVersion"=>"0.0.1", + # "kind"=>"rekord", + # "spec"=>{ + # "data"=>{ + # "hash"=>{ + # "algorithm"=>"sha256", + # "value"=>"230aed713b5d1cfce45228658bdb58e37502be1323f768fde0220e64233f1e32" + # } + # }, + # "signature"=>{ + # "content"=>"ngxsh3qbcWtjpZWxkdLcMA6hX61x/pmYf9LxN8MttwcJIaiwRNxiXeQWek0Y+5WiVD+fDZc42udM5OaEgBl3fjmU68NkLoe1WieTR/C+GJHlgjE69ZFLG80GwRgcP3hE4HiTYdk5UkCL8yFA2fJEgYnpLr8PhBOZoHZeLbt+9sQEXTOj6HqjSvvA6JLMSbJweXwUMP6EfjIUuEn2geKC2Hvh54tUu6sH+Hpk7EBADNDcBRL/57TX6OGJdku/Rrrkn9J5XhBaDzhZHr5vZVY86ZyB/NQYz3li2l3sbhLRQpIaUEQXdQmIP1AaJsPt27QIE6zJHMBJ2MUv1UMjYKsLGQ==", + # "format"=>"x509", + # "publicKey"=>{ + # "content"=>"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURkakNDQXYyZ0F3SUJBZ0lVQVAxSENMQnpnamtSYmp3bzFndkI3VXdoZHNFd0NnWUlLb1pJemowRUF3TXcKS2pFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUkV3RHdZRFZRUURFd2h6YVdkemRHOXlaVEFlRncweQpNVEV3TWpreE9UVTNNVE5hRncweU1URXdNamt5TURFM01USmFNQUF3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBCkE0SUJEd0F3Z2dFS0FvSUJBUUM0ZnRNREJDczJ0K0FqWFAyUjhTNkUyaTdDbHVHeTBsRC9tYmgzRFpDZXc1UmwKd2FoN1pZTlZhSFg3UW1mK1hOZmkrVldubU4rVmNRWlZjaTJHRGVuZnBYRUM0SnhDUHplQXBPSWxVY1NYTWV5cgpLSWh3L2Z1YkMrN0NvaElvUE5MRjM2RGUvTzNvTFo3MStudUFqMTIvTDl5bTlhVnRxNDEvcG1XY2ExQWJ4WWFNCjQwUHNUNi84cENGa0MyTWRxWmllWEl1bVJWWmdJMXVHVGh2eVR1dDQ4Q2tXdHlyYWFZZ3VjVk5VK3gyL21YQWUKZHZQdHRJUmVYR000bGZxam5SRVIxY3BOZ1RYbmFZRitQNW9YZ2hueXAwTWk1ek5WL2hpVXlyaUd0TDRBSmVDMApUOUljVjQyTGNCOEdCMDFsNUYzTjdTY1NUaXZjNjU4UFFDWEJyUWpiQWdNQkFBR2pnZ0ZlTUlJQldqQU9CZ05WCkhROEJBZjhFQkFNQ0I0QXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQWQKQmdOVkhRNEVGZ1FVSVl3Z1I5OURiS28yTVlTK2RjbXdBYklnakxVd0h3WURWUjBqQkJnd0ZvQVV5TVVkQUVHYQpKQ2t5VVNUckRhNUs3VW9HMCt3d2dZMEdDQ3NHQVFVRkJ3RUJCSUdBTUg0d2ZBWUlLd1lCQlFVSE1BS0djR2gwCmRIQTZMeTl3Y21sMllYUmxZMkV0WTI5dWRHVnVkQzAyTURObVpUZGxOeTB3TURBd0xUSXlNamN0WW1ZM05TMW0KTkdZMVpUZ3daREk1TlRRdWMzUnZjbUZuWlM1bmIyOW5iR1ZoY0dsekxtTnZiUzlqWVRNMllURmxPVFl5TkRKaQpPV1pqWWpFME5pOWpZUzVqY25Rd0p3WURWUjBSQVFIL0JCMHdHNEVaY205amFDNXNaV1psWW5aeVpVQnphRzl3CmFXWjVMbU52YlRBc0Jnb3JCZ0VFQVlPL01BRUJCQjVvZEhSd2N6b3ZMMmRwZEdoMVlpNWpiMjB2Ykc5bmFXNHYKYjJGMWRHZ3dDZ1lJS29aSXpqMEVBd01EWndBd1pBSXdjWlJFaWt1NVlDMGFmQjA4dzRIM1UrdEdvUTBCUzFvVQp4b1hqU0VzNUpuZk9YaDRrUWNldC90TmpvQytLdnhkWUFqQlhrT0hGMC81WTQ4RUN0SENHeTdDRWxSM1FKb1NkCjJSZVp6V3MwUmNreXl1U2JQb0FnUHlyM04rYXpkYkdDUHdzPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" + # } + # } + # } + # } + + entry_digest = rekord_hash.dig("spec", "data", "hash", "value") + raise "Expecting a hash in #{rekord_hash}" unless entry_digest + + signature = Base64.decode64(rekord_hash.dig("spec", "signature", "content")) + raise "Expecting a signature in #{rekord_hash}" unless entry_digest + + cert = Base64.decode64(rekord_hash.dig("spec", "signature", "publicKey", "content")) + raise "Expecting a publicKey in #{rekord_hash}" unless entry_digest + + key = key_from_cert(cert) + + key.verify(digest, signature, contents) + end + + def key_from_cert(cert) + cert = OpenSSL::X509::Certificate.new(cert) + cert.public_key + end + + def signer_email(rekord_hash) + cert = Base64.decode64(rekord_hash.dig("spec", "signature", "publicKey", "content")) + extensions = OpenSSL::X509::Certificate.new(cert).extensions.each_with_object({}) { |ext, hash| hash[ext.oid] = ext.value } + extensions["subjectAltName"]&.delete_prefix("email:") end end diff --git a/lib/rubygems/sigstore/http_client.rb b/lib/rubygems/sigstore/http_client.rb index f448d90..9d8b6dc 100644 --- a/lib/rubygems/sigstore/http_client.rb +++ b/lib/rubygems/sigstore/http_client.rb @@ -41,26 +41,55 @@ def submit_rekor(cert_chain, data_digest, data_signature, certPEM, data_raw, rek 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(cert_chain), - }, - }, - data: { - content: data_raw, - hash: { - algorithm: "sha256", - value: data_digest, - }, - }, + { + kind: "rekord", + apiVersion: "0.0.1", + spec: { + signature: { + format: "x509", + content: Base64.encode64(data_signature), + publicKey: { + content: Base64.encode64(cert_chain), + }, }, - }) + data: { + content: data_raw, + hash: { + algorithm: "sha256", + value: data_digest, + }, + }, + }, + }) return rekor_response.body end + + def get_rekor_entries(data_digest, rekor_host) + # rekor uses a self signed certificate which fails the ssl check + connection = Faraday.new(ssl: { verify: false }) do |request| + request.url_prefix = rekor_host + request.request :json + request.response :json, content_type: /json/ + request.adapter :net_http + end + + retrieve_response = connection.post("/api/v1/index/retrieve", + { + hash: "sha256:#{data_digest}", + } + ) + + unless retrieve_response.status == 200 + raise "Unexpected response from POST /api/v1/index/retrieve:\n #{retrieve_response}" + end + + retrieve_response.body.map do |uuid| + entry_response = connection.get("api/v1/log/entries/#{uuid}") + unless entry_response.status == 200 + raise "Unexpected response from GET api/v1/log/entries/#{uuid}:\n #{entry_response}" + end + + entry_response.body + end + end end From 36132021d329c515d5013b2d96b47d02d41d3df4 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Fri, 29 Oct 2021 11:03:44 -0700 Subject: [PATCH 09/56] add gemfile class --- lib/rubygems/commands/sign_command.rb | 9 +++--- lib/rubygems/commands/verify_command.rb | 11 +++---- lib/rubygems/sigstore/gemfile.rb | 39 +++++++++++++++++++++++++ lib/rubygems/sigstore/http_client.rb | 2 +- lib/rubygems/sigstore/sign_extend.rb | 25 +++------------- 5 files changed, 54 insertions(+), 32 deletions(-) create mode 100644 lib/rubygems/sigstore/gemfile.rb diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index 5be39f8..caaa10b 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -17,6 +17,7 @@ require "rubygems/sigstore/crypto" require "rubygems/sigstore/http_client" require "rubygems/sigstore/openid" +require "rubygems/sigstore/gemfile" require 'json/jwt' require "launchy" @@ -49,10 +50,8 @@ def execute print cert_response puts "" - gem_file_path = get_one_gem_name - gem_file = File.read(gem_file_path) - gem_file_digest = OpenSSL::Digest::SHA256.new(gem_file) - gem_file_signature = priv_key.sign gem_file_digest, gem_file + gem_file = Gem::Sigstore::Gemfile.new(get_one_gem_name) + gem_file_signature = priv_key.sign gem_file.digest, gem_file.content content = <<~CONTENT @@ -62,7 +61,7 @@ def execute CONTENT puts content - rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file_digest, gem_file_signature, nil, Base64.encode64(gem_file), config.rekor_host) + rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file.digest, gem_file_signature, nil, Base64.encode64(gem_file), config.rekor_host) puts "rekor response: " pp rekor_response end diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb index da2429e..46bf308 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +require "rubygems/sigstore/gemfile" class Gem::Commands::VerifyCommand < Gem::Command def initialize @@ -26,15 +27,15 @@ def execute puts "verify \"#{gem_path}\"" raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(gem_path) - contents = File.read(gem_path) - digest = OpenSSL::Digest::SHA256.new(contents) + + gemfile = Gem::Sigstore::Gemfile.new(gem_path) config = SigStoreConfig.new.config - entries = HttpClient.new.get_rekor_entries(digest, config.rekor_host) + entries = HttpClient.new.get_rekor_entries(gemfile.digest, config.rekor_host) rekord_hashes = entries.map { |entry| rekord_from_entry(entry.values.first) } - rekord = rekord_hashes.find { |rekord| valid_signature?(rekord, digest, contents) } + rekord = rekord_hashes.find { |rekord| valid_signature?(rekord, gemfile.digest, gemfile.content) } if rekord puts ":noice:, signed by #{signer_email(rekord)}" @@ -81,7 +82,7 @@ def valid_signature?(rekord_hash, digest, contents) key = key_from_cert(cert) - key.verify(digest, signature, contents) + key.verify(gemfile.digest, signature, contents) end def key_from_cert(cert) diff --git a/lib/rubygems/sigstore/gemfile.rb b/lib/rubygems/sigstore/gemfile.rb new file mode 100644 index 0000000..411819a --- /dev/null +++ b/lib/rubygems/sigstore/gemfile.rb @@ -0,0 +1,39 @@ +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 + "#{spec.full_name}.gem" + end + + def content + @content ||= File.read(path) + end + + def digest + @digest ||= OpenSSL::Digest::SHA256.new(content) + end + + def spec + @spec ||= Gem::Specification::load(@path) + end +end diff --git a/lib/rubygems/sigstore/http_client.rb b/lib/rubygems/sigstore/http_client.rb index 9d8b6dc..7205b27 100644 --- a/lib/rubygems/sigstore/http_client.rb +++ b/lib/rubygems/sigstore/http_client.rb @@ -53,7 +53,7 @@ def submit_rekor(cert_chain, data_digest, data_signature, certPEM, data_raw, rek }, }, data: { - content: data_raw, + content: Base64.encode64(data_raw), hash: { algorithm: "sha256", value: data_digest, diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb index f2c9d2f..7544bc1 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/sigstore/sign_extend.rb @@ -14,7 +14,6 @@ require 'digest' require 'fileutils' -require 'open3' require 'openssl' require 'rubygems/package' require 'rubygems/command_manager' @@ -23,20 +22,10 @@ require "rubygems/sigstore/crypto" require "rubygems/sigstore/http_client" require "rubygems/sigstore/openid" +require "rubygems/sigstore/gemfile" 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| @@ -59,14 +48,8 @@ def execute # 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) - - gem_file_path = "#{spec.full_name}.gem" - gem_file = File.read(gem_file_path) - gem_file_digest = OpenSSL::Digest::SHA256.new(gem_file) - gem_file_signature = priv_key.sign gem_file_digest, gem_file + gem_file = Gem::Sigstore::Gemfile.find_gemspec + gem_file_signature = priv_key.sign gem_file.digest, gem_file.content content = <<~CONTENT @@ -76,7 +59,7 @@ def execute CONTENT puts content - rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file_digest, gem_file_signature, certPEM, Base64.encode64(gem_file), config.rekor_host) + rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file.digest, gem_file_signature, certPEM, gem_file.content, config.rekor_host) puts "rekor response: " pp rekor_response end From 286d05f2e0b5de2c0e4f98821a97b5668d9da98a Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Fri, 29 Oct 2021 17:58:03 -0700 Subject: [PATCH 10/56] add namespaces --- lib/rubygems/commands/sign_command.rb | 13 ++++++---- lib/rubygems/commands/verify_command.rb | 7 +++++- lib/rubygems/sigstore/config.rb | 32 ++++++++++++++++++------- lib/rubygems/sigstore/crypto.rb | 9 ++++--- lib/rubygems/sigstore/openid.rb | 11 ++++++--- lib/rubygems/sigstore/sign_extend.rb | 13 ++++++---- 6 files changed, 61 insertions(+), 24 deletions(-) diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index caaa10b..8f4756c 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -12,6 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +module Gem + module Sigstore + end +end + require 'rubygems/command' require "rubygems/sigstore/config" require "rubygems/sigstore/crypto" @@ -42,9 +47,9 @@ def usage # :nodoc: end def execute - config = SigStoreConfig.new.config - priv_key, _pub_key, enc_pub_key = Crypto.new.generate_keys - proof, access_token = OpenIDHandler.new(priv_key).get_token + config = Gem::Sigstore::Config.read + priv_key, _pub_key, enc_pub_key = Gem::Sigstore::Crypto.new.generate_keys + proof, access_token = Gem::Sigstore::OpenID.new(priv_key).get_token cert_response = HttpClient.new.get_cert(access_token, proof, enc_pub_key, config.fulcio_host) puts "Fulcio cert chain" print cert_response @@ -61,7 +66,7 @@ def execute CONTENT puts content - rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file.digest, gem_file_signature, nil, Base64.encode64(gem_file), config.rekor_host) + rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file.digest, gem_file_signature, nil, gem_file, config.rekor_host) puts "rekor response: " pp rekor_response end diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb index 46bf308..ecc3fb9 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -12,6 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +module Gem + module Sigstore + end +end + require "rubygems/sigstore/gemfile" class Gem::Commands::VerifyCommand < Gem::Command @@ -30,7 +35,7 @@ def execute gemfile = Gem::Sigstore::Gemfile.new(gem_path) - config = SigStoreConfig.new.config + config = Gem::Sigstore::Config.read entries = HttpClient.new.get_rekor_entries(gemfile.digest, config.rekor_host) rekord_hashes = entries.map { |entry| rekord_from_entry(entry.values.first) } diff --git a/lib/rubygems/sigstore/config.rb b/lib/rubygems/sigstore/config.rb index eed9e66..6d06084 100644 --- a/lib/rubygems/sigstore/config.rb +++ b/lib/rubygems/sigstore/config.rb @@ -14,18 +14,32 @@ require 'config' -class SigStoreConfig - def initialize; end - def config - Config.setup do |config| - config.use_env = true - config.env_prefix = 'sigstore' - config.env_separator = '_' +module Gem + module Sigstore + end +end + +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 - settings_file = Config.setting_files( + + 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 index e47b4f0..5263d2e 100644 --- a/lib/rubygems/sigstore/crypto.rb +++ b/lib/rubygems/sigstore/crypto.rb @@ -12,12 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +module Gem + module Sigstore + end +end + require 'base64' require 'openssl' -class Crypto - def initialize; end - +class Gem::Sigstore::Crypto def generate_keys key = OpenSSL::PKey::RSA.generate(2048) pkey = key.public_key diff --git a/lib/rubygems/sigstore/openid.rb b/lib/rubygems/sigstore/openid.rb index 6dd8155..8a25e1c 100644 --- a/lib/rubygems/sigstore/openid.rb +++ b/lib/rubygems/sigstore/openid.rb @@ -12,6 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +module Gem + module Sigstore + end +end + require "rubygems/sigstore/config" require "rubygems/sigstore/crypto" @@ -22,13 +27,13 @@ require "launchy" require "openid_connect" -class OpenIDHandler +class Gem::Sigstore::OpenID def initialize(priv_key) @priv_key = priv_key end def get_token() - config = SigStoreConfig.new.config + config = Gem::Sigstore::Config.read session = {} session[:state] = SecureRandom.hex(16) session[:nonce] = SecureRandom.hex(16) @@ -127,7 +132,7 @@ def get_token() token = verify_token(access_token, provider_public_keys, config, session[:nonce]) - proof = Crypto.new.sign_proof(@priv_key, token["email"]) + proof = Gem::Sigstore::Crypto.new.sign_proof(@priv_key, token["email"]) return proof, access_token end diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb index 7544bc1..3b16735 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/sigstore/sign_extend.rb @@ -12,6 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +module Gem + module Sigstore + end +end + require 'digest' require 'fileutils' require 'openssl' @@ -35,12 +40,12 @@ class Gem::Commands::BuildCommand alias_method :original_execute, :execute def execute - config = SigStoreConfig.new.config + config = Gem::Sigstore::Config.read 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 + config = Gem::Sigstore::Config.read + priv_key, _pub_key, enc_pub_key = Gem::Sigstore::Crypto.new.generate_keys + proof, access_token = Gem::Sigstore::OpenID.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,}/) From 0e0f3076b369b178c5c77acad9c7ea9f57d18078 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Fri, 29 Oct 2021 18:31:15 -0700 Subject: [PATCH 11/56] move fulcio api calls out of http client --- lib/rubygems/commands/sign_command.rb | 6 +++++- lib/rubygems/sigstore/fulcio_api.rb | 31 +++++++++++++++++++++++++++ lib/rubygems/sigstore/http_client.rb | 15 ------------- lib/rubygems/sigstore/sign_extend.rb | 6 +++++- 4 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 lib/rubygems/sigstore/fulcio_api.rb diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index 8f4756c..4813bf6 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -20,6 +20,7 @@ module Sigstore require 'rubygems/command' require "rubygems/sigstore/config" require "rubygems/sigstore/crypto" +require "rubygems/sigstore/fulcio_api" require "rubygems/sigstore/http_client" require "rubygems/sigstore/openid" require "rubygems/sigstore/gemfile" @@ -50,7 +51,10 @@ def execute config = Gem::Sigstore::Config.read priv_key, _pub_key, enc_pub_key = Gem::Sigstore::Crypto.new.generate_keys proof, access_token = Gem::Sigstore::OpenID.new(priv_key).get_token - cert_response = HttpClient.new.get_cert(access_token, proof, enc_pub_key, config.fulcio_host) + + fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) + cert_response = fulcio_api.post(proof, enc_pub_key) + puts "Fulcio cert chain" print cert_response puts "" diff --git a/lib/rubygems/sigstore/fulcio_api.rb b/lib/rubygems/sigstore/fulcio_api.rb new file mode 100644 index 0000000..e5575cc --- /dev/null +++ b/lib/rubygems/sigstore/fulcio_api.rb @@ -0,0 +1,31 @@ +require "faraday_middleware" +require "openssl" + +class Gem::Sigstore::FulcioApi + def initialize(host:, token:) + @host = host + @token = token.to_s + end + + def post(proof, pub_key) + connection.post("/api/v1/signingCert", { + publicKey: { + content: pub_key, + algorithm: "ecdsa" + }, + signedEmailAddress: proof + }).body + end + + private + + def connection + Faraday.new do |request| + request.authorization :Bearer, @token + 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/http_client.rb b/lib/rubygems/sigstore/http_client.rb index 7205b27..9671f5a 100644 --- a/lib/rubygems/sigstore/http_client.rb +++ b/lib/rubygems/sigstore/http_client.rb @@ -16,21 +16,6 @@ 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(cert_chain, data_digest, data_signature, certPEM, data_raw, rekor_host) # rekor uses a self signed certificate which failes the ssl check connection = Faraday.new(ssl: { verify: false }) do |request| diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb index 3b16735..8d5d716 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/sigstore/sign_extend.rb @@ -25,6 +25,7 @@ module Sigstore require "rubygems/sigstore/config" require 'rubygems/sigstore/options' require "rubygems/sigstore/crypto" +require "rubygems/sigstore/fulcio_api" require "rubygems/sigstore/http_client" require "rubygems/sigstore/openid" require "rubygems/sigstore/gemfile" @@ -47,7 +48,10 @@ def execute priv_key, _pub_key, enc_pub_key = Gem::Sigstore::Crypto.new.generate_keys proof, access_token = Gem::Sigstore::OpenID.new(priv_key).get_token puts "" - cert_response = HttpClient.new.get_cert(access_token, proof, enc_pub_key, config.fulcio_host) + + fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) + cert_response = fulcio_api.post(proof, enc_pub_key) + certPEM, _rootPem = cert_response.split(/\n{2,}/) # Run the gem build process (original_execute) From 5e945af6fbc6e219370c752f9fd940e2529894a4 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Fri, 29 Oct 2021 19:05:14 -0700 Subject: [PATCH 12/56] move rekor api out of http --- lib/rubygems/commands/sign_command.rb | 7 ++- lib/rubygems/commands/verify_command.rb | 4 +- lib/rubygems/sigstore/fulcio_api.rb | 2 +- lib/rubygems/sigstore/http_client.rb | 80 ------------------------- lib/rubygems/sigstore/rekor_api.rb | 69 +++++++++++++++++++++ lib/rubygems/sigstore/sign_extend.rb | 10 ++-- 6 files changed, 81 insertions(+), 91 deletions(-) delete mode 100644 lib/rubygems/sigstore/http_client.rb create mode 100644 lib/rubygems/sigstore/rekor_api.rb diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index 4813bf6..8860d8b 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -21,7 +21,6 @@ module Sigstore require "rubygems/sigstore/config" require "rubygems/sigstore/crypto" require "rubygems/sigstore/fulcio_api" -require "rubygems/sigstore/http_client" require "rubygems/sigstore/openid" require "rubygems/sigstore/gemfile" @@ -53,7 +52,7 @@ def execute proof, access_token = Gem::Sigstore::OpenID.new(priv_key).get_token fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) - cert_response = fulcio_api.post(proof, enc_pub_key) + cert_response = fulcio_api.create(proof, enc_pub_key) puts "Fulcio cert chain" print cert_response @@ -70,7 +69,9 @@ def execute CONTENT puts content - rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file.digest, gem_file_signature, nil, gem_file, config.rekor_host) + data = Gem::Sigstore::RekorApi::Data.new(gem_file.digest, gem_file_signature, gem_file.content) + rekor_api = Gem::Sigstore::RekorApi.new(host: config.fulcio_host) + rekor_response = rekor_api.create(cert_response, data) puts "rekor response: " pp rekor_response end diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb index ecc3fb9..3e2fdf7 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -34,10 +34,10 @@ def execute raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(gem_path) gemfile = Gem::Sigstore::Gemfile.new(gem_path) - config = Gem::Sigstore::Config.read - entries = HttpClient.new.get_rekor_entries(gemfile.digest, config.rekor_host) + rekor_api = Gem::Sigstore::RekorApi.new(host: config.fulcio_host) + entries = rekor_api.where(data_digest: gemfile.digest) rekord_hashes = entries.map { |entry| rekord_from_entry(entry.values.first) } rekord = rekord_hashes.find { |rekord| valid_signature?(rekord, gemfile.digest, gemfile.content) } diff --git a/lib/rubygems/sigstore/fulcio_api.rb b/lib/rubygems/sigstore/fulcio_api.rb index e5575cc..d584cb5 100644 --- a/lib/rubygems/sigstore/fulcio_api.rb +++ b/lib/rubygems/sigstore/fulcio_api.rb @@ -7,7 +7,7 @@ def initialize(host:, token:) @token = token.to_s end - def post(proof, pub_key) + def create(proof, pub_key) connection.post("/api/v1/signingCert", { publicKey: { content: pub_key, diff --git a/lib/rubygems/sigstore/http_client.rb b/lib/rubygems/sigstore/http_client.rb deleted file mode 100644 index 9671f5a..0000000 --- a/lib/rubygems/sigstore/http_client.rb +++ /dev/null @@ -1,80 +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 submit_rekor(cert_chain, data_digest, data_signature, certPEM, data_raw, rekor_host) - # rekor uses a self signed certificate which failes the ssl check - connection = Faraday.new(ssl: { verify: false }) 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(cert_chain), - }, - }, - data: { - content: Base64.encode64(data_raw), - hash: { - algorithm: "sha256", - value: data_digest, - }, - }, - }, - }) - return rekor_response.body - end - - def get_rekor_entries(data_digest, rekor_host) - # rekor uses a self signed certificate which fails the ssl check - connection = Faraday.new(ssl: { verify: false }) do |request| - request.url_prefix = rekor_host - request.request :json - request.response :json, content_type: /json/ - request.adapter :net_http - end - - retrieve_response = connection.post("/api/v1/index/retrieve", - { - hash: "sha256:#{data_digest}", - } - ) - - unless retrieve_response.status == 200 - raise "Unexpected response from POST /api/v1/index/retrieve:\n #{retrieve_response}" - end - - retrieve_response.body.map do |uuid| - entry_response = connection.get("api/v1/log/entries/#{uuid}") - unless entry_response.status == 200 - raise "Unexpected response from GET api/v1/log/entries/#{uuid}:\n #{entry_response}" - end - - entry_response.body - end - end -end diff --git a/lib/rubygems/sigstore/rekor_api.rb b/lib/rubygems/sigstore/rekor_api.rb new file mode 100644 index 0000000..5abe7e9 --- /dev/null +++ b/lib/rubygems/sigstore/rekor_api.rb @@ -0,0 +1,69 @@ +require "faraday_middleware" +require "openssl" + +class Gem::Sigstore::RekorApi + Data = Struct.new(:digest, :signature, :raw) + + def initialize(host:) + @host = host + end + + def create(cert_chain, data) + 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(cert_chain), + }, + }, + data: { + content: Base64.encode64(data.raw), + hash: { + algorithm: "sha256", + value: data.digest, + }, + }, + }, + }).body + end + + def where(data_digest:) + retrieve_response = connection.post("/api/v1/index/retrieve", + { + hash: "sha256:#{data_digest}", + } + ) + + unless retrieve_response.status == 200 + raise "Unexpected response from POST /api/v1/index/retrieve:\n #{retrieve_response}" + end + + retrieve_response.body.map do |uuid| + entry_response = connection.get("api/v1/log/entries/#{uuid}") + unless entry_response.status == 200 + raise "Unexpected response from GET api/v1/log/entries/#{uuid}:\n #{entry_response}" + end + + entry_response.body + 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 +end + diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb index 8d5d716..286eaf5 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/sigstore/sign_extend.rb @@ -26,7 +26,7 @@ module Sigstore require 'rubygems/sigstore/options' require "rubygems/sigstore/crypto" require "rubygems/sigstore/fulcio_api" -require "rubygems/sigstore/http_client" +require "rubygems/sigstore/rekor_api" require "rubygems/sigstore/openid" require "rubygems/sigstore/gemfile" @@ -50,9 +50,7 @@ def execute puts "" fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) - cert_response = fulcio_api.post(proof, enc_pub_key) - - certPEM, _rootPem = cert_response.split(/\n{2,}/) + cert_response = fulcio_api.create(proof, enc_pub_key) # Run the gem build process (original_execute) original_execute @@ -68,7 +66,9 @@ def execute CONTENT puts content - rekor_response = HttpClient.new.submit_rekor(cert_response, gem_file.digest, gem_file_signature, certPEM, gem_file.content, config.rekor_host) + data = Gem::Sigstore::RekorApi::Data.new(gem_file.digest, gem_file_signature, gem_file.content) + rekor_api = Gem::Sigstore::RekorApi.new(host: config.fulcio_host) + rekor_response = rekor_api.create(cert_response, data) puts "rekor response: " pp rekor_response end From 195bd1bbe9d0426b629cbe0eccdc099148e5905d Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Fri, 29 Oct 2021 20:09:31 -0700 Subject: [PATCH 13/56] add gem signer and verifier classes --- lib/rubygems/commands/sign_command.rb | 36 ++++--------- lib/rubygems/commands/verify_command.rb | 71 +++---------------------- lib/rubygems/sigstore/cert_provider.rb | 12 +++++ lib/rubygems/sigstore/crypto.rb | 21 +++++--- lib/rubygems/sigstore/file_signer.rb | 25 +++++++++ lib/rubygems/sigstore/fulcio_api.rb | 2 +- lib/rubygems/sigstore/gem_singer.rb | 28 ++++++++++ lib/rubygems/sigstore/gem_verifier.rb | 24 +++++++++ lib/rubygems/sigstore/openid.rb | 3 +- lib/rubygems/sigstore/rekor_api.rb | 2 - lib/rubygems/sigstore/rekord_entries.rb | 63 ++++++++++++++++++++++ lib/rubygems/sigstore/sign_extend.rb | 40 ++++---------- 12 files changed, 194 insertions(+), 133 deletions(-) create mode 100644 lib/rubygems/sigstore/cert_provider.rb create mode 100644 lib/rubygems/sigstore/file_signer.rb create mode 100644 lib/rubygems/sigstore/gem_singer.rb create mode 100644 lib/rubygems/sigstore/gem_verifier.rb create mode 100644 lib/rubygems/sigstore/rekord_entries.rb diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index 8860d8b..c2316ef 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -23,6 +23,9 @@ module Sigstore require "rubygems/sigstore/fulcio_api" require "rubygems/sigstore/openid" require "rubygems/sigstore/gemfile" +require "rubygems/sigstore/cert_provider" +require "rubygems/sigstore/file_signer" +require "rubygems/sigstore/gem_signer" require 'json/jwt' require "launchy" @@ -47,32 +50,11 @@ def usage # :nodoc: end def execute - config = Gem::Sigstore::Config.read - priv_key, _pub_key, enc_pub_key = Gem::Sigstore::Crypto.new.generate_keys - proof, access_token = Gem::Sigstore::OpenID.new(priv_key).get_token - - fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) - cert_response = fulcio_api.create(proof, enc_pub_key) - - puts "Fulcio cert chain" - print cert_response - puts "" - - gem_file = Gem::Sigstore::Gemfile.new(get_one_gem_name) - gem_file_signature = priv_key.sign gem_file.digest, gem_file.content - - content = <<~CONTENT - - sigstore signing operation complete." - - sending signiture & certificate chain to rekor." - CONTENT - puts content - - data = Gem::Sigstore::RekorApi::Data.new(gem_file.digest, gem_file_signature, gem_file.content) - rekor_api = Gem::Sigstore::RekorApi.new(host: config.fulcio_host) - rekor_response = rekor_api.create(cert_response, data) - puts "rekor response: " - pp rekor_response + gemfile = Gem::Sigstore::Gemfile.new(get_one_gem_name) + rekor_entry = Gem::Sigstore::GemSigner.new( + gemfile: gemfile, + config: Gem::Sigstore::Config.read + ).run + pp rekor_entry end end diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb index 3e2fdf7..ecec91f 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -18,6 +18,7 @@ module Sigstore end require "rubygems/sigstore/gemfile" +require "rubygems/commands/verify_command" class Gem::Commands::VerifyCommand < Gem::Command def initialize @@ -34,70 +35,10 @@ def execute raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(gem_path) gemfile = Gem::Sigstore::Gemfile.new(gem_path) - config = Gem::Sigstore::Config.read - - rekor_api = Gem::Sigstore::RekorApi.new(host: config.fulcio_host) - entries = rekor_api.where(data_digest: gemfile.digest) - rekord_hashes = entries.map { |entry| rekord_from_entry(entry.values.first) } - - rekord = rekord_hashes.find { |rekord| valid_signature?(rekord, gemfile.digest, gemfile.content) } - - if rekord - puts ":noice:, signed by #{signer_email(rekord)}" - else - puts "not :noice: thxkbye" - end - end - - private - - def rekord_from_entry(entry_hash) - JSON.parse(Base64.decode64(entry_hash["body"])) - end - - def valid_signature?(rekord_hash, digest, contents) - # { - # "apiVersion"=>"0.0.1", - # "kind"=>"rekord", - # "spec"=>{ - # "data"=>{ - # "hash"=>{ - # "algorithm"=>"sha256", - # "value"=>"230aed713b5d1cfce45228658bdb58e37502be1323f768fde0220e64233f1e32" - # } - # }, - # "signature"=>{ - # "content"=>"ngxsh3qbcWtjpZWxkdLcMA6hX61x/pmYf9LxN8MttwcJIaiwRNxiXeQWek0Y+5WiVD+fDZc42udM5OaEgBl3fjmU68NkLoe1WieTR/C+GJHlgjE69ZFLG80GwRgcP3hE4HiTYdk5UkCL8yFA2fJEgYnpLr8PhBOZoHZeLbt+9sQEXTOj6HqjSvvA6JLMSbJweXwUMP6EfjIUuEn2geKC2Hvh54tUu6sH+Hpk7EBADNDcBRL/57TX6OGJdku/Rrrkn9J5XhBaDzhZHr5vZVY86ZyB/NQYz3li2l3sbhLRQpIaUEQXdQmIP1AaJsPt27QIE6zJHMBJ2MUv1UMjYKsLGQ==", - # "format"=>"x509", - # "publicKey"=>{ - # "content"=>"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURkakNDQXYyZ0F3SUJBZ0lVQVAxSENMQnpnamtSYmp3bzFndkI3VXdoZHNFd0NnWUlLb1pJemowRUF3TXcKS2pFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUkV3RHdZRFZRUURFd2h6YVdkemRHOXlaVEFlRncweQpNVEV3TWpreE9UVTNNVE5hRncweU1URXdNamt5TURFM01USmFNQUF3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBCkE0SUJEd0F3Z2dFS0FvSUJBUUM0ZnRNREJDczJ0K0FqWFAyUjhTNkUyaTdDbHVHeTBsRC9tYmgzRFpDZXc1UmwKd2FoN1pZTlZhSFg3UW1mK1hOZmkrVldubU4rVmNRWlZjaTJHRGVuZnBYRUM0SnhDUHplQXBPSWxVY1NYTWV5cgpLSWh3L2Z1YkMrN0NvaElvUE5MRjM2RGUvTzNvTFo3MStudUFqMTIvTDl5bTlhVnRxNDEvcG1XY2ExQWJ4WWFNCjQwUHNUNi84cENGa0MyTWRxWmllWEl1bVJWWmdJMXVHVGh2eVR1dDQ4Q2tXdHlyYWFZZ3VjVk5VK3gyL21YQWUKZHZQdHRJUmVYR000bGZxam5SRVIxY3BOZ1RYbmFZRitQNW9YZ2hueXAwTWk1ek5WL2hpVXlyaUd0TDRBSmVDMApUOUljVjQyTGNCOEdCMDFsNUYzTjdTY1NUaXZjNjU4UFFDWEJyUWpiQWdNQkFBR2pnZ0ZlTUlJQldqQU9CZ05WCkhROEJBZjhFQkFNQ0I0QXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQWQKQmdOVkhRNEVGZ1FVSVl3Z1I5OURiS28yTVlTK2RjbXdBYklnakxVd0h3WURWUjBqQkJnd0ZvQVV5TVVkQUVHYQpKQ2t5VVNUckRhNUs3VW9HMCt3d2dZMEdDQ3NHQVFVRkJ3RUJCSUdBTUg0d2ZBWUlLd1lCQlFVSE1BS0djR2gwCmRIQTZMeTl3Y21sMllYUmxZMkV0WTI5dWRHVnVkQzAyTURObVpUZGxOeTB3TURBd0xUSXlNamN0WW1ZM05TMW0KTkdZMVpUZ3daREk1TlRRdWMzUnZjbUZuWlM1bmIyOW5iR1ZoY0dsekxtTnZiUzlqWVRNMllURmxPVFl5TkRKaQpPV1pqWWpFME5pOWpZUzVqY25Rd0p3WURWUjBSQVFIL0JCMHdHNEVaY205amFDNXNaV1psWW5aeVpVQnphRzl3CmFXWjVMbU52YlRBc0Jnb3JCZ0VFQVlPL01BRUJCQjVvZEhSd2N6b3ZMMmRwZEdoMVlpNWpiMjB2Ykc5bmFXNHYKYjJGMWRHZ3dDZ1lJS29aSXpqMEVBd01EWndBd1pBSXdjWlJFaWt1NVlDMGFmQjA4dzRIM1UrdEdvUTBCUzFvVQp4b1hqU0VzNUpuZk9YaDRrUWNldC90TmpvQytLdnhkWUFqQlhrT0hGMC81WTQ4RUN0SENHeTdDRWxSM1FKb1NkCjJSZVp6V3MwUmNreXl1U2JQb0FnUHlyM04rYXpkYkdDUHdzPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" - # } - # } - # } - # } - - entry_digest = rekord_hash.dig("spec", "data", "hash", "value") - raise "Expecting a hash in #{rekord_hash}" unless entry_digest - - signature = Base64.decode64(rekord_hash.dig("spec", "signature", "content")) - raise "Expecting a signature in #{rekord_hash}" unless entry_digest - - cert = Base64.decode64(rekord_hash.dig("spec", "signature", "publicKey", "content")) - raise "Expecting a publicKey in #{rekord_hash}" unless entry_digest - - key = key_from_cert(cert) - - key.verify(gemfile.digest, signature, contents) - end - - def key_from_cert(cert) - cert = OpenSSL::X509::Certificate.new(cert) - cert.public_key - end - - def signer_email(rekord_hash) - cert = Base64.decode64(rekord_hash.dig("spec", "signature", "publicKey", "content")) - extensions = OpenSSL::X509::Certificate.new(cert).extensions.each_with_object({}) { |ext, hash| hash[ext.oid] = ext.value } - extensions["subjectAltName"]&.delete_prefix("email:") + verifier = Gem::Sigstore::GemVerifier.new( + gemfile: gemfile, + config: Gem::Sigstore::Config.read + ) + verifier.run end end diff --git a/lib/rubygems/sigstore/cert_provider.rb b/lib/rubygems/sigstore/cert_provider.rb new file mode 100644 index 0000000..23f880a --- /dev/null +++ b/lib/rubygems/sigstore/cert_provider.rb @@ -0,0 +1,12 @@ +class Gem::Sigstore::CertProvider + def initialize(config:, pkey:) + @config = config + @pkey = pkey + end + + def run + proof, access_token = Gem::Sigstore::OpenID.new(@pkey.private_key).get_token + fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) + fulcio_api.create(proof, @pkey.public_key.to_der) + end +end diff --git a/lib/rubygems/sigstore/crypto.rb b/lib/rubygems/sigstore/crypto.rb index 5263d2e..bd36ac8 100644 --- a/lib/rubygems/sigstore/crypto.rb +++ b/lib/rubygems/sigstore/crypto.rb @@ -20,16 +20,21 @@ module Sigstore require 'base64' require 'openssl' -class Gem::Sigstore::Crypto - def generate_keys - key = OpenSSL::PKey::RSA.generate(2048) - pkey = key.public_key - return [key, pkey, Base64.encode64(pkey.to_der)] +class Gem::Sigstore::PKey + def initialize(private_key: nil) + @private_key = private_key if private_key end - def sign_proof(priv_key, email) - proof = priv_key.sign(OpenSSL::Digest::SHA256.new, email) - return Base64.encode64(proof) + def sign_proof(email) + private_key.sign(OpenSSL::Digest::SHA256.new, email) + end + + def public_key + private_key.public_key + end + + def private_key + @private_key ||= OpenSSL::PKey::RSA.generate(2048) end end diff --git a/lib/rubygems/sigstore/file_signer.rb b/lib/rubygems/sigstore/file_signer.rb new file mode 100644 index 0000000..22197de --- /dev/null +++ b/lib/rubygems/sigstore/file_signer.rb @@ -0,0 +1,25 @@ +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 index d584cb5..0d03468 100644 --- a/lib/rubygems/sigstore/fulcio_api.rb +++ b/lib/rubygems/sigstore/fulcio_api.rb @@ -10,7 +10,7 @@ def initialize(host:, token:) def create(proof, pub_key) connection.post("/api/v1/signingCert", { publicKey: { - content: pub_key, + content: Base64.encode64(pub_key), algorithm: "ecdsa" }, signedEmailAddress: proof diff --git a/lib/rubygems/sigstore/gem_singer.rb b/lib/rubygems/sigstore/gem_singer.rb new file mode 100644 index 0000000..e81b3bd --- /dev/null +++ b/lib/rubygems/sigstore/gem_singer.rb @@ -0,0 +1,28 @@ +class Gem::Sigstore::GemSigner + Data = Struct.new(:digest, :signature, :raw) + + def initialize(gemfile:, config:, io: $stdout) + @gemfile = gemfile + @config = config + @io = io + end + + def run + pkey = Gem::Sigstore::PKey.new + cert = Gem::Sigstore::CertProvider.new(config: config).run + + yield if block_given? + + @io.puts "Fulcio cert chain" + @io.puts cert + @io.puts + @io.puts "sending signiture & certificate chain to rekor." + + Gem::Sigstore::FileSigner.new( + file: @gemfile, + pkey: pkey, + transparency_log: Gem::Sigstore::RekorApi.new(host: config.fulcio_host), + cert: cert + ).run + end +end diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb new file mode 100644 index 0000000..6e24e32 --- /dev/null +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -0,0 +1,24 @@ +require "rubygems/sigstore/rekord_entries" + +class Gem::Sigstore::GemVerifier + Data = Struct.new(:digest, :signature, :raw) + + def initialize(gemfile:, config:, io: $stdout) + @gemfile = gemfile + @config = config + @io = io + end + + def run + rekor_api = Gem::Sigstore::RekorApi.new(host: config.fulcio_host) + entries = rekor_api.where(data_digest: gemfile.digest) + rekord_entries = entries.map { |entry| RekordEntry.new(entry.values.first) } + rekord = rekord_entries.find { |entry| entry.valid_signature?(gemfile.digest, gemfile.content) } + + if rekord + @io.puts ":noice:, signed by #{rekord.signer_email}" + else + @io.puts "not :noice: thxkbye" + end + end +end diff --git a/lib/rubygems/sigstore/openid.rb b/lib/rubygems/sigstore/openid.rb index 8a25e1c..d534eec 100644 --- a/lib/rubygems/sigstore/openid.rb +++ b/lib/rubygems/sigstore/openid.rb @@ -132,7 +132,8 @@ def get_token() token = verify_token(access_token, provider_public_keys, config, session[:nonce]) - proof = Gem::Sigstore::Crypto.new.sign_proof(@priv_key, token["email"]) + pkey = Gem::Sigstore::PKey.new(private_key: @priv_key) + proof = pkey.sign_proof(token["email"]) return proof, access_token end diff --git a/lib/rubygems/sigstore/rekor_api.rb b/lib/rubygems/sigstore/rekor_api.rb index 5abe7e9..2d08807 100644 --- a/lib/rubygems/sigstore/rekor_api.rb +++ b/lib/rubygems/sigstore/rekor_api.rb @@ -2,8 +2,6 @@ require "openssl" class Gem::Sigstore::RekorApi - Data = Struct.new(:digest, :signature, :raw) - def initialize(host:) @host = host end diff --git a/lib/rubygems/sigstore/rekord_entries.rb b/lib/rubygems/sigstore/rekord_entries.rb new file mode 100644 index 0000000..2f5d511 --- /dev/null +++ b/lib/rubygems/sigstore/rekord_entries.rb @@ -0,0 +1,63 @@ +class Gem::Sigstore::RekordEntries + def initialize(entry) + @entry = entry + end + + private + + def body + @body ||= JSON.parse(Base64.decode64(@entry["body"])) + end + + def valid_signature?(digest, contents) + # { + # "apiVersion"=>"0.0.1", + # "kind"=>"rekord", + # "spec"=>{ + # "data"=>{ + # "hash"=>{ + # "algorithm"=>"sha256", + # "value"=>"230aed713b5d1cfce45228658bdb58e37502be1323f768fde0220e64233f1e32" + # } + # }, + # "signature"=>{ + # "content"=>"ngxsh3qbcWtjpZWxkdLcMA6hX61x/pmYf9LxN8MttwcJIaiwRNxiXeQWek0Y+5WiVD+fDZc42udM5OaEgBl3fjmU68NkLoe1WieTR/C+GJHlgjE69ZFLG80GwRgcP3hE4HiTYdk5UkCL8yFA2fJEgYnpLr8PhBOZoHZeLbt+9sQEXTOj6HqjSvvA6JLMSbJweXwUMP6EfjIUuEn2geKC2Hvh54tUu6sH+Hpk7EBADNDcBRL/57TX6OGJdku/Rrrkn9J5XhBaDzhZHr5vZVY86ZyB/NQYz3li2l3sbhLRQpIaUEQXdQmIP1AaJsPt27QIE6zJHMBJ2MUv1UMjYKsLGQ==", + # "format"=>"x509", + # "publicKey"=>{ + # "content"=>"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURkakNDQXYyZ0F3SUJBZ0lVQVAxSENMQnpnamtSYmp3bzFndkI3VXdoZHNFd0NnWUlLb1pJemowRUF3TXcKS2pFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUkV3RHdZRFZRUURFd2h6YVdkemRHOXlaVEFlRncweQpNVEV3TWpreE9UVTNNVE5hRncweU1URXdNamt5TURFM01USmFNQUF3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBCkE0SUJEd0F3Z2dFS0FvSUJBUUM0ZnRNREJDczJ0K0FqWFAyUjhTNkUyaTdDbHVHeTBsRC9tYmgzRFpDZXc1UmwKd2FoN1pZTlZhSFg3UW1mK1hOZmkrVldubU4rVmNRWlZjaTJHRGVuZnBYRUM0SnhDUHplQXBPSWxVY1NYTWV5cgpLSWh3L2Z1YkMrN0NvaElvUE5MRjM2RGUvTzNvTFo3MStudUFqMTIvTDl5bTlhVnRxNDEvcG1XY2ExQWJ4WWFNCjQwUHNUNi84cENGa0MyTWRxWmllWEl1bVJWWmdJMXVHVGh2eVR1dDQ4Q2tXdHlyYWFZZ3VjVk5VK3gyL21YQWUKZHZQdHRJUmVYR000bGZxam5SRVIxY3BOZ1RYbmFZRitQNW9YZ2hueXAwTWk1ek5WL2hpVXlyaUd0TDRBSmVDMApUOUljVjQyTGNCOEdCMDFsNUYzTjdTY1NUaXZjNjU4UFFDWEJyUWpiQWdNQkFBR2pnZ0ZlTUlJQldqQU9CZ05WCkhROEJBZjhFQkFNQ0I0QXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQWQKQmdOVkhRNEVGZ1FVSVl3Z1I5OURiS28yTVlTK2RjbXdBYklnakxVd0h3WURWUjBqQkJnd0ZvQVV5TVVkQUVHYQpKQ2t5VVNUckRhNUs3VW9HMCt3d2dZMEdDQ3NHQVFVRkJ3RUJCSUdBTUg0d2ZBWUlLd1lCQlFVSE1BS0djR2gwCmRIQTZMeTl3Y21sMllYUmxZMkV0WTI5dWRHVnVkQzAyTURObVpUZGxOeTB3TURBd0xUSXlNamN0WW1ZM05TMW0KTkdZMVpUZ3daREk1TlRRdWMzUnZjbUZuWlM1bmIyOW5iR1ZoY0dsekxtTnZiUzlqWVRNMllURmxPVFl5TkRKaQpPV1pqWWpFME5pOWpZUzVqY25Rd0p3WURWUjBSQVFIL0JCMHdHNEVaY205amFDNXNaV1psWW5aeVpVQnphRzl3CmFXWjVMbU52YlRBc0Jnb3JCZ0VFQVlPL01BRUJCQjVvZEhSd2N6b3ZMMmRwZEdoMVlpNWpiMjB2Ykc5bmFXNHYKYjJGMWRHZ3dDZ1lJS29aSXpqMEVBd01EWndBd1pBSXdjWlJFaWt1NVlDMGFmQjA4dzRIM1UrdEdvUTBCUzFvVQp4b1hqU0VzNUpuZk9YaDRrUWNldC90TmpvQytLdnhkWUFqQlhrT0hGMC81WTQ4RUN0SENHeTdDRWxSM1FKb1NkCjJSZVp6V3MwUmNreXl1U2JQb0FnUHlyM04rYXpkYkdDUHdzPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" + # } + # } + # } + # } + + public_key = cert.public_key + public_key.verify(digest, signature, contents) + end + + def cert + @cert ||= begin + cert = Base64.decode64(body.dig("spec", "signature", "publicKey", "content")) + raise "Expecting a publicKey in #{body}" unless cert + OpenSSL::X509::Certificate.new(cert) + end + end + + def signature + @signature ||= begin + signature = Base64.decode64(body.dig("spec", "signature", "content")) + raise "Expecting a signature in #{body}" unless signature + signature + end + end + + def signer_email + extensions["subjectAltName"]&.delete_prefix("email:") + end + + def extensions + @extensions ||= cert.extensions.each_with_object({}) do |ext, hash| + hash[ext.oid] = ext.value + end + end +end + diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb index 286eaf5..96b3147 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/sigstore/sign_extend.rb @@ -29,6 +29,9 @@ module Sigstore require "rubygems/sigstore/rekor_api" require "rubygems/sigstore/openid" require "rubygems/sigstore/gemfile" +require "rubygems/sigstore/cert_provider" +require "rubygems/sigstore/file_signer" +require "rubygems/sigstore/gem_signer" Gem::CommandManager.instance.register_command :sign @@ -41,36 +44,15 @@ module Sigstore class Gem::Commands::BuildCommand alias_method :original_execute, :execute def execute - config = Gem::Sigstore::Config.read - if Gem::Sigstore.options[:sign] - config = Gem::Sigstore::Config.read - priv_key, _pub_key, enc_pub_key = Gem::Sigstore::Crypto.new.generate_keys - proof, access_token = Gem::Sigstore::OpenID.new(priv_key).get_token - puts "" - - fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) - cert_response = fulcio_api.create(proof, enc_pub_key) - - # Run the gem build process (original_execute) - original_execute - - gem_file = Gem::Sigstore::Gemfile.find_gemspec - gem_file_signature = priv_key.sign gem_file.digest, gem_file.content - - content = <<~CONTENT - - sigstore signing operation complete." - - sending signiture & certificate chain to rekor." - CONTENT - puts content - - data = Gem::Sigstore::RekorApi::Data.new(gem_file.digest, gem_file_signature, gem_file.content) - rekor_api = Gem::Sigstore::RekorApi.new(host: config.fulcio_host) - rekor_response = rekor_api.create(cert_response, data) - puts "rekor response: " - pp rekor_response + gemfile = Gem::Sigstore::Gemfile.new(get_one_gem_name) + gem_signer = Gem::Sigstore::GemSigner.new( + gemfile: gemfile, + config: Gem::Sigstore::Config.read + ) + # Run the gem build process only if openid auth was successful (original_execute) + rekor_entry = gem_signer.run { original_execute } + pp rekor_entry end end end From ac7728f82bec0cc3e03822d47ca221749e48e588 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Sat, 30 Oct 2021 09:30:28 -0400 Subject: [PATCH 14/56] misc fixes --- .../sigstore/{gem_singer.rb => gem_signer.rb} | 2 +- lib/rubygems/sigstore/gem_verifier.rb | 4 +-- lib/rubygems/sigstore/rekor_api.rb | 30 +++++++++---------- .../{rekord_entries.rb => rekord_entry.rb} | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) rename lib/rubygems/sigstore/{gem_singer.rb => gem_signer.rb} (97%) rename lib/rubygems/sigstore/{rekord_entries.rb => rekord_entry.rb} (98%) diff --git a/lib/rubygems/sigstore/gem_singer.rb b/lib/rubygems/sigstore/gem_signer.rb similarity index 97% rename from lib/rubygems/sigstore/gem_singer.rb rename to lib/rubygems/sigstore/gem_signer.rb index e81b3bd..cca0acf 100644 --- a/lib/rubygems/sigstore/gem_singer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -21,7 +21,7 @@ def run Gem::Sigstore::FileSigner.new( file: @gemfile, pkey: pkey, - transparency_log: Gem::Sigstore::RekorApi.new(host: config.fulcio_host), + transparency_log: Gem::Sigstore::RekorApi.new(host: config.rekor_host), cert: cert ).run end diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index 6e24e32..28b12ed 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -1,4 +1,4 @@ -require "rubygems/sigstore/rekord_entries" +require "rubygems/sigstore/rekord_entry" class Gem::Sigstore::GemVerifier Data = Struct.new(:digest, :signature, :raw) @@ -10,7 +10,7 @@ def initialize(gemfile:, config:, io: $stdout) end def run - rekor_api = Gem::Sigstore::RekorApi.new(host: config.fulcio_host) + rekor_api = Gem::Sigstore::RekorApi.new(host: config.rekor_host) entries = rekor_api.where(data_digest: gemfile.digest) rekord_entries = entries.map { |entry| RekordEntry.new(entry.values.first) } rekord = rekord_entries.find { |entry| entry.valid_signature?(gemfile.digest, gemfile.content) } diff --git a/lib/rubygems/sigstore/rekor_api.rb b/lib/rubygems/sigstore/rekor_api.rb index 2d08807..0aa856d 100644 --- a/lib/rubygems/sigstore/rekor_api.rb +++ b/lib/rubygems/sigstore/rekor_api.rb @@ -10,23 +10,23 @@ def create(cert_chain, data) 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(cert_chain), - }, + apiVersion: "0.0.1", + spec: { + signature: { + format: "x509", + content: Base64.encode64(data.signature), + publicKey: { + content: Base64.encode64(cert_chain), }, - data: { - content: Base64.encode64(data.raw), - hash: { - algorithm: "sha256", - value: data.digest, - }, - }, }, + data: { + content: Base64.encode64(data.raw), + hash: { + algorithm: "sha256", + value: data.digest, + }, + }, + }, }).body end diff --git a/lib/rubygems/sigstore/rekord_entries.rb b/lib/rubygems/sigstore/rekord_entry.rb similarity index 98% rename from lib/rubygems/sigstore/rekord_entries.rb rename to lib/rubygems/sigstore/rekord_entry.rb index 2f5d511..515e08c 100644 --- a/lib/rubygems/sigstore/rekord_entries.rb +++ b/lib/rubygems/sigstore/rekord_entry.rb @@ -1,4 +1,4 @@ -class Gem::Sigstore::RekordEntries +class Gem::Sigstore::RekordEntry def initialize(entry) @entry = entry end From f20d826967d82804763ca7c59e5573c8e850f7bd Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Mon, 1 Nov 2021 08:55:28 -0400 Subject: [PATCH 15/56] various fixes to get sign & verify working --- lib/rubygems/commands/verify_command.rb | 4 +++- lib/rubygems/sigstore/cert_provider.rb | 8 ++++++-- lib/rubygems/sigstore/fulcio_api.rb | 2 +- lib/rubygems/sigstore/gem_signer.rb | 16 ++++++++++------ lib/rubygems/sigstore/gem_verifier.rb | 10 +++++++--- lib/rubygems/sigstore/gemfile.rb | 2 +- lib/rubygems/sigstore/rekord_entry.rb | 20 ++++++++++---------- 7 files changed, 38 insertions(+), 24 deletions(-) diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb index ecec91f..c96ee3d 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -17,8 +17,10 @@ module Sigstore end end -require "rubygems/sigstore/gemfile" require "rubygems/commands/verify_command" +require "rubygems/sigstore/config" +require "rubygems/sigstore/gemfile" +require "rubygems/sigstore/gem_verifier" class Gem::Commands::VerifyCommand < Gem::Command def initialize diff --git a/lib/rubygems/sigstore/cert_provider.rb b/lib/rubygems/sigstore/cert_provider.rb index 23f880a..17ec1ea 100644 --- a/lib/rubygems/sigstore/cert_provider.rb +++ b/lib/rubygems/sigstore/cert_provider.rb @@ -5,8 +5,12 @@ def initialize(config:, pkey:) end def run - proof, access_token = Gem::Sigstore::OpenID.new(@pkey.private_key).get_token + proof, access_token = Gem::Sigstore::OpenID.new(pkey.private_key).get_token fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) - fulcio_api.create(proof, @pkey.public_key.to_der) + fulcio_api.create(proof, pkey.public_key.to_der) end + + private + + attr_reader :config, :pkey end diff --git a/lib/rubygems/sigstore/fulcio_api.rb b/lib/rubygems/sigstore/fulcio_api.rb index 0d03468..0b0cb32 100644 --- a/lib/rubygems/sigstore/fulcio_api.rb +++ b/lib/rubygems/sigstore/fulcio_api.rb @@ -13,7 +13,7 @@ def create(proof, pub_key) content: Base64.encode64(pub_key), algorithm: "ecdsa" }, - signedEmailAddress: proof + signedEmailAddress: Base64.encode64(proof) }).body end diff --git a/lib/rubygems/sigstore/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb index cca0acf..253581d 100644 --- a/lib/rubygems/sigstore/gem_signer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -9,20 +9,24 @@ def initialize(gemfile:, config:, io: $stdout) def run pkey = Gem::Sigstore::PKey.new - cert = Gem::Sigstore::CertProvider.new(config: config).run + cert = Gem::Sigstore::CertProvider.new(config: config, pkey: pkey).run yield if block_given? - @io.puts "Fulcio cert chain" - @io.puts cert - @io.puts - @io.puts "sending signiture & certificate chain to rekor." + io.puts "Fulcio cert chain" + io.puts cert + io.puts + io.puts "sending signiture & certificate chain to rekor." Gem::Sigstore::FileSigner.new( - file: @gemfile, + file: gemfile, pkey: pkey, transparency_log: Gem::Sigstore::RekorApi.new(host: config.rekor_host), cert: cert ).run end + + private + + attr_reader :gemfile, :config, :io end diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index 28b12ed..a960b86 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -12,13 +12,17 @@ def initialize(gemfile:, config:, io: $stdout) def run rekor_api = Gem::Sigstore::RekorApi.new(host: config.rekor_host) entries = rekor_api.where(data_digest: gemfile.digest) - rekord_entries = entries.map { |entry| RekordEntry.new(entry.values.first) } + rekord_entries = entries.map { |entry| Gem::Sigstore::RekordEntry.new(entry.values.first) } rekord = rekord_entries.find { |entry| entry.valid_signature?(gemfile.digest, gemfile.content) } if rekord - @io.puts ":noice:, signed by #{rekord.signer_email}" + io.puts ":noice:, signed by #{rekord.signer_email}" else - @io.puts "not :noice: thxkbye" + io.puts "not :noice: thxkbye" end end + + private + + attr_reader :gemfile, :config, :io end diff --git a/lib/rubygems/sigstore/gemfile.rb b/lib/rubygems/sigstore/gemfile.rb index 411819a..1bfc4ee 100644 --- a/lib/rubygems/sigstore/gemfile.rb +++ b/lib/rubygems/sigstore/gemfile.rb @@ -22,7 +22,7 @@ def initialize(path) end def path - "#{spec.full_name}.gem" + @path end def content diff --git a/lib/rubygems/sigstore/rekord_entry.rb b/lib/rubygems/sigstore/rekord_entry.rb index 515e08c..12d8ae5 100644 --- a/lib/rubygems/sigstore/rekord_entry.rb +++ b/lib/rubygems/sigstore/rekord_entry.rb @@ -3,12 +3,6 @@ def initialize(entry) @entry = entry end - private - - def body - @body ||= JSON.parse(Base64.decode64(@entry["body"])) - end - def valid_signature?(digest, contents) # { # "apiVersion"=>"0.0.1", @@ -34,6 +28,16 @@ def valid_signature?(digest, contents) public_key.verify(digest, signature, contents) end + def signer_email + extensions["subjectAltName"]&.delete_prefix("email:") + end + + private + + def body + @body ||= JSON.parse(Base64.decode64(@entry["body"])) + end + def cert @cert ||= begin cert = Base64.decode64(body.dig("spec", "signature", "publicKey", "content")) @@ -50,10 +54,6 @@ def signature end end - def signer_email - extensions["subjectAltName"]&.delete_prefix("email:") - end - def extensions @extensions ||= cert.extensions.each_with_object({}) do |ext, hash| hash[ext.oid] = ext.value From 193ed4b85a052ee132b9c197777bb1a916543198 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Mon, 1 Nov 2021 15:12:57 -0400 Subject: [PATCH 16/56] exclude byebug_history from git --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index b4c4028..08ef41e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ /certs/ *.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 From a37e8f15ed3c1c1c8ee1fa9323fc49d1cc6fef8f Mon Sep 17 00:00:00 2001 From: Roch Lefebvre Date: Mon, 1 Nov 2021 16:30:35 -0400 Subject: [PATCH 17/56] Retrieve root certificate using signing certificate's AIA extension (#18) * define CertChain and CertExtensions * Extract cert code from RekordEntry * remove method_missing stuff from CertExtensions * move issuing certificate retrieval into CertExtensions * move subject_alt_name into CertExtensions --- lib/rubygems/sigstore/cert_chain.rb | 41 +++++++++++++++++ lib/rubygems/sigstore/cert_extensions.rb | 36 +++++++++++++++ lib/rubygems/sigstore/gem_verifier.rb | 11 ++++- lib/rubygems/sigstore/rekord_entry.rb | 57 ++++++++---------------- 4 files changed, 105 insertions(+), 40 deletions(-) create mode 100644 lib/rubygems/sigstore/cert_chain.rb create mode 100644 lib/rubygems/sigstore/cert_extensions.rb 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/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index a960b86..5fff4ec 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -13,7 +13,7 @@ def run rekor_api = Gem::Sigstore::RekorApi.new(host: config.rekor_host) entries = rekor_api.where(data_digest: gemfile.digest) rekord_entries = entries.map { |entry| Gem::Sigstore::RekordEntry.new(entry.values.first) } - rekord = rekord_entries.find { |entry| entry.valid_signature?(gemfile.digest, gemfile.content) } + rekord = rekord_entries.find { |entry| valid_signature?(entry, gemfile) } if rekord io.puts ":noice:, signed by #{rekord.signer_email}" @@ -25,4 +25,13 @@ def run private attr_reader :gemfile, :config, :io + + def valid_signature?(rekord_entry, gemfile) + public_key = rekord_entry.signer_public_key + digest = gemfile.digest + signature = rekord_entry.signature + content = gemfile.content + + public_key.verify(digest, signature, content) + end end diff --git a/lib/rubygems/sigstore/rekord_entry.rb b/lib/rubygems/sigstore/rekord_entry.rb index 12d8ae5..b0bc69d 100644 --- a/lib/rubygems/sigstore/rekord_entry.rb +++ b/lib/rubygems/sigstore/rekord_entry.rb @@ -1,35 +1,28 @@ +require "rubygems/sigstore/cert_chain" + class Gem::Sigstore::RekordEntry def initialize(entry) @entry = entry end - def valid_signature?(digest, contents) - # { - # "apiVersion"=>"0.0.1", - # "kind"=>"rekord", - # "spec"=>{ - # "data"=>{ - # "hash"=>{ - # "algorithm"=>"sha256", - # "value"=>"230aed713b5d1cfce45228658bdb58e37502be1323f768fde0220e64233f1e32" - # } - # }, - # "signature"=>{ - # "content"=>"ngxsh3qbcWtjpZWxkdLcMA6hX61x/pmYf9LxN8MttwcJIaiwRNxiXeQWek0Y+5WiVD+fDZc42udM5OaEgBl3fjmU68NkLoe1WieTR/C+GJHlgjE69ZFLG80GwRgcP3hE4HiTYdk5UkCL8yFA2fJEgYnpLr8PhBOZoHZeLbt+9sQEXTOj6HqjSvvA6JLMSbJweXwUMP6EfjIUuEn2geKC2Hvh54tUu6sH+Hpk7EBADNDcBRL/57TX6OGJdku/Rrrkn9J5XhBaDzhZHr5vZVY86ZyB/NQYz3li2l3sbhLRQpIaUEQXdQmIP1AaJsPt27QIE6zJHMBJ2MUv1UMjYKsLGQ==", - # "format"=>"x509", - # "publicKey"=>{ - # "content"=>"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURkakNDQXYyZ0F3SUJBZ0lVQVAxSENMQnpnamtSYmp3bzFndkI3VXdoZHNFd0NnWUlLb1pJemowRUF3TXcKS2pFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUkV3RHdZRFZRUURFd2h6YVdkemRHOXlaVEFlRncweQpNVEV3TWpreE9UVTNNVE5hRncweU1URXdNamt5TURFM01USmFNQUF3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBCkE0SUJEd0F3Z2dFS0FvSUJBUUM0ZnRNREJDczJ0K0FqWFAyUjhTNkUyaTdDbHVHeTBsRC9tYmgzRFpDZXc1UmwKd2FoN1pZTlZhSFg3UW1mK1hOZmkrVldubU4rVmNRWlZjaTJHRGVuZnBYRUM0SnhDUHplQXBPSWxVY1NYTWV5cgpLSWh3L2Z1YkMrN0NvaElvUE5MRjM2RGUvTzNvTFo3MStudUFqMTIvTDl5bTlhVnRxNDEvcG1XY2ExQWJ4WWFNCjQwUHNUNi84cENGa0MyTWRxWmllWEl1bVJWWmdJMXVHVGh2eVR1dDQ4Q2tXdHlyYWFZZ3VjVk5VK3gyL21YQWUKZHZQdHRJUmVYR000bGZxam5SRVIxY3BOZ1RYbmFZRitQNW9YZ2hueXAwTWk1ek5WL2hpVXlyaUd0TDRBSmVDMApUOUljVjQyTGNCOEdCMDFsNUYzTjdTY1NUaXZjNjU4UFFDWEJyUWpiQWdNQkFBR2pnZ0ZlTUlJQldqQU9CZ05WCkhROEJBZjhFQkFNQ0I0QXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQWQKQmdOVkhRNEVGZ1FVSVl3Z1I5OURiS28yTVlTK2RjbXdBYklnakxVd0h3WURWUjBqQkJnd0ZvQVV5TVVkQUVHYQpKQ2t5VVNUckRhNUs3VW9HMCt3d2dZMEdDQ3NHQVFVRkJ3RUJCSUdBTUg0d2ZBWUlLd1lCQlFVSE1BS0djR2gwCmRIQTZMeTl3Y21sMllYUmxZMkV0WTI5dWRHVnVkQzAyTURObVpUZGxOeTB3TURBd0xUSXlNamN0WW1ZM05TMW0KTkdZMVpUZ3daREk1TlRRdWMzUnZjbUZuWlM1bmIyOW5iR1ZoY0dsekxtTnZiUzlqWVRNMllURmxPVFl5TkRKaQpPV1pqWWpFME5pOWpZUzVqY25Rd0p3WURWUjBSQVFIL0JCMHdHNEVaY205amFDNXNaV1psWW5aeVpVQnphRzl3CmFXWjVMbU52YlRBc0Jnb3JCZ0VFQVlPL01BRUJCQjVvZEhSd2N6b3ZMMmRwZEdoMVlpNWpiMjB2Ykc5bmFXNHYKYjJGMWRHZ3dDZ1lJS29aSXpqMEVBd01EWndBd1pBSXdjWlJFaWt1NVlDMGFmQjA4dzRIM1UrdEdvUTBCUzFvVQp4b1hqU0VzNUpuZk9YaDRrUWNldC90TmpvQytLdnhkWUFqQlhrT0hGMC81WTQ4RUN0SENHeTdDRWxSM1FKb1NkCjJSZVp6V3MwUmNreXl1U2JQb0FnUHlyM04rYXpkYkdDUHdzPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" - # } - # } - # } - # } - - public_key = cert.public_key - public_key.verify(digest, signature, contents) + 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 - extensions["subjectAltName"]&.delete_prefix("email:") + cert_chain.signing_cert.subject_alt_name + end + + def signer_public_key + cert_chain.signing_cert.public_key end private @@ -42,21 +35,7 @@ def cert @cert ||= begin cert = Base64.decode64(body.dig("spec", "signature", "publicKey", "content")) raise "Expecting a publicKey in #{body}" unless cert - OpenSSL::X509::Certificate.new(cert) - end - end - - def signature - @signature ||= begin - signature = Base64.decode64(body.dig("spec", "signature", "content")) - raise "Expecting a signature in #{body}" unless signature - signature - end - end - - def extensions - @extensions ||= cert.extensions.each_with_object({}) do |ext, hash| - hash[ext.oid] = ext.value + cert end end end From 714cda534599690fa92f5d9946ac388c7fc85b7f Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Mon, 1 Nov 2021 09:42:15 -0400 Subject: [PATCH 18/56] print all unique emails from valid signature entries --- lib/rubygems/sigstore/gem_verifier.rb | 29 +++++++++++++++++++++++---- lib/rubygems/sigstore/gemfile.rb | 10 ++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index 5fff4ec..63453fd 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -13,12 +13,13 @@ def run rekor_api = Gem::Sigstore::RekorApi.new(host: config.rekor_host) entries = rekor_api.where(data_digest: gemfile.digest) rekord_entries = entries.map { |entry| Gem::Sigstore::RekordEntry.new(entry.values.first) } - rekord = rekord_entries.find { |entry| valid_signature?(entry, gemfile) } + rekords = rekord_entries.select { |entry| valid_signature?(entry, gemfile) } - if rekord - io.puts ":noice:, signed by #{rekord.signer_email}" - else + if rekords.empty? io.puts "not :noice: thxkbye" + else + io.puts ":noice:" + print_signers(rekords) end end @@ -34,4 +35,24 @@ def valid_signature?(rekord_entry, gemfile) 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? + io.puts "Signed by maintainer#{maintainers.size == 1 ? '' : 's'}: #{email_list(maintainers)}" + end + + unless others.empty? + io.puts "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[...-1].join(", ") + " and #{emails.last}" + end end diff --git a/lib/rubygems/sigstore/gemfile.rb b/lib/rubygems/sigstore/gemfile.rb index 1bfc4ee..35f3aa1 100644 --- a/lib/rubygems/sigstore/gemfile.rb +++ b/lib/rubygems/sigstore/gemfile.rb @@ -33,7 +33,15 @@ def digest @digest ||= OpenSSL::Digest::SHA256.new(content) end + def package + @package ||= Gem::Package.new(path) + end + def spec - @spec ||= Gem::Specification::load(@path) + package.spec + end + + def maintainer?(email) + Array(spec.email).include?(email) end end From 83428c0d69c3d8cb2ae3a1498b082af162e96b10 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Tue, 2 Nov 2021 10:04:03 -0400 Subject: [PATCH 19/56] Clean up some of the printed messages --- lib/rubygems/commands/sign_command.rb | 8 +++++++- lib/rubygems/commands/verify_command.rb | 2 +- lib/rubygems/sigstore/gem_signer.rb | 4 ++-- lib/rubygems/sigstore/gem_verifier.rb | 4 ++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index c2316ef..1f5b8ec 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -55,6 +55,12 @@ def execute gemfile: gemfile, config: Gem::Sigstore::Config.read ).run - pp rekor_entry + pp log_entry_url(rekor_entry) + end + + private + + 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 index c96ee3d..8ad8ec9 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -32,7 +32,7 @@ def initialize def execute gem_path = get_one_gem_name - puts "verify \"#{gem_path}\"" + puts "Verifying #{gem_path}" raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(gem_path) diff --git a/lib/rubygems/sigstore/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb index 253581d..85e2ec1 100644 --- a/lib/rubygems/sigstore/gem_signer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -13,10 +13,10 @@ def run yield if block_given? - io.puts "Fulcio cert chain" + io.puts "Fulcio certificate chain" io.puts cert io.puts - io.puts "sending signiture & certificate chain to rekor." + io.puts "Sending gem digest, signature & certificate chain to transparency log." Gem::Sigstore::FileSigner.new( file: gemfile, diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index 63453fd..6576d66 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -16,7 +16,7 @@ def run rekords = rekord_entries.select { |entry| valid_signature?(entry, gemfile) } if rekords.empty? - io.puts "not :noice: thxkbye" + io.puts "No valid signatures found for digest #{gemfile.digest}" else io.puts ":noice:" print_signers(rekords) @@ -53,6 +53,6 @@ def print_signers(rekords) def email_list(emails) return emails.first if emails.size == 1 - emails[...-1].join(", ") + " and #{emails.last}" + emails[0...-1].join(", ") + " and #{emails.last}" end end From bd97622be2c06bd8732b552145a2277134e8ce44 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Thu, 18 Nov 2021 16:05:13 -0500 Subject: [PATCH 20/56] update gitignore, Rakefile and dependencies; remove rspec --- .gitignore | 2 +- .rspec | 3 --- Gemfile | 10 +++++++- Gemfile.lock | 57 +++++++++++++++++++++---------------------- Rakefile | 12 ++++++--- ruby-sigstore.gemspec | 2 +- 6 files changed, 47 insertions(+), 39 deletions(-) delete mode 100644 .rspec diff --git a/.gitignore b/.gitignore index 08ef41e..05ccabe 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ /tmp/ /vendor/ /certs/ -*.gem +/*.gem .byebug_history 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/Gemfile b/Gemfile index 9a5b0c0..d917ef2 100644 --- a/Gemfile +++ b/Gemfile @@ -13,5 +13,13 @@ group :development do gem "rubocop", "~> 0.80.1" gem "rubocop-performance", "~> 1.5.2" gem "rake", "~> 12.0" - gem "rspec", "~> 3.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 357208f..93878be 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) @@ -27,13 +27,15 @@ GEM 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) dry-configurable (0.12.1) concurrent-ruby (~> 1.0) dry-core (~> 0.5, >= 0.5.0) @@ -79,9 +81,10 @@ 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) jaro_winkler (1.5.4) json-jwt (1.13.0) @@ -92,7 +95,7 @@ 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) oa-core (0.0.3) @@ -107,7 +110,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) @@ -120,12 +123,13 @@ GEM 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 @@ -139,19 +143,6 @@ GEM rainbow (3.0.0) rake (12.3.3) rexml (3.2.5) - 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) rubocop (0.80.1) jaro_winkler (~> 1.5.1) parallel (~> 1.10) @@ -167,10 +158,12 @@ GEM 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 tzinfo (2.0.4) concurrent-ruby (~> 1.0) unicode-display_width (1.6.1) @@ -180,16 +173,21 @@ GEM 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) @@ -197,11 +195,12 @@ DEPENDENCIES 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/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/ruby-sigstore.gemspec b/ruby-sigstore.gemspec index 44c5b33..ff1f911 100644 --- a/ruby-sigstore.gemspec +++ b/ruby-sigstore.gemspec @@ -43,7 +43,7 @@ Gem::Specification.new do |spec| 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" From e5076effc9e4d08fc0d19ae729fea68f43041242 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Thu, 18 Nov 2021 16:05:50 -0500 Subject: [PATCH 21/56] add rake workflow; temporarily remove verify workflow --- .github/workflows/main.yml | 29 +++++++++++++++++++++++++++++ .github/workflows/verify.yml | 24 ------------------------ spec/ruby/sigstore_spec.rb | 23 ----------------------- spec/spec_helper.rb | 28 ---------------------------- 4 files changed, 29 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/main.yml delete mode 100644 .github/workflows/verify.yml delete mode 100644 spec/ruby/sigstore_spec.rb delete mode 100644 spec/spec_helper.rb diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..ba4ac75 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,29 @@ +name: Test + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + ruby-version: + - 2.6 + - 2.7 + - 3.0 + + 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 the default task + run: bundle exec rake 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/spec/ruby/sigstore_spec.rb b/spec/ruby/sigstore_spec.rb deleted file mode 100644 index da681da..0000000 --- a/spec/ruby/sigstore_spec.rb +++ /dev/null @@ -1,23 +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. - -RSpec.describe Ruby::Sigstore do - it "has a version number" do - expect(Ruby::Sigstore::VERSION).not_to be nil - end - - it "does something useful" do - expect(false).to eq(true) - end -end 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 From cd278f30dc999c8e538f860543a42dd3bec5cfb6 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Thu, 18 Nov 2021 16:11:39 -0500 Subject: [PATCH 22/56] Use Gem::UserInteration instead of put $stdout --- lib/rubygems/commands/sign_command.rb | 3 ++- lib/rubygems/commands/verify_command.rb | 2 +- lib/rubygems/sigstore/gem_signer.rb | 15 ++++++++------- lib/rubygems/sigstore/gem_verifier.rb | 15 ++++++++------- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index 1f5b8ec..65a3fea 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -21,6 +21,7 @@ module Sigstore require "rubygems/sigstore/config" require "rubygems/sigstore/crypto" require "rubygems/sigstore/fulcio_api" +require "rubygems/sigstore/rekor_api" require "rubygems/sigstore/openid" require "rubygems/sigstore/gemfile" require "rubygems/sigstore/cert_provider" @@ -55,7 +56,7 @@ def execute gemfile: gemfile, config: Gem::Sigstore::Config.read ).run - pp log_entry_url(rekor_entry) + say log_entry_url(rekor_entry) end private diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb index 8ad8ec9..0e69c36 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -32,7 +32,7 @@ def initialize def execute gem_path = get_one_gem_name - puts "Verifying #{gem_path}" + say "Verifying #{gem_path}" raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(gem_path) diff --git a/lib/rubygems/sigstore/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb index 85e2ec1..5444bed 100644 --- a/lib/rubygems/sigstore/gem_signer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -1,10 +1,11 @@ class Gem::Sigstore::GemSigner + include Gem::UserInteraction + Data = Struct.new(:digest, :signature, :raw) - def initialize(gemfile:, config:, io: $stdout) + def initialize(gemfile:, config:) @gemfile = gemfile @config = config - @io = io end def run @@ -13,10 +14,10 @@ def run yield if block_given? - io.puts "Fulcio certificate chain" - io.puts cert - io.puts - io.puts "Sending gem digest, signature & certificate chain to transparency log." + say "Fulcio certificate chain" + say cert + say + say "Sending gem digest, signature & certificate chain to transparency log." Gem::Sigstore::FileSigner.new( file: gemfile, @@ -28,5 +29,5 @@ def run private - attr_reader :gemfile, :config, :io + attr_reader :gemfile, :config end diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index 6576d66..7f2e7ae 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -1,12 +1,13 @@ require "rubygems/sigstore/rekord_entry" class Gem::Sigstore::GemVerifier + include Gem::UserInteraction + Data = Struct.new(:digest, :signature, :raw) - def initialize(gemfile:, config:, io: $stdout) + def initialize(gemfile:, config:) @gemfile = gemfile @config = config - @io = io end def run @@ -16,16 +17,16 @@ def run rekords = rekord_entries.select { |entry| valid_signature?(entry, gemfile) } if rekords.empty? - io.puts "No valid signatures found for digest #{gemfile.digest}" + say "No valid signatures found for digest #{gemfile.digest}" else - io.puts ":noice:" + say ":noice:" print_signers(rekords) end end private - attr_reader :gemfile, :config, :io + attr_reader :gemfile, :config def valid_signature?(rekord_entry, gemfile) public_key = rekord_entry.signer_public_key @@ -42,11 +43,11 @@ def print_signers(rekords) end unless maintainers.empty? - io.puts "Signed by maintainer#{maintainers.size == 1 ? '' : 's'}: #{email_list(maintainers)}" + say "Signed by maintainer#{maintainers.size == 1 ? '' : 's'}: #{email_list(maintainers)}" end unless others.empty? - io.puts "Signed by non-maintainer#{others.size == 1 ? '' : 's'}: #{email_list(others)}" + say "Signed by non-maintainer#{others.size == 1 ? '' : 's'}: #{email_list(others)}" end end From dc81dd6231f0c1e32ab9fdd5ce5d63fd11727f1a Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Thu, 18 Nov 2021 16:13:18 -0500 Subject: [PATCH 23/56] stub requests with webmock; add integration test for `gem sign` --- lib/rubygems/sigstore/openid.rb | 51 ++++++----- test/{ => fixtures/gems}/hello-world.gem | Bin test/helper.rb | 50 ++++++++++ test/support/fulcio_helper.rb | 81 ++++++++++++++++ test/support/rekor_helper.rb | 62 +++++++++++++ test/support/sigstore_auth_helper.rb | 112 +++++++++++++++++++++++ test/support/url_helper.rb | 22 +++++ test/support/webmock_helper.rb | 9 ++ test/test_sign_command.rb | 44 +++++++++ 9 files changed, 409 insertions(+), 22 deletions(-) rename test/{ => fixtures/gems}/hello-world.gem (100%) create mode 100644 test/helper.rb create mode 100644 test/support/fulcio_helper.rb create mode 100644 test/support/rekor_helper.rb create mode 100644 test/support/sigstore_auth_helper.rb create mode 100644 test/support/url_helper.rb create mode 100644 test/support/webmock_helper.rb create mode 100644 test/test_sign_command.rb diff --git a/lib/rubygems/sigstore/openid.rb b/lib/rubygems/sigstore/openid.rb index d534eec..0c5afae 100644 --- a/lib/rubygems/sigstore/openid.rb +++ b/lib/rubygems/sigstore/openid.rb @@ -28,6 +28,8 @@ module Sigstore require "openid_connect" class Gem::Sigstore::OpenID + include Gem::UserInteraction + def initialize(priv_key) @priv_key = priv_key end @@ -43,7 +45,7 @@ def get_token() pkce = generate_pkce # If development env, used a fixed port - if config.development == true + if config.development == true || ENV["SIGSTORE_TEST"] server = TCPServer.new 5678 server_addr = "5678" else @@ -91,31 +93,36 @@ def get_token() 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: "http://localhost:" + server_addr, - secret: config.oidc_secret, - token_endpoint: oidc_discovery.token_endpoint, - ) + 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], - ) + 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 + 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 @@ -152,8 +159,8 @@ def generate_pkce() 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 + rescue JSON::JWS::VerificationFailed => e + abort 'JWT Verification Failed: ' + e.to_s else #success token = JSON.parse(decoded_access_token.to_json) 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/helper.rb b/test/helper.rb new file mode 100644 index 0000000..b95c05b --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,50 @@ +require 'test/unit' +require 'webmock/test_unit' +require 'rubygems/mock_gem_ui' +require 'json/jwt' + +require_relative 'support/webmock_helper' +require_relative 'support/url_helper' +require_relative 'support/sigstore_auth_helper' +require_relative 'support/fulcio_helper' +require_relative '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\+\/=\\]/ + + 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..be51dd5 --- /dev/null +++ b/test/support/fulcio_helper.rb @@ -0,0 +1,81 @@ +module FulcioHelper + include UrlHelper + include SigstoreAuthHelper + + FULCIO_BASE_URL = 'https://fulcio.sigstore.dev/' + + 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)), + }.merge(returning) + end + end + + def build_fulcio_cert_chain(public_signing_key) + ef = OpenSSL::X509::ExtensionFactory.new + + root_key = OpenSSL::PKey::RSA.new(1024) + root_subject = "/O=sigstore.dev/CN=sigstore" + + root_cert = OpenSSL::X509::Certificate.new + root_cert.subject = root_cert.issuer = OpenSSL::X509::Name.parse(root_subject) + root_cert.not_before = Time.now + root_cert.not_after = Time.now + 10.years + root_cert.public_key = root_key.public_key + root_cert.serial = 0x0 + root_cert.version = 2 + root_cert.add_extension(ef.create_extension("basicConstraints","CA:FALSE",true)) + root_cert.add_extension(ef.create_extension("keyUsage","keyCertSign, cRLSign", true)) + + root_cert.sign(root_key, OpenSSL::Digest.new("SHA256")) + + leaf_cert = OpenSSL::X509::Certificate.new + leaf_cert.issuer = OpenSSL::X509::Name.parse(root_subject) + leaf_cert.not_before = Time.now + leaf_cert.not_after = Time.now + 10.minutes + leaf_cert.public_key = public_signing_key + leaf_cert.serial = 0x0 + leaf_cert.version = 2 + leaf_cert.add_extension(ef.create_extension("basicConstraints","CA:TRUE",true)) + leaf_cert.add_extension(ef.create_extension("keyUsage","digitalSignature", true)) + leaf_cert.add_extension(ef.create_extension("extendedKeyUsage","codeSigning", true)) + # TODO leaf_cert.add_extension(ef.create_extension("authorityInfoAccess","CA Issuers - URI:http://privateca-content-603fe7e7-0000-2227-bf75-f4f5e80d2954.storage.googleapis.com/ca36a1e96242b9fcb146/ca.crt", true)) + leaf_cert.add_extension(ef.create_extension("subjectAltName","email:someone@example.org", true)) + leaf_cert.sign(root_key, OpenSSL::Digest.new("SHA256")) + + [root_cert, leaf_cert].map(&:to_pem).join + 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..c2e3bf7 --- /dev/null +++ b/test/support/rekor_helper.rb @@ -0,0 +1,62 @@ +module RekorHelper + include UrlHelper + + REKOR_BASE_URL = 'https://rekor.sigstore.dev/' + + 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_log_entry(digest, body: {}, returning: {}) + stub_request(:post, rekor_log_entries_url) + .with( + headers: { + content_type: 'application/json', + }, + body: hash_including({ + kind: "rekord", + 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({ + content: BASE64_ENCODED_PATTERN, + hash: hash_including({ + algorithm: "sha256", + value: digest + }) + }) + }) + }) + ) + .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 +end diff --git a/test/support/sigstore_auth_helper.rb b/test/support/sigstore_auth_helper.rb new file mode 100644 index 0000000..3bfdb43 --- /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/' + + 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..cf9bab7 --- /dev/null +++ b/test/support/url_helper.rb @@ -0,0 +1,22 @@ +module UrlHelper + BASE64_ENCODED_PATTERN = /[a-zA-Z0-9\+\/=\\]/ + + 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_sign_command.rb b/test/test_sign_command.rb new file mode 100644 index 0000000..ee5ac41 --- /dev/null +++ b/test/test_sign_command.rb @@ -0,0 +1,44 @@ +require 'helper' +require "rubygems/commands/sign_command" + +class TestSignCommand < Gem::TestCase + include SigstoreAuthHelper + include FulcioHelper + include RekorHelper + + def setup + super + + @gem_path = gem_path("hello-world.gem") + @cmd = Gem::Commands::SignCommand.new + + stub_sigstore_auth_get_openid_config + stub_sigstore_auth_create_token + stub_sigstore_auth_get_keys + stub_fulcio_create_signing_cert + stub_rekor_create_log_entry(gem_digest(@gem_path)) + end + + def test_sign + @cmd.options[:args] = [@gem_path] + + 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 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 +end From a90fc2ae0b97de2a95fe5e88d1f9b4407f59e4f4 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Mon, 22 Nov 2021 11:22:06 -0500 Subject: [PATCH 24/56] remore circular `require`; declare Gem::Sigstore module in Gemfile --- lib/rubygems/commands/verify_command.rb | 1 - lib/rubygems/sigstore/gemfile.rb | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb index 0e69c36..8de9b2c 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -17,7 +17,6 @@ module Sigstore end end -require "rubygems/commands/verify_command" require "rubygems/sigstore/config" require "rubygems/sigstore/gemfile" require "rubygems/sigstore/gem_verifier" diff --git a/lib/rubygems/sigstore/gemfile.rb b/lib/rubygems/sigstore/gemfile.rb index 35f3aa1..5417615 100644 --- a/lib/rubygems/sigstore/gemfile.rb +++ b/lib/rubygems/sigstore/gemfile.rb @@ -3,6 +3,11 @@ require 'digest' require 'fileutils' +module Gem + module Sigstore + end +end + class Gem::Sigstore::Gemfile class << self def find_gemspec(glob = "*.gemspec") @@ -42,6 +47,10 @@ def spec end def maintainer?(email) - Array(spec.email).include?(email) + maintainers.include?(email) + end + + def maintainers + Array(spec.email) end end From f5c4c14ceeb782007b3b79aaaa50f558e81256d2 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Mon, 22 Nov 2021 11:24:01 -0500 Subject: [PATCH 25/56] stub log and CA cert requests; add `gem verify` integration suite --- lib/rubygems/sigstore/gem_verifier.rb | 2 +- test/support/fulcio_helper.rb | 12 +-- test/support/rekor_helper.rb | 142 ++++++++++++++++++++++---- test/test_sign_command.rb | 2 +- test/test_verify_command.rb | 30 ++++++ 5 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 test/test_verify_command.rb diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index 7f2e7ae..e9003b0 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -12,7 +12,7 @@ def initialize(gemfile:, config:) def run rekor_api = Gem::Sigstore::RekorApi.new(host: config.rekor_host) - entries = rekor_api.where(data_digest: gemfile.digest) + entries = rekor_api.where(data_digest: gemfile.digest) # TODO: we should only pass on the entries where body.kind == "rekord" rekord_entries = entries.map { |entry| Gem::Sigstore::RekordEntry.new(entry.values.first) } rekords = rekord_entries.select { |entry| valid_signature?(entry, gemfile) } diff --git a/test/support/fulcio_helper.rb b/test/support/fulcio_helper.rb index be51dd5..63c87a2 100644 --- a/test/support/fulcio_helper.rb +++ b/test/support/fulcio_helper.rb @@ -34,12 +34,12 @@ def stub_fulcio_create_signing_cert(headers: {}, body: {}, returning: {}) { status: 201, headers: {}, - body: build_fulcio_cert_chain(signing_cert_key(request)), + body: build_fulcio_cert_chain(signing_cert_key(request)).join, }.merge(returning) end end - def build_fulcio_cert_chain(public_signing_key) + def build_fulcio_cert_chain(public_signing_key, not_before: Time.now) ef = OpenSSL::X509::ExtensionFactory.new root_key = OpenSSL::PKey::RSA.new(1024) @@ -59,19 +59,19 @@ def build_fulcio_cert_chain(public_signing_key) leaf_cert = OpenSSL::X509::Certificate.new leaf_cert.issuer = OpenSSL::X509::Name.parse(root_subject) - leaf_cert.not_before = Time.now - leaf_cert.not_after = Time.now + 10.minutes + leaf_cert.not_before = not_before + leaf_cert.not_after = not_before + 10.minutes leaf_cert.public_key = public_signing_key leaf_cert.serial = 0x0 leaf_cert.version = 2 leaf_cert.add_extension(ef.create_extension("basicConstraints","CA:TRUE",true)) leaf_cert.add_extension(ef.create_extension("keyUsage","digitalSignature", true)) leaf_cert.add_extension(ef.create_extension("extendedKeyUsage","codeSigning", true)) - # TODO leaf_cert.add_extension(ef.create_extension("authorityInfoAccess","CA Issuers - URI:http://privateca-content-603fe7e7-0000-2227-bf75-f4f5e80d2954.storage.googleapis.com/ca36a1e96242b9fcb146/ca.crt", true)) + leaf_cert.add_extension(ef.create_extension("authorityInfoAccess","caIssuers;URI:http://some-ca-authority.org/ca.crt", true)) leaf_cert.add_extension(ef.create_extension("subjectAltName","email:someone@example.org", true)) leaf_cert.sign(root_key, OpenSSL::Digest.new("SHA256")) - [root_cert, leaf_cert].map(&:to_pem).join + [root_cert, leaf_cert].map(&:to_pem) end def signing_cert_key(request) diff --git a/test/support/rekor_helper.rb b/test/support/rekor_helper.rb index c2e3bf7..f7b2bfa 100644 --- a/test/support/rekor_helper.rb +++ b/test/support/rekor_helper.rb @@ -1,7 +1,12 @@ +require 'rubygems/sigstore/gemfile' +require 'rubygems/sigstore/crypto' + module RekorHelper include UrlHelper + include FulcioHelper REKOR_BASE_URL = 'https://rekor.sigstore.dev/' + REKOR_FAKE_CA_BASE_URL = 'http://some-ca-authority.org/' def rekor_api_url(*path, **kwargs) url_regex(REKOR_BASE_URL, 'api', 'v1', path, **kwargs) @@ -11,32 +16,36 @@ def rekor_log_entries_url rekor_api_url('log', 'entries') end - def stub_rekor_create_log_entry(digest, body: {}, returning: {}) + 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: "rekord", - apiVersion: "0.0.1", - spec: hash_including({ - signature: hash_including({ - format: "x509", - content: BASE64_ENCODED_PATTERN, - publicKey: hash_including({ + body: hash_including( + { + kind: "rekord", + apiVersion: "0.0.1", + spec: hash_including({ + signature: hash_including({ + format: "x509", content: BASE64_ENCODED_PATTERN, - }) - }), - data: hash_including({ - content: BASE64_ENCODED_PATTERN, - hash: hash_including({ - algorithm: "sha256", - value: digest + publicKey: hash_including({ + content: BASE64_ENCODED_PATTERN, + }) + }), + data: hash_including({ + content: BASE64_ENCODED_PATTERN, + 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] || {}), @@ -59,4 +68,101 @@ def build_rekord_entry(options) } }.deep_merge(options) end + + def rekor_index_retrieve_url(uuid) + rekor_api_url('index', 'retrieve') + end + + def stub_rekor_search_index_by_digest(gem_path: @gem_path, uuid: "dummy_entry_uuid", body: {}, returning: nil) + gem = Gem::Sigstore::Gemfile.new(gem_path) + + stub_request(:post, rekor_index_retrieve_url(uuid)) + .with( + headers: { + content_type: 'application/json', + }, + body: { + hash: "sha256:#{gem.digest}", + }, + ) + .to_return_json(returning || ["dummy_entry_uuid"]) + end + + def rekor_log_entry_url(uuid) + rekor_api_url('log', 'entries', uuid) + end + + def stub_rekor_get_rekord_by_uuid( + gem_path: @gem_path, + uuid: "dummy_entry_uuid", + log_entry_options: {}, + rekord_options: {} + ) + stub_request(:get, rekor_log_entry_url(uuid)) + .to_return_json( + build_rekord_log_entry( + uuid: uuid, + log_entry_options: log_entry_options, + rekord_options: rekord_options, + gem_path: gem_path + ) + ) + end + + def build_rekord_log_entry(uuid:, log_entry_options:, rekord_options:, gem_path:) + { + uuid => { + body: Base64.encode64(build_rekord(rekord_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, gem_path) + gem = Gem::Sigstore::Gemfile.new(gem_path) + pkey = Gem::Sigstore::PKey.new + cert_chain = build_fulcio_cert_chain(pkey.public_key) + 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(REKOR_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/test_sign_command.rb b/test/test_sign_command.rb index ee5ac41..198185d 100644 --- a/test/test_sign_command.rb +++ b/test/test_sign_command.rb @@ -16,7 +16,7 @@ def setup stub_sigstore_auth_create_token stub_sigstore_auth_get_keys stub_fulcio_create_signing_cert - stub_rekor_create_log_entry(gem_digest(@gem_path)) + stub_rekor_create_rekord end def test_sign diff --git a/test/test_verify_command.rb b/test/test_verify_command.rb new file mode 100644 index 0000000..d4c8804 --- /dev/null +++ b/test/test_verify_command.rb @@ -0,0 +1,30 @@ +require 'helper' +require "rubygems/commands/verify_command" + +class TestVerifyCommand < Gem::TestCase + include RekorHelper + + def setup + super + + @gem_path = gem_path("hello-world.gem") + @cmd = Gem::Commands::VerifyCommand.new + + stub_rekor_search_index_by_digest + stub_rekor_get_rekord_by_uuid + end + + def test_verify + @cmd.options[:args] = [@gem_path] + + 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 +end From cb9b5b1a2a16b5469431dbc14c8139eb5c3115b3 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Fri, 19 Nov 2021 13:29:49 -0500 Subject: [PATCH 26/56] Partial refactor of the rekor client code --- lib/rubygems/commands/sign_command.rb | 2 +- lib/rubygems/sigstore/gem_signer.rb | 2 +- lib/rubygems/sigstore/gem_verifier.rb | 21 ++++++------ lib/rubygems/sigstore/rekor.rb | 6 ++++ .../sigstore/{rekor_api.rb => rekor/api.rb} | 4 +-- lib/rubygems/sigstore/rekor/log_entry.rb | 32 +++++++++++++++++++ .../{rekord_entry.rb => rekor/rekord.rb} | 9 +----- lib/rubygems/sigstore/sign_extend.rb | 2 +- 8 files changed, 55 insertions(+), 23 deletions(-) create mode 100644 lib/rubygems/sigstore/rekor.rb rename lib/rubygems/sigstore/{rekor_api.rb => rekor/api.rb} (94%) create mode 100644 lib/rubygems/sigstore/rekor/log_entry.rb rename lib/rubygems/sigstore/{rekord_entry.rb => rekor/rekord.rb} (80%) diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index 65a3fea..9096d23 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -21,7 +21,7 @@ module Sigstore require "rubygems/sigstore/config" require "rubygems/sigstore/crypto" require "rubygems/sigstore/fulcio_api" -require "rubygems/sigstore/rekor_api" +require "rubygems/sigstore/rekor" require "rubygems/sigstore/openid" require "rubygems/sigstore/gemfile" require "rubygems/sigstore/cert_provider" diff --git a/lib/rubygems/sigstore/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb index 5444bed..2056b04 100644 --- a/lib/rubygems/sigstore/gem_signer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -22,7 +22,7 @@ def run Gem::Sigstore::FileSigner.new( file: gemfile, pkey: pkey, - transparency_log: Gem::Sigstore::RekorApi.new(host: config.rekor_host), + transparency_log: Gem::Sigstore::Rekor::Api.new(host: config.rekor_host), cert: cert ).run end diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index e9003b0..1ed6ed3 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -1,4 +1,4 @@ -require "rubygems/sigstore/rekord_entry" +require "rubygems/sigstore/rekor" class Gem::Sigstore::GemVerifier include Gem::UserInteraction @@ -11,16 +11,17 @@ def initialize(gemfile:, config:) end def run - rekor_api = Gem::Sigstore::RekorApi.new(host: config.rekor_host) - entries = rekor_api.where(data_digest: gemfile.digest) # TODO: we should only pass on the entries where body.kind == "rekord" - rekord_entries = entries.map { |entry| Gem::Sigstore::RekordEntry.new(entry.values.first) } - rekords = rekord_entries.select { |entry| valid_signature?(entry, gemfile) } + 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| entry.kind == :rekord } - if rekords.empty? + 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(rekords) + print_signers(valid_signature_rekords) end end @@ -28,10 +29,10 @@ def run attr_reader :gemfile, :config - def valid_signature?(rekord_entry, gemfile) - public_key = rekord_entry.signer_public_key + def valid_signature?(rekord, gemfile) + public_key = rekord.signer_public_key digest = gemfile.digest - signature = rekord_entry.signature + signature = rekord.signature content = gemfile.content public_key.verify(digest, signature, content) diff --git a/lib/rubygems/sigstore/rekor.rb b/lib/rubygems/sigstore/rekor.rb new file mode 100644 index 0000000..7c666fa --- /dev/null +++ b/lib/rubygems/sigstore/rekor.rb @@ -0,0 +1,6 @@ +module Gem::Sigstore::Rekor +end + +require "rubygems/sigstore/rekor/api" +require "rubygems/sigstore/rekor/log_entry" +require "rubygems/sigstore/rekor/rekord" diff --git a/lib/rubygems/sigstore/rekor_api.rb b/lib/rubygems/sigstore/rekor/api.rb similarity index 94% rename from lib/rubygems/sigstore/rekor_api.rb rename to lib/rubygems/sigstore/rekor/api.rb index 0aa856d..64961fc 100644 --- a/lib/rubygems/sigstore/rekor_api.rb +++ b/lib/rubygems/sigstore/rekor/api.rb @@ -1,7 +1,7 @@ require "faraday_middleware" require "openssl" -class Gem::Sigstore::RekorApi +class Gem::Sigstore::Rekor::Api def initialize(host:) @host = host end @@ -47,7 +47,7 @@ def where(data_digest:) raise "Unexpected response from GET api/v1/log/entries/#{uuid}:\n #{entry_response}" end - entry_response.body + Gem::Sigstore::Rekor::LogEntry.from(entry_response.body) end 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..b727cb8 --- /dev/null +++ b/lib/rubygems/sigstore/rekor/log_entry.rb @@ -0,0 +1,32 @@ +class Gem::Sigstore::Rekor::LogEntry + + def self.from(entry_response) + uuid = entry_response.keys.first + entry = entry_response[uuid] + body = encoded_body_to_hash(entry["body"]) + + case body["kind"] + 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/rekord_entry.rb b/lib/rubygems/sigstore/rekor/rekord.rb similarity index 80% rename from lib/rubygems/sigstore/rekord_entry.rb rename to lib/rubygems/sigstore/rekor/rekord.rb index b0bc69d..b956d0c 100644 --- a/lib/rubygems/sigstore/rekord_entry.rb +++ b/lib/rubygems/sigstore/rekor/rekord.rb @@ -1,9 +1,6 @@ require "rubygems/sigstore/cert_chain" -class Gem::Sigstore::RekordEntry - def initialize(entry) - @entry = entry - end +class Gem::Sigstore::Rekor::Rekord < Gem::Sigstore::Rekor::LogEntry def signature @signature ||= begin @@ -27,10 +24,6 @@ def signer_public_key private - def body - @body ||= JSON.parse(Base64.decode64(@entry["body"])) - end - def cert @cert ||= begin cert = Base64.decode64(body.dig("spec", "signature", "publicKey", "content")) diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/sigstore/sign_extend.rb index 96b3147..d9e4509 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/sigstore/sign_extend.rb @@ -26,7 +26,7 @@ module Sigstore require 'rubygems/sigstore/options' require "rubygems/sigstore/crypto" require "rubygems/sigstore/fulcio_api" -require "rubygems/sigstore/rekor_api" +require "rubygems/sigstore/rekor" require "rubygems/sigstore/openid" require "rubygems/sigstore/gemfile" require "rubygems/sigstore/cert_provider" From 980d49e1c8f0f4eaafa17e1913e44b3e4a8c41be Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Mon, 22 Nov 2021 16:19:02 -0500 Subject: [PATCH 27/56] add missing require statements --- lib/rubygems/sigstore/gem_signer.rb | 6 ++++++ lib/rubygems/sigstore/gem_verifier.rb | 1 + lib/rubygems/sigstore/rekor/api.rb | 1 + lib/rubygems/sigstore/rekor/rekord.rb | 1 + 4 files changed, 9 insertions(+) diff --git a/lib/rubygems/sigstore/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb index 2056b04..d8364c2 100644 --- a/lib/rubygems/sigstore/gem_signer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -1,3 +1,9 @@ +require "rubygems/user_interaction" +require "rubygems/sigstore/crypto" +require "rubygems/sigstore/cert_provider" +require "rubygems/sigstore/file_signer" +require "rubygems/sigstore/rekor" + class Gem::Sigstore::GemSigner include Gem::UserInteraction diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index 1ed6ed3..8bfbf39 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -1,3 +1,4 @@ +require "rubygems/user_interaction" require "rubygems/sigstore/rekor" class Gem::Sigstore::GemVerifier diff --git a/lib/rubygems/sigstore/rekor/api.rb b/lib/rubygems/sigstore/rekor/api.rb index 64961fc..f288334 100644 --- a/lib/rubygems/sigstore/rekor/api.rb +++ b/lib/rubygems/sigstore/rekor/api.rb @@ -1,5 +1,6 @@ require "faraday_middleware" require "openssl" +require "rubygems/sigstore/rekor/log_entry" class Gem::Sigstore::Rekor::Api def initialize(host:) diff --git a/lib/rubygems/sigstore/rekor/rekord.rb b/lib/rubygems/sigstore/rekor/rekord.rb index b956d0c..b7d9677 100644 --- a/lib/rubygems/sigstore/rekor/rekord.rb +++ b/lib/rubygems/sigstore/rekor/rekord.rb @@ -1,4 +1,5 @@ require "rubygems/sigstore/cert_chain" +require "rubygems/sigstore/rekor/log_entry" class Gem::Sigstore::Rekor::Rekord < Gem::Sigstore::Rekor::LogEntry From 5538614ec5cc7ba6e98adb5d8f34d18039b9d80e Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Mon, 22 Nov 2021 13:48:06 -0500 Subject: [PATCH 28/56] add sigstore module file; clean up require statements --- lib/rubygems/commands/sign_command.rb | 21 ++++--------------- .../{sigstore => commands}/sign_extend.rb | 16 +------------- lib/rubygems/commands/verify_command.rb | 10 ++------- .../{sigstore => commands}/verify_extend.rb | 3 +-- lib/rubygems/sigstore.rb | 17 +++++++++++++++ lib/rubygems/sigstore/config.rb | 5 ----- lib/rubygems/sigstore/crypto.rb | 5 ----- lib/rubygems/sigstore/gemfile.rb | 5 ----- lib/rubygems/sigstore/openid.rb | 9 ++------ lib/rubygems_plugin.rb | 7 +++++-- test/helper.rb | 12 ++++++----- 11 files changed, 39 insertions(+), 71 deletions(-) rename lib/rubygems/{sigstore => commands}/sign_extend.rb (78%) rename lib/rubygems/{sigstore => commands}/verify_extend.rb (94%) create mode 100644 lib/rubygems/sigstore.rb diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index 9096d23..eceb327 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -12,26 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -module Gem - module Sigstore - end -end - require 'rubygems/command' -require "rubygems/sigstore/config" -require "rubygems/sigstore/crypto" -require "rubygems/sigstore/fulcio_api" -require "rubygems/sigstore/rekor" -require "rubygems/sigstore/openid" -require "rubygems/sigstore/gemfile" -require "rubygems/sigstore/cert_provider" -require "rubygems/sigstore/file_signer" -require "rubygems/sigstore/gem_signer" +require 'rubygems/sigstore' require 'json/jwt' -require "launchy" -require "openid_connect" -require "socket" +require 'launchy' +require 'openid_connect' +require 'socket' class Gem::Commands::SignCommand < Gem::Command def initialize diff --git a/lib/rubygems/sigstore/sign_extend.rb b/lib/rubygems/commands/sign_extend.rb similarity index 78% rename from lib/rubygems/sigstore/sign_extend.rb rename to lib/rubygems/commands/sign_extend.rb index d9e4509..54419b5 100644 --- a/lib/rubygems/sigstore/sign_extend.rb +++ b/lib/rubygems/commands/sign_extend.rb @@ -12,26 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -module Gem - module Sigstore - end -end - require 'digest' require 'fileutils' require 'openssl' require 'rubygems/package' require 'rubygems/command_manager' -require "rubygems/sigstore/config" -require 'rubygems/sigstore/options' -require "rubygems/sigstore/crypto" -require "rubygems/sigstore/fulcio_api" -require "rubygems/sigstore/rekor" -require "rubygems/sigstore/openid" -require "rubygems/sigstore/gemfile" -require "rubygems/sigstore/cert_provider" -require "rubygems/sigstore/file_signer" -require "rubygems/sigstore/gem_signer" +require 'rubygems/sigstore' Gem::CommandManager.instance.register_command :sign diff --git a/lib/rubygems/commands/verify_command.rb b/lib/rubygems/commands/verify_command.rb index 8de9b2c..0cac168 100644 --- a/lib/rubygems/commands/verify_command.rb +++ b/lib/rubygems/commands/verify_command.rb @@ -12,14 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -module Gem - module Sigstore - end -end - -require "rubygems/sigstore/config" -require "rubygems/sigstore/gemfile" -require "rubygems/sigstore/gem_verifier" +require 'rubygems/command' +require 'rubygems/sigstore' class Gem::Commands::VerifyCommand < Gem::Command def initialize diff --git a/lib/rubygems/sigstore/verify_extend.rb b/lib/rubygems/commands/verify_extend.rb similarity index 94% rename from lib/rubygems/sigstore/verify_extend.rb rename to lib/rubygems/commands/verify_extend.rb index 8bc5ee5..d956c34 100644 --- a/lib/rubygems/sigstore/verify_extend.rb +++ b/lib/rubygems/commands/verify_extend.rb @@ -13,8 +13,7 @@ # limitations under the License. require 'rubygems/command_manager' -require "rubygems/sigstore/config" -require 'rubygems/sigstore/options' +require 'rubygems/sigstore' Gem::CommandManager.instance.register_command :verify diff --git a/lib/rubygems/sigstore.rb b/lib/rubygems/sigstore.rb new file mode 100644 index 0000000..fe6a8ac --- /dev/null +++ b/lib/rubygems/sigstore.rb @@ -0,0 +1,17 @@ +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/crypto' +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/version' diff --git a/lib/rubygems/sigstore/config.rb b/lib/rubygems/sigstore/config.rb index 6d06084..d3f25a0 100644 --- a/lib/rubygems/sigstore/config.rb +++ b/lib/rubygems/sigstore/config.rb @@ -14,11 +14,6 @@ require 'config' -module Gem - module Sigstore - end -end - class Gem::Sigstore::Config class << self def read diff --git a/lib/rubygems/sigstore/crypto.rb b/lib/rubygems/sigstore/crypto.rb index bd36ac8..f0b0934 100644 --- a/lib/rubygems/sigstore/crypto.rb +++ b/lib/rubygems/sigstore/crypto.rb @@ -12,11 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -module Gem - module Sigstore - end -end - require 'base64' require 'openssl' diff --git a/lib/rubygems/sigstore/gemfile.rb b/lib/rubygems/sigstore/gemfile.rb index 5417615..50e6775 100644 --- a/lib/rubygems/sigstore/gemfile.rb +++ b/lib/rubygems/sigstore/gemfile.rb @@ -3,11 +3,6 @@ require 'digest' require 'fileutils' -module Gem - module Sigstore - end -end - class Gem::Sigstore::Gemfile class << self def find_gemspec(glob = "*.gemspec") diff --git a/lib/rubygems/sigstore/openid.rb b/lib/rubygems/sigstore/openid.rb index 0c5afae..df2191b 100644 --- a/lib/rubygems/sigstore/openid.rb +++ b/lib/rubygems/sigstore/openid.rb @@ -12,13 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -module Gem - module Sigstore - end -end - -require "rubygems/sigstore/config" -require "rubygems/sigstore/crypto" +require 'rubygems/sigstore/config' +require 'rubygems/sigstore/crypto' require 'base64' require 'cgi' diff --git a/lib/rubygems_plugin.rb b/lib/rubygems_plugin.rb index 1356b99..1eff9f6 100644 --- a/lib/rubygems_plugin.rb +++ b/lib/rubygems_plugin.rb @@ -13,8 +13,11 @@ # limitations under the License. require 'rubygems/command_manager' -require 'rubygems/sigstore/sign_extend' -require 'rubygems/sigstore/verify_extend' +require 'rubygems/sigstore' +require 'rubygems/commands/sign_command' +require 'rubygems/commands/sign_extend' +require 'rubygems/commands/verify_command' +require 'rubygems/commands/verify_extend' Gem::CommandManager.instance.register_command :sign Gem::CommandManager.instance.register_command :verify diff --git a/test/helper.rb b/test/helper.rb index b95c05b..81242d7 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -3,11 +3,13 @@ require 'rubygems/mock_gem_ui' require 'json/jwt' -require_relative 'support/webmock_helper' -require_relative 'support/url_helper' -require_relative 'support/sigstore_auth_helper' -require_relative 'support/fulcio_helper' -require_relative 'support/rekor_helper' +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) From 4d63839f2b4f68207d9fa460a14941d291ce5121 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Tue, 23 Nov 2021 09:32:45 -0500 Subject: [PATCH 29/56] rename crypto.rb to pkey.rb --- lib/rubygems/sigstore.rb | 2 +- lib/rubygems/sigstore/gem_signer.rb | 2 +- lib/rubygems/sigstore/openid.rb | 2 +- lib/rubygems/sigstore/{crypto.rb => pkey.rb} | 16 ---------------- test/support/rekor_helper.rb | 2 +- 5 files changed, 4 insertions(+), 20 deletions(-) rename lib/rubygems/sigstore/{crypto.rb => pkey.rb} (67%) diff --git a/lib/rubygems/sigstore.rb b/lib/rubygems/sigstore.rb index fe6a8ac..43dfdd3 100644 --- a/lib/rubygems/sigstore.rb +++ b/lib/rubygems/sigstore.rb @@ -5,7 +5,7 @@ module Gem::Sigstore require 'rubygems/sigstore/cert_extensions' require 'rubygems/sigstore/cert_provider' require 'rubygems/sigstore/config' -require 'rubygems/sigstore/crypto' +require 'rubygems/sigstore/pkey' require 'rubygems/sigstore/file_signer' require 'rubygems/sigstore/fulcio_api' require 'rubygems/sigstore/gem_signer' diff --git a/lib/rubygems/sigstore/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb index d8364c2..6a7c311 100644 --- a/lib/rubygems/sigstore/gem_signer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -1,5 +1,5 @@ require "rubygems/user_interaction" -require "rubygems/sigstore/crypto" +require "rubygems/sigstore/pkey" require "rubygems/sigstore/cert_provider" require "rubygems/sigstore/file_signer" require "rubygems/sigstore/rekor" diff --git a/lib/rubygems/sigstore/openid.rb b/lib/rubygems/sigstore/openid.rb index df2191b..020463b 100644 --- a/lib/rubygems/sigstore/openid.rb +++ b/lib/rubygems/sigstore/openid.rb @@ -13,7 +13,7 @@ # limitations under the License. require 'rubygems/sigstore/config' -require 'rubygems/sigstore/crypto' +require 'rubygems/sigstore/pkey' require 'base64' require 'cgi' diff --git a/lib/rubygems/sigstore/crypto.rb b/lib/rubygems/sigstore/pkey.rb similarity index 67% rename from lib/rubygems/sigstore/crypto.rb rename to lib/rubygems/sigstore/pkey.rb index f0b0934..301ffd8 100644 --- a/lib/rubygems/sigstore/crypto.rb +++ b/lib/rubygems/sigstore/pkey.rb @@ -32,19 +32,3 @@ def private_key @private_key ||= OpenSSL::PKey::RSA.generate(2048) 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/test/support/rekor_helper.rb b/test/support/rekor_helper.rb index f7b2bfa..f8ff7ef 100644 --- a/test/support/rekor_helper.rb +++ b/test/support/rekor_helper.rb @@ -1,5 +1,5 @@ require 'rubygems/sigstore/gemfile' -require 'rubygems/sigstore/crypto' +require 'rubygems/sigstore/pkey' module RekorHelper include UrlHelper From d6990440a5dfd708ec712566a9314ce5f40ae7bc Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Mon, 22 Nov 2021 16:38:23 -0800 Subject: [PATCH 30/56] add rubocop --- .github/workflows/main.yml | 14 +++++++++++++- .rubocop.yml | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ba4ac75..9f11686 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,5 +25,17 @@ jobs: with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - - name: Run the default task + - 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/.rubocop.yml b/.rubocop.yml index 940cc26..0457076 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,6 +6,7 @@ AllCops: - 'bundler/**/*' - 'lib/rubygems/resolver/molinillo/**/*' - 'pkg/**/*' + - 'vendor/bundle/**/*' - 'tmp/**/*' TargetRubyVersion: 2.3 From ea6fdbeda7961076d079f3019cd73cd9e89fba85 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Mon, 22 Nov 2021 16:44:32 -0800 Subject: [PATCH 31/56] apply rubocop changes --- lib/rubygems/sigstore/file_signer.rb | 1 - lib/rubygems/sigstore/fulcio_api.rb | 4 +-- lib/rubygems/sigstore/rekor/api.rb | 1 - lib/rubygems/sigstore/rekor/rekord.rb | 1 - test/helper.rb | 3 +-- test/support/fulcio_helper.rb | 2 +- test/support/rekor_helper.rb | 36 +++++++++++++-------------- test/support/sigstore_auth_helper.rb | 8 +++--- test/support/url_helper.rb | 10 ++++---- 9 files changed, 31 insertions(+), 35 deletions(-) diff --git a/lib/rubygems/sigstore/file_signer.rb b/lib/rubygems/sigstore/file_signer.rb index 22197de..738908d 100644 --- a/lib/rubygems/sigstore/file_signer.rb +++ b/lib/rubygems/sigstore/file_signer.rb @@ -22,4 +22,3 @@ 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 index 0b0cb32..fee567a 100644 --- a/lib/rubygems/sigstore/fulcio_api.rb +++ b/lib/rubygems/sigstore/fulcio_api.rb @@ -11,9 +11,9 @@ def create(proof, pub_key) connection.post("/api/v1/signingCert", { publicKey: { content: Base64.encode64(pub_key), - algorithm: "ecdsa" + algorithm: "ecdsa", }, - signedEmailAddress: Base64.encode64(proof) + signedEmailAddress: Base64.encode64(proof), }).body end diff --git a/lib/rubygems/sigstore/rekor/api.rb b/lib/rubygems/sigstore/rekor/api.rb index f288334..11058be 100644 --- a/lib/rubygems/sigstore/rekor/api.rb +++ b/lib/rubygems/sigstore/rekor/api.rb @@ -65,4 +65,3 @@ def connection end end end - diff --git a/lib/rubygems/sigstore/rekor/rekord.rb b/lib/rubygems/sigstore/rekor/rekord.rb index b7d9677..85d5dd8 100644 --- a/lib/rubygems/sigstore/rekor/rekord.rb +++ b/lib/rubygems/sigstore/rekor/rekord.rb @@ -33,4 +33,3 @@ def cert end end end - diff --git a/test/helper.rb b/test/helper.rb index 81242d7..81e9331 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -21,11 +21,10 @@ module DefaultUserInteraction @ui = Gem::MockGemUi.new end - class Gem::TestCase < Test::Unit::TestCase include Gem::DefaultUserInteraction - BASE64_ENCODED_PATTERN = /[a-zA-Z0-9\+\/=\\]/ + BASE64_ENCODED_PATTERN = /[a-zA-Z0-9\+\/=\\]/.freeze def setup @back_ui = Gem::DefaultUserInteraction.ui diff --git a/test/support/fulcio_helper.rb b/test/support/fulcio_helper.rb index 63c87a2..17537fd 100644 --- a/test/support/fulcio_helper.rb +++ b/test/support/fulcio_helper.rb @@ -2,7 +2,7 @@ module FulcioHelper include UrlHelper include SigstoreAuthHelper - FULCIO_BASE_URL = 'https://fulcio.sigstore.dev/' + FULCIO_BASE_URL = 'https://fulcio.sigstore.dev/'.freeze def fulcio_api_url(*path, **kwargs) url_regex(FULCIO_BASE_URL, 'api', 'v1', path, **kwargs) diff --git a/test/support/rekor_helper.rb b/test/support/rekor_helper.rb index f8ff7ef..127721e 100644 --- a/test/support/rekor_helper.rb +++ b/test/support/rekor_helper.rb @@ -5,8 +5,8 @@ module RekorHelper include UrlHelper include FulcioHelper - REKOR_BASE_URL = 'https://rekor.sigstore.dev/' - REKOR_FAKE_CA_BASE_URL = 'http://some-ca-authority.org/' + REKOR_BASE_URL = 'https://rekor.sigstore.dev/'.freeze + REKOR_FAKE_CA_BASE_URL = 'http://some-ca-authority.org/'.freeze def rekor_api_url(*path, **kwargs) url_regex(REKOR_BASE_URL, 'api', 'v1', path, **kwargs) @@ -34,23 +34,23 @@ def stub_rekor_create_rekord(gem_path: @gem_path, body: {}, returning: {}) content: BASE64_ENCODED_PATTERN, publicKey: hash_including({ content: BASE64_ENCODED_PATTERN, - }) + }), }), data: hash_including({ content: BASE64_ENCODED_PATTERN, hash: hash_including({ algorithm: "sha256", - value: gem.digest - }) - }) - }) + value: gem.digest, + }), + }), + }), }.merge(body) # deep_merge is incompatible with nested hash_including() ) ) .to_return_json( build_rekord_entry(returning[:body] || {}), { - status: 201 + status: 201, } ) end @@ -63,9 +63,9 @@ def build_rekord_entry(options) logID: "dummy rekord logID", logIndex: 864991, verification: { - signedEntryTimestamp: "dummy timestamp signature" - } - } + signedEntryTimestamp: "dummy timestamp signature", + }, + }, }.deep_merge(options) end @@ -117,9 +117,9 @@ def build_rekord_log_entry(uuid:, log_entry_options:, rekord_options:, gem_path: logID: "dummy rekord logID", logIndex: 864991, verification: { - signedEntryTimestamp: "dummy timestamp signature" - } - } + signedEntryTimestamp: "dummy timestamp signature", + }, + }, }.deep_merge(log_entry_options) end @@ -137,16 +137,16 @@ def build_rekord(rekord_options, gem_path) "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 diff --git a/test/support/sigstore_auth_helper.rb b/test/support/sigstore_auth_helper.rb index 3bfdb43..2d9d6d4 100644 --- a/test/support/sigstore_auth_helper.rb +++ b/test/support/sigstore_auth_helper.rb @@ -1,7 +1,7 @@ module SigstoreAuthHelper include UrlHelper - SIGSTORE_OAUTH2_BASE_URL = 'https://oauth2.sigstore.dev/' + SIGSTORE_OAUTH2_BASE_URL = 'https://oauth2.sigstore.dev/'.freeze def sigstore_auth_url(*path, **kwargs) url_regex(SIGSTORE_OAUTH2_BASE_URL, 'auth', path, **kwargs) @@ -33,7 +33,7 @@ def build_sigstore_auth_openid_config(options) 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"] + claims_supported: ["iss", "sub", "aud", "iat", "exp", "email", "email_verified", "locale", "name", "preferred_username", "at_hash"], }.merge(options) end @@ -47,7 +47,7 @@ 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 + authorization: 'Basic c2lnc3RvcmU6', #u: sigstore, no password. From settings.yml content_type: 'application/x-www-form-urlencoded', }.merge(headers), body: hash_including( @@ -83,7 +83,7 @@ def stub_sigstore_auth_get_keys(returning: {}) def build_sigstore_auth_keys(options) { - keys: [access_token_jwk] + keys: [access_token_jwk], }.merge(options) end diff --git a/test/support/url_helper.rb b/test/support/url_helper.rb index cf9bab7..bdcf4cd 100644 --- a/test/support/url_helper.rb +++ b/test/support/url_helper.rb @@ -1,19 +1,19 @@ module UrlHelper - BASE64_ENCODED_PATTERN = /[a-zA-Z0-9\+\/=\\]/ + 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 } + escape = lambda {|v| v.is_a?(String) ? Regexp.escape(v) : v.to_s } params = [base, path] .flatten - .map { |param| escape.call(param) } + .map {|param| escape.call(param) } url = File.join(params) - kwargs = kwargs.delete_if { |_, v| v.blank? } + 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('=') } + .map {|k, v| [escape.call(k), escape.call(v)].join('=') } .join('&') url += query end From 54907c57d8092c46eb4b10a908b6d56d26d327da Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Tue, 23 Nov 2021 09:43:03 -0500 Subject: [PATCH 32/56] Correct style errors from newer commits --- lib/rubygems/sigstore/gem_verifier.rb | 4 ++-- lib/rubygems/sigstore/rekor/log_entry.rb | 1 - lib/rubygems/sigstore/rekor/rekord.rb | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index 8bfbf39..a51828c 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -14,9 +14,9 @@ def initialize(gemfile:, config:) 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| entry.kind == :rekord } + rekords = log_entries.select {|entry| entry.kind == :rekord } - valid_signature_rekords = rekords.select { |rekord| valid_signature?(rekord, gemfile) } + valid_signature_rekords = rekords.select {|rekord| valid_signature?(rekord, gemfile) } if valid_signature_rekords.empty? say "No valid signatures found for digest #{gemfile.digest}" diff --git a/lib/rubygems/sigstore/rekor/log_entry.rb b/lib/rubygems/sigstore/rekor/log_entry.rb index b727cb8..42a85a9 100644 --- a/lib/rubygems/sigstore/rekor/log_entry.rb +++ b/lib/rubygems/sigstore/rekor/log_entry.rb @@ -1,5 +1,4 @@ class Gem::Sigstore::Rekor::LogEntry - def self.from(entry_response) uuid = entry_response.keys.first entry = entry_response[uuid] diff --git a/lib/rubygems/sigstore/rekor/rekord.rb b/lib/rubygems/sigstore/rekor/rekord.rb index 85d5dd8..487b93c 100644 --- a/lib/rubygems/sigstore/rekor/rekord.rb +++ b/lib/rubygems/sigstore/rekor/rekord.rb @@ -2,7 +2,6 @@ require "rubygems/sigstore/rekor/log_entry" class Gem::Sigstore::Rekor::Rekord < Gem::Sigstore::Rekor::LogEntry - def signature @signature ||= begin signature = Base64.decode64(body.dig("spec", "signature", "content")) From 97e3b3ff722a9dd91ecb09ef6c68e15b0b6af9ae Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Tue, 23 Nov 2021 10:20:50 -0800 Subject: [PATCH 33/56] move openid class to openid::dynamic --- lib/rubygems/sigstore/cert_provider.rb | 2 +- lib/rubygems/sigstore/openid.rb | 194 +----------------------- lib/rubygems/sigstore/openid/dynamic.rb | 192 +++++++++++++++++++++++ 3 files changed, 198 insertions(+), 190 deletions(-) create mode 100644 lib/rubygems/sigstore/openid/dynamic.rb diff --git a/lib/rubygems/sigstore/cert_provider.rb b/lib/rubygems/sigstore/cert_provider.rb index 17ec1ea..7f0c2aa 100644 --- a/lib/rubygems/sigstore/cert_provider.rb +++ b/lib/rubygems/sigstore/cert_provider.rb @@ -5,7 +5,7 @@ def initialize(config:, pkey:) end def run - proof, access_token = Gem::Sigstore::OpenID.new(pkey.private_key).get_token + proof, access_token = Gem::Sigstore::OpenID::Dynamic.new(pkey.private_key).get_token fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) fulcio_api.create(proof, pkey.public_key.to_der) end diff --git a/lib/rubygems/sigstore/openid.rb b/lib/rubygems/sigstore/openid.rb index 020463b..11d13a3 100644 --- a/lib/rubygems/sigstore/openid.rb +++ b/lib/rubygems/sigstore/openid.rb @@ -1,192 +1,8 @@ -# 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 - include Gem::UserInteraction - - def initialize(priv_key) - @priv_key = priv_key - end - - 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"]) - 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 - 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' +module Gem + module Sigstore + module OpenID 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 + +require "rubygems/sigstore/openid/dynamic" diff --git a/lib/rubygems/sigstore/openid/dynamic.rb b/lib/rubygems/sigstore/openid/dynamic.rb new file mode 100644 index 0000000..af9b4c8 --- /dev/null +++ b/lib/rubygems/sigstore/openid/dynamic.rb @@ -0,0 +1,192 @@ +# 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 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"]) + 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 + 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 From 3229a77911fd4daaec4e7ba1e0d6271b15c90c67 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Tue, 23 Nov 2021 19:32:22 -0800 Subject: [PATCH 34/56] change openid::dynamic interface to expose token and proof --- lib/rubygems/sigstore/cert_provider.rb | 6 +++--- lib/rubygems/sigstore/fulcio_api.rb | 14 ++++++++------ lib/rubygems/sigstore/openid/dynamic.rb | 18 ++++++++++++++---- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/rubygems/sigstore/cert_provider.rb b/lib/rubygems/sigstore/cert_provider.rb index 7f0c2aa..548553a 100644 --- a/lib/rubygems/sigstore/cert_provider.rb +++ b/lib/rubygems/sigstore/cert_provider.rb @@ -5,9 +5,9 @@ def initialize(config:, pkey:) end def run - proof, access_token = Gem::Sigstore::OpenID::Dynamic.new(pkey.private_key).get_token - fulcio_api = Gem::Sigstore::FulcioApi.new(token: access_token, host: config.fulcio_host) - fulcio_api.create(proof, pkey.public_key.to_der) + oidp = Gem::Sigstore::OpenID::Dynamic.new(pkey.private_key) + fulcio_api = Gem::Sigstore::FulcioApi.new(oidp: oidp, host: config.fulcio_host) + fulcio_api.create(pkey.public_key.to_der) end private diff --git a/lib/rubygems/sigstore/fulcio_api.rb b/lib/rubygems/sigstore/fulcio_api.rb index fee567a..89514b2 100644 --- a/lib/rubygems/sigstore/fulcio_api.rb +++ b/lib/rubygems/sigstore/fulcio_api.rb @@ -2,27 +2,29 @@ require "openssl" class Gem::Sigstore::FulcioApi - def initialize(host:, token:) + def initialize(host:, oidp:) @host = host - @token = token.to_s + @oidp = oidp end - def create(proof, pub_key) + def create(pub_key) connection.post("/api/v1/signingCert", { publicKey: { content: Base64.encode64(pub_key), algorithm: "ecdsa", }, - signedEmailAddress: Base64.encode64(proof), + signedEmailAddress: Base64.encode64(oidp.proof), }).body end private + attr_reader :host, :oidp + def connection Faraday.new do |request| - request.authorization :Bearer, @token - request.url_prefix = @host + request.authorization :Bearer, oidp.token.to_s + request.url_prefix = host request.request :json request.response :json, content_type: /json/ request.adapter :net_http diff --git a/lib/rubygems/sigstore/openid/dynamic.rb b/lib/rubygems/sigstore/openid/dynamic.rb index af9b4c8..ea4eb34 100644 --- a/lib/rubygems/sigstore/openid/dynamic.rb +++ b/lib/rubygems/sigstore/openid/dynamic.rb @@ -29,6 +29,18 @@ 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 = {} @@ -135,12 +147,10 @@ def get_token() 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"]) - return proof, access_token + @proof = pkey.sign_proof(token["email"]) + @token = access_token end - private - def generate_pkce() pkce = {} pkce[:method] = "S256" From de11ce3dbb13ecbff8151f715b8afadf852fc829 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Tue, 23 Nov 2021 19:33:31 -0800 Subject: [PATCH 35/56] remove some go-isms --- lib/rubygems/sigstore/openid/dynamic.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/rubygems/sigstore/openid/dynamic.rb b/lib/rubygems/sigstore/openid/dynamic.rb index ea4eb34..efba306 100644 --- a/lib/rubygems/sigstore/openid/dynamic.rb +++ b/lib/rubygems/sigstore/openid/dynamic.rb @@ -41,7 +41,7 @@ def token private - def get_token() + def get_token config = Gem::Sigstore::Config.read session = {} session[:state] = SecureRandom.hex(16) @@ -151,14 +151,14 @@ def get_token() @token = access_token end - def generate_pkce() + 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 + pkce end def verify_token(access_token, public_keys, config, nonce) @@ -197,6 +197,6 @@ def verify_token(access_token, public_keys, config, nonce) abort 'Email address in OIDC token has not been verified by provider' end - return token + token end end From 93c9037a18c1684d49c725c9e2bb2bb82b1481dc Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Tue, 23 Nov 2021 19:40:23 -0800 Subject: [PATCH 36/56] move openid from cert provider to gem signer --- lib/rubygems/sigstore/cert_provider.rb | 11 +++++++---- lib/rubygems/sigstore/gem_signer.rb | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/rubygems/sigstore/cert_provider.rb b/lib/rubygems/sigstore/cert_provider.rb index 548553a..d165652 100644 --- a/lib/rubygems/sigstore/cert_provider.rb +++ b/lib/rubygems/sigstore/cert_provider.rb @@ -1,16 +1,19 @@ class Gem::Sigstore::CertProvider - def initialize(config:, pkey:) + def initialize(config:, pkey:, oidp:) @config = config @pkey = pkey + @oidp = oidp end def run - oidp = Gem::Sigstore::OpenID::Dynamic.new(pkey.private_key) - fulcio_api = Gem::Sigstore::FulcioApi.new(oidp: oidp, host: config.fulcio_host) fulcio_api.create(pkey.public_key.to_der) end private - attr_reader :config, :pkey + 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/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb index 6a7c311..bcc423f 100644 --- a/lib/rubygems/sigstore/gem_signer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -16,7 +16,8 @@ def initialize(gemfile:, config:) def run pkey = Gem::Sigstore::PKey.new - cert = Gem::Sigstore::CertProvider.new(config: config, pkey: pkey).run + oidp = Gem::Sigstore::OpenID::Dynamic.new(pkey.private_key) + cert = Gem::Sigstore::CertProvider.new(config: config, pkey: pkey, oidp: oidp).run yield if block_given? From 61f318aa02b877e5159289c6dcf5ae2b9ae224bb Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Tue, 23 Nov 2021 19:45:12 -0800 Subject: [PATCH 37/56] simplify run method of gem_signer --- lib/rubygems/sigstore/gem_signer.rb | 31 ++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/lib/rubygems/sigstore/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb index bcc423f..36f63a1 100644 --- a/lib/rubygems/sigstore/gem_signer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -15,9 +15,7 @@ def initialize(gemfile:, config:) end def run - pkey = Gem::Sigstore::PKey.new - oidp = Gem::Sigstore::OpenID::Dynamic.new(pkey.private_key) - cert = Gem::Sigstore::CertProvider.new(config: config, pkey: pkey, oidp: oidp).run + cert = cert_provider.run yield if block_given? @@ -26,15 +24,30 @@ def run say say "Sending gem digest, signature & certificate chain to transparency log." - Gem::Sigstore::FileSigner.new( - file: gemfile, - pkey: pkey, - transparency_log: Gem::Sigstore::Rekor::Api.new(host: config.rekor_host), - cert: cert - ).run + gemfile_signer(cert).run end private attr_reader :gemfile, :config + + 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 ||= Gem::Sigstore::OpenID::Dynamic.new(pkey.private_key) + 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 From 80bcfd9aeadecd92fd4beb72d171e8878615bfae Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Wed, 24 Nov 2021 09:45:16 -0500 Subject: [PATCH 38/56] bulk-retrieve log entries by uuid; add two-sig verify test --- lib/rubygems/sigstore/rekor/api.rb | 13 ++--- lib/rubygems/sigstore/rekor/log_entry.rb | 4 +- test/support/fulcio_helper.rb | 66 ++++++++++++++---------- test/support/rekor_helper.rb | 57 +++++++++++--------- test/test_verify_command.rb | 32 +++++++++++- 5 files changed, 109 insertions(+), 63 deletions(-) diff --git a/lib/rubygems/sigstore/rekor/api.rb b/lib/rubygems/sigstore/rekor/api.rb index 11058be..9a12cb1 100644 --- a/lib/rubygems/sigstore/rekor/api.rb +++ b/lib/rubygems/sigstore/rekor/api.rb @@ -42,13 +42,14 @@ def where(data_digest:) raise "Unexpected response from POST /api/v1/index/retrieve:\n #{retrieve_response}" end - retrieve_response.body.map do |uuid| - entry_response = connection.get("api/v1/log/entries/#{uuid}") - unless entry_response.status == 200 - raise "Unexpected response from GET api/v1/log/entries/#{uuid}:\n #{entry_response}" - end + retrieve_response = connection.post("api/v1/log/entries/retrieve", entryUUIDs: retrieve_response.body) - Gem::Sigstore::Rekor::LogEntry.from(entry_response.body) + unless retrieve_response.status == 200 + raise "Unexpected response from POST api/v1/log/entries/retrieve:\n #{entry_response}" + end + + retrieve_response.body.reduce(:merge).map do |uuid, entry| + Gem::Sigstore::Rekor::LogEntry.from(uuid, entry) end end diff --git a/lib/rubygems/sigstore/rekor/log_entry.rb b/lib/rubygems/sigstore/rekor/log_entry.rb index 42a85a9..4034b00 100644 --- a/lib/rubygems/sigstore/rekor/log_entry.rb +++ b/lib/rubygems/sigstore/rekor/log_entry.rb @@ -1,7 +1,5 @@ class Gem::Sigstore::Rekor::LogEntry - def self.from(entry_response) - uuid = entry_response.keys.first - entry = entry_response[uuid] + def self.from(uuid, entry) body = encoded_body_to_hash(entry["body"]) case body["kind"] diff --git a/test/support/fulcio_helper.rb b/test/support/fulcio_helper.rb index 17537fd..8a09012 100644 --- a/test/support/fulcio_helper.rb +++ b/test/support/fulcio_helper.rb @@ -2,7 +2,8 @@ module FulcioHelper include UrlHelper include SigstoreAuthHelper - FULCIO_BASE_URL = 'https://fulcio.sigstore.dev/'.freeze + 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) @@ -39,39 +40,48 @@ def stub_fulcio_create_signing_cert(headers: {}, body: {}, returning: {}) end end - def build_fulcio_cert_chain(public_signing_key, not_before: Time.now) + def build_fulcio_cert_chain(public_signing_key, signing_cert_options: {}) ef = OpenSSL::X509::ExtensionFactory.new - root_key = OpenSSL::PKey::RSA.new(1024) - root_subject = "/O=sigstore.dev/CN=sigstore" + issuing_key = OpenSSL::PKey::RSA.new(1024) + issuer_subject = "/O=sigstore.dev/CN=sigstore" - root_cert = OpenSSL::X509::Certificate.new - root_cert.subject = root_cert.issuer = OpenSSL::X509::Name.parse(root_subject) - root_cert.not_before = Time.now - root_cert.not_after = Time.now + 10.years - root_cert.public_key = root_key.public_key - root_cert.serial = 0x0 - root_cert.version = 2 - root_cert.add_extension(ef.create_extension("basicConstraints","CA:FALSE",true)) - root_cert.add_extension(ef.create_extension("keyUsage","keyCertSign, cRLSign", true)) + 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)) - root_cert.sign(root_key, OpenSSL::Digest.new("SHA256")) + issuing_cert.sign(issuing_key, OpenSSL::Digest.new("SHA256")) - leaf_cert = OpenSSL::X509::Certificate.new - leaf_cert.issuer = OpenSSL::X509::Name.parse(root_subject) - leaf_cert.not_before = not_before - leaf_cert.not_after = not_before + 10.minutes - leaf_cert.public_key = public_signing_key - leaf_cert.serial = 0x0 - leaf_cert.version = 2 - leaf_cert.add_extension(ef.create_extension("basicConstraints","CA:TRUE",true)) - leaf_cert.add_extension(ef.create_extension("keyUsage","digitalSignature", true)) - leaf_cert.add_extension(ef.create_extension("extendedKeyUsage","codeSigning", true)) - leaf_cert.add_extension(ef.create_extension("authorityInfoAccess","caIssuers;URI:http://some-ca-authority.org/ca.crt", true)) - leaf_cert.add_extension(ef.create_extension("subjectAltName","email:someone@example.org", true)) - leaf_cert.sign(root_key, OpenSSL::Digest.new("SHA256")) + options = default_signing_cert_options.merge(signing_cert_options) - [root_cert, leaf_cert].map(&:to_pem) + 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) diff --git a/test/support/rekor_helper.rb b/test/support/rekor_helper.rb index 127721e..4d7db17 100644 --- a/test/support/rekor_helper.rb +++ b/test/support/rekor_helper.rb @@ -5,8 +5,7 @@ module RekorHelper include UrlHelper include FulcioHelper - REKOR_BASE_URL = 'https://rekor.sigstore.dev/'.freeze - REKOR_FAKE_CA_BASE_URL = 'http://some-ca-authority.org/'.freeze + REKOR_BASE_URL = 'https://rekor.sigstore.dev'.freeze def rekor_api_url(*path, **kwargs) url_regex(REKOR_BASE_URL, 'api', 'v1', path, **kwargs) @@ -69,14 +68,14 @@ def build_rekord_entry(options) }.deep_merge(options) end - def rekor_index_retrieve_url(uuid) + def rekor_index_retrieve_url rekor_api_url('index', 'retrieve') end - def stub_rekor_search_index_by_digest(gem_path: @gem_path, uuid: "dummy_entry_uuid", body: {}, returning: nil) + 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(uuid)) + stub_request(:post, rekor_index_retrieve_url) .with( headers: { content_type: 'application/json', @@ -88,31 +87,41 @@ def stub_rekor_search_index_by_digest(gem_path: @gem_path, uuid: "dummy_entry_uu .to_return_json(returning || ["dummy_entry_uuid"]) end - def rekor_log_entry_url(uuid) - rekor_api_url('log', 'entries', uuid) + def rekor_log_entries_retrieve_url + rekor_api_url('log', 'entries', 'retrieve') end - def stub_rekor_get_rekord_by_uuid( - gem_path: @gem_path, - uuid: "dummy_entry_uuid", - log_entry_options: {}, - rekord_options: {} + 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(:get, rekor_log_entry_url(uuid)) + stub_request(:post, rekor_log_entries_retrieve_url) + .with( + body: { + entryUUIDs: uuids, + } + ) .to_return_json( - build_rekord_log_entry( - uuid: uuid, - log_entry_options: log_entry_options, - rekord_options: rekord_options, - gem_path: gem_path - ) + 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:, gem_path:) + 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, gem_path).to_json), + body: Base64.encode64(build_rekord(rekord_options, cert_options, gem_path).to_json), integratedTime: 1637154947, logID: "dummy rekord logID", logIndex: 864991, @@ -123,10 +132,10 @@ def build_rekord_log_entry(uuid:, log_entry_options:, rekord_options:, gem_path: }.deep_merge(log_entry_options) end - def build_rekord(rekord_options, gem_path) + 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) + cert_chain = build_fulcio_cert_chain(pkey.public_key, signing_cert_options: cert_options) stub_get_ca_certificate(certificate: cert_chain.first) { @@ -151,7 +160,7 @@ def build_rekord(rekord_options, gem_path) end def ca_authority_url(*path, **kwargs) - url_regex(REKOR_FAKE_CA_BASE_URL, path, **kwargs) + url_regex(FULCIO_FAKE_CA_BASE_URL, path, **kwargs) end def stub_get_ca_certificate(certificate:, returning: {}) diff --git a/test/test_verify_command.rb b/test/test_verify_command.rb index d4c8804..5541919 100644 --- a/test/test_verify_command.rb +++ b/test/test_verify_command.rb @@ -11,10 +11,10 @@ def setup @cmd = Gem::Commands::VerifyCommand.new stub_rekor_search_index_by_digest - stub_rekor_get_rekord_by_uuid + stub_rekor_get_rekords_by_uuid end - def test_verify + def test_one_non_maintainer_signature @cmd.options[:args] = [@gem_path] use_ui @ui do @@ -27,4 +27,32 @@ def test_verify assert_equal "Signed by non-maintainer: someone@example.org", output.shift assert_equal [], output end + + def test_maintainer_signature_and_non_maintainer_signature + @cmd.options[:args] = [@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 end From 574e658c572f3db9822dc6f9e56d4fb8d89d76e5 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Wed, 24 Nov 2021 17:53:56 -0800 Subject: [PATCH 39/56] add static openid provider --- README.md | 9 ++++ lib/rubygems/commands/sign_command.rb | 8 ++- lib/rubygems/commands/sign_extend.rb | 8 ++- lib/rubygems/sigstore/gem_signer.rb | 11 ++-- lib/rubygems/sigstore/openid.rb | 1 + lib/rubygems/sigstore/openid/static.rb | 73 ++++++++++++++++++++++++++ test/helper.rb | 1 + test/test_sign_command.rb | 18 +++++++ 8 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 lib/rubygems/sigstore/openid/static.rb diff --git a/README.md b/README.md index 488365a..01e6c86 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,15 @@ Or install it yourself as: `gem 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 sign sign --identity-token=$(gcloud auth print-identity-token) +``` + ### Verify an existing gem file `gem verify foo.gem` diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb index eceb327..485770d 100644 --- a/lib/rubygems/commands/sign_command.rb +++ b/lib/rubygems/commands/sign_command.rb @@ -23,6 +23,11 @@ class Gem::Commands::SignCommand < Gem::Command def initialize super "sign", "Sign a gem" + + add_option("--identity-token", String, + "Provide a static token for automated environments") do |value, options| + options[:identity_token] = value + end end def arguments # :nodoc: @@ -41,7 +46,8 @@ def execute gemfile = Gem::Sigstore::Gemfile.new(get_one_gem_name) rekor_entry = Gem::Sigstore::GemSigner.new( gemfile: gemfile, - config: Gem::Sigstore::Config.read + config: Gem::Sigstore::Config.read, + identity_token: options[:identity_token], ).run say log_entry_url(rekor_entry) end diff --git a/lib/rubygems/commands/sign_extend.rb b/lib/rubygems/commands/sign_extend.rb index 54419b5..38d6e9c 100644 --- a/lib/rubygems/commands/sign_extend.rb +++ b/lib/rubygems/commands/sign_extend.rb @@ -27,6 +27,11 @@ 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 @@ -34,7 +39,8 @@ def execute gemfile = Gem::Sigstore::Gemfile.new(get_one_gem_name) gem_signer = Gem::Sigstore::GemSigner.new( gemfile: gemfile, - config: Gem::Sigstore::Config.read + 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 } diff --git a/lib/rubygems/sigstore/gem_signer.rb b/lib/rubygems/sigstore/gem_signer.rb index 36f63a1..3834aa8 100644 --- a/lib/rubygems/sigstore/gem_signer.rb +++ b/lib/rubygems/sigstore/gem_signer.rb @@ -9,9 +9,10 @@ class Gem::Sigstore::GemSigner Data = Struct.new(:digest, :signature, :raw) - def initialize(gemfile:, config:) + def initialize(gemfile:, config:, identity_token: nil) @gemfile = gemfile @config = config + @identity_token = identity_token end def run @@ -29,7 +30,7 @@ def run private - attr_reader :gemfile, :config + attr_reader :gemfile, :config, :identity_token def cert_provider Gem::Sigstore::CertProvider.new(config: config, pkey: pkey, oidp: oidp) @@ -40,7 +41,11 @@ def pkey end def oidp - @oidp ||= Gem::Sigstore::OpenID::Dynamic.new(pkey.private_key) + @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) diff --git a/lib/rubygems/sigstore/openid.rb b/lib/rubygems/sigstore/openid.rb index 11d13a3..61604a4 100644 --- a/lib/rubygems/sigstore/openid.rb +++ b/lib/rubygems/sigstore/openid.rb @@ -6,3 +6,4 @@ module OpenID end require "rubygems/sigstore/openid/dynamic" +require "rubygems/sigstore/openid/static" 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/test/helper.rb b/test/helper.rb index 81e9331..f5820dd 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -2,6 +2,7 @@ require 'webmock/test_unit' require 'rubygems/mock_gem_ui' require 'json/jwt' +require 'byebug' require 'rubygems/sigstore' diff --git a/test/test_sign_command.rb b/test/test_sign_command.rb index 198185d..c3e79c3 100644 --- a/test/test_sign_command.rb +++ b/test/test_sign_command.rb @@ -36,6 +36,24 @@ def test_sign assert_equal [], output end + def test_static_sign + @cmd.options[:args] = [@gem_path] + @cmd.options[:identity_token] = access_token + + 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 assert_certificate(output) assert_equal "-----BEGIN CERTIFICATE-----", output.shift assert_match BASE64_ENCODED_PATTERN, output.shift until output.first == "-----END CERTIFICATE-----" From d5ba747062dac36682364f80d8c6a0db9c5280d0 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Wed, 15 Dec 2021 10:57:01 -0500 Subject: [PATCH 40/56] Support empty responses in Rekor::Api#where --- lib/rubygems/sigstore/rekor/api.rb | 42 +++++++++++++++++++----------- test/test_verify_command.rb | 14 ++++++++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/lib/rubygems/sigstore/rekor/api.rb b/lib/rubygems/sigstore/rekor/api.rb index 9a12cb1..80e7489 100644 --- a/lib/rubygems/sigstore/rekor/api.rb +++ b/lib/rubygems/sigstore/rekor/api.rb @@ -32,23 +32,11 @@ def create(cert_chain, data) end def where(data_digest:) - retrieve_response = connection.post("/api/v1/index/retrieve", - { - hash: "sha256:#{data_digest}", - } - ) + log_entry_uuids = find_log_entry_uuids_by_digest(data_digest) - unless retrieve_response.status == 200 - raise "Unexpected response from POST /api/v1/index/retrieve:\n #{retrieve_response}" - end - - retrieve_response = connection.post("api/v1/log/entries/retrieve", entryUUIDs: retrieve_response.body) + return [] if log_entry_uuids.empty? - unless retrieve_response.status == 200 - raise "Unexpected response from POST api/v1/log/entries/retrieve:\n #{entry_response}" - end - - retrieve_response.body.reduce(:merge).map do |uuid, entry| + find_log_entries_by_uuid(log_entry_uuids).reduce({}, :merge).map do |uuid, entry| Gem::Sigstore::Rekor::LogEntry.from(uuid, entry) end end @@ -65,4 +53,28 @@ def connection 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 #{entry_response}" + end + + log_entries_response.body + end end diff --git a/test/test_verify_command.rb b/test/test_verify_command.rb index 5541919..4ec1c6d 100644 --- a/test/test_verify_command.rb +++ b/test/test_verify_command.rb @@ -14,6 +14,20 @@ def setup stub_rekor_get_rekords_by_uuid end + def test_unsigned_gem + @cmd.options[:args] = [@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.options[:args] = [@gem_path] From 770c03a135cd16332c3c2de0afce8584010e061a Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Wed, 15 Dec 2021 09:13:48 -0800 Subject: [PATCH 41/56] add decision log --- DECISIONLOG | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 DECISIONLOG 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. From 7c00ea3b8f716dcda4c08107849fd5aefe762e16 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Thu, 16 Dec 2021 14:26:17 -0500 Subject: [PATCH 42/56] Combine `sign` and `verify` into a new `signature` command --- lib/rubygems/commands/signatures_command.rb | 103 ++++++++++++ lib/rubygems_plugin.rb | 4 +- test/test_signatures_command.rb | 174 ++++++++++++++++++++ 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 lib/rubygems/commands/signatures_command.rb create mode 100644 test/test_signatures_command.rb diff --git a/lib/rubygems/commands/signatures_command.rb b/lib/rubygems/commands/signatures_command.rb new file mode 100644 index 0000000..549c90c --- /dev/null +++ b/lib/rubygems/commands/signatures_command.rb @@ -0,0 +1,103 @@ +# 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) + + gemfile = Gem::Sigstore::Gemfile.new(gem_path) + + sign(gemfile) if options[:sign] + verify(gemfile) if verify_signatures? + end + + private + + 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_plugin.rb b/lib/rubygems_plugin.rb index 1eff9f6..2e8f600 100644 --- a/lib/rubygems_plugin.rb +++ b/lib/rubygems_plugin.rb @@ -14,14 +14,16 @@ require 'rubygems/command_manager' require 'rubygems/sigstore' +require 'rubygems/commands/signatures_command' require 'rubygems/commands/sign_command' require 'rubygems/commands/sign_extend' require 'rubygems/commands/verify_command' require 'rubygems/commands/verify_extend' +Gem::CommandManager.instance.register_command :signatures Gem::CommandManager.instance.register_command :sign Gem::CommandManager.instance.register_command :verify -[:sign, :verify, :build, :install].each do |cmd_name| +[:signatures, :sign, :verify, :build, :install].each do |cmd_name| cmd = Gem::CommandManager.instance[cmd_name] end diff --git a/test/test_signatures_command.rb b/test/test_signatures_command.rb new file mode 100644 index 0000000..a61a09d --- /dev/null +++ b/test/test_signatures_command.rb @@ -0,0 +1,174 @@ +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 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 From f9684af827be51e85433ea117f1fcdb745b76726 Mon Sep 17 00:00:00 2001 From: Frederik Dudzik Date: Wed, 15 Dec 2021 17:44:21 -0800 Subject: [PATCH 43/56] add verify to install command add siging_policy --- lib/rubygems/commands/verify_extend.rb | 21 +++++++++++++++--- lib/rubygems/sigstore.rb | 1 + lib/rubygems/sigstore/signing_policy.rb | 29 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 lib/rubygems/sigstore/signing_policy.rb diff --git a/lib/rubygems/commands/verify_extend.rb b/lib/rubygems/commands/verify_extend.rb index d956c34..ba11ec4 100644 --- a/lib/rubygems/commands/verify_extend.rb +++ b/lib/rubygems/commands/verify_extend.rb @@ -27,10 +27,25 @@ 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 + + say "Verifying #{gem_path}" + + raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(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/sigstore.rb b/lib/rubygems/sigstore.rb index 43dfdd3..b1cdf7f 100644 --- a/lib/rubygems/sigstore.rb +++ b/lib/rubygems/sigstore.rb @@ -14,4 +14,5 @@ module Gem::Sigstore 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/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 From e09fa5a3f2650e4883e90481265237aaa49a8fd7 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Fri, 17 Dec 2021 14:00:59 -0500 Subject: [PATCH 44/56] Delete gem sign and gem verify --- README.md | 10 +-- ...sign_extend.rb => build_command_extend.rb} | 8 +-- ...fy_extend.rb => install_command_extend.rb} | 5 +- lib/rubygems/commands/sign_command.rb | 60 ---------------- lib/rubygems/commands/verify_command.rb | 39 ---------- lib/rubygems_plugin.rb | 10 +-- test/test_sign_command.rb | 62 ---------------- test/test_verify_command.rb | 72 ------------------- 8 files changed, 11 insertions(+), 255 deletions(-) rename lib/rubygems/commands/{sign_extend.rb => build_command_extend.rb} (87%) rename lib/rubygems/commands/{verify_extend.rb => install_command_extend.rb} (94%) delete mode 100644 lib/rubygems/commands/sign_command.rb delete mode 100644 lib/rubygems/commands/verify_command.rb delete mode 100644 test/test_sign_command.rb delete mode 100644 test/test_verify_command.rb diff --git a/README.md b/README.md index 01e6c86..0b992df 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Or install it yourself as: ### Sign an existing gem file -`gem sign foo.gem` +`gem signatures --sign foo.gem` ### Identity Tokens @@ -34,12 +34,12 @@ In automated environments, gem also supports directly using OIDC Identity Tokens These can be supplied on the command line with the `--identity-token` flag. ```shell -$ gem sign sign --identity-token=$(gcloud auth print-identity-token) +$ 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 @@ -49,10 +49,6 @@ $ gem sign sign --identity-token=$(gcloud auth print-identity-token) `gem install foo --verify` -### Install a gem without verification - -`gem install foo --noverify` - ## 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. diff --git a/lib/rubygems/commands/sign_extend.rb b/lib/rubygems/commands/build_command_extend.rb similarity index 87% rename from lib/rubygems/commands/sign_extend.rb rename to lib/rubygems/commands/build_command_extend.rb index 38d6e9c..401a060 100644 --- a/lib/rubygems/commands/sign_extend.rb +++ b/lib/rubygems/commands/build_command_extend.rb @@ -19,11 +19,9 @@ require 'rubygems/command_manager' require 'rubygems/sigstore' -Gem::CommandManager.instance.register_command :sign - -# overde the generic gem build command to lay are own --sign option on top +# override the generic gem build command to lay our own --sign option on top b = Gem::CommandManager.instance[:build] -b.add_option("--sign", "Sign gem with sigstore.") do |value, options| +b.add_option("--[no-]sign", "Sign gem with sigstore.") do |value, options| Gem::Sigstore.options[:sign] = true end @@ -44,7 +42,7 @@ def execute ) # Run the gem build process only if openid auth was successful (original_execute) rekor_entry = gem_signer.run { original_execute } - pp rekor_entry + say rekor_entry end end end diff --git a/lib/rubygems/commands/verify_extend.rb b/lib/rubygems/commands/install_command_extend.rb similarity index 94% rename from lib/rubygems/commands/verify_extend.rb rename to lib/rubygems/commands/install_command_extend.rb index ba11ec4..d638ebd 100644 --- a/lib/rubygems/commands/verify_extend.rb +++ b/lib/rubygems/commands/install_command_extend.rb @@ -13,10 +13,9 @@ # limitations under the License. require 'rubygems/command_manager' +require "rubygems/user_interaction" require 'rubygems/sigstore' -Gem::CommandManager.instance.register_command :verify - # gem install hooks i = Gem::CommandManager.instance[:install] i.add_option("--[no-]verify", @@ -33,7 +32,7 @@ if (package = installer.package) gem_path = package.gem.path - say "Verifying #{gem_path}" + installer.say "Verifying #{gem_path}" raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(gem_path) diff --git a/lib/rubygems/commands/sign_command.rb b/lib/rubygems/commands/sign_command.rb deleted file mode 100644 index 485770d..0000000 --- a/lib/rubygems/commands/sign_command.rb +++ /dev/null @@ -1,60 +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' - -require 'json/jwt' -require 'launchy' -require 'openid_connect' -require 'socket' - -class Gem::Commands::SignCommand < Gem::Command - def initialize - super "sign", "Sign a gem" - - add_option("--identity-token", String, - "Provide a static token for automated environments") do |value, options| - options[:identity_token] = value - end - 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 - gemfile = Gem::Sigstore::Gemfile.new(get_one_gem_name) - 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 - - private - - 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 0cac168..0000000 --- a/lib/rubygems/commands/verify_command.rb +++ /dev/null @@ -1,39 +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' - -class Gem::Commands::VerifyCommand < Gem::Command - def initialize - super 'verify', "Opens the gem's documentation" - add_option('--rekor-host HOST', 'Rekor host') do |value, options| - options[:host] = value - end - end - - def execute - gem_path = get_one_gem_name - say "Verifying #{gem_path}" - - raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(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 diff --git a/lib/rubygems_plugin.rb b/lib/rubygems_plugin.rb index 2e8f600..086691d 100644 --- a/lib/rubygems_plugin.rb +++ b/lib/rubygems_plugin.rb @@ -15,15 +15,11 @@ require 'rubygems/command_manager' require 'rubygems/sigstore' require 'rubygems/commands/signatures_command' -require 'rubygems/commands/sign_command' -require 'rubygems/commands/sign_extend' -require 'rubygems/commands/verify_command' -require 'rubygems/commands/verify_extend' +require 'rubygems/commands/build_command_extend' +require 'rubygems/commands/install_command_extend' Gem::CommandManager.instance.register_command :signatures -Gem::CommandManager.instance.register_command :sign -Gem::CommandManager.instance.register_command :verify -[:signatures, :sign, :verify, :build, :install].each do |cmd_name| +[:signatures, :build, :install].each do |cmd_name| cmd = Gem::CommandManager.instance[cmd_name] end diff --git a/test/test_sign_command.rb b/test/test_sign_command.rb deleted file mode 100644 index c3e79c3..0000000 --- a/test/test_sign_command.rb +++ /dev/null @@ -1,62 +0,0 @@ -require 'helper' -require "rubygems/commands/sign_command" - -class TestSignCommand < Gem::TestCase - include SigstoreAuthHelper - include FulcioHelper - include RekorHelper - - def setup - super - - @gem_path = gem_path("hello-world.gem") - @cmd = Gem::Commands::SignCommand.new - - stub_sigstore_auth_get_openid_config - stub_sigstore_auth_create_token - stub_sigstore_auth_get_keys - stub_fulcio_create_signing_cert - stub_rekor_create_rekord - end - - def test_sign - @cmd.options[:args] = [@gem_path] - - 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.options[:args] = [@gem_path] - @cmd.options[:identity_token] = access_token - - 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 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 -end diff --git a/test/test_verify_command.rb b/test/test_verify_command.rb deleted file mode 100644 index 4ec1c6d..0000000 --- a/test/test_verify_command.rb +++ /dev/null @@ -1,72 +0,0 @@ -require 'helper' -require "rubygems/commands/verify_command" - -class TestVerifyCommand < Gem::TestCase - include RekorHelper - - def setup - super - - @gem_path = gem_path("hello-world.gem") - @cmd = Gem::Commands::VerifyCommand.new - - stub_rekor_search_index_by_digest - stub_rekor_get_rekords_by_uuid - end - - def test_unsigned_gem - @cmd.options[:args] = [@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.options[:args] = [@gem_path] - - 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.options[:args] = [@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 -end From 0508d64f3d063b42256b1da9b8afb8af08f42e33 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Fri, 17 Dec 2021 14:16:53 -0500 Subject: [PATCH 45/56] Rename `install` command's --verify option to --verify-signatures --- README.md | 2 +- lib/rubygems/commands/install_command_extend.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0b992df..90a8dc6 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ $ gem signatures --sign --identity-token=$(gcloud auth print-identity-token) ### Install and verify a gem -`gem install foo --verify` +`gem install foo --verify-signatures` ## Development diff --git a/lib/rubygems/commands/install_command_extend.rb b/lib/rubygems/commands/install_command_extend.rb index d638ebd..6691ab1 100644 --- a/lib/rubygems/commands/install_command_extend.rb +++ b/lib/rubygems/commands/install_command_extend.rb @@ -18,7 +18,7 @@ # 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 From 393af47f52ede52afe223274d8354f5f280bc3a4 Mon Sep 17 00:00:00 2001 From: rochlefebvre Date: Tue, 14 Dec 2021 13:29:34 -0500 Subject: [PATCH 46/56] Update the README --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 90a8dc6..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 @@ -53,7 +55,12 @@ $ gem signatures --sign --identity-token=$(gcloud auth print-identity-token) 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 From e2eeeac6b8cf136ba87067f24066126210a5f6f6 Mon Sep 17 00:00:00 2001 From: Ashley Ellis Pierce Date: Wed, 22 Dec 2021 11:14:51 -0500 Subject: [PATCH 47/56] Validate file is a gem on signatures command Co-authored-by: Jacques Chester --- lib/rubygems/commands/signatures_command.rb | 9 +++++++++ test/fixtures/not_a_gem | 1 + test/test_signatures_command.rb | 12 ++++++++++++ 3 files changed, 22 insertions(+) create mode 100644 test/fixtures/not_a_gem diff --git a/lib/rubygems/commands/signatures_command.rb b/lib/rubygems/commands/signatures_command.rb index 549c90c..0b77d24 100644 --- a/lib/rubygems/commands/signatures_command.rb +++ b/lib/rubygems/commands/signatures_command.rb @@ -55,6 +55,7 @@ def defaults_str # :nodoc: 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) @@ -64,6 +65,14 @@ def execute 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, 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/test_signatures_command.rb b/test/test_signatures_command.rb index a61a09d..8f4f7bc 100644 --- a/test/test_signatures_command.rb +++ b/test/test_signatures_command.rb @@ -156,6 +156,18 @@ def test_nonexistent_file 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-----" From 37460c426b4cf0b631fdaa5ab2cc618786f8d4c2 Mon Sep 17 00:00:00 2001 From: Ashley Ellis Pierce Date: Wed, 22 Dec 2021 14:15:19 -0500 Subject: [PATCH 48/56] Remove unreachable raise When these pre-install hooks are called, Rubygems has already validated that the given package is a valid gem, both locally and remotely. If the file does not exist or is not a valid gemfile, no package exists on the installer at line 32. Plus, Rubygems raises an error. Co-authored-by: Jacques Chester --- lib/rubygems/commands/install_command_extend.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/rubygems/commands/install_command_extend.rb b/lib/rubygems/commands/install_command_extend.rb index 6691ab1..5628a89 100644 --- a/lib/rubygems/commands/install_command_extend.rb +++ b/lib/rubygems/commands/install_command_extend.rb @@ -34,8 +34,6 @@ installer.say "Verifying #{gem_path}" - raise Gem::CommandLineError, "#{gem_path} is not a file" unless File.file?(gem_path) - gemfile = Gem::Sigstore::Gemfile.new(gem_path) verifier = Gem::Sigstore::GemVerifier.new( gemfile: gemfile, From 86a64beda7610eca2b6512c0c3ba8da17e8fcb30 Mon Sep 17 00:00:00 2001 From: Ashley Ellis Pierce Date: Tue, 18 Jan 2022 13:14:46 -0500 Subject: [PATCH 49/56] Fix Ruby 3.1 net/smtp bug Ruby 3.1 adds net/smtp to default standard library gems. Since we don't have a mailer in this project we need to explicitly not include it. Ref: https://stackoverflow.com/questions/70500220/rails-7-ruby-3-1-loaderror-cannot-load-such-file-net-smtp --- Gemfile | 1 + Gemfile.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/Gemfile b/Gemfile index d917ef2..86ea282 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ gem "oa-openid", "~> 0.0.2" gem "omniauth-openid", "~> 2.0.1" gem "ruby-openid-apps-discovery", "~> 1.2.0" gem "json-jwt", "~> 1.13.0" +gem 'net-smtp', require: false group :development do gem "rubocop", "~> 0.80.1" diff --git a/Gemfile.lock b/Gemfile.lock index 93878be..8284ec4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -36,6 +36,7 @@ GEM crack (0.4.5) rexml deep_merge (1.2.1) + digest (3.1.0) dry-configurable (0.12.1) concurrent-ruby (~> 1.0) dry-core (~> 0.5, >= 0.5.0) @@ -86,6 +87,7 @@ GEM httpclient (2.8.3) 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) @@ -98,6 +100,13 @@ GEM 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) @@ -164,6 +173,7 @@ GEM 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) @@ -191,6 +201,7 @@ DEPENDENCIES 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) From 489a1a454a2b27e5b85a0f3aaa6acf63194e1c1c Mon Sep 17 00:00:00 2001 From: Ashley Ellis Pierce Date: Tue, 18 Jan 2022 14:20:10 -0500 Subject: [PATCH 50/56] Add quotes around ruby version numbers If numbers are not quoted, the YAML parser will treat 3.0 as '3' and so the latest version minor version of 3, 3.1 will run instead of sticking with the 3.0.x patch version. Also adds quotes around the other ruby versions for consistency --- .github/workflows/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9f11686..96e85bb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,9 +14,9 @@ jobs: strategy: matrix: ruby-version: - - 2.6 - - 2.7 - - 3.0 + - '2.6' + - '2.7' + - '3.0' steps: - uses: actions/checkout@v2 From 3274d9b4a09400ad0534cf15ff9ece142b26449f Mon Sep 17 00:00:00 2001 From: Ashley Ellis Pierce Date: Tue, 18 Jan 2022 14:23:00 -0500 Subject: [PATCH 51/56] Test 3.1 --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 96e85bb..ca7bb0c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,6 +17,7 @@ jobs: - '2.6' - '2.7' - '3.0' + - '3.1' steps: - uses: actions/checkout@v2 From 6c65818e5ee98e16f6046a7a3a8bceeef85ee370 Mon Sep 17 00:00:00 2001 From: Roch Lefebvre Date: Mon, 24 Jan 2022 08:57:26 -0500 Subject: [PATCH 52/56] Fix reference to undefined variable --- lib/rubygems/sigstore/rekor/api.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubygems/sigstore/rekor/api.rb b/lib/rubygems/sigstore/rekor/api.rb index 80e7489..0666b7f 100644 --- a/lib/rubygems/sigstore/rekor/api.rb +++ b/lib/rubygems/sigstore/rekor/api.rb @@ -72,7 +72,7 @@ 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 #{entry_response}" + raise "Unexpected response from POST api/v1/log/entries/retrieve:\n #{log_entries_response}" end log_entries_response.body From 4bc8603c451cd7e1fa4f18a8ce1efd6a268f64d2 Mon Sep 17 00:00:00 2001 From: Roch Lefebvre Date: Thu, 27 Jan 2022 13:52:45 -0500 Subject: [PATCH 53/56] Check responses from Fulcio/Rekor POSTs, raise unless expected --- lib/rubygems/sigstore/fulcio_api.rb | 13 ++++++++++--- lib/rubygems/sigstore/rekor/api.rb | 11 +++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/rubygems/sigstore/fulcio_api.rb b/lib/rubygems/sigstore/fulcio_api.rb index 89514b2..02aa59a 100644 --- a/lib/rubygems/sigstore/fulcio_api.rb +++ b/lib/rubygems/sigstore/fulcio_api.rb @@ -8,13 +8,20 @@ def initialize(host:, oidp:) end def create(pub_key) - connection.post("/api/v1/signingCert", { + response = connection.post("/api/v1/signingCert", { publicKey: { content: Base64.encode64(pub_key), algorithm: "ecdsa", }, - signedEmailAddress: Base64.encode64(oidp.proof), - }).body + 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 diff --git a/lib/rubygems/sigstore/rekor/api.rb b/lib/rubygems/sigstore/rekor/api.rb index 0666b7f..d684959 100644 --- a/lib/rubygems/sigstore/rekor/api.rb +++ b/lib/rubygems/sigstore/rekor/api.rb @@ -8,7 +8,7 @@ def initialize(host:) end def create(cert_chain, data) - connection.post("/api/v1/log/entries", + response = connection.post("/api/v1/log/entries", { kind: "rekord", apiVersion: "0.0.1", @@ -28,7 +28,14 @@ def create(cert_chain, data) }, }, }, - }).body + } + ) + + unless response.status == 201 + raise "Unexpected response from POST api/v1/log/entries:\n #{response.body}" + end + + response.body end def where(data_digest:) From a9a6f905660af10e40483fd50803a8a982bf042e Mon Sep 17 00:00:00 2001 From: Roch Lefebvre Date: Tue, 15 Feb 2022 13:47:01 -0500 Subject: [PATCH 54/56] extract Signature module from Rekord --- lib/rubygems/sigstore/rekor.rb | 1 + lib/rubygems/sigstore/rekor/rekord.rb | 32 ++--------------------- lib/rubygems/sigstore/rekor/signature.rb | 33 ++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 lib/rubygems/sigstore/rekor/signature.rb diff --git a/lib/rubygems/sigstore/rekor.rb b/lib/rubygems/sigstore/rekor.rb index 7c666fa..aa54af7 100644 --- a/lib/rubygems/sigstore/rekor.rb +++ b/lib/rubygems/sigstore/rekor.rb @@ -4,3 +4,4 @@ module Gem::Sigstore::Rekor require "rubygems/sigstore/rekor/api" require "rubygems/sigstore/rekor/log_entry" require "rubygems/sigstore/rekor/rekord" +require "rubygems/sigstore/rekor/signature" diff --git a/lib/rubygems/sigstore/rekor/rekord.rb b/lib/rubygems/sigstore/rekor/rekord.rb index 487b93c..b86281a 100644 --- a/lib/rubygems/sigstore/rekor/rekord.rb +++ b/lib/rubygems/sigstore/rekor/rekord.rb @@ -1,34 +1,6 @@ -require "rubygems/sigstore/cert_chain" require "rubygems/sigstore/rekor/log_entry" +require "rubygems/sigstore/rekor/signature" class Gem::Sigstore::Rekor::Rekord < Gem::Sigstore::Rekor::LogEntry - 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 + 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 From 14c84d992a9a9bfcf6fb27140a35df893bd7f8a8 Mon Sep 17 00:00:00 2001 From: Roch Lefebvre Date: Tue, 15 Feb 2022 14:01:32 -0500 Subject: [PATCH 55/56] store signatures in hashedrekord --- lib/rubygems/sigstore/rekor/api.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/rubygems/sigstore/rekor/api.rb b/lib/rubygems/sigstore/rekor/api.rb index 0666b7f..63f42bf 100644 --- a/lib/rubygems/sigstore/rekor/api.rb +++ b/lib/rubygems/sigstore/rekor/api.rb @@ -10,7 +10,7 @@ def initialize(host:) def create(cert_chain, data) connection.post("/api/v1/log/entries", { - kind: "rekord", + kind: "hashedrekord", apiVersion: "0.0.1", spec: { signature: { @@ -21,7 +21,6 @@ def create(cert_chain, data) }, }, data: { - content: Base64.encode64(data.raw), hash: { algorithm: "sha256", value: data.digest, From 19c060617913f3d152fc107368fb19199f1619b7 Mon Sep 17 00:00:00 2001 From: Roch Lefebvre Date: Tue, 15 Feb 2022 14:02:15 -0500 Subject: [PATCH 56/56] support verification of hashedrekord signatures --- lib/rubygems/sigstore/gem_verifier.rb | 2 +- lib/rubygems/sigstore/rekor.rb | 1 + lib/rubygems/sigstore/rekor/hashed_rekord.rb | 6 ++++++ lib/rubygems/sigstore/rekor/log_entry.rb | 2 ++ test/support/rekor_helper.rb | 3 +-- 5 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 lib/rubygems/sigstore/rekor/hashed_rekord.rb diff --git a/lib/rubygems/sigstore/gem_verifier.rb b/lib/rubygems/sigstore/gem_verifier.rb index a51828c..1cdcde5 100644 --- a/lib/rubygems/sigstore/gem_verifier.rb +++ b/lib/rubygems/sigstore/gem_verifier.rb @@ -14,7 +14,7 @@ def initialize(gemfile:, config:) 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| entry.kind == :rekord } + rekords = log_entries.select {|entry| %i[rekord hashedrekord].include?(entry.kind) } valid_signature_rekords = rekords.select {|rekord| valid_signature?(rekord, gemfile) } diff --git a/lib/rubygems/sigstore/rekor.rb b/lib/rubygems/sigstore/rekor.rb index aa54af7..42bd3b8 100644 --- a/lib/rubygems/sigstore/rekor.rb +++ b/lib/rubygems/sigstore/rekor.rb @@ -2,6 +2,7 @@ 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/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 index 4034b00..842563b 100644 --- a/lib/rubygems/sigstore/rekor/log_entry.rb +++ b/lib/rubygems/sigstore/rekor/log_entry.rb @@ -3,6 +3,8 @@ 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 diff --git a/test/support/rekor_helper.rb b/test/support/rekor_helper.rb index 4d7db17..d74ce45 100644 --- a/test/support/rekor_helper.rb +++ b/test/support/rekor_helper.rb @@ -25,7 +25,7 @@ def stub_rekor_create_rekord(gem_path: @gem_path, body: {}, returning: {}) }, body: hash_including( { - kind: "rekord", + kind: "hashedrekord", apiVersion: "0.0.1", spec: hash_including({ signature: hash_including({ @@ -36,7 +36,6 @@ def stub_rekor_create_rekord(gem_path: @gem_path, body: {}, returning: {}) }), }), data: hash_including({ - content: BASE64_ENCODED_PATTERN, hash: hash_including({ algorithm: "sha256", value: gem.digest,