diff --git a/README.md b/README.md index dbbbc64d..c52f043b 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,34 @@ decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519' } **RSASSA-PSS** -Not implemented. +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 + +```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 diff --git a/lib/jwt/algos/ps.rb b/lib/jwt/algos/ps.rb new file mode 100644 index 00000000..864e7c2c --- /dev/null +++ b/lib/jwt/algos/ps.rb @@ -0,0 +1,43 @@ +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 + + 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) + require_openssl! + + SecurityUtils.verify_ps(to_verify.algorithm, to_verify.public_key, to_verify.signing_input, to_verify.signature) + end + + def require_openssl! + 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 + end +end diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index ae296f12..bf63145b 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) + RequiredDependencyError = Class.new(StandardError) VerificationError = Class.new(DecodeError) ExpiredSignature = Class.new(DecodeError) 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) diff --git a/ruby-jwt.gemspec b/ruby-jwt.gemspec index 1085e4fd..823be024 100644 --- a/ruby-jwt.gemspec +++ b/ruby-jwt.gemspec @@ -29,4 +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_development_dependency 'openssl', '~> 2.1' end 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