diff --git a/CHANGELOG.md b/CHANGELOG.md index c7fa7df6..c0f1ab07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ **Fixes and enhancements:** - Refactor claim validators into their own classes [#605](https://github.com/jwt/ruby-jwt/pull/605) ([@anakinj](https://github.com/anakinj), [@MatteoPierro](https://github.com/MatteoPierro)) +- Allow extending available algorithms [#607](https://github.com/jwt/ruby-jwt/pull/607) ([@anakinj](https://github.com/anakinj)) - Your contribution here ## [v2.8.2](https://github.com/jwt/ruby-jwt/tree/v2.8.2) (2024-06-18) diff --git a/README.md b/README.md index 41beb8f1..fa8f49d9 100644 --- a/README.md +++ b/README.md @@ -223,18 +223,22 @@ puts decoded_token ### **Custom algorithms** -An object implementing custom signing or verification behaviour can be passed in the `algorithm` option when encoding and decoding. The given object needs to implement the method `valid_alg?` and `verify` and/or `alg` and `sign`, depending if object is used for encoding or decoding. +When encoding or decoding a token, you can pass in a custom object through the `algorithm` option to handle signing or verification. This custom object must include or extend the `JWT::JWA::SigningAlgorithm` module and implement certain methods: + +- For decoding/verifying: The object must implement the methods `alg` and `verify`. +- For encoding/signing: The object must implement the methods `alg` and `sign`. + +For customization options check the details from `JWT::JWA::SigningAlgorithm`. + ```ruby module CustomHS512Algorithm + extend JWT::JWA::SigningAlgorithm + def self.alg 'HS512' end - def self.valid_alg?(alg_to_validate) - alg_to_validate == alg - end - def self.sign(data:, signing_key:) OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha512'), data, signing_key) end diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index d3196ae4..a8de603d 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -90,7 +90,7 @@ def allowed_algorithms end def resolve_allowed_algorithms - algs = given_algorithms.map { |alg| JWA.create(alg) } + algs = given_algorithms.map { |alg| JWA.resolve(alg) } sort_by_alg_header(algs) end diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index 3859c4cb..973f5b2f 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -6,14 +6,11 @@ module JWT # Encoding logic for JWT class Encode - ALG_KEY = 'alg' - def initialize(options) @payload = options[:payload] @key = options[:key] - @algorithm = JWA.create(options[:algorithm]) + @algorithm = JWA.resolve(options[:algorithm]) @headers = options[:headers].transform_keys(&:to_s) - @headers[ALG_KEY] = @algorithm.alg end def segments @@ -40,7 +37,7 @@ def encoded_header_and_payload end def encode_header - encode_data(@headers) + encode_data(@headers.merge(@algorithm.header(signing_key: @key))) end def encode_payload diff --git a/lib/jwt/jwa.rb b/lib/jwt/jwa.rb index 6e88f514..8d515d26 100644 --- a/lib/jwt/jwa.rb +++ b/lib/jwt/jwa.rb @@ -8,54 +8,35 @@ raise if defined?(RbNaCl) end -require_relative 'jwa/hmac' -require_relative 'jwa/eddsa' +require_relative 'jwa/signing_algorithm' + require_relative 'jwa/ecdsa' -require_relative 'jwa/rsa' -require_relative 'jwa/ps' +require_relative 'jwa/eddsa' +require_relative 'jwa/hmac' require_relative 'jwa/none' +require_relative 'jwa/ps' +require_relative 'jwa/rsa' require_relative 'jwa/unsupported' require_relative 'jwa/wrapper' +if JWT.rbnacl_6_or_greater? + require_relative 'jwa/hmac_rbnacl' +elsif JWT.rbnacl? + require_relative 'jwa/hmac_rbnacl_fixed' +end + module JWT module JWA - ALGOS = [Hmac, Ecdsa, Rsa, Eddsa, Ps, None, Unsupported].tap do |l| - if ::JWT.rbnacl_6_or_greater? - require_relative 'jwa/hmac_rbnacl' - l << Algos::HmacRbNaCl - elsif ::JWT.rbnacl? - require_relative 'jwa/hmac_rbnacl_fixed' - l << Algos::HmacRbNaClFixed - end - end.freeze - class << self - def find(algorithm) - indexed[algorithm&.downcase] - end - - def create(algorithm) - return algorithm if JWA.implementation?(algorithm) - - Wrapper.new(*find(algorithm)) - end - - def implementation?(algorithm) - (algorithm.respond_to?(:valid_alg?) && algorithm.respond_to?(:verify)) || - (algorithm.respond_to?(:alg) && algorithm.respond_to?(:sign)) - end + def resolve(algorithm) + return find(algorithm) if algorithm.is_a?(String) || algorithm.is_a?(Symbol) - private - - def indexed - @indexed ||= begin - fallback = [nil, Unsupported] - ALGOS.each_with_object(Hash.new(fallback)) do |cls, hash| - cls.const_get(:SUPPORTED).each do |alg| - hash[alg.downcase] = [alg, cls] - end - end + unless algorithm.is_a?(SigningAlgorithm) + Deprecations.warning('Custom algorithms are required to include JWT::JWA::SigningAlgorithm') + return Wrapper.new(algorithm) end + + algorithm end end end diff --git a/lib/jwt/jwa/ecdsa.rb b/lib/jwt/jwa/ecdsa.rb index 8d9313c1..abc7246e 100644 --- a/lib/jwt/jwa/ecdsa.rb +++ b/lib/jwt/jwa/ecdsa.rb @@ -2,8 +2,35 @@ module JWT module JWA - module Ecdsa - module_function + class Ecdsa + include JWT::JWA::SigningAlgorithm + + def initialize(alg, digest) + @alg = alg + @digest = OpenSSL::Digest.new(digest) + end + + def sign(data:, signing_key:) + curve_definition = curve_by_name(signing_key.group.curve_name) + key_algorithm = curve_definition[:algorithm] + if alg != key_algorithm + raise IncorrectAlgorithm, "payload algorithm is #{alg} but #{key_algorithm} signing key was provided" + end + + asn1_to_raw(signing_key.dsa_sign_asn1(digest.digest(data)), signing_key) + end + + def verify(data:, signature:, verification_key:) + curve_definition = curve_by_name(verification_key.group.curve_name) + key_algorithm = curve_definition[:algorithm] + if alg != key_algorithm + raise IncorrectAlgorithm, "payload algorithm is #{alg} but #{key_algorithm} verification key was provided" + end + + verification_key.dsa_verify_asn1(digest.digest(data), raw_to_asn1(signature, verification_key)) + rescue OpenSSL::PKey::PKeyError + raise JWT::VerificationError, 'Signature verification raised' + end NAMED_CURVES = { 'prime256v1' => { @@ -28,36 +55,22 @@ module Ecdsa } }.freeze - SUPPORTED = NAMED_CURVES.map { |_, c| c[:algorithm] }.uniq.freeze + NAMED_CURVES.each_value do |v| + register_algorithm(new(v[:algorithm], v[:digest])) + end - def sign(algorithm, msg, key) - curve_definition = curve_by_name(key.group.curve_name) - key_algorithm = curve_definition[:algorithm] - if algorithm != key_algorithm - raise IncorrectAlgorithm, "payload algorithm is #{algorithm} but #{key_algorithm} signing key was provided" + def self.curve_by_name(name) + NAMED_CURVES.fetch(name) do + raise UnsupportedEcdsaCurve, "The ECDSA curve '#{name}' is not supported" end - - digest = OpenSSL::Digest.new(curve_definition[:digest]) - asn1_to_raw(key.dsa_sign_asn1(digest.digest(msg)), key) end - def verify(algorithm, public_key, signing_input, signature) - curve_definition = curve_by_name(public_key.group.curve_name) - key_algorithm = curve_definition[:algorithm] - if algorithm != key_algorithm - raise IncorrectAlgorithm, "payload algorithm is #{algorithm} but #{key_algorithm} verification key was provided" - end + private - digest = OpenSSL::Digest.new(curve_definition[:digest]) - public_key.dsa_verify_asn1(digest.digest(signing_input), raw_to_asn1(signature, public_key)) - rescue OpenSSL::PKey::PKeyError - raise JWT::VerificationError, 'Signature verification raised' - end + attr_reader :digest def curve_by_name(name) - NAMED_CURVES.fetch(name) do - raise UnsupportedEcdsaCurve, "The ECDSA curve '#{name}' is not supported" - end + self.class.curve_by_name(name) end def raw_to_asn1(signature, private_key) diff --git a/lib/jwt/jwa/eddsa.rb b/lib/jwt/jwa/eddsa.rb index eef27f1b..3db3e3e6 100644 --- a/lib/jwt/jwa/eddsa.rb +++ b/lib/jwt/jwa/eddsa.rb @@ -2,41 +2,33 @@ module JWT module JWA - module Eddsa - SUPPORTED = %w[ED25519 EdDSA].freeze - SUPPORTED_DOWNCASED = SUPPORTED.map(&:downcase).freeze + class Eddsa + include JWT::JWA::SigningAlgorithm - class << self - def sign(algorithm, msg, key) - unless key.is_a?(RbNaCl::Signatures::Ed25519::SigningKey) - raise EncodeError, "Key given is a #{key.class} but has to be an RbNaCl::Signatures::Ed25519::SigningKey" - end - - validate_algorithm!(algorithm) + def initialize(alg) + @alg = alg + end - key.sign(msg) + def sign(data:, signing_key:) + unless signing_key.is_a?(RbNaCl::Signatures::Ed25519::SigningKey) + raise_encode_error!("Key given is a #{signing_key.class} but has to be an RbNaCl::Signatures::Ed25519::SigningKey") end - def verify(algorithm, public_key, signing_input, signature) - unless public_key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey) - raise DecodeError, "key given is a #{public_key.class} but has to be a RbNaCl::Signatures::Ed25519::VerifyKey" - end - - validate_algorithm!(algorithm) + signing_key.sign(data) + end - public_key.verify(signature, signing_input) - rescue RbNaCl::CryptoError - false + def verify(data:, signature:, verification_key:) + unless verification_key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey) + raise_decode_error!("key given is a #{verification_key.class} but has to be a RbNaCl::Signatures::Ed25519::VerifyKey") end - private - - def validate_algorithm!(algorithm) - return if SUPPORTED_DOWNCASED.include?(algorithm.downcase) - - raise IncorrectAlgorithm, "Algorithm #{algorithm} not supported. Supported algoritms are #{SUPPORTED.join(', ')}" - end + verification_key.verify(signature, data) + rescue RbNaCl::CryptoError + false end + + register_algorithm(new('ED25519')) + register_algorithm(new('EdDSA')) end end end diff --git a/lib/jwt/jwa/hmac.rb b/lib/jwt/jwa/hmac.rb index da73ff64..1dac71b3 100644 --- a/lib/jwt/jwa/hmac.rb +++ b/lib/jwt/jwa/hmac.rb @@ -2,35 +2,40 @@ module JWT module JWA - module Hmac - module_function + class Hmac + include JWT::JWA::SigningAlgorithm - MAPPING = { - 'HS256' => OpenSSL::Digest::SHA256, - 'HS384' => OpenSSL::Digest::SHA384, - 'HS512' => OpenSSL::Digest::SHA512 - }.freeze - - SUPPORTED = MAPPING.keys + def initialize(alg, digest) + @alg = alg + @digest = digest + end - def sign(algorithm, msg, key) - key ||= '' + def sign(data:, signing_key:) + signing_key ||= '' - raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) + raise_verify_error!('HMAC key expected to be a String') unless signing_key.is_a?(String) - OpenSSL::HMAC.digest(MAPPING[algorithm].new, key, msg) + OpenSSL::HMAC.digest(digest.new, signing_key, data) rescue OpenSSL::HMACError => e - if key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure' - raise JWT::DecodeError, 'OpenSSL 3.0 does not support nil or empty hmac_secret' + if signing_key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure' + raise_verify_error!('OpenSSL 3.0 does not support nil or empty hmac_secret') end raise e end - def verify(algorithm, key, signing_input, signature) - SecurityUtils.secure_compare(signature, sign(algorithm, signing_input, key)) + def verify(data:, signature:, verification_key:) + SecurityUtils.secure_compare(signature, sign(data: data, signing_key: verification_key)) end + register_algorithm(new('HS256', OpenSSL::Digest::SHA256)) + register_algorithm(new('HS384', OpenSSL::Digest::SHA384)) + register_algorithm(new('HS512', OpenSSL::Digest::SHA512)) + + private + + attr_reader :digest + # Copy of https://github.com/rails/rails/blob/v7.0.3.1/activesupport/lib/active_support/security_utils.rb # rubocop:disable Naming/MethodParameterName, Style/StringLiterals, Style/NumericPredicate module SecurityUtils diff --git a/lib/jwt/jwa/hmac_rbnacl.rb b/lib/jwt/jwa/hmac_rbnacl.rb index 67c1ac41..15870828 100644 --- a/lib/jwt/jwa/hmac_rbnacl.rb +++ b/lib/jwt/jwa/hmac_rbnacl.rb @@ -1,49 +1,44 @@ # frozen_string_literal: true module JWT - module Algos - module HmacRbNaCl - MAPPING = { 'HS512256' => ::RbNaCl::HMAC::SHA512256 }.freeze - SUPPORTED = MAPPING.keys - class << self - def sign(algorithm, msg, key) - Deprecations.warning("The use of the algorithm #{algorithm} is deprecated and will be removed in the next major version of ruby-jwt") - if (hmac = resolve_algorithm(algorithm)) - hmac.auth(key_for_rbnacl(hmac, key).encode('binary'), msg.encode('binary')) - else - Hmac.sign(algorithm, msg, key) - end - end - - def verify(algorithm, key, signing_input, signature) - Deprecations.warning("The use of the algorithm #{algorithm} is deprecated and will be removed in the next major version of ruby-jwt") - if (hmac = resolve_algorithm(algorithm)) - hmac.verify(key_for_rbnacl(hmac, key).encode('binary'), signature.encode('binary'), signing_input.encode('binary')) - else - Hmac.verify(algorithm, key, signing_input, signature) - end - rescue ::RbNaCl::BadAuthenticatorError, ::RbNaCl::LengthError - false - end - - private - - def key_for_rbnacl(hmac, key) - key ||= '' - raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) - - return padded_empty_key(hmac.key_bytes) if key == '' - - key - end - - def resolve_algorithm(algorithm) - MAPPING.fetch(algorithm) - end - - def padded_empty_key(length) - Array.new(length, 0x0).pack('C*').encode('binary') - end + module JWA + class HmacRbNaCl + include JWT::JWA::SigningAlgorithm + + def initialize(alg, hmac) + @alg = alg + @hmac = hmac + end + + def sign(data:, signing_key:) + Deprecations.warning("The use of the algorithm #{alg} is deprecated and will be removed in the next major version of ruby-jwt") + hmac.auth(key_for_rbnacl(hmac, signing_key).encode('binary'), data.encode('binary')) + end + + def verify(data:, signature:, verification_key:) + Deprecations.warning("The use of the algorithm #{alg} is deprecated and will be removed in the next major version of ruby-jwt") + hmac.verify(key_for_rbnacl(hmac, verification_key).encode('binary'), signature.encode('binary'), data.encode('binary')) + rescue ::RbNaCl::BadAuthenticatorError, ::RbNaCl::LengthError + false + end + + register_algorithm(new('HS512256', ::RbNaCl::HMAC::SHA512256)) + + private + + attr_reader :hmac + + def key_for_rbnacl(hmac, key) + key ||= '' + raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) + + return padded_empty_key(hmac.key_bytes) if key == '' + + key + end + + def padded_empty_key(length) + Array.new(length, 0x0).pack('C*').encode('binary') end end end diff --git a/lib/jwt/jwa/hmac_rbnacl_fixed.rb b/lib/jwt/jwa/hmac_rbnacl_fixed.rb index cece2d52..909cc810 100644 --- a/lib/jwt/jwa/hmac_rbnacl_fixed.rb +++ b/lib/jwt/jwa/hmac_rbnacl_fixed.rb @@ -1,45 +1,41 @@ # frozen_string_literal: true module JWT - module Algos - module HmacRbNaClFixed - MAPPING = { 'HS512256' => ::RbNaCl::HMAC::SHA512256 }.freeze - SUPPORTED = MAPPING.keys - - class << self - def sign(algorithm, msg, key) - key ||= '' - Deprecations.warning("The use of the algorithm #{algorithm} is deprecated and will be removed in the next major version of ruby-jwt") - raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) - - if (hmac = resolve_algorithm(algorithm)) && key.bytesize <= hmac.key_bytes - hmac.auth(padded_key_bytes(key, hmac.key_bytes), msg.encode('binary')) - else - Hmac.sign(algorithm, msg, key) - end - end - - def verify(algorithm, key, signing_input, signature) - key ||= '' - Deprecations.warning("The use of the algorithm #{algorithm} is deprecated and will be removed in the next major version of ruby-jwt") - raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) - - if (hmac = resolve_algorithm(algorithm)) && key.bytesize <= hmac.key_bytes - hmac.verify(padded_key_bytes(key, hmac.key_bytes), signature.encode('binary'), signing_input.encode('binary')) - else - Hmac.verify(algorithm, key, signing_input, signature) - end - rescue ::RbNaCl::BadAuthenticatorError, ::RbNaCl::LengthError - false - end - - def resolve_algorithm(algorithm) - MAPPING.fetch(algorithm) - end - - def padded_key_bytes(key, bytesize) - key.bytes.fill(0, key.bytesize...bytesize).pack('C*') - end + module JWA + class HmacRbNaClFixed + include JWT::JWA::SigningAlgorithm + + def initialize(alg, hmac) + @alg = alg + @hmac = hmac + end + + def sign(data:, signing_key:) + signing_key ||= '' + Deprecations.warning("The use of the algorithm #{alg} is deprecated and will be removed in the next major version of ruby-jwt") + raise JWT::DecodeError, 'HMAC key expected to be a String' unless signing_key.is_a?(String) + + hmac.auth(padded_key_bytes(signing_key, hmac.key_bytes), data.encode('binary')) + end + + def verify(data:, signature:, verification_key:) + verification_key ||= '' + Deprecations.warning("The use of the algorithm #{alg} is deprecated and will be removed in the next major version of ruby-jwt") + raise JWT::DecodeError, 'HMAC key expected to be a String' unless verification_key.is_a?(String) + + hmac.verify(padded_key_bytes(verification_key, hmac.key_bytes), signature.encode('binary'), data.encode('binary')) + rescue ::RbNaCl::BadAuthenticatorError, ::RbNaCl::LengthError + false + end + + register_algorithm(new('HS512256', ::RbNaCl::HMAC::SHA512256)) + + private + + attr_reader :hmac + + def padded_key_bytes(key, bytesize) + key.bytes.fill(0, key.bytesize...bytesize).pack('C*') end end end diff --git a/lib/jwt/jwa/none.rb b/lib/jwt/jwa/none.rb index d6fd7e62..ec2db20c 100644 --- a/lib/jwt/jwa/none.rb +++ b/lib/jwt/jwa/none.rb @@ -2,10 +2,12 @@ module JWT module JWA - module None - module_function + class None + include JWT::JWA::SigningAlgorithm - SUPPORTED = %w[none].freeze + def initialize + @alg = 'none' + end def sign(*) '' @@ -14,6 +16,8 @@ def sign(*) def verify(*) true end + + register_algorithm(new) end end end diff --git a/lib/jwt/jwa/ps.rb b/lib/jwt/jwa/ps.rb index f7488bd0..ed27f792 100644 --- a/lib/jwt/jwa/ps.rb +++ b/lib/jwt/jwa/ps.rb @@ -2,29 +2,35 @@ module JWT module JWA - module Ps - # RSASSA-PSS signing algorithms + class Ps + include JWT::JWA::SigningAlgorithm - module_function - - SUPPORTED = %w[PS256 PS384 PS512].freeze + def initialize(alg) + @alg = alg + @digest_algorithm = alg.sub('PS', 'sha') + end - def sign(algorithm, msg, key) - unless key.is_a?(::OpenSSL::PKey::RSA) - raise EncodeError, "The given key is a #{key_class}. It has to be an OpenSSL::PKey::RSA instance." + def sign(data:, signing_key:) + unless signing_key.is_a?(::OpenSSL::PKey::RSA) + raise_sign_error!("The given key is a #{signing_key.class}. It has to be an OpenSSL::PKey::RSA instance.") end - translated_algorithm = algorithm.sub('PS', 'sha') - - key.sign_pss(translated_algorithm, msg, salt_length: :digest, mgf1_hash: translated_algorithm) + signing_key.sign_pss(digest_algorithm, data, salt_length: :digest, mgf1_hash: digest_algorithm) end - def verify(algorithm, public_key, signing_input, signature) - translated_algorithm = algorithm.sub('PS', 'sha') - public_key.verify_pss(translated_algorithm, signature, signing_input, salt_length: :auto, mgf1_hash: translated_algorithm) + def verify(data:, signature:, verification_key:) + verification_key.verify_pss(digest_algorithm, signature, data, salt_length: :auto, mgf1_hash: digest_algorithm) rescue OpenSSL::PKey::PKeyError raise JWT::VerificationError, 'Signature verification raised' end + + register_algorithm(new('PS256')) + register_algorithm(new('PS384')) + register_algorithm(new('PS512')) + + private + + attr_reader :digest_algorithm end end end diff --git a/lib/jwt/jwa/rsa.rb b/lib/jwt/jwa/rsa.rb index f9970bda..79ca52a0 100644 --- a/lib/jwt/jwa/rsa.rb +++ b/lib/jwt/jwa/rsa.rb @@ -2,24 +2,35 @@ module JWT module JWA - module Rsa - module_function + class Rsa + include JWT::JWA::SigningAlgorithm - SUPPORTED = %w[RS256 RS384 RS512].freeze + def initialize(alg) + @alg = alg + @digest = OpenSSL::Digest.new(alg.sub('RS', 'SHA')) + end - def sign(algorithm, msg, key) - unless key.is_a?(OpenSSL::PKey::RSA) - raise EncodeError, "The given key is a #{key.class}. It has to be an OpenSSL::PKey::RSA instance" + def sign(data:, signing_key:) + unless signing_key.is_a?(OpenSSL::PKey::RSA) + raise_sign_error!("The given key is a #{signing_key.class}. It has to be an OpenSSL::PKey::RSA instance") end - key.sign(OpenSSL::Digest.new(algorithm.sub('RS', 'sha')), msg) + signing_key.sign(digest, data) end - def verify(algorithm, public_key, signing_input, signature) - public_key.verify(OpenSSL::Digest.new(algorithm.sub('RS', 'sha')), signature, signing_input) + def verify(data:, signature:, verification_key:) + verification_key.verify(digest, signature, data) rescue OpenSSL::PKey::PKeyError raise JWT::VerificationError, 'Signature verification raised' end + + register_algorithm(new('RS256')) + register_algorithm(new('RS384')) + register_algorithm(new('RS512')) + + private + + attr_reader :digest end end end diff --git a/lib/jwt/jwa/signing_algorithm.rb b/lib/jwt/jwa/signing_algorithm.rb new file mode 100644 index 00000000..d00f1c01 --- /dev/null +++ b/lib/jwt/jwa/signing_algorithm.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module JWT + module JWA + module SigningAlgorithm + module ClassMethods + def register_algorithm(algo) + ::JWT::JWA.register_algorithm(algo) + end + end + + def self.included(klass) + klass.extend(ClassMethods) + end + + attr_reader :alg + + def valid_alg?(alg_to_check) + alg&.casecmp(alg_to_check)&.zero? == true + end + + def header(*) + { 'alg' => alg } + end + + def sign(*) + raise_sign_error!('Algorithm implementation is missing the sign method') + end + + def verify(*) + raise_verify_error!('Algorithm implementation is missing the verify method') + end + + def raise_verify_error!(message) + raise(DecodeError.new(message).tap { |e| e.set_backtrace(caller(1)) }) + end + + def raise_sign_error!(message) + raise(EncodeError.new(message).tap { |e| e.set_backtrace(caller(1)) }) + end + end + + class << self + def register_algorithm(algo) + algorithms[algo.alg.to_s.downcase] = algo + end + + def find(algo) + algorithms.fetch(algo.to_s.downcase, Unsupported) + end + + private + + def algorithms + @algorithms ||= {} + end + end + end +end diff --git a/lib/jwt/jwa/unsupported.rb b/lib/jwt/jwa/unsupported.rb index 681a6a75..5d596101 100644 --- a/lib/jwt/jwa/unsupported.rb +++ b/lib/jwt/jwa/unsupported.rb @@ -3,16 +3,16 @@ module JWT module JWA module Unsupported - module_function + class << self + include JWT::JWA::SigningAlgorithm - SUPPORTED = [].freeze + def sign(*) + raise_sign_error!('Unsupported signing method') + end - def sign(*) - raise NotImplementedError, 'Unsupported signing method' - end - - def verify(*) - raise JWT::VerificationError, 'Algorithm not supported' + def verify(*) + raise JWT::VerificationError, 'Algorithm not supported' + end end end end diff --git a/lib/jwt/jwa/wrapper.rb b/lib/jwt/jwa/wrapper.rb index e53713e1..46ff0e2f 100644 --- a/lib/jwt/jwa/wrapper.rb +++ b/lib/jwt/jwa/wrapper.rb @@ -3,23 +3,40 @@ module JWT module JWA class Wrapper - attr_reader :alg, :cls + include SigningAlgorithm - def initialize(alg, cls) - @alg = alg - @cls = cls + def initialize(algorithm) + @algorithm = algorithm + end + + def alg + return @algorithm.alg if @algorithm.respond_to?(:alg) + + super end def valid_alg?(alg_to_check) - alg&.casecmp(alg_to_check)&.zero? == true + return @algorithm.valid_alg?(alg_to_check) if @algorithm.respond_to?(:valid_alg?) + + super end - def sign(data:, signing_key:) - cls.sign(alg, data, signing_key) + def header(*args, **kwargs) + return @algorithm.header(*args, **kwargs) if @algorithm.respond_to?(:header) + + super end - def verify(data:, signature:, verification_key:) - cls.verify(alg, verification_key, data, signature) + def sign(*args, **kwargs) + return @algorithm.sign(*args, **kwargs) if @algorithm.respond_to?(:sign) + + super + end + + def verify(*args, **kwargs) + return @algorithm.verify(*args, **kwargs) if @algorithm.respond_to?(:verify) + + super end end end diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index 98d96ee9..ff488a96 100644 --- a/spec/integration/readme_examples_spec.rb +++ b/spec/integration/readme_examples_spec.rb @@ -451,14 +451,12 @@ context 'custom algorithm example' do it 'allows a module to be used as algorithm on encode and decode' do custom_hs512_alg = Module.new do + extend JWT::JWA::SigningAlgorithm + def self.alg 'HS512' end - def self.valid_alg?(alg_to_validate) - alg_to_validate == alg - end - def self.sign(data:, signing_key:) OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha512'), data, signing_key) end @@ -469,7 +467,8 @@ def self.verify(data:, signature:, verification_key:) end token = JWT.encode({ 'pay' => 'load' }, 'secret', custom_hs512_alg) - _payload, _header = JWT.decode(token, 'secret', true, algorithm: custom_hs512_alg) + _payload, header = JWT.decode(token, 'secret', true, algorithm: custom_hs512_alg) + expect(header).to include('alg' => 'HS512') end end end diff --git a/spec/jwt/jwa/hmac_rbnacl_fixed_spec.rb b/spec/jwt/jwa/hmac_rbnacl_fixed_spec.rb new file mode 100644 index 00000000..e095cf15 --- /dev/null +++ b/spec/jwt/jwa/hmac_rbnacl_fixed_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe 'JWT::JWA::HmacRbNaClFixed' do + subject(:instance) { JWT::JWA::HmacRbNaClFixed.new('HS512256', RbNaCl::HMAC::SHA512256) } + let(:data) { 'test' } + + before do + skip('Requires the rbnacl gem') unless JWT.rbnacl? && !JWT.rbnacl_6_or_greater? + end + + describe '#sign' do + subject(:sign) { instance.sign(data: data, signing_key: signing_key) } + + let(:signing_key) { '*' * (RbNaCl::HMAC::SHA512256.key_bytes - 1) } + + it { is_expected.not_to be_empty } + + context 'when signing_key key is larger than hmac key bytes' do + let(:signing_key) { '*' * (RbNaCl::HMAC::SHA512256.key_bytes + 1) } + + it 'raises length error' do + expect { sign }.to raise_error(RbNaCl::LengthError, a_string_including('key was 33 bytes (Expected 32)')) + end + end + end + + describe '#verify' do + subject(:verify) { instance.verify(data: data, signature: signature, verification_key: verification_key) } + + let(:signature) { instance.sign(data: data, signing_key: signing_key) } + + let(:verification_key) { '*' * (RbNaCl::HMAC::SHA512256.key_bytes - 1) } + let(:signing_key) { verification_key } + + it { is_expected.to be(true) } + + context 'when verification_key key is larger than hmac key bytes' do + let(:verification_key) { '*' * (RbNaCl::HMAC::SHA512256.key_bytes + 1) } + let(:signature) { 'a_signature' } + + it { is_expected.to be(false) } + end + end +end diff --git a/spec/jwt/jwa/hmac_spec.rb b/spec/jwt/jwa/hmac_spec.rb index 84bc6734..736f1e56 100644 --- a/spec/jwt/jwa/hmac_spec.rb +++ b/spec/jwt/jwa/hmac_spec.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true RSpec.describe JWT::JWA::Hmac do - describe '.sign' do - subject { described_class.sign('HS256', 'test', hmac_secret) } + let(:instance) { described_class.new('HS256', OpenSSL::Digest::SHA256) } + + describe '#sign' do + subject { instance.sign(data: 'test', signing_key: hmac_secret) } # Address OpenSSL 3.0 errors with empty hmac_secret - https://github.com/jwt/ruby-jwt/issues/526 context 'when nil hmac_secret is passed' do diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index ddd68ecd..d7324495 100644 --- a/spec/jwt/jwt_spec.rb +++ b/spec/jwt/jwt_spec.rb @@ -314,10 +314,10 @@ end context 'Invalid' do - it 'algorithm should raise NotImplementedError' do + it 'algorithm should raise DecodeError' do expect do JWT.encode payload, 'secret', 'HS255' - end.to raise_error NotImplementedError + end.to raise_error JWT::EncodeError end it 'raises "No verification key available" error' do @@ -651,7 +651,7 @@ it 'raises error for invalid algorithm' do expect do JWT.encode(payload, '', 'xyz') - end.to raise_error(NotImplementedError) + end.to raise_error(JWT::EncodeError) end end @@ -851,11 +851,11 @@ context 'when algorithm is a custom class' do let(:custom_algorithm) do Class.new do - attr_reader :alg + include JWT::JWA::SigningAlgorithm def initialize(signature: 'custom_signature', alg: 'custom') @signature = signature - @alg = alg + @alg = alg end def sign(*) @@ -865,10 +865,6 @@ def sign(*) def verify(data:, signature:, verification_key:) # rubocop:disable Lint/UnusedMethodArgument signature == @signature end - - def valid_alg?(alg) - alg == self.alg - end end end @@ -889,6 +885,50 @@ def valid_alg?(alg) end end + context 'when class has custom header method' do + before do + custom_algorithm.class_eval do + def header(*) + { 'alg' => alg, 'foo' => 'bar' } + end + end + end + + it 'uses the provided header' do + expect(JWT.decode(token, 'secret', true, algorithm: custom_algorithm.new)).to eq([payload, { 'alg' => 'custom', 'foo' => 'bar' }]) + end + end + + context 'when class is not utilizing the ::JWT::JWA::SigningAlgorithm module' do + let(:custom_algorithm) do + Class.new do + attr_reader :alg + + def initialize(signature: 'custom_signature', alg: 'custom') + @signature = signature + @alg = alg + end + + def header(*) + { 'alg' => @alg, 'foo' => 'bar' } + end + + def sign(*) + @signature + end + + def verify(*) + true + end + end + end + + it 'emits a deprecation warning' do + expect { token }.to output("[DEPRECATION WARNING] Custom algorithms are required to include JWT::JWA::SigningAlgorithm\n").to_stderr + expect(JWT.decode(token, 'secret', true, algorithm: custom_algorithm.new)).to eq([payload, { 'alg' => 'custom', 'foo' => 'bar' }]) + end + end + context 'when alg is not matching' do it 'fails the validation process' do expect { JWT.decode(token, 'secret', true, algorithms: custom_algorithm.new(alg: 'not_a_match')) }.to raise_error(JWT::IncorrectAlgorithm, 'Expected a different algorithm') @@ -908,9 +948,8 @@ def valid_alg?(alg) end end - # This behaviour should be somehow nicer it 'raises an error on encoding' do - expect { token }.to raise_error(NoMethodError) + expect { token }.to raise_error(JWT::EncodeError, /missing the sign method/) end it 'allows decoding' do @@ -929,9 +968,8 @@ def valid_alg?(alg) expect(token).to eq(expected_token) end - # This behaviour should be somehow nicer it 'raises error on decoding' do - expect { JWT.decode(expected_token, 'secret', true, algorithm: custom_algorithm.new) }.to raise_error(NoMethodError) + expect { JWT.decode(expected_token, 'secret', true, algorithm: custom_algorithm.new) }.to raise_error(JWT::DecodeError, /missing the verify method/) end end end