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

Support exporting RSA JWK private keys #375

Merged
merged 3 commits into from Oct 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
38 changes: 26 additions & 12 deletions 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'
3 changes: 2 additions & 1 deletion lib/jwt/jwk/hmac.rb
Expand Up @@ -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)
Expand Down
32 changes: 0 additions & 32 deletions lib/jwt/jwk/key_abstract.rb

This file was deleted.

18 changes: 18 additions & 0 deletions 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
106 changes: 74 additions & 32 deletions lib/jwt/jwk/rsa.rb
Expand Up @@ -2,16 +2,15 @@

module JWT
module JWK
class RSA
attr_reader :keypair

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)
def initialize(keypair, kid = nil)
raise ArgumentError, 'keypair must be of type OpenSSL::PKey::RSA' unless keypair.is_a?(OpenSSL::PKey::RSA)

@keypair = keypair
super(keypair, kid || generate_kid(keypair.public_key))
end

def private?
Expand All @@ -22,51 +21,94 @@ def public_key
keypair.public_key
end

def 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(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)
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

private

raise JWT::JWKError, 'Key format is invalid for RSA' unless jwk_n && jwk_e
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

self.new(rsa_pkey(jwk_n, jwk_e))
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

if key.respond_to?(:set_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
7 changes: 7 additions & 0 deletions spec/jwk_spec.rb
Expand Up @@ -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
Expand Down