diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0d71fa..f5a8e05e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ **Features:** +- Add `enforce_hmac_key_length` configuration option [#716](https://github.com/jwt/ruby-jwt/pull/716) - ([@304](https://github.com/304)) - Your contribution here **Fixes and enhancements:** diff --git a/lib/jwt/configuration/decode_configuration.rb b/lib/jwt/configuration/decode_configuration.rb index 4acfd3eb..7506ce89 100644 --- a/lib/jwt/configuration/decode_configuration.rb +++ b/lib/jwt/configuration/decode_configuration.rb @@ -24,6 +24,8 @@ class DecodeConfiguration # @return [Array] the list of acceptable algorithms. # @!attribute [rw] required_claims # @return [Array] the list of required claims. + # @!attribute [rw] enforce_hmac_key_length + # @return [Boolean] whether to enforce minimum HMAC key lengths. false disables validation (default). attr_accessor :verify_expiration, :verify_not_before, @@ -34,7 +36,8 @@ class DecodeConfiguration :verify_sub, :leeway, :algorithms, - :required_claims + :required_claims, + :enforce_hmac_key_length # Initializes a new DecodeConfiguration instance with default settings. def initialize @@ -48,6 +51,7 @@ def initialize @leeway = 0 @algorithms = ['HS256'] @required_claims = [] + @enforce_hmac_key_length = false end # @api private @@ -62,7 +66,8 @@ def to_h verify_sub: verify_sub, leeway: leeway, algorithms: algorithms, - required_claims: required_claims + required_claims: required_claims, + enforce_hmac_key_length: enforce_hmac_key_length } end end diff --git a/lib/jwt/jwa/hmac.rb b/lib/jwt/jwa/hmac.rb index 86b3278c..3629a563 100644 --- a/lib/jwt/jwa/hmac.rb +++ b/lib/jwt/jwa/hmac.rb @@ -6,6 +6,15 @@ module JWA class Hmac include JWT::JWA::SigningAlgorithm + # Minimum key lengths for HMAC algorithms based on RFC 7518 Section 3.2. + # Keys must be at least the size of the hash output to ensure sufficient + # entropy for the algorithm's security level. + MIN_KEY_LENGTHS = { + 'HS256' => 32, + 'HS384' => 48, + 'HS512' => 64 + }.freeze + def initialize(alg, digest) @alg = alg @digest = digest @@ -15,6 +24,8 @@ def sign(data:, signing_key:) signing_key ||= '' raise_verify_error!('HMAC key expected to be a String') unless signing_key.is_a?(String) + validate_key_length!(signing_key) + OpenSSL::HMAC.digest(digest.new, signing_key, data) rescue OpenSSL::HMACError => e raise_verify_error!('OpenSSL 3.0 does not support nil or empty hmac_secret') if signing_key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure' @@ -23,6 +34,11 @@ def sign(data:, signing_key:) end def verify(data:, signature:, verification_key:) + validation_key = verification_key || '' + raise_verify_error!('HMAC key expected to be a String') unless validation_key.is_a?(String) + + validate_key_length!(validation_key) + SecurityUtils.secure_compare(signature, sign(data: data, signing_key: verification_key)) end @@ -34,6 +50,15 @@ def verify(data:, signature:, verification_key:) attr_reader :digest + def validate_key_length!(key) + return unless JWT.configuration.decode.enforce_hmac_key_length + + min_length = MIN_KEY_LENGTHS[alg] + return if key.bytesize >= min_length + + raise_verify_error!("HMAC key must be at least #{min_length} bytes for #{alg} algorithm") + end + # 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/spec/jwt/jwa/hmac_spec.rb b/spec/jwt/jwa/hmac_spec.rb index 20504cde..b499db16 100644 --- a/spec/jwt/jwa/hmac_spec.rb +++ b/spec/jwt/jwa/hmac_spec.rb @@ -108,6 +108,32 @@ it { is_expected.to eql(response) } end end + + context 'when enforce_hmac_key_length is enabled' do + before do + JWT.configuration.decode.enforce_hmac_key_length = true + end + + after do + JWT.configuration.decode.enforce_hmac_key_length = false + end + + context 'when key shorter than algorithm minimum' do + let(:hmac_secret) { 'short' } + + it 'raises error' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key must be at least 32 bytes for HS256 algorithm') + end + end + + context 'when key meets minimum length' do + let(:hmac_secret) { 'a' * 32 } + + it 'does not raise error' do + expect { subject }.not_to raise_error + end + end + end end describe '#verify' do @@ -124,5 +150,42 @@ it { is_expected.to be(false) } end + + context 'when verification_key is not a String' do + let(:signature) { valid_signature } + let(:hmac_secret) { 123 } + + it 'raises error' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + end + end + + context 'when enforce_hmac_key_length is enabled' do + before do + JWT.configuration.decode.enforce_hmac_key_length = true + end + + after do + JWT.configuration.decode.enforce_hmac_key_length = false + end + + let(:signature) { valid_signature } + + context 'when key shorter than algorithm minimum' do + let(:hmac_secret) { 'short' } + + it 'raises error' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key must be at least 32 bytes for HS256 algorithm') + end + end + + context 'when key meets minimum length' do + let(:hmac_secret) { 'a' * 32 } + + it 'does not raise error' do + expect { subject }.not_to raise_error + end + end + end end end