From 876f6cdc63ebc7c3555f23b71295d840ca657d87 Mon Sep 17 00:00:00 2001 From: Ryan Metzler Date: Mon, 10 Jun 2019 01:01:41 -0400 Subject: [PATCH 1/3] preserve kid when importing --- lib/jwt/jwk/rsa.rb | 10 ++++++---- spec/jwk_spec.rb | 7 +++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/jwt/jwk/rsa.rb b/lib/jwt/jwk/rsa.rb index a3ad9b76..e7410a92 100644 --- a/lib/jwt/jwk/rsa.rb +++ b/lib/jwt/jwk/rsa.rb @@ -4,13 +4,14 @@ module JWT module JWK class RSA attr_reader :keypair + attr_reader :jwk_kid BINARY = 2 KTY = 'RSA'.freeze - def initialize(keypair) + 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 end @@ -23,6 +24,7 @@ def 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) @@ -47,7 +49,7 @@ def self.import(jwk_data) raise JWT::JWKError, 'Key format is invalid for RSA' unless jwk_n && jwk_e - self.new(rsa_pkey(jwk_n, jwk_e)) + self.new(rsa_pkey(jwk_n, jwk_e), jwk_data[:kid] || jwk_data['kid']) end def self.rsa_pkey(jwk_n, jwk_e) @@ -55,7 +57,7 @@ def self.rsa_pkey(jwk_n, jwk_e) key_n = decode_open_ssl_bn(jwk_n) key_e = decode_open_ssl_bn(jwk_e) - if key.respond_to?(:set_key) + self.new(imported_key) key.set_key(key_n, key_e, nil) else key.n = key_n diff --git a/spec/jwk_spec.rb b/spec/jwk_spec.rb index 12d85f89..29f5a773 100644 --- a/spec/jwk_spec.rb +++ b/spec/jwk_spec.rb @@ -33,6 +33,13 @@ expect { subject }.to raise_error(JWT::JWKError) end end + + context 'when keypair with defined kid is imported' do + it 'returns the predefined kid if jwt_data contains a kid' do + params[:kid] = "CUSTOM_KID" + expect(subject.export).to eq(params) + end + end end describe '.new' do From f44445b3e21dfcff855985a807c3d44cee8c3420 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Fri, 25 Sep 2020 08:56:53 +0300 Subject: [PATCH 2/3] Support exporting RSA private keys --- README.md | 11 ++++ lib/jwt/jwk/key_abstract.rb | 4 ++ lib/jwt/jwk/rsa.rb | 110 ++++++++++++++++++++++++------------ spec/jwk/rsa_spec.rb | 22 +++++++- 4 files changed, 110 insertions(+), 37 deletions(-) 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 From 89247c4dbd1dccad536028e7648a0ab50f8283b9 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Fri, 9 Oct 2020 10:07:54 +0300 Subject: [PATCH 3/3] Use the KeyBase to register the different key types --- lib/jwt/jwk.rb | 38 +++++++++++++++++++++++++------------ lib/jwt/jwk/hmac.rb | 3 ++- lib/jwt/jwk/key_abstract.rb | 36 ----------------------------------- lib/jwt/jwk/key_base.rb | 18 ++++++++++++++++++ lib/jwt/jwk/rsa.rb | 12 ++++++------ 5 files changed, 52 insertions(+), 55 deletions(-) delete mode 100644 lib/jwt/jwk/key_abstract.rb create mode 100644 lib/jwt/jwk/key_base.rb diff --git a/lib/jwt/jwk.rb b/lib/jwt/jwk.rb index 04aee444..01d8167a 100644 --- a/lib/jwt/jwk.rb +++ b/lib/jwt/jwk.rb @@ -1,36 +1,50 @@ # frozen_string_literal: true -require_relative 'jwk/key_abstract' -require_relative 'jwk/rsa' -require_relative 'jwk/hmac' require_relative 'jwk/key_finder' module JWT module JWK - MAPPINGS = { - 'RSA' => ::JWT::JWK::RSA, - OpenSSL::PKey::RSA => ::JWT::JWK::RSA, - 'oct' => ::JWT::JWK::HMAC, - String => ::JWT::JWK::HMAC - }.freeze - class << self def import(jwk_data) jwk_kty = jwk_data[:kty] || jwk_data['kty'] raise JWT::JWKError, 'Key type (kty) not provided' unless jwk_kty - MAPPINGS.fetch(jwk_kty.to_s) do |kty| + mappings.fetch(jwk_kty.to_s) do |kty| raise JWT::JWKError, "Key type #{kty} not supported" end.import(jwk_data) end def create_from(keypair) - MAPPINGS.fetch(keypair.class) do |klass| + mappings.fetch(keypair.class) do |klass| raise JWT::JWKError, "Cannot create JWK from a #{klass.name}" end.new(keypair) end + def classes + @mappings = nil # reset the cached mappings + @classes ||= [] + end + alias new create_from + + private + + def mappings + @mappings ||= generate_mappings + end + + def generate_mappings + classes.each_with_object({}) do |klass, hash| + next unless klass.const_defined?('KTYS') + Array(klass::KTYS).each do |kty| + hash[kty] = klass + end + end + end end end end + +require_relative 'jwk/key_base' +require_relative 'jwk/rsa' +require_relative 'jwk/hmac' diff --git a/lib/jwt/jwk/hmac.rb b/lib/jwt/jwk/hmac.rb index 10c4fec1..61839e97 100644 --- a/lib/jwt/jwk/hmac.rb +++ b/lib/jwt/jwk/hmac.rb @@ -2,8 +2,9 @@ module JWT module JWK - class HMAC < KeyAbstract + class HMAC < KeyBase KTY = 'oct'.freeze + KTYS = [KTY, String].freeze def initialize(keypair, kid = nil) raise ArgumentError, 'keypair must be of type String' unless keypair.is_a?(String) diff --git a/lib/jwt/jwk/key_abstract.rb b/lib/jwt/jwk/key_abstract.rb deleted file mode 100644 index 1251e2bc..00000000 --- a/lib/jwt/jwk/key_abstract.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module JWT - module JWK - class KeyAbstract - attr_reader :keypair, :kid - - def initialize(keypair, kid = nil) - @keypair = keypair - @kid = kid - end - - def private? - raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" - end - - def public_key - raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" - end - - 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__}'" - end - end - end - end -end diff --git a/lib/jwt/jwk/key_base.rb b/lib/jwt/jwk/key_base.rb new file mode 100644 index 00000000..46619a79 --- /dev/null +++ b/lib/jwt/jwk/key_base.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module JWT + module JWK + class KeyBase + attr_reader :keypair, :kid + + def initialize(keypair, kid = nil) + @keypair = keypair + @kid = kid + end + + def self.inherited(klass) + ::JWT::JWK.classes << klass + end + end + end +end diff --git a/lib/jwt/jwk/rsa.rb b/lib/jwt/jwk/rsa.rb index 975131d4..bd66d509 100644 --- a/lib/jwt/jwk/rsa.rb +++ b/lib/jwt/jwk/rsa.rb @@ -2,15 +2,15 @@ module JWT module JWK - class RSA < KeyAbstract - BINARY = 2 - KTY = 'RSA'.freeze + class RSA < KeyBase + BINARY = 2 + KTY = 'RSA'.freeze + KTYS = [KTY, OpenSSL::PKey::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) - super - self.kid ||= generate_kid + super(keypair, kid || generate_kid(keypair.public_key)) end def private? @@ -36,7 +36,7 @@ def export(options = {}) private - def generate_kid + def generate_kid(public_key) 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)