Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RSASSA-PSS signature signing support #285

Merged
merged 8 commits into from
Sep 27, 2018
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions lib/jwt/algos/ps.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module JWT
module Algos
module Ps

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT::Algos::Ps has no descriptive comment

Read more about it here.

# RSASSA-PSS signing algorithms

module_function

SUPPORTED = %w[PS256 PS384 PS512].freeze

def sign(to_sign)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT::Algos::Ps#sign has approx 6 statements

Read more about it here.

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')
Copy link
Author

@oliver-hohn oliver-hohn Sep 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jtara Thoughts on this approach of using Object#const_defined? over catching NoMethodErrors?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this belt-and-suspenders approach even better.

  • It uses a check for constant def rather than using a Gem function
  • It's ALSO easy to monkey-patch for those who have some other special requirement. While in MY use case, OpenSSL would have already been loaded, perhaps others need to load dynamically, so they can monkey-patch.

I will ask the Rhodes maintainers to comment as well.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I see you already had the belt-and-suspenders (def require_openssl! method) in the previous proposed solution as well.

I've asked the Rhodes developers to comment on the two two proposals.

Thanks for your willingness to keep the library as pure as possible to make it easily usable in strange execution environments!

Copy link

@jtara jtara Sep 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have this preliminary feedback from the Rhodes team:

Tau Technologies Inc. Wednesday at 16:20
Jon,

Team is investigating what preference is more suitable for Rhodes.
We'll post an update to ruby-jwt github issue thread as we get results.

Regards,
Tau Technologies Team.

Tau Technologies Inc. Thursday at 16:54
Jon,

We assume the both methods are well.

What about another possible approach on the application level: to create stub Gem object with stub method loaded_specs?

Regards,
Tau Technologies Team.

Thanks for taking this into consideration. Of course, they are right, Gem object could be stubbed in application. But it invites further creeping of dependencies. Since the library is currently dependency-free, it's a worthy goal, I think, to keep it that way, i think we are agreed.

I think we are good with whatever method you deem best, so please don't hold anything up on account of this.

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
5 changes: 3 additions & 2 deletions lib/jwt/error.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
6 changes: 6 additions & 0 deletions lib/jwt/security_utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT::SecurityUtils#verify_ps has 4 parameters

Read more about it here.

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
Expand Down
2 changes: 2 additions & 0 deletions lib/jwt/signature.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -23,6 +24,7 @@ module Signature
Algos::Ecdsa,
Algos::Rsa,
Algos::Eddsa,
Algos::Ps,
Algos::Unsupported
].freeze
ToSign = Struct.new(:algorithm, :msg, :key)
Expand Down
2 changes: 2 additions & 0 deletions ruby-jwt.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions spec/integration/readme_examples_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion spec/jwt_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@
'RS512' => 'eyJhbGciOiJSUzUxMiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.LIIAUEuCkGNdpYguOO5LoW4rZ7ED2POJrB0pmEAAchyTdIK4HKh1jcLxc6KyGwZv40njCgub3y72q6vcQTn7oD0zWFCVQRIDW1911Ii2hRNHuigiPUnrnZh1OQ6z65VZRU6GKs8omoBGU9vrClBU0ODqYE16KxYmE_0n4Xw2h3D_L1LF0IAOtDWKBRDa3QHwZRM9sHsHNsBuD5ye9KzDYN1YALXj64LBfA-DoCKfpVAm9NkRPOyzjR2X2C3TomOSJgqWIVHJucudKDDAZyEbO4RA5pI-UFYy1370p9bRajvtDyoBuLDCzoSkMyQ4L2DnLhx5CbWcnD7Cd3GUmnjjTA',
'ES256' => '',
'ES384' => '',
'ES512' => ''
'ES512' => '',
'PS256' => '',
'PS384' => '',
'PS512' => ''
}
end

Expand Down Expand Up @@ -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
Expand Down