From da2ac2d4192ff6d7958e292f1596fe69b98f1a3e Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 10 Sep 2018 15:28:54 +0100 Subject: [PATCH 1/8] Adds PS* (RSA with probabilistic signature scheme) support for signing JWTs --- lib/jwt/algos/ps.rb | 20 ++++++++++++++++++++ lib/jwt/security_utils.rb | 6 ++++++ lib/jwt/signature.rb | 2 ++ 3 files changed, 28 insertions(+) create mode 100644 lib/jwt/algos/ps.rb diff --git a/lib/jwt/algos/ps.rb b/lib/jwt/algos/ps.rb new file mode 100644 index 00000000..13eb69f0 --- /dev/null +++ b/lib/jwt/algos/ps.rb @@ -0,0 +1,20 @@ +module JWT + module Algos + module Ps + module_function + + SUPPORTED = %w[PS256 PS384 PS512].freeze + + def sign(to_sign) + algorithm, msg, key = to_sign.values + raise EncodeError, "The given key is a #{key.class}. It has to be an OpenSSL::PKey::RSA instance." if key.class == String + + key.sign_pss(algorithm.sub('PS', 'sha'), msg, salt_length: :max, mgf1_hash: algorithm.sub('PS', 'sha')) + end + + def verify(to_verify) + SecurityUtils.verify_ps(to_verify.algorithm, to_verify.public_key, to_verify.signing_input, to_verify.signature) + end + end + end +end diff --git a/lib/jwt/security_utils.rb b/lib/jwt/security_utils.rb index 7193dc26..b95dbe81 100644 --- a/lib/jwt/security_utils.rb +++ b/lib/jwt/security_utils.rb @@ -20,6 +20,12 @@ def verify_rsa(algorithm, public_key, signing_input, signature) public_key.verify(OpenSSL::Digest.new(algorithm.sub('RS', 'sha')), signature, signing_input) end + def verify_ps(algorithm, public_key, signing_input, signature) + formatted_algorithm = algorithm.sub('PS', 'sha') + + public_key.verify_pss(formatted_algorithm, signature, signing_input, salt_length: :auto, mgf1_hash: formatted_algorithm) + end + def asn1_to_raw(signature, public_key) byte_size = (public_key.group.degree + 7) / 8 OpenSSL::ASN1.decode(signature).value.map { |value| value.value.to_s(2).rjust(byte_size, "\x00") }.join diff --git a/lib/jwt/signature.rb b/lib/jwt/signature.rb index 8bc296da..bd933201 100644 --- a/lib/jwt/signature.rb +++ b/lib/jwt/signature.rb @@ -6,6 +6,7 @@ require 'jwt/algos/eddsa' require 'jwt/algos/ecdsa' require 'jwt/algos/rsa' +require 'jwt/algos/ps' require 'jwt/algos/unsupported' begin require 'rbnacl' @@ -23,6 +24,7 @@ module Signature Algos::Ecdsa, Algos::Rsa, Algos::Eddsa, + Algos::Ps, Algos::Unsupported ].freeze ToSign = Struct.new(:algorithm, :msg, :key) From 598be5cda8721d600b639bd95931789f9ee09b87 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 10 Sep 2018 15:29:21 +0100 Subject: [PATCH 2/8] Updates README with examples for PS* supported signing algorithm --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dbbbc64d..5b8daa28 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,28 @@ decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519' } **RSASSA-PSS** -Not implemented. +* PS256 - RSASSA-PSS using SHA-256 hash algorithm +* PS384 - RSASSA-PSS using SHA-384 hash algorithm +* PS512 - RSASSA-PSS using SHA-512 hash algorithm + +```ruby +rsa_private = OpenSSL::PKey::RSA.generate 2048 +rsa_public = rsa_private.public_key + +token = JWT.encode payload, rsa_private, 'PS256' + +# eyJhbGciOiJQUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.KEmqagMUHM-NcmXo6818ZazVTIAkn9qU9KQFT1c5Iq91n0KRpAI84jj4ZCdkysDlWokFs3Dmn4MhcXP03oJKLFgnoPL40_Wgg9iFr0jnIVvnMUp1kp2RFUbL0jqExGTRA3LdAhuvw6ZByGD1bkcWjDXygjQw-hxILrT1bENjdr0JhFd-cB0-ps5SB0mwhFNcUw-OM3Uu30B1-mlFaelUY8jHJYKwLTZPNxHzndt8RGXF8iZLp7dGb06HSCKMcVzhASGMH4ZdFystRe2hh31cwcvnl-Eo_D4cdwmpN3Abhk_8rkxawQJR3duh8HNKc4AyFPo7SabEaSu2gLnLfN3yfg +puts token + +decoded_token = JWT.decode token, rsa_public, true, { algorithm: 'PS256' } + +# Array +# [ +# {"data"=>"test"}, # payload +# {"alg"=>"PS256"} # header +# ] +puts decoded_token +``` ## Support for reserved claim names JSON Web Token defines some reserved claim names and defines how they should be From fa92222a935280ad55f9ed778bee6cd4d689a214 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 10 Sep 2018 15:29:42 +0100 Subject: [PATCH 3/8] Adds specs for PS* signing algorithm --- spec/integration/readme_examples_spec.rb | 13 ++++++ spec/jwt_spec.rb | 54 +++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index 9ffd8818..a3351699 100644 --- a/spec/integration/readme_examples_spec.rb +++ b/spec/integration/readme_examples_spec.rb @@ -56,6 +56,19 @@ { 'alg' => 'ES256' } ] end + + it 'RSASSA-PSS' do + rsa_private = OpenSSL::PKey::RSA.generate 2048 + rsa_public = rsa_private.public_key + + token = JWT.encode payload, rsa_private, 'PS256' + decoded_token = JWT.decode token, rsa_public, true, algorithm: 'PS256' + + expect(decoded_token).to eq [ + { 'data' => 'test' }, + { 'alg' => 'PS256' } + ] + end end context 'claims' do diff --git a/spec/jwt_spec.rb b/spec/jwt_spec.rb index fbade515..65085aff 100644 --- a/spec/jwt_spec.rb +++ b/spec/jwt_spec.rb @@ -31,7 +31,10 @@ 'RS512' => 'eyJhbGciOiJSUzUxMiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.LIIAUEuCkGNdpYguOO5LoW4rZ7ED2POJrB0pmEAAchyTdIK4HKh1jcLxc6KyGwZv40njCgub3y72q6vcQTn7oD0zWFCVQRIDW1911Ii2hRNHuigiPUnrnZh1OQ6z65VZRU6GKs8omoBGU9vrClBU0ODqYE16KxYmE_0n4Xw2h3D_L1LF0IAOtDWKBRDa3QHwZRM9sHsHNsBuD5ye9KzDYN1YALXj64LBfA-DoCKfpVAm9NkRPOyzjR2X2C3TomOSJgqWIVHJucudKDDAZyEbO4RA5pI-UFYy1370p9bRajvtDyoBuLDCzoSkMyQ4L2DnLhx5CbWcnD7Cd3GUmnjjTA', 'ES256' => '', 'ES384' => '', - 'ES512' => '' + 'ES512' => '', + 'PS256' => '', + 'PS384' => '', + 'PS512' => '' } end @@ -205,6 +208,55 @@ end end + %w[PS256 PS384 PS512].each do |alg| + context "alg: #{alg}" do + before(:each) do + data[alg] = JWT.encode payload, data[:rsa_private], alg + end + + let(:wrong_key) { data[:wrong_rsa_public] } + + it 'should generate a valid token' do + token = data[alg] + + header, body, signature = token.split('.') + + expect(header).to eql(Base64.strict_encode64({ alg: alg }.to_json)) + expect(body).to eql(Base64.strict_encode64(payload.to_json)) + + # Validate signature is made of up header and body of JWT + translated_alg = alg.gsub('PS', 'sha') + valid_signature = data[:rsa_public].verify_pss( + translated_alg, + JWT::Decode.base64url_decode(signature), + [header, body].join('.'), + salt_length: :auto, + mgf1_hash: translated_alg + ) + expect(valid_signature).to be true + end + + it 'should decode a valid token' do + jwt_payload, header = JWT.decode data[alg], data[:rsa_public], true, algorithm: alg + + expect(header['alg']).to eq alg + expect(jwt_payload).to eq payload + end + + it 'wrong key should raise JWT::DecodeError' do + expect do + JWT.decode data[alg], wrong_key + end.to raise_error JWT::DecodeError + end + + it 'wrong key and verify = false should not raise JWT::DecodeError' do + expect do + JWT.decode data[alg], wrong_key, false + end.not_to raise_error + end + end + end + context 'Invalid' do it 'algorithm should raise NotImplementedError' do expect do From 0979f73cf3c20e360ddb985292d733e0a8f22f1c Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 10 Sep 2018 15:35:37 +0100 Subject: [PATCH 4/8] Sets OpenSSL dependency, as versions before OpenSSL 2.1.0 did not include RSASSA-PSS support --- ruby-jwt.gemspec | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ruby-jwt.gemspec b/ruby-jwt.gemspec index 1085e4fd..ff4f0cb6 100644 --- a/ruby-jwt.gemspec +++ b/ruby-jwt.gemspec @@ -29,4 +29,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'codeclimate-test-reporter' spec.add_development_dependency 'codacy-coverage' spec.add_development_dependency 'rbnacl' + + # RSASSA-PSS support provided by OpenSSL +2.1 + spec.add_runtime_dependency 'openssl', '~> 2.1' end From 5d841fae10aa96bee90eb7db7061a9499c373c85 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 10 Sep 2018 15:48:20 +0100 Subject: [PATCH 5/8] Refactors repeated code for algorithm translation and class type to it's own variables. --- lib/jwt/algos/ps.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/jwt/algos/ps.rb b/lib/jwt/algos/ps.rb index 13eb69f0..cdc36f3a 100644 --- a/lib/jwt/algos/ps.rb +++ b/lib/jwt/algos/ps.rb @@ -7,9 +7,14 @@ module Ps def sign(to_sign) algorithm, msg, key = to_sign.values - raise EncodeError, "The given key is a #{key.class}. It has to be an OpenSSL::PKey::RSA instance." if key.class == String - key.sign_pss(algorithm.sub('PS', 'sha'), msg, salt_length: :max, mgf1_hash: algorithm.sub('PS', 'sha')) + key_class = key.class + + raise EncodeError, "The given key is a #{key_class}. It has to be an OpenSSL::PKey::RSA instance." if key_class == String + + translated_algorithm = algorithm.sub('PS', 'sha') + + key.sign_pss(translated_algorithm, msg, salt_length: :max, mgf1_hash: translated_algorithm) end def verify(to_verify) From c14d70b7ffa18418d57ce0cfa6ceb3b5a24605c2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Sep 2018 18:11:47 +0100 Subject: [PATCH 6/8] Makes OpenSSL +2.1 an optional gem, however it is required for the PS* algorithms. --- README.md | 6 ++++++ lib/jwt/algos/ps.rb | 12 ++++++++++++ lib/jwt/error.rb | 5 +++-- ruby-jwt.gemspec | 3 +-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5b8daa28..c52f043b 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,12 @@ decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519' } **RSASSA-PSS** +In order to use this algorithm you need to add the `openssl` gem to you `Gemfile` with a version greater or equal to `2.1`. + +```ruby +gem 'openssl', '~> 2.1' +``` + * PS256 - RSASSA-PSS using SHA-256 hash algorithm * PS384 - RSASSA-PSS using SHA-384 hash algorithm * PS512 - RSASSA-PSS using SHA-512 hash algorithm diff --git a/lib/jwt/algos/ps.rb b/lib/jwt/algos/ps.rb index cdc36f3a..221790e4 100644 --- a/lib/jwt/algos/ps.rb +++ b/lib/jwt/algos/ps.rb @@ -1,11 +1,15 @@ module JWT module Algos module Ps + # RSASSA-PSS signing algorithms + module_function SUPPORTED = %w[PS256 PS384 PS512].freeze def sign(to_sign) + require_openssl! + algorithm, msg, key = to_sign.values key_class = key.class @@ -18,8 +22,16 @@ def sign(to_sign) end def verify(to_verify) + require_openssl! + SecurityUtils.verify_ps(to_verify.algorithm, to_verify.public_key, to_verify.signing_input, to_verify.signature) end + + def require_openssl! + unless Gem.loaded_specs['openssl'] && Gem.loaded_specs['openssl'].version.release >= Gem::Version.new('2.1') + raise JWT::RequiredGemError, 'OpenSSL +2.1 is required to support RSASSA-PSS algorithms' + end + end end end end diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index ae296f12..0a72169f 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true module JWT - EncodeError = Class.new(StandardError) - DecodeError = Class.new(StandardError) + EncodeError = Class.new(StandardError) + DecodeError = Class.new(StandardError) + RequiredGemError = Class.new(StandardError) VerificationError = Class.new(DecodeError) ExpiredSignature = Class.new(DecodeError) diff --git a/ruby-jwt.gemspec b/ruby-jwt.gemspec index ff4f0cb6..823be024 100644 --- a/ruby-jwt.gemspec +++ b/ruby-jwt.gemspec @@ -29,7 +29,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'codeclimate-test-reporter' spec.add_development_dependency 'codacy-coverage' spec.add_development_dependency 'rbnacl' - # RSASSA-PSS support provided by OpenSSL +2.1 - spec.add_runtime_dependency 'openssl', '~> 2.1' + spec.add_development_dependency 'openssl', '~> 2.1' end From 1d165583488754211adfead17eaaec61d6a6929f Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Sep 2018 18:14:33 +0100 Subject: [PATCH 7/8] Refactors "Gem.loaded_specs['openssl']" into it's own variable --- lib/jwt/algos/ps.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/jwt/algos/ps.rb b/lib/jwt/algos/ps.rb index 221790e4..e0724741 100644 --- a/lib/jwt/algos/ps.rb +++ b/lib/jwt/algos/ps.rb @@ -28,7 +28,9 @@ def verify(to_verify) end def require_openssl! - unless Gem.loaded_specs['openssl'] && Gem.loaded_specs['openssl'].version.release >= Gem::Version.new('2.1') + openssl_gem = Gem.loaded_specs['openssl'] + + unless openssl_gem && openssl_gem.version.release >= Gem::Version.new('2.1') raise JWT::RequiredGemError, 'OpenSSL +2.1 is required to support RSASSA-PSS algorithms' end end From df315b542927b0dd2921e90c3b61c164a0f94382 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 13 Sep 2018 18:02:30 +0100 Subject: [PATCH 8/8] Replaces usage of Gem#loaded_specs with Object#const_defined? to require OpenSSL +2.1. Replaces RequiredGemError with RequiredDependencyError --- lib/jwt/algos/ps.rb | 12 ++++++++---- lib/jwt/error.rb | 6 +++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/jwt/algos/ps.rb b/lib/jwt/algos/ps.rb index e0724741..864e7c2c 100644 --- a/lib/jwt/algos/ps.rb +++ b/lib/jwt/algos/ps.rb @@ -28,10 +28,14 @@ def verify(to_verify) end def require_openssl! - openssl_gem = Gem.loaded_specs['openssl'] - - unless openssl_gem && openssl_gem.version.release >= Gem::Version.new('2.1') - raise JWT::RequiredGemError, 'OpenSSL +2.1 is required to support RSASSA-PSS algorithms' + if Object.const_defined?('OpenSSL') + major, minor = OpenSSL::VERSION.split('.').first(2) + + unless major.to_i >= 2 && minor.to_i >= 1 + raise JWT::RequiredDependencyError, "You currently have OpenSSL #{OpenSSL::VERSION}. PS support requires >= 2.1" + end + else + raise JWT::RequiredDependencyError, 'PS signing requires OpenSSL +2.1' end end end diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index 0a72169f..bf63145b 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true module JWT - EncodeError = Class.new(StandardError) - DecodeError = Class.new(StandardError) - RequiredGemError = Class.new(StandardError) + EncodeError = Class.new(StandardError) + DecodeError = Class.new(StandardError) + RequiredDependencyError = Class.new(StandardError) VerificationError = Class.new(DecodeError) ExpiredSignature = Class.new(DecodeError)