diff --git a/README.md b/README.md index 83c1723e..2924ac9c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/jwt/jwk/key_abstract.rb b/lib/jwt/jwk/key_abstract.rb index b9271469..1251e2bc 100644 --- a/lib/jwt/jwk/key_abstract.rb +++ b/lib/jwt/jwk/key_abstract.rb @@ -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__}'" diff --git a/lib/jwt/jwk/rsa.rb b/lib/jwt/jwk/rsa.rb index e7410a92..975131d4 100644 --- a/lib/jwt/jwk/rsa.rb +++ b/lib/jwt/jwk/rsa.rb @@ -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? @@ -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 diff --git a/spec/jwk/rsa_spec.rb b/spec/jwk/rsa_spec.rb index 9d823772..4b6c9274 100644 --- a/spec/jwk/rsa_spec.rb +++ b/spec/jwk/rsa_spec.rb @@ -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 @@ -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 @@ -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 @@ -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