Skip to content

Commit

Permalink
Support exporting RSA private keys
Browse files Browse the repository at this point in the history
  • Loading branch information
anakinj committed Oct 9, 2020
1 parent 876f6cd commit f44445b
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 37 deletions.
11 changes: 11 additions & 0 deletions README.md
Expand Up @@ -486,6 +486,17 @@ jwks = { keys: [{ ... }] } # keys needs to be Symbol
JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwks})
```

### Importing and exporting JSON Web Keys

The ::JWT::JWK class can be used to import and export both the public key (default behaviour) and the private key. To include the private key in the export pass the `include_private` parameter to the export method.

```ruby
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048))

jwk_hash = jwk.export
jwk_hash_with_private_key = jwk.export(include_private: true)
```

# Development and Tests

We depend on [Bundler](http://rubygems.org/gems/bundler) for defining gemspec and performing releases to rubygems.org, which can be done with
Expand Down
4 changes: 4 additions & 0 deletions lib/jwt/jwk/key_abstract.rb
Expand Up @@ -22,6 +22,10 @@ def export(_options = {})
raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
end

protected

attr_writer :kid

class << self
def import(_jwk_data)
raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
Expand Down
110 changes: 75 additions & 35 deletions lib/jwt/jwk/rsa.rb
Expand Up @@ -2,17 +2,15 @@

module JWT
module JWK
class RSA
attr_reader :keypair
attr_reader :jwk_kid

BINARY = 2
KTY = 'RSA'.freeze
class RSA < KeyAbstract
BINARY = 2
KTY = 'RSA'.freeze
RSA_KEY_ELEMENTS = %i[n e d p q dp dq qi].freeze

def initialize(keypair, kid = nil)
raise ArgumentError, 'keypair must be of type OpenSSL::PKey::RSA' unless keypair.is_a?(OpenSSL::PKey::RSA)
@jwk_kid = kid
@keypair = keypair
super
self.kid ||= generate_kid
end

def private?
Expand All @@ -23,52 +21,94 @@ def public_key
keypair.public_key
end

def kid
return jwk_kid if jwk_kid
sequence = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer.new(public_key.n),
OpenSSL::ASN1::Integer.new(public_key.e)])
OpenSSL::Digest::SHA256.hexdigest(sequence.to_der)
end

def export
{
def export(options = {})
exported_hash = {
kty: KTY,
n: encode_open_ssl_bn(public_key.n),
e: encode_open_ssl_bn(public_key.e),
kid: kid
}

return exported_hash unless private? && options[:include_private] == true

append_private_parts(exported_hash)
end

private

def generate_kid
sequence = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer.new(public_key.n),
OpenSSL::ASN1::Integer.new(public_key.e)])
OpenSSL::Digest::SHA256.hexdigest(sequence.to_der)
end

def append_private_parts(the_hash)
the_hash.merge(
d: encode_open_ssl_bn(keypair.d),
p: encode_open_ssl_bn(keypair.p),
q: encode_open_ssl_bn(keypair.q),
dp: encode_open_ssl_bn(keypair.dmp1),
dq: encode_open_ssl_bn(keypair.dmq1),
qi: encode_open_ssl_bn(keypair.iqmp)
)
end

def encode_open_ssl_bn(key_part)
::Base64.urlsafe_encode64(key_part.to_s(BINARY), padding: false)
end

def self.import(jwk_data)
jwk_n = jwk_data[:n] || jwk_data['n']
jwk_e = jwk_data[:e] || jwk_data['e']
class << self
def import(jwk_data)
pkey_params = jwk_attributes(jwk_data, *RSA_KEY_ELEMENTS) do |value|
decode_open_ssl_bn(value)
end
kid = jwk_attributes(jwk_data, :kid)[:kid]
self.new(rsa_pkey(pkey_params), kid)
end

raise JWT::JWKError, 'Key format is invalid for RSA' unless jwk_n && jwk_e
private

self.new(rsa_pkey(jwk_n, jwk_e), jwk_data[:kid] || jwk_data['kid'])
end
def jwk_attributes(jwk_data, *attributes)
attributes.each_with_object({}) do |attribute, hash|
value = jwk_data[attribute] || jwk_data[attribute.to_s]
value = yield(value) if block_given?
hash[attribute] = value
end
end

def rsa_pkey(rsa_parameters)
raise JWT::JWKError, 'Key format is invalid for RSA' unless rsa_parameters[:n] && rsa_parameters[:e]

def self.rsa_pkey(jwk_n, jwk_e)
key = OpenSSL::PKey::RSA.new
key_n = decode_open_ssl_bn(jwk_n)
key_e = decode_open_ssl_bn(jwk_e)
populate_key(OpenSSL::PKey::RSA.new, rsa_parameters)
end

self.new(imported_key)
key.set_key(key_n, key_e, nil)
if OpenSSL::PKey::RSA.new.respond_to?(:set_key)
def populate_key(rsa_key, rsa_parameters)
rsa_key.set_key(rsa_parameters[:n], rsa_parameters[:e], rsa_parameters[:d])
rsa_key.set_factors(rsa_parameters[:p], rsa_parameters[:q]) if rsa_parameters[:p] && rsa_parameters[:q]
rsa_key.set_crt_params(rsa_parameters[:dp], rsa_parameters[:dq], rsa_parameters[:qi]) if rsa_parameters[:dp] && rsa_parameters[:dq] && rsa_parameters[:qi]
rsa_key
end
else
key.n = key_n
key.e = key_e
def populate_key(rsa_key, rsa_parameters) # rubocop:disable Metrics/CyclomaticComplexity
rsa_key.n = rsa_parameters[:n]
rsa_key.e = rsa_parameters[:e]
rsa_key.d = rsa_parameters[:d] if rsa_parameters[:d]
rsa_key.p = rsa_parameters[:p] if rsa_parameters[:p]
rsa_key.q = rsa_parameters[:q] if rsa_parameters[:q]
rsa_key.dmp1 = rsa_parameters[:dp] if rsa_parameters[:dp]
rsa_key.dmq1 = rsa_parameters[:dq] if rsa_parameters[:dq]
rsa_key.iqmp = rsa_parameters[:qi] if rsa_parameters[:qi]

rsa_key
end
end

key
end
def decode_open_ssl_bn(jwk_data)
return nil unless jwk_data

def self.decode_open_ssl_bn(jwk_data)
OpenSSL::BN.new(::Base64.urlsafe_decode64(jwk_data), BINARY)
OpenSSL::BN.new(::Base64.urlsafe_decode64(jwk_data), BINARY)
end
end
end
end
Expand Down
22 changes: 20 additions & 2 deletions spec/jwk/rsa_spec.rb
Expand Up @@ -34,7 +34,7 @@
it 'returns a hash with the public parts of the key' do
expect(subject).to be_a Hash
expect(subject).to include(:kty, :n, :e, :kid)
expect(subject).not_to include(:d)
expect(subject).not_to include(:d, :p, :dp, :dq, :qi)
end
end

Expand All @@ -43,7 +43,7 @@
it 'returns a hash with the public parts of the key' do
expect(subject).to be_a Hash
expect(subject).to include(:kty, :n, :e, :kid)
expect(subject).not_to include(:d)
expect(subject).not_to include(:d, :p, :dp, :dq, :qi)
end
end

Expand All @@ -53,6 +53,15 @@
expect { subject }.to raise_error(ArgumentError, 'keypair must be of type OpenSSL::PKey::RSA')
end
end

context 'when private key is requested' do
subject { described_class.new(keypair).export(include_private: true) }
let(:keypair) { rsa_key }
it 'returns a hash with the public AND private parts of the key' do
expect(subject).to be_a Hash
expect(subject).to include(:kty, :n, :e, :kid, :d, :p, :q, :dp, :dq, :qi)
end
end
end

describe '.import' do
Expand All @@ -77,6 +86,15 @@
end
end

context 'when private key is included in the data' do
let(:exported_key) { described_class.new(rsa_key).export(include_private: true) }
let(:params) { exported_key }
it 'creates a complete keypair' do
expect(subject).to be_a described_class
expect(subject.private?).to eq true
end
end

context 'when jwk_data is given without e and/or n' do
let(:params) { { kty: "RSA" } }
it 'raises an error' do
Expand Down

0 comments on commit f44445b

Please sign in to comment.