diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd19790..f20db635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Features:** - Support custom algorithms by passing algorithm objects[#512](https://github.com/jwt/ruby-jwt/pull/512) ([@anakinj](https://github.com/anakinj)). +- Support descriptive (not key related) JWK parameters[#520](https://github.com/jwt/ruby-jwt/pull/520) ([@bellebaum](https://github.com/bellebaum)). - Your contribution here **Fixes and enhancements:** diff --git a/README.md b/README.md index e66eeca9..10b8b0f7 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ JWK is a JSON structure representing a cryptographic key. Currently only support If the kid is not found from the given set the loader will be called a second time with the `kid_not_found` option set to `true`. The application can choose to implement some kind of JWK cache invalidation or other mechanism to handle such cases. ```ruby - jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'optional-kid') + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), kid: 'optional-kid') payload = { data: 'data' } headers = { kid: jwk.kid } @@ -612,13 +612,27 @@ 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. +The ::JWT::JWK class can be used to import both JSON Web Keys and OpenSSL keys +and export to either format with and without the private key included. + +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)) +# Import a JWK Hash (showing an HMAC example) +jwk = JWT::JWK.new({ kty: 'oct', k: 'my-secret', kid: 'my-kid' }) + +# Import an OpenSSL key +# You can optionally add descriptive parameters to the JWK +desc_params = { kid: 'my-kid', use: 'sig' } +jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), desc_params) +# Export as JWK Hash (public key only by default) jwk_hash = jwk.export jwk_hash_with_private_key = jwk.export(include_private: true) + +# Export as OpenSSL key +public_key = jwk.public_key +private_key = jwk.keypair if jwk.private? ``` ### Key ID (kid) and JWKs @@ -630,7 +644,7 @@ JWT.configuration.jwk.kid_generator_type = :rfc7638_thumbprint # OR JWT.configuration.jwk.kid_generator = ::JWT::JWK::Thumbprint # OR -jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), kid_generator: ::JWT::JWK::Thumbprint) +jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), nil, kid_generator: ::JWT::JWK::Thumbprint) jwk_hash = jwk.export diff --git a/lib/jwt/jwk.rb b/lib/jwt/jwk.rb index 1db2060e..578a59b3 100644 --- a/lib/jwt/jwk.rb +++ b/lib/jwt/jwk.rb @@ -5,19 +5,19 @@ module JWT module JWK 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| - raise JWT::JWKError, "Key type #{kty} not supported" - end.import(jwk_data) - end + def create_from(key, params = nil, options = {}) + if key.is_a?(Hash) + jwk_kty = key[:kty] || key['kty'] + raise JWT::JWKError, 'Key type (kty) not provided' unless jwk_kty + + return mappings.fetch(jwk_kty.to_s) do |kty| + raise JWT::JWKError, "Key type #{kty} not supported" + end.new(key, params, options) + end - def create_from(keypair, kid = nil) - mappings.fetch(keypair.class) do |klass| + mappings.fetch(key.class) do |klass| raise JWT::JWKError, "Cannot create JWK from a #{klass.name}" - end.new(keypair, kid) + end.new(key, params, options) end def classes @@ -26,6 +26,7 @@ def classes end alias new create_from + alias import create_from private diff --git a/lib/jwt/jwk/ec.rb b/lib/jwt/jwk/ec.rb index 77cf7c44..c194d991 100644 --- a/lib/jwt/jwk/ec.rb +++ b/lib/jwt/jwk/ec.rb @@ -11,37 +11,48 @@ class EC < KeyBase # rubocop:disable Metrics/ClassLength KTY = 'EC' KTYS = [KTY, OpenSSL::PKey::EC].freeze BINARY = 2 + EC_PUBLIC_KEY_ELEMENTS = %i[kty crv x y].freeze + EC_PRIVATE_KEY_ELEMENTS = %i[d].freeze + EC_KEY_ELEMENTS = (EC_PRIVATE_KEY_ELEMENTS + EC_PUBLIC_KEY_ELEMENTS).freeze + + def initialize(key, params = nil, options = {}) + params ||= {} + + # For backwards compatibility when kid was a String + params = { kid: params } if params.is_a?(String) + + key_params = case key + when OpenSSL::PKey::EC # Accept OpenSSL key as input + @keypair = key # Preserve the object to avoid recreation + parse_ec_key(key) + when Hash + key.transform_keys(&:to_sym) + else + raise ArgumentError, 'key must be of type OpenSSL::PKey::EC or Hash with key parameters' + end - attr_reader :keypair - - def initialize(keypair, options = {}) - raise ArgumentError, 'keypair must be of type OpenSSL::PKey::EC' unless keypair.is_a?(OpenSSL::PKey::EC) + params = params.transform_keys(&:to_sym) + check_jwk(key_params, params) - @keypair = keypair + super(options, key_params.merge(params)) + end - super(options) + def keypair + @keypair ||= create_ec_key(self[:crv], self[:x], self[:y], self[:d]) end def private? - @keypair.private_key? + keypair.private_key? end def members - crv, x_octets, y_octets = keypair_components(keypair) - { - kty: KTY, - crv: crv, - x: encode_octets(x_octets), - y: encode_octets(y_octets) - } + EC_PUBLIC_KEY_ELEMENTS.each_with_object({}) { |i, h| h[i] = self[i] } end def export(options = {}) - exported_hash = members.merge(kid: kid) - - return exported_hash unless private? && options[:include_private] == true - - append_private_parts(exported_hash) + exported = parameters.clone + exported.reject! { |k, _| EC_PRIVATE_KEY_ELEMENTS.include? k } unless private? && options[:include_private] == true + exported end def key_digest @@ -51,13 +62,20 @@ def key_digest OpenSSL::Digest::SHA256.hexdigest(sequence.to_der) end + def []=(key, value) + if EC_KEY_ELEMENTS.include?(key.to_sym) + raise ArgumentError, 'cannot overwrite cryptographic key attributes' + end + + super(key, value) + end + private - def append_private_parts(the_hash) - octets = keypair.private_key.to_bn.to_s(BINARY) - the_hash.merge( - d: encode_octets(octets) - ) + def check_jwk(keypair, params) + raise ArgumentError, 'cannot overwrite cryptographic key attributes' unless (EC_KEY_ELEMENTS & params.keys).empty? + raise JWT::JWKError, "Incorrect 'kty' value: #{keypair[:kty]}, expected #{KTY}" unless keypair[:kty] == KTY + raise JWT::JWKError, 'Key format is invalid for EC' unless keypair[:crv] && keypair[:x] && keypair[:y] end def keypair_components(ec_keypair) @@ -82,6 +100,8 @@ def keypair_components(ec_keypair) end def encode_octets(octets) + return unless octets + ::JWT::Base64.url_encode(octets) end @@ -89,15 +109,94 @@ def encode_open_ssl_bn(key_part) ::JWT::Base64.url_encode(key_part.to_s(BINARY)) end - class << self - def import(jwk_data) - # See https://tools.ietf.org/html/rfc7518#section-6.2.1 for an - # explanation of the relevant parameters. + def parse_ec_key(key) + crv, x_octets, y_octets = keypair_components(key) + octets = key.private_key&.to_bn&.to_s(BINARY) + { + kty: KTY, + crv: crv, + x: encode_octets(x_octets), + y: encode_octets(y_octets), + d: encode_octets(octets) + }.compact + end - jwk_crv, jwk_x, jwk_y, jwk_d, jwk_kid = jwk_attrs(jwk_data, %i[crv x y d kid]) - raise JWT::JWKError, 'Key format is invalid for EC' unless jwk_crv && jwk_x && jwk_y + if ::JWT.openssl_3? + def create_ec_key(jwk_crv, jwk_x, jwk_y, jwk_d) # rubocop:disable Metrics/MethodLength + curve = EC.to_openssl_curve(jwk_crv) + + x_octets = decode_octets(jwk_x) + y_octets = decode_octets(jwk_y) + + point = OpenSSL::PKey::EC::Point.new( + OpenSSL::PKey::EC::Group.new(curve), + OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2) + ) + + sequence = if jwk_d + # https://datatracker.ietf.org/doc/html/rfc5915.html + # ECPrivateKey ::= SEQUENCE { + # version INTEGER { ecPrivkeyVer1(1) } (ecPrivkeyVer1), + # privateKey OCTET STRING, + # parameters [0] ECParameters {{ NamedCurve }} OPTIONAL, + # publicKey [1] BIT STRING OPTIONAL + # } + + OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::Integer(1), + OpenSSL::ASN1::OctetString(OpenSSL::BN.new(decode_octets(jwk_d), 2).to_s(2)), + OpenSSL::ASN1::ObjectId(curve, 0, :EXPLICIT), + OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed), 1, :EXPLICIT) + ]) + else + OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId(curve)]), + OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed)) + ]) + end - new(ec_pkey(jwk_crv, jwk_x, jwk_y, jwk_d), kid: jwk_kid) + OpenSSL::PKey::EC.new(sequence.to_der) + end + else + def create_ec_key(jwk_crv, jwk_x, jwk_y, jwk_d) + curve = EC.to_openssl_curve(jwk_crv) + + x_octets = decode_octets(jwk_x) + y_octets = decode_octets(jwk_y) + + key = OpenSSL::PKey::EC.new(curve) + + # The details of the `Point` instantiation are covered in: + # - https://docs.ruby-lang.org/en/2.4.0/OpenSSL/PKey/EC.html + # - https://www.openssl.org/docs/manmaster/man3/EC_POINT_new.html + # - https://tools.ietf.org/html/rfc5480#section-2.2 + # - https://www.secg.org/SEC1-Ver-1.0.pdf + # Section 2.3.3 of the last of these references specifies that the + # encoding of an uncompressed point consists of the byte `0x04` followed + # by the x value then the y value. + point = OpenSSL::PKey::EC::Point.new( + OpenSSL::PKey::EC::Group.new(curve), + OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2) + ) + + key.public_key = point + key.private_key = OpenSSL::BN.new(decode_octets(jwk_d), 2) if jwk_d + + key + end + end + + def decode_octets(jwk_data) + ::JWT::Base64.url_decode(jwk_data) + end + + def decode_open_ssl_bn(jwk_data) + OpenSSL::BN.new(::JWT::Base64.url_decode(jwk_data), BINARY) + end + + class << self + def import(jwk_data) + new(jwk_data) end def to_openssl_curve(crv) @@ -112,87 +211,6 @@ def to_openssl_curve(crv) else raise JWT::JWKError, 'Invalid curve provided' end end - - private - - def jwk_attrs(jwk_data, attrs) - attrs.map do |attr| - jwk_data[attr] || jwk_data[attr.to_s] - end - end - - if ::JWT.openssl_3? - def ec_pkey(jwk_crv, jwk_x, jwk_y, jwk_d) # rubocop:disable Metrics/MethodLength - curve = to_openssl_curve(jwk_crv) - - x_octets = decode_octets(jwk_x) - y_octets = decode_octets(jwk_y) - - point = OpenSSL::PKey::EC::Point.new( - OpenSSL::PKey::EC::Group.new(curve), - OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2) - ) - - sequence = if jwk_d - # https://datatracker.ietf.org/doc/html/rfc5915.html - # ECPrivateKey ::= SEQUENCE { - # version INTEGER { ecPrivkeyVer1(1) } (ecPrivkeyVer1), - # privateKey OCTET STRING, - # parameters [0] ECParameters {{ NamedCurve }} OPTIONAL, - # publicKey [1] BIT STRING OPTIONAL - # } - - OpenSSL::ASN1::Sequence([ - OpenSSL::ASN1::Integer(1), - OpenSSL::ASN1::OctetString(OpenSSL::BN.new(decode_octets(jwk_d), 2).to_s(2)), - OpenSSL::ASN1::ObjectId(curve, 0, :EXPLICIT), - OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed), 1, :EXPLICIT) - ]) - else - OpenSSL::ASN1::Sequence([ - OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId(curve)]), - OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed)) - ]) - end - - OpenSSL::PKey::EC.new(sequence.to_der) - end - else - def ec_pkey(jwk_crv, jwk_x, jwk_y, jwk_d) - curve = to_openssl_curve(jwk_crv) - - x_octets = decode_octets(jwk_x) - y_octets = decode_octets(jwk_y) - - key = OpenSSL::PKey::EC.new(curve) - - # The details of the `Point` instantiation are covered in: - # - https://docs.ruby-lang.org/en/2.4.0/OpenSSL/PKey/EC.html - # - https://www.openssl.org/docs/manmaster/man3/EC_POINT_new.html - # - https://tools.ietf.org/html/rfc5480#section-2.2 - # - https://www.secg.org/SEC1-Ver-1.0.pdf - # Section 2.3.3 of the last of these references specifies that the - # encoding of an uncompressed point consists of the byte `0x04` followed - # by the x value then the y value. - point = OpenSSL::PKey::EC::Point.new( - OpenSSL::PKey::EC::Group.new(curve), - OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2) - ) - - key.public_key = point - key.private_key = OpenSSL::BN.new(decode_octets(jwk_d), 2) if jwk_d - - key - end - end - - def decode_octets(jwk_data) - ::JWT::Base64.url_decode(jwk_data) - end - - def decode_open_ssl_bn(jwk_data) - OpenSSL::BN.new(::JWT::Base64.url_decode(jwk_data), BINARY) - end end end end diff --git a/lib/jwt/jwk/hmac.rb b/lib/jwt/jwk/hmac.rb index 98573a90..1200bbef 100644 --- a/lib/jwt/jwk/hmac.rb +++ b/lib/jwt/jwk/hmac.rb @@ -5,14 +5,33 @@ module JWK class HMAC < KeyBase KTY = 'oct' KTYS = [KTY, String].freeze + HMAC_PUBLIC_KEY_ELEMENTS = %i[kty].freeze + HMAC_PRIVATE_KEY_ELEMENTS = %i[k].freeze + HMAC_KEY_ELEMENTS = (HMAC_PRIVATE_KEY_ELEMENTS + HMAC_PUBLIC_KEY_ELEMENTS).freeze - attr_reader :signing_key + def initialize(key, params = nil, options = {}) + params ||= {} - def initialize(signing_key, options = {}) - raise ArgumentError, 'signing_key must be of type String' unless signing_key.is_a?(String) + # For backwards compatibility when kid was a String + params = { kid: params } if params.is_a?(String) - @signing_key = signing_key - super(options) + key_params = case key + when String # Accept String key as input + { kty: KTY, k: key } + when Hash + key.transform_keys(&:to_sym) + else + raise ArgumentError, 'key must be of type String or Hash with key parameters' + end + + params = params.transform_keys(&:to_sym) + check_jwk(key_params, params) + + super(options, key_params.merge(params)) + end + + def keypair + self[:k] end def private? @@ -25,26 +44,16 @@ def public_key # See https://tools.ietf.org/html/rfc7517#appendix-A.3 def export(options = {}) - exported_hash = { - kty: KTY, - kid: kid - } - - return exported_hash unless private? && options[:include_private] == true - - exported_hash.merge( - k: signing_key - ) + exported = parameters.clone + exported.reject! { |k, _| HMAC_PRIVATE_KEY_ELEMENTS.include? k } unless private? && options[:include_private] == true + exported end def members - { - kty: KTY, - k: signing_key - } + HMAC_KEY_ELEMENTS.each_with_object({}) { |i, h| h[i] = self[i] } end - alias keypair signing_key # for backwards compatibility + alias signing_key keypair # for backwards compatibility def key_digest sequence = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::UTF8String.new(signing_key), @@ -52,14 +61,25 @@ def key_digest OpenSSL::Digest::SHA256.hexdigest(sequence.to_der) end - class << self - def import(jwk_data) - jwk_k = jwk_data[:k] || jwk_data['k'] - jwk_kid = jwk_data[:kid] || jwk_data['kid'] + def []=(key, value) + if HMAC_KEY_ELEMENTS.include?(key.to_sym) + raise ArgumentError, 'cannot overwrite cryptographic key attributes' + end + + super(key, value) + end - raise JWT::JWKError, 'Key format is invalid for HMAC' unless jwk_k + private - new(jwk_k, kid: jwk_kid) + def check_jwk(keypair, params) + raise ArgumentError, 'cannot overwrite cryptographic key attributes' unless (HMAC_KEY_ELEMENTS & params.keys).empty? + raise JWT::JWKError, "Incorrect 'kty' value: #{keypair[:kty]}, expected #{KTY}" unless keypair[:kty] == KTY + raise JWT::JWKError, 'Key format is invalid for HMAC' unless keypair[:k] + end + + class << self + def import(jwk_data) + new(jwk_data) end end end diff --git a/lib/jwt/jwk/key_base.rb b/lib/jwt/jwk/key_base.rb index 8c796990..7c301ca7 100644 --- a/lib/jwt/jwk/key_base.rb +++ b/lib/jwt/jwk/key_base.rb @@ -8,28 +8,34 @@ def self.inherited(klass) ::JWT::JWK.classes << klass end - def initialize(options) + def initialize(options, params = {}) options ||= {} - if options.is_a?(String) # For backwards compatibility when kid was a String - options = { kid: options } - end + @parameters = params.transform_keys(&:to_sym) # Uniform interface - @kid = options[:kid] - @kid_generator = options[:kid_generator] || ::JWT.configuration.jwk.kid_generator + # For backwards compatibility, kid_generator may be specified in the parameters + options[:kid_generator] ||= @parameters.delete(:kid_generator) + + # Make sure the key has a kid + kid_generator = options[:kid_generator] || ::JWT.configuration.jwk.kid_generator + self[:kid] ||= kid_generator.new(self).generate end def kid - @kid ||= generate_kid + self[:kid] end - private - - attr_reader :kid_generator + def [](key) + @parameters[key.to_sym] + end - def generate_kid - kid_generator.new(self).generate + def []=(key, value) + @parameters[key.to_sym] = value end + + private + + attr_reader :parameters end end end diff --git a/lib/jwt/jwk/rsa.rb b/lib/jwt/jwk/rsa.rb index 5f6d2288..d99b08f0 100644 --- a/lib/jwt/jwk/rsa.rb +++ b/lib/jwt/jwk/rsa.rb @@ -6,16 +6,34 @@ class RSA < KeyBase BINARY = 2 KTY = 'RSA' KTYS = [KTY, OpenSSL::PKey::RSA].freeze - RSA_KEY_ELEMENTS = %i[n e d p q dp dq qi].freeze - - attr_reader :keypair + RSA_PUBLIC_KEY_ELEMENTS = %i[kty n e].freeze + RSA_PRIVATE_KEY_ELEMENTS = %i[d p q dp dq qi].freeze + RSA_KEY_ELEMENTS = (RSA_PRIVATE_KEY_ELEMENTS + RSA_PUBLIC_KEY_ELEMENTS).freeze + + def initialize(key, params = nil, options = {}) + params ||= {} + + # For backwards compatibility when kid was a String + params = { kid: params } if params.is_a?(String) + + key_params = case key + when OpenSSL::PKey::RSA # Accept OpenSSL key as input + @keypair = key # Preserve the object to avoid recreation + parse_rsa_key(key) + when Hash + key.transform_keys(&:to_sym) + else + raise ArgumentError, 'key must be of type OpenSSL::PKey::RSA or Hash with key parameters' + end - def initialize(keypair, options = {}) - raise ArgumentError, 'keypair must be of type OpenSSL::PKey::RSA' unless keypair.is_a?(OpenSSL::PKey::RSA) + params = params.transform_keys(&:to_sym) + check_jwk(key_params, params) - @keypair = keypair + super(options, key_params.merge(params)) + end - super(options) + def keypair + @keypair ||= create_rsa_key(jwk_attributes(*(RSA_KEY_ELEMENTS - [:kty]))) end def private? @@ -27,19 +45,13 @@ def public_key end def export(options = {}) - exported_hash = members.merge(kid: kid) - - return exported_hash unless private? && options[:include_private] == true - - append_private_parts(exported_hash) + exported = parameters.clone + exported.reject! { |k, _| RSA_PRIVATE_KEY_ELEMENTS.include? k } unless private? && options[:include_private] == true + exported end def members - { - kty: KTY, - n: encode_open_ssl_bn(public_key.n), - e: encode_open_ssl_bn(public_key.e) - } + RSA_PUBLIC_KEY_ELEMENTS.each_with_object({}) { |i, h| h[i] = self[i] } end def key_digest @@ -48,89 +60,95 @@ def key_digest OpenSSL::Digest::SHA256.hexdigest(sequence.to_der) end - private + def []=(key, value) + if RSA_KEY_ELEMENTS.include?(key.to_sym) + raise ArgumentError, 'cannot overwrite cryptographic key attributes' + 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) - ) + super(key, value) end - def encode_open_ssl_bn(key_part) - ::JWT::Base64.url_encode(key_part.to_s(BINARY)) - end + private - class << self - def import(jwk_data) - pkey_params = jwk_attributes(jwk_data, *RSA_KEY_ELEMENTS) do |value| - decode_open_ssl_bn(value) - end - new(rsa_pkey(pkey_params), kid: jwk_attributes(jwk_data, :kid)[:kid]) - end + def check_jwk(keypair, params) + raise ArgumentError, 'cannot overwrite cryptographic key attributes' unless (RSA_KEY_ELEMENTS & params.keys).empty? + raise JWT::JWKError, "Incorrect 'kty' value: #{keypair[:kty]}, expected #{KTY}" unless keypair[:kty] == KTY + raise JWT::JWKError, 'Key format is invalid for RSA' unless keypair[:n] && keypair[:e] + end - private + def parse_rsa_key(key) + { + kty: KTY, + n: encode_open_ssl_bn(key.n), + e: encode_open_ssl_bn(key.e), + d: encode_open_ssl_bn(key.d), + p: encode_open_ssl_bn(key.p), + q: encode_open_ssl_bn(key.q), + dp: encode_open_ssl_bn(key.dmp1), + dq: encode_open_ssl_bn(key.dmq1), + qi: encode_open_ssl_bn(key.iqmp) + }.compact + 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 + def jwk_attributes(*attributes) + attributes.each_with_object({}) do |attribute, hash| + hash[attribute] = decode_open_ssl_bn(self[attribute]) end + end - def rsa_pkey(rsa_parameters) - raise JWT::JWKError, 'Key format is invalid for RSA' unless rsa_parameters[:n] && rsa_parameters[:e] - - create_rsa_key(rsa_parameters) - end + def encode_open_ssl_bn(key_part) + return unless key_part - if ::JWT.openssl_3? - ASN1_SEQUENCE = %i[n e d p q dp dq qi].freeze - def create_rsa_key(rsa_parameters) - sequence = ASN1_SEQUENCE.each_with_object([]) do |key, arr| - next if rsa_parameters[key].nil? + ::JWT::Base64.url_encode(key_part.to_s(BINARY)) + end - arr << OpenSSL::ASN1::Integer.new(rsa_parameters[key]) - end + if ::JWT.openssl_3? + ASN1_SEQUENCE = %i[n e d p q dp dq qi].freeze + def create_rsa_key(rsa_parameters) + sequence = ASN1_SEQUENCE.each_with_object([]) do |key, arr| + next if rsa_parameters[key].nil? - if sequence.size > 2 # For a private key - sequence.unshift(OpenSSL::ASN1::Integer.new(0)) - end + arr << OpenSSL::ASN1::Integer.new(rsa_parameters[key]) + end - OpenSSL::PKey::RSA.new(OpenSSL::ASN1::Sequence(sequence).to_der) + if sequence.size > 2 # For a private key + sequence.unshift(OpenSSL::ASN1::Integer.new(0)) end - elsif OpenSSL::PKey::RSA.new.respond_to?(:set_key) - def create_rsa_key(rsa_parameters) - OpenSSL::PKey::RSA.new.tap do |rsa_key| - 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] - end + + OpenSSL::PKey::RSA.new(OpenSSL::ASN1::Sequence(sequence).to_der) + end + elsif OpenSSL::PKey::RSA.new.respond_to?(:set_key) + def create_rsa_key(rsa_parameters) + OpenSSL::PKey::RSA.new.tap do |rsa_key| + 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] end - else - def create_rsa_key(rsa_parameters) # rubocop:disable Metrics/AbcSize - OpenSSL::PKey::RSA.new.tap do |rsa_key| - 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] - end + end + else + def create_rsa_key(rsa_parameters) # rubocop:disable Metrics/AbcSize + OpenSSL::PKey::RSA.new.tap do |rsa_key| + 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] end end + end - def decode_open_ssl_bn(jwk_data) - return nil unless jwk_data + def decode_open_ssl_bn(jwk_data) + return nil unless jwk_data - OpenSSL::BN.new(::JWT::Base64.url_decode(jwk_data), BINARY) + OpenSSL::BN.new(::JWT::Base64.url_decode(jwk_data), BINARY) + end + + class << self + def import(jwk_data) + new(jwk_data) end end end diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index 009aadef..42866dc3 100644 --- a/spec/integration/readme_examples_spec.rb +++ b/spec/integration/readme_examples_spec.rb @@ -277,7 +277,7 @@ let(:logger_output) { StringIO.new } let(:logger) { Logger.new(logger_output) } - it 'works as expected' do + it 'works as expected (legacy)' do jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'optional-kid') payload = { data: 'data' } headers = { kid: jwk.kid } @@ -322,6 +322,70 @@ token = JWT.encode(payload, jwk.keypair, 'RS512', headers) expect { JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) }.to raise_error(JWT::DecodeError, 'Could not find public key for kid yet-another-new-kid') end + + it 'works as expected' do + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), kid: 'optional-kid') + payload = { data: 'data' } + headers = { kid: jwk.kid } + + token = JWT.encode(payload, jwk.keypair, 'RS512', headers) + + # The jwk loader would fetch the set of JWKs from a trusted source, + # to avoid malicious invalidations some kind of protection needs to be implemented. + # This example only allows cache invalidations every 5 minutes. + jwk_loader = ->(options) do + if options[:kid_not_found] && @cache_last_update < Time.now.to_i - 300 + logger.info("Invalidating JWK cache. #{options[:kid]} not found from previous cache") + @cached_keys = nil + end + @cached_keys ||= begin + @cache_last_update = Time.now.to_i + { keys: [jwk.export] } + end + end + + begin + JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) + rescue JWT::JWKError + # Handle problems with the provided JWKs + rescue JWT::DecodeError + # Handle other decode related issues e.g. no kid in header, no matching public key found etc. + end + + ## This is not in the example but verifies that the cache is invalidated after 5 minutes + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'new-kid') + + headers = { kid: jwk.kid } + + token = JWT.encode(payload, jwk.keypair, 'RS512', headers) + @cache_last_update = Time.now.to_i - 301 + + JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) + expect(logger_output.string.chomp).to match(/^I, .* : Invalidating JWK cache. new-kid not found from previous cache/) + + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'yet-another-new-kid') + headers = { kid: jwk.kid } + token = JWT.encode(payload, jwk.keypair, 'RS512', headers) + expect { JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) }.to raise_error(JWT::DecodeError, 'Could not find public key for kid yet-another-new-kid') + end + end + + it 'JWK import and export' do + # Import a JWK Hash (showing an HMAC example) + _jwk = JWT::JWK.new({ kty: 'oct', k: 'my-secret', kid: 'my-kid' }) + + # Import an OpenSSL key + # You can optionally add descriptive parameters to the JWK + desc_params = { kid: 'my-kid', use: 'sig' } + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), desc_params) + + # Export as JWK Hash (public key only by default) + _jwk_hash = jwk.export + _jwk_hash_with_private_key = jwk.export(include_private: true) + + # Export as OpenSSL key + _public_key = jwk.public_key + _private_key = jwk.keypair if jwk.private? end it 'JWK with thumbprint as kid via symbol' do @@ -344,13 +408,21 @@ expect(jwk_hash[:kid].size).to eq(43) end - it 'JWK with thumbprint given in the initializer' do + it 'JWK with thumbprint given in the initializer (legacy)' do jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), kid_generator: ::JWT::JWK::Thumbprint) jwk_hash = jwk.export expect(jwk_hash[:kid].size).to eq(43) end + + it 'JWK with thumbprint given in the initializer' do + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), nil, kid_generator: ::JWT::JWK::Thumbprint) + + jwk_hash = jwk.export + + expect(jwk_hash[:kid].size).to eq(43) + end end context 'custom algorithm example' do diff --git a/spec/jwk/ec_spec.rb b/spec/jwk/ec_spec.rb index f5ecc360..2ff5f01b 100644 --- a/spec/jwk/ec_spec.rb +++ b/spec/jwk/ec_spec.rb @@ -69,6 +69,15 @@ expect(subject).to include(:d) end end + + context 'when a common parameter is given' do + let(:parameters) { { use: 'sig' } } + let(:keypair) { ec_key } + subject { described_class.new(keypair, parameters).export } + it 'returns a hash including the common parameter' do + expect(subject).to include(:use) + end + end end describe '.import' do diff --git a/spec/jwk/hmac_spec.rb b/spec/jwk/hmac_spec.rb index d7594077..ae2c3f27 100644 --- a/spec/jwk/hmac_spec.rb +++ b/spec/jwk/hmac_spec.rb @@ -58,6 +58,15 @@ expect(subject.kid).to eq('custom_key_identifier') end end + + context 'with a common parameter' do + let(:exported_key) { + super().merge(use: 'sig') + } + it 'imports that common parameter' do + expect(subject[:use]).to eq('sig') + end + end end end end diff --git a/spec/jwk/rsa_spec.rb b/spec/jwk/rsa_spec.rb index c9228778..d677f54a 100644 --- a/spec/jwk/rsa_spec.rb +++ b/spec/jwk/rsa_spec.rb @@ -47,7 +47,7 @@ context 'when unsupported keypair is given' do let(:keypair) { 'key' } it 'raises an error' do - expect { subject }.to raise_error(ArgumentError, 'keypair must be of type OpenSSL::PKey::RSA') + expect { subject }.to raise_error(ArgumentError) end end @@ -78,19 +78,31 @@ end end - context 'when kid is given as in a hash parameter' do + context 'when kid is given in a hash parameter' do it 'uses the given kid' do expect(described_class.new(OpenSSL::PKey::RSA.new(2048), kid: 'given').kid).to eq('given') end end end + describe '.common_parameters' do + context 'when a common parameters hash is given' do + it 'imports the common parameter' do + expect(described_class.new(OpenSSL::PKey::RSA.new(2048), use: 'sig')[:use]).to eq('sig') + end + + it 'converts string keys to symbol keys' do + expect(described_class.new(OpenSSL::PKey::RSA.new(2048), { 'use' => 'sig' })[:use]).to eq('sig') + end + end + end + describe '.import' do subject { described_class.import(params) } let(:exported_key) { described_class.new(rsa_key).export } context 'when keypair is imported with symbol keys' do - let(:params) { { e: exported_key[:e], n: exported_key[:n] } } + let(:params) { { kty: 'RSA', e: exported_key[:e], n: exported_key[:n] } } it 'returns a hash with the public parts of the key' do expect(subject).to be_a described_class expect(subject.private?).to eq false @@ -99,7 +111,7 @@ end context 'when keypair is imported with string keys from JSON' do - let(:params) { { 'e' => exported_key[:e], 'n' => exported_key[:n] } } + let(:params) { { 'kty' => 'RSA', 'e' => exported_key[:e], 'n' => exported_key[:n] } } it 'returns a hash with the public parts of the key' do expect(subject).to be_a described_class expect(subject.private?).to eq false diff --git a/spec/jwk_spec.rb b/spec/jwk_spec.rb index 3d3543f0..79ba8a2d 100644 --- a/spec/jwk_spec.rb +++ b/spec/jwk_spec.rb @@ -38,11 +38,18 @@ expect(subject.export).to eq(params) end end + + context 'when a common JWK parameter is specified' do + it 'returns the defined common JWK parameter' do + params[:use] = 'sig' + expect(subject.export).to eq(params) + end + end end describe '.new' do - let(:kid) { nil } - subject { described_class.new(keypair, kid) } + let(:options) { nil } + subject { described_class.new(keypair, options) } context 'when RSA key is given' do let(:keypair) { rsa_key } @@ -61,10 +68,38 @@ context 'when kid is given' do let(:keypair) { rsa_key } - let(:kid) { 'CUSTOM_KID' } + let(:options) { 'CUSTOM_KID' } it 'sets the kid' do - expect(subject.kid).to eq(kid) + expect(subject.kid).to eq(options) + end + end + + context 'when a common parameter is given' do + subject { described_class.new(keypair, params) } + let(:keypair) { rsa_key } + let(:params) { { 'use' => 'sig' } } + it 'sets the common parameter' do + expect(subject[:use]).to eq('sig') end end end + + describe '.[]' do + let(:params) { { use: 'sig' } } + let(:keypair) { rsa_key } + subject { described_class.new(keypair, params) } + + it 'allows to read common parameters via the key-accessor' do + expect(subject[:use]).to eq('sig') + end + + it 'allows to set common parameters via the key-accessor' do + subject[:use] = 'enc' + expect(subject[:use]).to eq('enc') + end + + it 'rejects key parameters as keys via the key-accessor' do + expect { subject[:kty] = 'something' }.to raise_error(ArgumentError) + end + end end